ci(部署): 部署走 GitHub Actions,产物挪到 runner 上编
Some checks failed
Deploy / deploy (push) Has been cancelled

服务器性能差,镜像 builder 阶段(1238 个包的 bun install + bun compile +
vite build)首次约 5 分钟,光前端就 160s。改成在 runner 上编好 dist/oj2-api
和 apps/web/dist,rsync 过去,服务器那边只剩几条 COPY。

服务器自己编的能力没有砍掉。Dockerfile 顶部加了全局 ARG ARTIFACTS:

  build(默认)    builder 阶段自己编,只要有 docker 就能手动部署
  prebuilt         用构建上下文里现成的产物,builder 不进构建图

实现是两个 scratch 阶段归一成 /artifacts/ 布局,再用变量阶段名
FROM artifacts-${ARTIFACTS} 选一个,运行时阶段不关心产物是谁编的。

deploy.sh 加 --prebuilt:产物缺失在自检就 die;顺带检测 buildx —— 没装的话
compose 会退回 classic builder,那个不看依赖图、所有阶段挨个跑,产物白编
(结果正确但不省时间),黄字提醒不阻断。

.dockerignore 末尾放行两个产物路径,必须在 dist/ 那些规则之后,
dockerignore 是最后一条匹配说了算。

顺手删掉 apps/web/.github/ —— 从 ojnext 抄来的残留,路径不对(GitHub 只认
仓库根的 .github/workflows/)从来没跑过,内容也是 ojnext 的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:30:22 -06:00
parent d3b05b8629
commit 6356643cc9
7 changed files with 205 additions and 49 deletions

View File

@@ -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/**

99
.github/workflows/deploy.yml vendored Normal file
View File

@@ -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** 这个 remoteorigin 是 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-x64runner 就是 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"

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.20depends_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 "没装 buildxcompose 会退回老 builder它不跳过用不到的阶段
—— 这次 --prebuilt 省不下时间(结果仍然正确)。
apt-get install docker-buildx-plugin"
else
ok "产物在镜像里现编(首次约 5 分钟CI 部署走的是 --prebuilt"
fi
cfg=$("${COMPOSE[@]}" config) || die "compose 配置解析失败,看上面的报错"
# ① DATA_DIR不生效的话卷会挂到 OJ2/data 这个空目录上 —— 站点起得来,