Skip to content

CI/CD 与生产部署运维

本章目标:

  1. 看懂 a-cdm 这条"push → MR → CI 绿 → 合 main → 60 秒自动部署"的全自动流水线由哪些零件拼成
  2. 弄清 CD 为什么走"172 cron 拉模式"而不是"runner ssh 推模式",以及 cd-poll.sh 如何按 diff 路径选择性部署
  3. 掌握生产 8GB ARM64 VM 的 OOM 铁律、BUILD_HASH 缓存机制、deploy.ps1 应急回退边界

TL;DR

a-cdm 的发布链是单向拉模式:K8s runner(team-ai namespace,ARM64)与生产 172.20.21.28 网络不互通,所以 CI 只做 lint/test(.gitlab-ci.yml),不做部署;部署交给生产机上 acdm-ci-deploy 账号的 cron 每分钟跑一次 cd-poll.sh——比对 git rev-parse HEADorigin/main,有新 commit 就按 git diff --name-only 命中的路径决定重建哪个 app(acdm-backend / acdm-frontend / deer-flow-backend / demoo_service)。CI 靠一张预装全部依赖的 ci-base 镜像把单次 pipeline 从 20min 压到 1-2min 且脱离外网 PyPI。生产是 8GB ARM64 VM,核心约束是 6 条部署铁律(rebuild 前 stop 腾内存、改 basePath 必重 build、Caddyfile 用 restart 不用 reload 等),build 加速靠 BUILD_HASH=$commit_sha 让 COPY 层精确失效而 pip/pnpm 层 cache 命中。

Overview(为什么 CD 是"拉"不是"推")

部署自动化最直觉的做法是推模式:CI runner 跑完测试,直接 ssh deploy@prod 'docker compose up'。a-cdm 一开始也想这么做(MANUAL.md 里的"方案 A"),但撞了一堵墙:

GitLab Kubernetes Runner 跑在 team-ai namespace,与生产 172.20.21.28 网络不互通——runner 既 ssh 不到生产,连 ssh-keyscan 172 都失败。早期那个 deploy:smoke job 每次 main push 都因 keyscan 失败挂掉,反而挡住了 merge 门槛(.gitlab-ci.yml:279-281)。

推不通,就反过来让生产主动拉(方案 B):

  1. 生产机建一个权限限死的 acdm-ci-deploy 账号(sudo 只允许 docker / rsync)
  2. 它的 SSH 公钥作 GitLab 项目 Deploy Key(read-only),cron 里 git fetch origin main 用它拉代码
  3. cron 每分钟跑 cd-poll.sh,本地 HEAD ≠ origin/main 就部署

这套设计的代价是最多 60 秒延迟(cron 周期),收益是:零网络打洞、生产侧完全自治、CI 彻底无状态(挂了也不影响已合并代码的部署)。

Architecture

整条流水线分两段:CI 段(GitLab 侧,只验不部)与 CD 段(生产侧,只部不验)。两段唯一耦合点是 main 分支的 commit SHA。

组件职责入口文件Source
.gitlab-ci.yml定义 lint/test 两 stage,按 rules.changes 路径选择性触发 job.gitlab-ci.yml.gitlab-ci.yml:25-28
ci-base 镜像预装 Python 3.11+3.12 / Node 22 / pnpm / uv + 全部项目依赖,CI 脱离外网deploy/ci-base-image/Dockerfiledeploy/ci-base-image/Dockerfile:25-129
cd-poll.sh生产 cron 拉模式部署器,diff 路径 → rsync → compose build/up → health checkdeploy/ci-deploy-account/cd-poll.shdeploy/ci-deploy-account/cd-poll.sh:120-282
acdm-ci-deploy 账号CD 专用账号,sudo 限死 docker/rsync,独立 SSH keydeploy/ci-deploy-account/MANUAL.mddeploy/ci-deploy-account/MANUAL.md:30-48
platform compose基础设施(Caddy 网关 + Postgres),建 platform-net 外部网deploy/prod/platform/compose.ymldeploy/prod/platform/compose.yml:21-69
各 app composeacdm-backend / acdm-frontend / deer-flow-backend 三栈,挂 platform-netdeploy/prod/*/compose.ymldeploy/prod/acdm-backend/compose.yml:17-49
deploy.ps1本地 PowerShell 应急部署(打 tar→scp→ssh build),绕过 CIdeploy.ps1deploy.ps1:38-72
Docker prune cron每周清 dangling image + build cache,保护 PG 数据卷deploy/prod/host-setup/acdm-docker-prune.shdeploy/prod/host-setup/cron-acdm-docker-prune:7

Components / Subsystems

CI:.gitlab-ci.yml 选择性触发

职责: 两个 stage(lint / test),用 rules.changes 让每个 job 只在相关文件改动时跑。

关键设计 1 — compare_to 固定 main: rules.changes 默认用 compare_to(MR 触发对比 target),但分支首次 push 时 CI_COMMIT_BEFORE_SHA=0000...,changes 会把所有文件视为 changed 误触发全部 job。所以每个 job 显式写 compare_to: 'refs/heads/main'(.gitlab-ci.yml:55-56),让 MR 触发和分支 push 触发两种场景都正确。

关键设计 2 — empty pipeline 死锁规避: 项目设了 only_allow_merge_if_pipeline_succeeds=true。纯 docs MR 若不命中任何 job,GitLab 直接拒绝创建 pipeline(empty pipeline),MR 卡死。为此专设 docs:ok job 覆盖纯文档/部署模板路径(.gitlab-ci.yml:344-364),demoo-service:ok 覆盖 demoo_service 路径(.gitlab-ci.yml:371-386)——都只 echo 一句,秒过,但保证有可过的 pipeline。

关键设计 3 — alembic 单 head 离线检测: backend:alembic-single-head(.gitlab-ci.yml:111-154)纯 grep migration 文件构图,断言 head == 1。根因:不同 MR 同时把新 migration 指向同一 down_revision,git 不冲突,但 main rebuild 时 alembic upgrade head 报 Multiple head revisions 导致 acdm-backend restart loop。普通 backend:test-db 抓不到——每个 MR 从空 PG 跑全链自己看到的是单 head,双 head 只在 main rebuild 才暴露。

关键设计 4 — db marker 拆分: backend:testpytest -m "not db"(不碰真 DB,靠注入 dummy DATABASE_URL/SECRET_KEY/KEYCLOAK_CLIENT_SECRETSettings() eager 初始化过关,.gitlab-ci.yml:36-49);backend:test-db 带真 Postgres service(registry.r7.../postgres:16-alpine mirror),用 alembic upgrade head 建 schema 再跑 -m "db"(.gitlab-ci.yml:185-233)。

CI base 镜像:依赖前置打包

职责: 一张 ARM64 Debian 12 镜像,预装全部工具链 + 项目依赖,让 CI 完全不依赖外网 PyPI/npm。

关键类/构建逻辑: deploy/ci-base-image/Dockerfile

演进史(写在 .gitlab-ci.yml:10-16 与 Dockerfile 注释):

  • v1.0(2026-04-20): 只预装工具(python/node),CI 里仍 pip install -r requirements.txt——一旦 K8s runner DNS 波动解析不到 pypi.tuna 就挂
  • v2.0: 把 acdm-backend 全部依赖打进系统 site-packages,backend:test 从 3min → <1min
  • v2.2(2026-04-22): 增装 Python 3.12 + deer-flow backend 全量 deps 到 /opt/deerflow-venv,deerflow-backend:import-check 从 30min timeout → <1min

巧妙处 — deer-flow venv 链接复用: ci-base build 时 uv sync --frozen 生成 venv 后 mv .venv /opt/deerflow-venv(deploy/ci-base-image/Dockerfile:111-116);CI job 里 ln -sfn /opt/deerflow-venv .venvuv sync --frozen 见 venv 存在,只 reinstall workspace member deerflow-harness 到实际源码路径(秒级)(.gitlab-ci.yml:306-312)。

已知债务 — FIXME 临时补装: .backend-basebefore_script 有一长串 python -c "import X" || pip install X(.gitlab-ci.yml:65-95)——这些包(openai/bs4/mcp/slowapi/langgraph-sdk 等)在 requirements.txt 加入时 v2.2 镜像已 build 完没赶上,临时补装,正解是 rebuild ci-base v2.3。注意 backend:test-dbextendsbefore_script 是覆盖非追加,这串补装必须复制一份(.gitlab-ci.yml:209-219)。

CD:cd-poll.sh 拉模式部署器

职责: 每分钟检测 main 新 commit,按 diff 路径自动部署对应 app。

关键流程(deploy/ci-deploy-account/cd-poll.sh):

  1. flock -n 9 抢锁(:33-34)——前一轮没跑完时下一轮直接 exit 0,避免叠加
  2. git fetch origin main → 比对 git rev-parse HEAD vs origin/main(:123-130),相等就 exit 0
  3. CHANGED=$(git diff --name-only "$LOCAL" "$REMOTE") + git reset --hard origin/main(:135-138)
  4. 对每个 app 用 grep -qE "^acdm-backend/" 之类判定是否命中(:141),命中才进该 app 部署块
  5. sudo rsync -a --delete(排除 .venv/.env/__pycache__)同步源码到 /data/apps/<app>/(:145-147)
  6. docker compose build --build-arg BUILD_HASH="$REMOTE" + docker compose up -d(:153)
  7. health check(acdm-backend 探 :8002/health,deer-flow 探 gateway :8001/health)

关键设计 — set -o pipefail: 脚本第 18 行特意 set -o pipefail(:18),否则 docker compose up -d | tail 这种 pipeline 即使 container create 失败,因 tail 退 0 也会误打 DEPLOY OK(2026-04-23 demoo_service 首次部署踩过)。

关键设计 — 触发条件含部署模板: acdm-frontend 的触发条件是 ^(deer-flow/frontend/|deploy/prod/acdm-frontend/)(:172)——后者覆盖 Dockerfile/compose.yml,改它必须重 build/up 否则编辑等于无效(2026-04-29 教训)。deer-flow-backend 同理覆盖 deploy/prod/deer-flow-backend/(:199)。

GitLab Deployment API 反馈(非阻塞)

cd-poll.sh 内有 gitlab_create_deployment / gitlab_update_deployment(:66-118),每个 app 部署前 POST 建 production-<app> environment 的 deployment(status=running),成功/失败再 PUT 更新——这就是 MR 页面 "Deployed to production-acdm-backend" 徽章的来源。

全程非阻塞: token 缺失/API 失败只打 WARN,不阻塞部署。gitlab_token_check_revoked(:46-57)首次探测到 invalid_token/Token was revoked 就置 GITLAB_TOKEN_VALID=0 跳过本轮后续调用(避免每 app 重复打噪音);cron 下一轮 fresh bash 进程自动重置,运维 rotate 新 token 后 ≤60s 自动生效,无需改脚本。

一个隐蔽 bug 的修复痕迹: gitlab_create_deployment 内所有 log 性 echo 必须 >&2,因为 caller 用 dep_id=$(gitlab_create_deployment ...) 捕获 stdout。只有末尾 echo "$id" 是真返回值;其余 echo 不加 >&2 会污染 dep_id 变成 multi-line,后续拼 URL 时带 \n 报 "bad range in URL"(:59-65 注释,2026-04-22 修复)。

Data Flow / 一次完整发布

Implementation Details

BUILD_HASH:精确缓存失效

最值得讲的算法是 BUILD_HASH 这招(2026-05-08 L95 引入)。问题:cd-poll 同步源码后 docker compose build 想让"源码变了就重 COPY"但"依赖没变就复用 pip 层"。但 BuildKit 的 COPY layer cache 可能 silent miss(L91)——源码改了 cache 却命中,跑的还是老代码。

解法:Dockerfile 在 pip 层之后、COPY 源码之前插一个 ARG:

dockerfile
# 摘自 acdm-backend/Dockerfile:14-22
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
ARG BUILD_HASH=unset
RUN echo "build hash: $BUILD_HASH" > /app/.build-hash
COPY app /app/app

cd-poll.sh--build-arg BUILD_HASH=$REMOTE(commit SHA)。commit 一变,ARG BUILD_HASH 值变 → 那条 RUN echo 层失效 → 后续所有 COPY 强制重拉新 src;而 requirements.txt 没变时上面 pip install 层 cache 仍命中。效果:build 从 12min → 2-3min(deploy/ci-deploy-account/cd-poll.sh:150-153)。四个 app(acdm-backend/frontend/deer-flow-backend/demoo_service)的 Dockerfile 都加了这个 ARG。

部署 6 条铁律

每条都踩过,出自 docs/runbooks/deploy-topology.md:115-122:

#铁律根因
1先部署上游容器,再改 Caddyfile上游没起就切路由瞬间 502 白屏
2改 basePath 必须重 buildNext.js basePath 是 build-time 静态替换,烘焙进所有 client bundle,Caddy rewrite 全失败
3rebuild 前必须 docker compose stop 腾内存8GB VM 多任务并发 OOM(下节详述)
4Caddyfile 用 restart 不用 reloadbind mount 按 inode 挂,scp 换 inode,reload 读不到新内容
5bind mount ≠ 进程读到的代码deer-flow compose 只 mount config.yaml/skills,backend/app 是打进 image 的,改了必须 build 不能只 restart
6deploy.ps1 <single> 不同步其他服务跨多服务改动用 deploy.ps1 all + 跑 check-deployment.sh

OOM 诊断三态

生产是 8GB ARM64 VM(2026-04-21 由 4GB 扩容)。遇异常先别重启 VM,按症状判断(docs/runbooks/deploy-vm.md:74-78):

现象含义处置
SSH banner timeout + ping 通OS 级 OOM 颠簸(sshd 活但 fork 慢到超时)等 OOM-killer 自救 5-30min,强制重启
SSH refused + ping 通sshd 挂了(罕见)云控制台重启 sshd/VM
SSH refused + ping 不通VM 整个挂了云控制台强制重启

三道护栏(8GB 下降级为 fallback 保护):2GB swap、Next.js NODE_OPTIONS=--max-old-space-size=1536 限堆、build 前临停 deer-flow ×2 腾 ~400MB(deploy.ps1:182-199 自动做)。

速查表

CI Job 矩阵

Jobstage触发路径命令硬卡Source
backend:lintlintacdm-backend/**ruff check app/.gitlab-ci.yml:97-101
backend:alembic-single-headlintacdm-backend/alembic/versions/**grep 构图断言 head==1.gitlab-ci.yml:111-154
backend:testtestacdm-backend/**pytest -m "not db".gitlab-ci.yml:156-183
backend:test-dbtestacdm-backend/**Postgres svc + pytest -m "db".gitlab-ci.yml:185-233
frontend:checklintdeer-flow/frontend/**pnpm check(eslint+tsc).gitlab-ci.yml:239-264
deerflow-backend:import-checklintdeer-flow/backend/**+config.yamlimport app.gateway.app.gitlab-ci.yml:283-331
docs:oklintdocs/**/openspec/**/deploy/**echo(empty pipeline 规避)-.gitlab-ci.yml:344-364
demoo-service:oklintdemoo_service/**py_compile + 文件存在断言.gitlab-ci.yml:371-386

cd-poll.sh 部署 app 矩阵

App触发路径(grep)rsync 目标health checkSource
acdm-backend^acdm-backend//data/apps/acdm-backend/:8002/health(8s 后)cd-poll.sh:141-167
acdm-frontend^(deer-flow/frontend/|deploy/prod/acdm-frontend/)/data/apps/acdm-frontend/frontend/无(Next.js ~30s 起,不探)cd-poll.sh:172-194
deer-flow-backend^(deer-flow/(backend/|skills/|config.yaml|extensions_config.json)|deploy/prod/deer-flow-backend/)/data/apps/deer-flow-backend/deer-flow/backend/gateway :8001/health(warn-only)cd-poll.sh:199-228
demoo_service^demoo_service//data/apps/demoo_service/:8088/api/proxy-rec/status(轮询 60s)cd-poll.sh:231-280

容器编排速查(platform-net 拓扑)

compose容器镜像网络Source
platformplatform-gateway / platform-dbcaddy:2-alpine / postgres:15-alpineplatform-netdeploy/prod/platform/compose.yml:21-69
acdm-backendacdm-backend自建(python:3.11-slim)挂 platform-netdeploy/prod/acdm-backend/compose.yml:17-49
acdm-frontendacdm-frontend自建(Next.js standalone)挂 platform-netdeploy/prod/acdm-frontend/compose.yml:7-33
deer-flow-backenddeer-flow-gateway / -langgraph / -redis自建 + redis:7-alpine挂 platform-netdeploy/prod/deer-flow-backend/compose.yml:24-137

Configuration

Config默认值含义影响Source
BASE_IMAGEregistry.r7.../ci-base:v2.2CI 所有 job 共用基础镜像requirements 改了需 rebuild + 升 tag.gitlab-ci.yml:19
BUILD_HASH$REMOTE(commit SHA)Dockerfile ARG,精确失效 COPY 层commit 变则 src 必重 COPY,pip 层 cache 保留acdm-backend/Dockerfile:19
cron 周期每分钟cd-poll 检测 main 频率部署延迟上限 = 60 秒deploy/ci-cd.md:106
RUNTIME_BACKENDlanggraphdeer-flow-langgraph 运行时(langgraph/aegra)aegra 切换触发一次性 thread 清零deploy/prod/deer-flow-backend/compose.yml:84-114
Docker prune cron周日 UTC 19:00清 dangling image + build cache--volumes=false 保护 PG 卷,until=24h 防误删今日镜像deploy/prod/host-setup/cron-acdm-docker-prune:7
acdm-ci-deploy sudoersNOPASSWD: docker / rsyncCD 账号权限边界不能动 systemd/rebootdeploy/ci-deploy-account/MANUAL.md:38-43

安全相关: acdm-ci-deploy 的 SSH 私钥唯一拷贝在生产 home + GitLab CI/CD variables(Protected),本地与 git 历史绝不留(deploy/ci-deploy-account/MANUAL.md:103-125)。.env 凭据靠 sudo rsync ... --exclude='.env' 永不被同步覆盖(cd-poll.sh:146),生产 .env 手工 scp 维护。

Common Pitfalls / 实战 Tips

  • ssh 上去手改 /data/apps/ 会被覆盖: cd-poll 每轮 git reset --hard origin/main + rsync --delete,手改下一分钟没。必须走 MR 回 main(docs/runbooks/ci-cd.md:180)。
  • deploy.ps1 应急后必须补 MR: deploy.ps1 绕过 CI,部完事后必须把同样改动走一遍 MR 同步 main,否则下次 cron pull 覆盖(docs/runbooks/ci-cd.md:207)。
  • cd-poll.sh 不能热替换自己: 它是 canonical in repo,改动合 main 后 cron 会拉到 a-cdm-repo/不会自动覆盖 /home/acdm-ci-deploy/cd-poll.sh——必须 ssh 手动覆盖(cron 正用旧版跑时改它会出诡异行为,deploy/ci-deploy-account/MANUAL.md:131-160)。
  • 加新 Python 依赖 CI 必红: ci-base 没预装新包。要么通知技术组 rebuild ci-base v2.x+1 升 tag,要么先用 .backend-base 那串 FIXME 临时补装兜底(docs/runbooks/ci-cd.md:182-193)。
  • alembic 双 head 修复 SOP: backend:alembic-single-head 红 → 新建 empty merge migration,down_revision=(head1,head2),upgrade/downgrade 都 pass,push 即恢复(.gitlab-ci.yml:149-153)。
  • 不要同时 build 两个前端容器: 内存峰值叠加必挂(docs/runbooks/deploy-vm.md:94)。
  • 跨架构 build 报 MODULE_NOT_FOUND: QEMU --platform=linux/arm64 build deer-flow 前端会炸 Turbopack native,ARM64 build 必须服务器原生跑(docs/runbooks/deploy-vm.md:93)。

References

  • .gitlab-ci.yml:25-387 — CI 两 stage 全部 job 定义、rules.changes 路由、db marker 拆分、alembic 单 head 检测(本章主源)
  • deploy/ci-deploy-account/cd-poll.sh:1-282 — 生产 cron 拉模式部署器(diff→rsync→build→up→health→GitLab API 反馈)
  • deploy/ci-base-image/Dockerfile:25-129 — CI base 镜像构建(Python 3.11+3.12/Node 22/uv + 依赖前置打包 + venv 复用)
  • deploy/ci-deploy-account/MANUAL.md:1-332 — acdm-ci-deploy 账号建立、SSH key、GitLab Deployment token rotate SOP
  • acdm-backend/Dockerfile:14-32 — BUILD_HASH 精确缓存失效机制 + 分层缓存
  • deploy/prod/platform/compose.yml:1-69 — 平台基础设施(Caddy 网关 + Postgres + platform-net)
  • deploy/prod/deer-flow-backend/compose.yml:1-137 — 三容器栈(gateway/langgraph/redis)+ RUNTIME_BACKEND 切换
  • deploy.ps1:1-235 — 本地 PowerShell 应急部署(tar→scp→ssh build,含 OOM 防护)
  • docs/runbooks/deploy-topology.md:115-122 — 部署 6 条铁律
  • docs/runbooks/deploy-vm.md:70-95 — OOM 诊断三态 + 三道内存护栏
PageRelationship
Caddy 网关与生产路由拓扑本章 platform-gateway 容器的 Caddyfile 路由由该章详解;铁律 1/4 涉及 Caddyfile 切换
请求生命周期与服务拓扑本章部署的 8 容器 + platform-net 拓扑在该章描述运行时请求路径
Aegra 运行时与 LangGraph本章 deer-flow-backend compose 的 RUNTIME_BACKEND 切换机制该章详解
环境变量凭据与降级开关本章 rsync --exclude=.env、CI dummy 凭据注入与该章凭据体系互引
OpenSpec 规格治理与测试策略本章 CI 跑的 pytest/ruff 测试策略与覆盖率门槛在该章定义
本地开发环境搭建本章生产 compose 对应该章的 docker-compose.dev.yml 本地栈

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析