Files
OJ2/docker/Dockerfile
yuetsh 586c88f629
Some checks failed
Deploy / deploy (push) Has been cancelled
build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉
不再需要的 Django 表,最后接进部署和 CI。

## 1. 让 drizzle-kit generate 可用

原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条
`DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。
逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的:

- **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。
  已从 0000_snapshot.json 清掉。
- **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS
  number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条
  ALTER COLUMN。改成字符串。
- **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带
  opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。

改完 `generate` 是干净的 no-op。

两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢
(单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY
DESC,实测同样 0.08ms);migrator 把所有语句包一个事务,
CREATE INDEX CONCURRENTLY 跑不了。

最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate
会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。

## 2. 0001 提交列表索引

`WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的
contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把
`IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把
enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列
contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。

换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照
(12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks →
0.22ms / 读 34 blocks。端到端 94ms → 6ms。

真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍
shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。

## 3. 0002 删掉 Django 残留

确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何
OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有
漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随
DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。

**回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」
都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。

## 4. migrate 接进部署与 CI

deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧
容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据,
也不用把生产库对外开放。

迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。
这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移
不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移
手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」
那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个
数据目录不在此列。)

三道闸门,都是写完测出来才补上的:

- **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA /
  ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要
  `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算,
  拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。
- **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是
  `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败
  留下的)同样是没基线。
- **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle
  的堆栈,现在直接说该检查哪一行。

另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着,
可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从
0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。
现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。

验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、
放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。
tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:56:30 -06:00

165 lines
8.5 KiB
Docker
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OJ2 镜像。一次构建产出两个 target
#
# api —— 单二进制后端serve / worker / sql-child 三个子命令共用一份运行时)
# web —— Caddy + 前端构建产物
#
# 构建上下文是**仓库根**,不是 docker/
# docker compose -f docker/compose.debian.yml build
#
# ## 产物从哪来ARTIFACTS
#
# 两个产物(后端单二进制 + 前端 dist可以在镜像里现编也可以用外面编好的
#
# ARTIFACTS=build 默认builder 阶段自己编。服务器上手动部署走这条,
# 只要有 docker 就行,不依赖 CI、不依赖别处传产物。
# ARTIFACTS=prebuilt 直接拿构建上下文里现成的 dist/oj2-api 和 apps/web/dist
# builder 阶段整个不进构建图(连 bun 镜像都不会拉)。
#
# ⚠️ prebuilt 省时间的前提是 **BuildKit**:只有它按依赖图构建、跳过用不到的阶段。
# 没装 buildx 的机器上 compose 会退回 classic builder那个把所有阶段挨个跑一遍
# 于是 builder 照样编一次,产物白编(结果仍然正确,只是没省下时间)。
# docker/deploy.sh --prebuilt 会检查并提醒。
#
# 加这个开关是因为服务器性能差builder 里 1238 个包的 bun install + bun compile +
# vite build首次约 5 分钟,光前端就 160s。GitHub Actions 的 runner 编完 rsync
# 过来,服务器这边只剩几条 COPY。见 .github/workflows/deploy.yml。
#
# 两条路都得留着 —— CI 挂了 / 改不动网络时,服务器上 `docker/deploy.sh` 原样能跑。
#
# 手工产出 prebuilt 产物(仓库根):
# bun install --frozen-lockfile
# bun run --filter '@oj2/api' build # → dist/oj2-api
# cd apps/web && bun run build # → apps/web/dist
#
ARG ARTIFACTS=build
# 旧后端是一个容器里用 supervisord 跑 caddy + gunicorn + dramatiq 三个进程。
# 这里拆开Docker 自己就是进程管理器,拆开之后 worker 挂了能单独重启、
# 也能单独看日志,少一层 supervisord 要维护。
# ---------------------------------------------------------------- 构建
FROM oven/bun:1 AS builder
WORKDIR /build
# 走国内镜像源。不是图快(总耗时差不多),是图能装上:
# mermaid@9.4.3 那个 11.9MB 的包从容器里连 registry.npmjs.com 稳定失败
# (主机上没问题,容器网络这一跳过不去),换源之后 1238 个包一次装齐。
# 这个包是机房老 Chrome 的 mermaid-legacy 依赖,不能砍。
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV BUN_CONFIG_REGISTRY=${NPM_REGISTRY}
# 先只拷 manifest依赖没变时这一层能命中缓存。
# bunfig.toml 必须一起拷:它设了 linker = "hoisted",漏掉的话容器里会用默认的
# isolated 布局(包都在 node_modules/.bun/ 下),于是本地靠"提升"才解析得到的包
# 在容器里一律 Could not resolve —— 而本地构建始终是好的,只有镜像构建才炸。
# 真正的修法是把直接 import 的包声明成直接依赖(已做),这里让两边布局一致是第二道保险。
COPY package.json bun.lock bunfig.toml ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/contract/package.json packages/contract/
# 重试两次:网络抖动不该让整次构建从头再来
RUN bun install --frozen-lockfile \
|| bun install --frozen-lockfile \
|| bun install --frozen-lockfile
COPY . .
# 后端:编译成不依赖 node_modules 的单二进制。
# 能这么编是因为 wasm / 原生模块 / 词典都在源码里用 `with { type: "file" }`
# 内嵌成了资源,详见 apps/api/src/vendor/jieba.ts 的注释。
RUN bun build --compile --target=bun-linux-x64 apps/api/src/main.ts --outfile /build/oj2-api
# 前端:产物直接进镜像,不走挂载。
# 一次构建 = 一个版本,切换那天不会出现「后端换了前端忘了拷」这种半新半旧状态。
RUN cd apps/web && bun run build
# ---------------------------------------------------------------- 产物来源
# 两个来源都归一成同样的布局(/artifacts/oj2-api、/artifacts/web下面的运行时
# 阶段只认这个布局,不关心产物是谁编的。
#
# 用 scratch 是因为这里只是「摆放文件」,不需要任何基底;这两个阶段也不会单独产出
# 镜像,只被 COPY --from 引用。
#
# BuildKit 只构建进了依赖图的阶段:选 prebuilt 时 builder 和 oven/bun 镜像
# 完全不参与,选 build 时下面那条 `COPY dist/oj2-api` 不存在也不报错。
FROM scratch AS artifacts-build
COPY --from=builder /build/oj2-api /artifacts/oj2-api
COPY --from=builder /build/apps/web/dist /artifacts/web
FROM scratch AS artifacts-prebuilt
COPY dist/oj2-api /artifacts/oj2-api
COPY apps/web/dist /artifacts/web
# 变量阶段名,取值来自顶部那个全局 ARG
FROM artifacts-${ARTIFACTS} AS artifacts
# ---------------------------------------------------------------- 后端运行时
# 用 debian 基底而不是 alpine内嵌的 jieba 原生模块是 linux-x64-**gnu**
# musl 基底跑不起来(换基底必须同步改 vendor/jieba.ts 里写死的导入)。
FROM debian:trixie-slim AS api
ENV NODE_ENV=production
# clang-format 给 C 代码格式化用,对应旧镜像 apt 装的那个。
#
# **不装 ca-certificates。** AI 接口那条 https 出站不需要它 —— Bun 和 Node 一样
# 内嵌了一份根证书,走自己那份,不读系统的 `/etc/ssl/certs`。实测:把探针二进制
# 丢进裸 debian:trixie-slim没有 ca-certificates请求 api.deepseek.com
# 握手正常、返回 401没带 key和装了的镜像行为一致。
# 哪天镜像里加了用 OpenSSL 做 TLS 的东西curl、wget 之类),这条要重新考虑。
#
# apt 走国内镜像源,理由和上面的 npm 源一样deb.debian.org 从服务器上
# `apt-get update` 会卡死(不是慢,是挂住不返回)。
#
# 两个坑:
# 1. trixie 的源是 deb822 格式,在 `/etc/apt/sources.list.d/debian.sources`
# 不是老的 `/etc/apt/sources.list`(那个文件在这个基底里是空的)。
# 2. **必须保持 http不能换成 https** —— 镜像里没有根证书(见上),
# https 源会直接证书校验失败。
#
# 只替换主机名:清华同时提供 /debian 和 /debian-security路径不用动。
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
RUN sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends clang-format \
&& rm -rf /var/lib/apt/lists/*
# ruff 给 Python 代码格式化用。旧后端是 pip 装的 ruff==0.15.12,这里取同版本的
# 官方静态二进制 —— 不为它引入整个 Python 运行时。
COPY --from=ghcr.io/astral-sh/ruff:0.15.12 /ruff /usr/local/bin/ruff
COPY --from=artifacts /artifacts/oj2-api /usr/local/bin/oj2-api
# 迁移文件**不内嵌进二进制**随镜像装在这个固定路径下apps/api/src/runtime.ts
# 的 migrationsDir 写死的就是它)。这样 drizzle 的 migrate() 能原样用 —— 它靠
# meta/_journal.json 自动发现迁移,新增迁移不用改任何代码。内嵌的话得为每条迁移
# 手写一行 import那是迟早会漏的账。
#
# 只拷 .sql 和 journal不拷 meta/*_snapshot.json那是 drizzle-kit generate 用的,
# 运行时不读,几百 KB 没必要进镜像)。
COPY apps/api/src/db/*.sql /usr/local/share/oj2/migrations/
COPY apps/api/src/db/meta/_journal.json /usr/local/share/oj2/migrations/meta/
# /data 下是要持久化的东西:测试用例、上传的图片、头像。
# 相对路径按 cwd 解析(见 apps/api/src/runtime.ts所以 workdir 设成 /data
# 但下面的路径全写绝对值,不靠这个巧合。
#
# 目录布局**照抄旧后端**test_case、public/upload、public/avatar
# 这不是审美问题:切换那天不用搬动任何文件,回滚时旧后端也立刻能找到自己的数据。
# 少一次几十 GB 的 mv就少一个在停机窗口里出错的机会。
WORKDIR /data
ENV TEST_CASE_DIRECTORY=/data/test_case \
HITOKOTO_DIRECTORY=/data/hitokoto \
UPLOAD_DIRECTORY=/data/public/upload \
AVATAR_DIRECTORY=/data/public/avatar \
PORT=3000
EXPOSE 3000
# 默认起 HTTP 服务worker 容器在 compose 里把 command 换成 ["oj2-api","worker"]
CMD ["oj2-api", "serve"]
# ---------------------------------------------------------------- 前端 + 反代
FROM caddy:2-alpine AS web
COPY --from=artifacts /artifacts/web /srv
COPY docker/Caddyfile /etc/caddy/Caddyfile
EXPOSE 8000