主题
CI/CD 与生产部署运维
本章目标:
- 看懂 a-cdm 这条"push → MR → CI 绿 → 合 main → 60 秒自动部署"的全自动流水线由哪些零件拼成
- 弄清 CD 为什么走"172 cron 拉模式"而不是"runner ssh 推模式",以及 cd-poll.sh 如何按 diff 路径选择性部署
- 掌握生产 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 HEAD 与 origin/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):
- 生产机建一个权限限死的
acdm-ci-deploy账号(sudo 只允许 docker / rsync) - 它的 SSH 公钥作 GitLab 项目 Deploy Key(read-only),cron 里
git fetch origin main用它拉代码 - 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/Dockerfile | deploy/ci-base-image/Dockerfile:25-129 |
cd-poll.sh | 生产 cron 拉模式部署器,diff 路径 → rsync → compose build/up → health check | deploy/ci-deploy-account/cd-poll.sh | deploy/ci-deploy-account/cd-poll.sh:120-282 |
acdm-ci-deploy 账号 | CD 专用账号,sudo 限死 docker/rsync,独立 SSH key | deploy/ci-deploy-account/MANUAL.md | deploy/ci-deploy-account/MANUAL.md:30-48 |
| platform compose | 基础设施(Caddy 网关 + Postgres),建 platform-net 外部网 | deploy/prod/platform/compose.yml | deploy/prod/platform/compose.yml:21-69 |
| 各 app compose | acdm-backend / acdm-frontend / deer-flow-backend 三栈,挂 platform-net | deploy/prod/*/compose.yml | deploy/prod/acdm-backend/compose.yml:17-49 |
deploy.ps1 | 本地 PowerShell 应急部署(打 tar→scp→ssh build),绕过 CI | deploy.ps1 | deploy.ps1:38-72 |
| Docker prune cron | 每周清 dangling image + build cache,保护 PG 数据卷 | deploy/prod/host-setup/acdm-docker-prune.sh | deploy/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:test 跑 pytest -m "not db"(不碰真 DB,靠注入 dummy DATABASE_URL/SECRET_KEY/KEYCLOAK_CLIENT_SECRET 让 Settings() 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 .venv 后 uv sync --frozen 见 venv 存在,只 reinstall workspace member deerflow-harness 到实际源码路径(秒级)(.gitlab-ci.yml:306-312)。
已知债务 — FIXME 临时补装: .backend-base 的 before_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-db 因 extends 对 before_script 是覆盖非追加,这串补装必须复制一份(.gitlab-ci.yml:209-219)。
CD:cd-poll.sh 拉模式部署器
职责: 每分钟检测 main 新 commit,按 diff 路径自动部署对应 app。
关键流程(deploy/ci-deploy-account/cd-poll.sh):
flock -n 9抢锁(:33-34)——前一轮没跑完时下一轮直接exit 0,避免叠加git fetch origin main→ 比对git rev-parse HEADvsorigin/main(:123-130),相等就exit 0CHANGED=$(git diff --name-only "$LOCAL" "$REMOTE")+git reset --hard origin/main(:135-138)- 对每个 app 用
grep -qE "^acdm-backend/"之类判定是否命中(:141),命中才进该 app 部署块 sudo rsync -a --delete(排除.venv/.env/__pycache__)同步源码到/data/apps/<app>/(:145-147)docker compose build --build-arg BUILD_HASH="$REMOTE"+docker compose up -d(:153)- 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/appcd-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 必须重 build | Next.js basePath 是 build-time 静态替换,烘焙进所有 client bundle,Caddy rewrite 全失败 |
| 3 | rebuild 前必须 docker compose stop 腾内存 | 8GB VM 多任务并发 OOM(下节详述) |
| 4 | Caddyfile 用 restart 不用 reload | bind mount 按 inode 挂,scp 换 inode,reload 读不到新内容 |
| 5 | bind mount ≠ 进程读到的代码 | deer-flow compose 只 mount config.yaml/skills,backend/app 是打进 image 的,改了必须 build 不能只 restart |
| 6 | deploy.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 矩阵
| Job | stage | 触发路径 | 命令 | 硬卡 | Source |
|---|---|---|---|---|---|
backend:lint | lint | acdm-backend/** | ruff check app/ | ✓ | .gitlab-ci.yml:97-101 |
backend:alembic-single-head | lint | acdm-backend/alembic/versions/** | grep 构图断言 head==1 | ✓ | .gitlab-ci.yml:111-154 |
backend:test | test | acdm-backend/** | pytest -m "not db" | ✓ | .gitlab-ci.yml:156-183 |
backend:test-db | test | acdm-backend/** | Postgres svc + pytest -m "db" | ✓ | .gitlab-ci.yml:185-233 |
frontend:check | lint | deer-flow/frontend/** | pnpm check(eslint+tsc) | ✓ | .gitlab-ci.yml:239-264 |
deerflow-backend:import-check | lint | deer-flow/backend/**+config.yaml | import app.gateway.app | ✓ | .gitlab-ci.yml:283-331 |
docs:ok | lint | docs/**/openspec/**/deploy/** | echo(empty pipeline 规避) | - | .gitlab-ci.yml:344-364 |
demoo-service:ok | lint | demoo_service/** | py_compile + 文件存在断言 | ✓ | .gitlab-ci.yml:371-386 |
cd-poll.sh 部署 app 矩阵
| App | 触发路径(grep) | rsync 目标 | health check | Source |
|---|---|---|---|---|
| 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 |
|---|---|---|---|---|
| platform | platform-gateway / platform-db | caddy:2-alpine / postgres:15-alpine | 建 platform-net | deploy/prod/platform/compose.yml:21-69 |
| acdm-backend | acdm-backend | 自建(python:3.11-slim) | 挂 platform-net | deploy/prod/acdm-backend/compose.yml:17-49 |
| acdm-frontend | acdm-frontend | 自建(Next.js standalone) | 挂 platform-net | deploy/prod/acdm-frontend/compose.yml:7-33 |
| deer-flow-backend | deer-flow-gateway / -langgraph / -redis | 自建 + redis:7-alpine | 挂 platform-net | deploy/prod/deer-flow-backend/compose.yml:24-137 |
Configuration
| Config | 默认值 | 含义 | 影响 | Source |
|---|---|---|---|---|
BASE_IMAGE | registry.r7.../ci-base:v2.2 | CI 所有 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_BACKEND | langgraph | deer-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 sudoers | NOPASSWD: docker / rsync | CD 账号权限边界 | 不能动 systemd/reboot | deploy/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/arm64build 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 SOPacdm-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 诊断三态 + 三道内存护栏
Related Pages
| Page | Relationship |
|---|---|
| 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 本地栈 |