KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

配套项目 · 一个「被交付物」:board 任务看板 — keel 龙骨

私有化交付工程:把一套系统送进客户内网 的配套项目:配套项目 · 一个「被交付物」:board 任务看板

这个项目不是要替代任何一套生产级的交付方案,也不是能直接卖给客户的产品。
它的身份是被交付物——这门课讲的是「怎么把一套系统送进客户内网」,
所以需要一个足够真、但小到几分钟就能走完全链路的系统:从打一个包开始,
到它在一个没有出口的机器上跑起来,再到升级、回滚、报障为止。

「足够真」不是把功能堆多,而是留下三件真实交付里一定会撞上的事:

  1. 一个镜像,五种角色。 web / worker / migrate / config / doctor 共用同一份代码,
    靠启动命令区分。所以交付包里只有一个应用制品——但**「健康的 web」和「健康的 worker」是两回事**,
    探针也必须因此分成两套。
  2. 启动有依赖顺序。 migrate 必须等数据库能连上,web 与 worker 又必须等 migrate 跑完。
    顺序错的现场不是「慢一点」,是一堆红字,而且不同的错法留下的残局不一样。
  3. 任务比一次重启更长。 worker 处理一个任务要好几秒到几十秒,
    而停机的宽限期是一个可以设小的数。设小了,任务就被砍在中间。

只用 Python 标准库加 psycopg[binary]——app/requirements.txt 里只有这一行依赖。

三层

flowchart TD
    subgraph IMG["① 镜像层:一个镜像,五种角色"]
        SRC["app/board/ 七个源文件"] --> DF["Dockerfile 多阶段 / 非 root / 不带 HEALTHCHECK"]
        DF --> IMAGE["board:版本号"]
    end

    subgraph ORC["② 编排层:同一份镜像,每个角色一套健康与停机定义"]
        IMAGE --> DB["db 只在数据面网络里"]
        DB -->|"service_healthy"| MIG["migrate 跑完就退,退出码是唯一产物"]
        MIG -->|"service_completed_successfully"| WEB["web 唯一发布端口的角色"]
        MIG --> WK["worker 长任务 + 停机窗口"]
    end

    subgraph PKG["③ 交付层 · 打包侧(有外网)"]
        BB["deploy/build-bundle.sh"] --> BUN["board-delivery-版本号.tar.gz"]
    end

    subgraph FLD["③ 交付层 · 现场侧(没有出口)"]
        INS["install.sh"] --> CHK["selfcheck.sh"]
        UPG["upgrade.sh"] --> RBK["rollback.sh"]
    end

    BUN -->|"搬进去"| INS
    IMAGE -.->|"两个镜像都进包"| BUN
    WK -.->|"这两层是包里的内容"| BUN

图里那条从镜像层绕到打包侧的虚线,是这门课最容易被跳过的一步:
跑起来需要的镜像不止应用那一个,数据库镜像也得在包里。

文件

文件 作用 对应章节
app/board/config.py 所有外部输入在一处被显式认领:要么有默认值,要么当场报错 05
app/board/log.py 行分隔 JSON 写 stdout,每条都带 role 与 instance 05、07
app/board/db.py 三条迁移,每条都带上行与下行;可指定目标版本 04、06
app/board/web.py /healthz 不碰数据库,/readyz 才碰 07
app/board/worker.py 领任务、跑任务、把超期的 running 判为孤儿放回 pending 06
app/board/__main__.py 角色分发,以及 config / doctor 两个自述角色 04、05、07
Dockerfile 多阶段构建,非 root,刻意不写 HEALTHCHECK 02
deploy/build-bundle.sh 打离线包:构建、组装、算哈希、打包(跑在有外网那一侧) 01、03
deploy/self-hosted/compose.yml 客户内网版编排:只有 image:、必填变量用 ${VAR:?}、两个网络 02、04、05
deploy/self-hosted/env.example 现场配置模板,密码一项刻意留空 04、05
deploy/self-hosted/install.sh 五步安装,全程幂等、不问任何交互问题 04
deploy/self-hosted/upgrade.sh 备份 → 导入 → 起栈 → 自检,任一步失败自动回滚 06
deploy/self-hosted/rollback.sh 退结构、退镜像、按需恢复数据 06
deploy/self-hosted/selfcheck.sh 只陈述事实,退出码等于失败条数 04、07
deploy/variants/v1.0.0/ 初版的差异文件,让 06 章的升级演练有真实的起点(见下节) 06

打一个包(有外网的那一侧)

cd courses/delivery/private-deployment-engineering/course/project
./deploy/build-bundle.sh 1.1.0

跑完会得到一个 bundle/board-delivery-1.1.0.tar.gz。解开就是客户要的东西:

board-delivery-1.1.0/
  MANIFEST.sha256          每个制品的哈希
  images/
    board__1.1.0.tar       应用镜像
    postgres__16-alpine.tar  数据库镜像 ← 最常见的漏项
  compose.yml  env.example
  install.sh  upgrade.sh  rollback.sh  selfcheck.sh

装一遍(客户内网那一侧)

把包拷进现场,解开,一条命令:

./install.sh

它按固定顺序做五件事,哪一步失败、以及失败时现场处于什么状态,是 04 章的整章内容:

步 动作 失败时现场处于什么状态
1 前置检查:docker / daemon / compose v2 / 可用磁盘 什么都没发生
2 校验制品:镜像包与 MANIFEST 逐个对 sha256 什么都没发生——这是校验放在最前面的全部意义
3 导入镜像:docker load,全程不访问外网 daemon 里多了几个镜像,栈没起
4 生成现场配置:数据库密码在这里、也只在这里产生 .env 在,但没有任何容器
5 起栈并自检 半成品:migrate 可能已经改过结构

第 5 步是唯一会留下半成品的地方,所以它里面的顺序(db → migrate → web / worker)
不能换——换了的后果在 04 章逐条列出。

装完自己看一遍:

docker compose ps
curl -s http://127.0.0.1:8080/healthz
python -m board doctor          # 或在容器里:docker compose exec web python -m board doctor

两个版本:为什么这里留了一份「初版」

app/board/ 里放的是修过的那一版(1.1.0)。仓库应该放能用的代码,这是常识。

但 06 章要讲的是一次升级:客户现场跑的是 1.0.0,我们手里是 1.1.0,
中间隔着「改了什么、怎么送过去、失败了退回哪里」。要真跑一遍这件事,
就得同时有两个版本的镜像——只有一个版本,「升级」就是个空操作。

./deploy/build-bundle.sh 1.0.0     # 客户现场正在跑的那一版
./deploy/build-bundle.sh 1.1.0     # 我们手里修完的那一版

deploy/variants/v1.0.0/ 存的是初版与当前版本的差异文件,只放有差异的那几个——
这样「这一版到底改了什么」在目录里就能数出来,不用先跑 diff。
build-bundle.sh 会把主树复制成一份构建上下文、再把差异文件覆盖上去,
所以覆盖之后的那份才是被构建的,主树一个字节不动。

两版之间是这五处,对应 06 章那两组正反对照:

# 文件 改了什么 为什么非改不可
1 __init__.py __version__ 由 1.0.0 改成 1.1.0 版本号只有一个真值来源,制品与自述必须对得上
2 config.py 新增 BOARD_ORPHAN_AFTER_SECONDS(默认 120) 「多久没动静算孤儿」需要一个能拧的旋钮,否则演练只能靠干等
3 db.py 迁移增加第 3 条:tasks.claimed_at 列 + 一个只覆盖 running 行的部分索引,带下行语句 判孤儿要靠「这一行是什么时候被领走的」,而这个信息原来根本没落库
4 worker.py 领任务的 UPDATE 里多写一个 claimed_at = now() 不写这个时间戳,第 3 条加的列永远是空的。迁移只负责加列,让它有值是应用的事
5 worker.py 新增 REAP 语句与 reap_orphans(),在 run() 开头调用;失败只记日志,不让 worker 起不来 原来被 SIGKILL 的任务会永久停在 running,重启也不收拾

第 3、4 两条必须放在一起看:只加列不写值,列是空的,回收判据等于失效;
只写值不加列,SQL 直接报错。这类「迁移与应用必须同一次上线」的约束,
在交付里出现的频率比想象中高——它也是为什么「回滚时该用哪一版镜像执行下行迁移」
不能靠猜。

这份快照是快照,不是要跟着主树一起演进的代码:它应该一直停在 1.0.0 的样子,
因为它代表的是「客户机器上已经装好的那一版」。另外补一句边界,
这里做的是同一架构、同一台机器上的版本演进;真实交付里「客户跑的是半年前那一版」
往往还叠着操作系统、CPU 架构、数据库小版本的差异,那些不在这份快照覆盖的范围内。

它真的按上面说的那样工作吗

这个项目里没有单元测试——它要证明的不是「某个函数返回值对不对」,
而是一批关于顺序与后果的判断。所以每条断言都配一个「故意做错」的动作,
做错之后必须看到指定的现象,否则那条结论就不该写进正文:

断言 怎么故意做错 必须看到
解析期失败不产生副作用 把 .env 移走再 docker compose up 报错,且创建的容器数为 0
校验拦在任何副作用之前 改包里任意一个文件再跑 install.sh FAILED + 中止,且这之前没有任何安装动作
停机窗口必须大于任务耗时 把 BOARD_WORKER_STOP_GRACE 设成小于 BOARD_JOB_SECONDS 日志断在 shutdown.requested,任务停在 running
孤儿回收能救回被砍的任务 把 BOARD_ORPHAN_AFTER_SECONDS 调小后重启 worker orphans.reaped,随后那个任务跑到 done
回滚必须用认识新结构的镜像 用旧镜像执行下行迁移 退出码 0、什么都没做——这就是最容易写错的那一条
迁移失败停在中间而不是没发生 让某一条迁移故意报错 migrate.done from 3 to 3,而不是「什么都没做」
internal 网络真的没有出口 在只挂数据面网络的容器里出网 内核拒绝(Network is unreachable),不是超时
healthz 不受数据库影响 停掉 db 再看两个端点 /healthz 仍是 200,/readyz 变成 503

这些动作的具体命令在对应章节的「动手」与「故障注入」两节里。

明确不做的三件事

进入 keel 阅读