diff --git a/.dockerignore b/.dockerignore index 810c59a..5b3eba3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,3 +27,14 @@ docker/.env.* # 前端 dist 由构建阶段自己生成,本地那份不要带进去污染 apps/web/dist/ + +# —— 例外:ARTIFACTS=prebuilt 时,产物本身就是构建输入 —— +# +# 这几条必须放在文件最后:dockerignore 是**最后一条匹配的规则说了算**, +# 写在上面那些 dist 规则前面等于没写。 +# +# ARTIFACTS=build(默认,服务器手动部署走的那条)时它们只是让上下文多传 +# 几十 MB,镜像里用不到;本地根本没编过的话这两个路径不存在,也不影响。 +!dist/oj2-api +!apps/web/dist +!apps/web/dist/** diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..890fc00 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,99 @@ +# 在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。 +# +# 为什么产物在这里编:服务器性能差,镜像 builder 阶段(1238 个包的 bun install + +# bun compile + vite build)首次约 5 分钟,光前端就 160s。runner 编完传过去, +# 服务器那边的镜像构建只剩几条 COPY。 +# +# 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的 +# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖这个工作流。 +# +# 触发的是 push 到 **github** 这个 remote(origin 是 git.xuyue.cc,平时那次 +# push 不会触发): +# +# git push github main +# +# 仓库 Settings → Secrets 需要两个: +# HOST 服务器地址 +# KEY 能以 root 登录该服务器的私钥(完整 PEM,含首尾 BEGIN/END 行) +name: Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +# 两次 push 挨得近时,别让两个 deploy.sh 在服务器上同时起栈。 +concurrency: + group: deploy + cancel-in-progress: false + +env: + REMOTE_PORT: 22 + REMOTE_DIR: /root/OJDeploy/OJ2 + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # 依赖装到 ~/.bun/install/cache,命中的话 install 只剩几秒 + - uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - name: 装依赖 + run: bun install --frozen-lockfile + + # 单二进制,--target=bun-linux-x64(runner 就是 linux-x64,和服务器一致)。 + # 输出到 dist/oj2-api,正是 Dockerfile 里 artifacts-prebuilt 阶段要的位置。 + - name: 编后端 + run: bun run --filter '@oj2/api' build + + # 默认 production 模式,读 apps/web/.env.production(在版本库里)。 + - name: 编前端 + run: cd apps/web && bun run build + + - name: 配置 SSH + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "${{ secrets.KEY }}" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + + # 源码和产物一起传。dist/oj2-api 有 77MB,但 rsync 对已存在的文件做增量, + # 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。 + # + # --delete 的排除项少一个就会出事,逐条说明: + # docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。 + # 删了下一步 deploy.sh 自检就直接 die。 + # data/ 测试点、题面图片、学生上传的文件,还有「自带数据」形态下的 + # 整个 postgres 数据目录。删了就是删库。 + # node_modules 服务器上根本不需要(prebuilt 形态下连编都不编),传过去纯浪费。 + # .git checkout 出来的 .git 没有任何用处。 + - name: 同步到服务器 + run: | + rsync -az --delete \ + -e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT" \ + --exclude node_modules \ + --exclude .git \ + --exclude data \ + --exclude 'docker/.env*' \ + ./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" + + # deploy.sh 自检不过 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出, + # 这一步跟着红,日志里能看到具体是哪一条。 + - name: 起栈 + run: | + ssh -i ~/.ssh/id_deploy -p "$REMOTE_PORT" "root@${{ secrets.HOST }}" \ + "cd $REMOTE_DIR && docker/deploy.sh --prebuilt" diff --git a/apps/web/.github/workflows/deploy.yml b/apps/web/.github/workflows/deploy.yml deleted file mode 100644 index 79cfa2c..0000000 --- a/apps/web/.github/workflows/deploy.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy - -on: - push: - branches: - - main - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - name: debian - build_command: build - remote_port: 22 - target: /root/OJDeploy/data/clientnext - - name: school - build_command: build:staging - remote_port: 8822 - target: /root/OJ/data/dist - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: npm - - run: npm install - - run: npm run ${{ matrix.build_command }} - env: - CI: false - - - uses: easingthemes/ssh-deploy@main - with: - SSH_PRIVATE_KEY: ${{ secrets.KEY }} - REMOTE_HOST: ${{ secrets.HOST }} - REMOTE_PORT: ${{ matrix.remote_port }} - ARGS: "-avzr --delete" - SOURCE: dist/ - REMOTE_USER: root - TARGET: ${{ matrix.target }} \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 52c58e1..acb9584 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,6 +6,33 @@ # 构建上下文是**仓库根**,不是 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 要维护。 @@ -46,6 +73,26 @@ RUN bun build --compile --target=bun-linux-x64 apps/api/src/main.ts --outfile /b # 一次构建 = 一个版本,切换那天不会出现「后端换了前端忘了拷」这种半新半旧状态。 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 里写死的导入)。 @@ -80,7 +127,7 @@ RUN sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sou # 官方静态二进制 —— 不为它引入整个 Python 运行时。 COPY --from=ghcr.io/astral-sh/ruff:0.15.12 /ruff /usr/local/bin/ruff -COPY --from=builder /build/oj2-api /usr/local/bin/oj2-api +COPY --from=artifacts /artifacts/oj2-api /usr/local/bin/oj2-api # /data 下是要持久化的东西:测试用例、上传的图片、头像。 # 相对路径按 cwd 解析(见 apps/api/src/runtime.ts),所以 workdir 设成 /data; @@ -102,6 +149,6 @@ CMD ["oj2-api", "serve"] # ---------------------------------------------------------------- 前端 + 反代 FROM caddy:2-alpine AS web -COPY --from=builder /build/apps/web/dist /srv +COPY --from=artifacts /artifacts/web /srv COPY docker/Caddyfile /etc/caddy/Caddyfile EXPOSE 8000 diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml index 790af3e..5d34442 100644 --- a/docker/compose.debian.yml +++ b/docker/compose.debian.yml @@ -104,6 +104,10 @@ services: context: .. dockerfile: docker/Dockerfile target: api + # 产物来源,见 docker/Dockerfile 顶部。默认 build(服务器自己编); + # CI 传 ARTIFACTS=prebuilt 用 runner 编好的产物。 + args: + ARTIFACTS: ${ARTIFACTS:-build} image: oj2-api:latest container_name: oj-api restart: always @@ -166,6 +170,8 @@ services: context: .. dockerfile: docker/Dockerfile target: web + args: + ARTIFACTS: ${ARTIFACTS:-build} image: oj2-web:latest container_name: oj-web restart: always diff --git a/docker/compose.school.yml b/docker/compose.school.yml index fe448b7..9b09746 100644 --- a/docker/compose.school.yml +++ b/docker/compose.school.yml @@ -59,6 +59,10 @@ services: context: .. dockerfile: docker/Dockerfile target: api + # 产物来源,见 docker/Dockerfile 顶部。默认 build(服务器自己编); + # CI 传 ARTIFACTS=prebuilt 用 runner 编好的产物。 + args: + ARTIFACTS: ${ARTIFACTS:-build} image: oj2-api:latest container_name: oj-api restart: always @@ -105,6 +109,8 @@ services: context: .. dockerfile: docker/Dockerfile target: web + args: + ARTIFACTS: ${ARTIFACTS:-build} image: oj2-web:latest container_name: oj-web restart: always diff --git a/docker/deploy.sh b/docker/deploy.sh index 22af2eb..9c54cb8 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -7,6 +7,13 @@ # docker/deploy.sh # 自检 → 构建 → 起栈 → 冒烟 # docker/deploy.sh --check # 只自检,不动任何容器 # docker/deploy.sh --no-build # 跳过构建(只改了 env / compose 时用) +# docker/deploy.sh --prebuilt # 用现成产物,不在这台机器上编(CI 走这条) +# +# 默认(不带 --prebuilt)是**服务器自己编**:只要有 docker 就能跑,不依赖 CI, +# 也不依赖别处传产物过来。代价是这台机器性能差,builder 阶段首次约 5 分钟。 +# +# --prebuilt 则要求 dist/oj2-api 和 apps/web/dist 已经在这个目录里(GitHub +# Actions 编好后 rsync 过来的),镜像构建只剩几条 COPY。产物缺失会在自检就拦下。 # # 代码怎么上到服务器不归它管。没有 git remote 的话,在本机: # @@ -31,12 +38,15 @@ for arg in "$@"; do case "$arg" in --check) CHECK_ONLY=1 ;; --no-build) BUILD=0 ;; - *) echo "未知参数:$arg(可用:--check、--no-build)" >&2; exit 2 ;; + # compose 里写的是 args: ARTIFACTS: ${ARTIFACTS:-build},导出即可生效 + --prebuilt) export ARTIFACTS=prebuilt ;; + *) echo "未知参数:$arg(可用:--check、--no-build、--prebuilt)" >&2; exit 2 ;; esac done say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; } # ---------------------------------------------------------------- 自检 @@ -52,6 +62,29 @@ ver=$(docker compose version --short 2>/dev/null || echo 0) || die "docker compose 版本太老($ver),需要 ≥ 2.20(depends_on.required 是那个版本才有的字段)" ok "docker compose $ver" +# --prebuilt 时产物就是构建输入。缺了的话 docker 只会甩一句 COPY 找不到文件, +# 这里提前拦下并说清楚该怎么办。 +if [ "${ARTIFACTS:-build}" = prebuilt ]; then + for f in dist/oj2-api apps/web/dist/index.html; do + [ -e "$f" ] || die "--prebuilt 需要现成产物,但 $f 不存在。 + +要么让 CI 把产物 rsync 过来(.github/workflows/deploy.yml), +要么去掉 --prebuilt 让这台机器自己编(慢,但不依赖任何外部环节)。" + done + ok "用现成产物($(du -sh dist/oj2-api apps/web/dist 2>/dev/null | tr '\n' ' '))" + + # 没有 buildx 时 compose 会退回 classic builder(构建日志末尾那行 + # `LABEL com.docker.compose.image.builder=classic` 就是它)。classic **不看 + # 依赖图,所有阶段挨个跑**,于是 builder 照样 bun install + vite build 一遍, + # 产物白编了 —— 结果是对的,只是一分钟没省下来。 + docker buildx version >/dev/null 2>&1 \ + || warn "没装 buildx,compose 会退回老 builder,它不跳过用不到的阶段 + —— 这次 --prebuilt 省不下时间(结果仍然正确)。 + 修:apt-get install docker-buildx-plugin" +else + ok "产物在镜像里现编(首次约 5 分钟;CI 部署走的是 --prebuilt)" +fi + cfg=$("${COMPOSE[@]}" config) || die "compose 配置解析失败,看上面的报错" # ① DATA_DIR:不生效的话卷会挂到 OJ2/data 这个空目录上 —— 站点起得来,