KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 从压缩包到跑起来,中间隔着五步 — keel 龙骨

这一章要回答的是:客户拿到那个包之后,到底发生了什么,以及哪一步错了会留下什么。 答案里有五步,顺序不能换;更要紧的是——每一步失败时,已经发生的事情不一样。

这一章要回答的是:客户拿到那个包之后,到底发生了什么,以及哪一步错了会留下什么。
答案里有五步,顺序不能换;更要紧的是——每一步失败时,已经发生的事情不一样。


一、五步,以及每一步失败时「已经发生了什么」

install.sh 是客户唯一需要知道的那条命令。它做五件事:

# 这一步做什么 这一步失败时,现场处于什么状态
① 前置检查:docker 在不在、daemon 活不活、磁盘够不够 什么都没做。一条 die,磁盘上没多任何东西
② 校验制品:sha256sum -c MANIFEST.sha256 什么都没做。中止发生在任何副作用之前
③ docker load 两个镜像 只往本地 daemon 里塞了镜像。运行中的栈没被碰过
④ 生成现场配置 .env .env 可能已经写了一半——所以这一步要能反复跑
⑤ 起栈并自检 容器可能已经起了一部分。这是唯一会留下「半成品状态」的一步

这张表的最后一列才是这一步存在的理由。「先做什么」的答案不来自逻辑顺序,来自「失败时能退回哪里」。 展开成路径:

flowchart TD
    A["交付包 board-delivery-1.0.0.tar.gz"] --> S1["① 前置检查:docker / daemon / compose v2 / 磁盘"]
    S1 --> S2["② 校验制品:sha256sum -c MANIFEST.sha256"]
    S2 --> S3["③ docker load 两个镜像(已存在则跳过)"]
    S3 --> S4["④ 生成现场配置 .env(权限 600,密码就地生成)"]
    S4 --> S5["⑤ docker compose up -d --pull never"]
    S5 --> S6["等待 web 就绪,然后跑 selfcheck.sh"]
    S1 -.->|"docker 命令在,但连不上 daemon"| F1["当场停下:磁盘上没多任何东西"]
    S2 -.->|"env.example FAILED"| F2["当场停下:不要继续安装"]
    S5 -.->|"缺必填变量"| F3["解析期失败:一个容器都没创建"]
    S5 -.->|"端口已被占用"| F4["半启动:db 与 worker 已在跑,只有 web 没起来"]

看清两件事:

二、第一道闸门在解析期,不在启动期

大多数人以为「配置错了」的后果是「容器起来之后崩掉」。这套编排刻意不是这样:缺参数在解析期就失败,一个容器都不会被创建。

把 .env 拿掉再起栈:

error while interpolating services.db.environment.POSTGRES_DB: required variable BOARD_DB_NAME is missing a value
error while interpolating services.migrate.image: required variable BOARD_IMAGE is missing a value
...(每个用到的地方各报一条)

机制是 ${VAR:?} 这个写法:? 后面没写内容,意思是「没有默认值,缺了就直接失败」。为什么值得这么做?因为在启动期失败和在解析期失败,现场要面对的局面完全不同:

解析期失败(本课做法) 启动期失败("有默认值就行"的做法)
有没有容器被创建 没有 有,而且可能一半好一半坏
报几条错 一次报全部缺的名字 通常只报第一条,修完再报下一条
客户要清理什么 什么都不用 要先把那半套容器清掉再重来

代价是:参数必须分成两类,不能含糊。 把编排文件里的变量按「必填」和「有默认值」过一遍,读数是——4 个必填,7 个有默认值:

NAME                        REQUIRED            DEFAULT VALUE
BOARD_DB_USER               true
BOARD_IMAGE                 true
BOARD_DB_NAME               true
BOARD_DB_PASSWORD           true
BOARD_INSTANCE_NAME         false               board
BOARD_POSTGRES_IMAGE        false               postgres:16-alpine
BOARD_HTTP_PORT             false               8080
BOARD_LOG_LEVEL             false               info
BOARD_JOB_SECONDS           false               3
BOARD_WORKER_POLL_SECONDS   false               2
BOARD_WORKER_STOP_GRACE     false               60s

这张表本身就是一份设计说明。四个必填的,全是「我们不知道、只有现场知道」的值:数据库的用户名、库名、密码,以及装的是哪个镜像。七个有默认值的,全是「有一个合理取值、现场多半不用改」的运行时参数——端口被占是唯一的例外,所以在 env.example 里它被标了出来。

三、顺序:为什么 db 一定要在 migrate 之前

解析完之后,docker compose config 能给出依赖解析后的拓扑顺序:

db
migrate
web
worker

这四行的顺序不是文件里写的顺序,是 depends_on 解析出来的。名字已经说明了依赖的形状:

服务 等谁 等什么条件
db 没人 —
migrate db service_healthy——数据库真的能应答
web migrate service_completed_successfully——migrate 跑完并且退出码为 0
worker migrate 同上

其中有一根线最关键:migrate 到 web 用的是「成功退出」而不是「已启动」。

migrate 是一次性进程:跑完就退。它的退出码是它唯一的产物。所以它不能 restart——重试会把失败藏起来;而下游必须等它成功。这解释了一个看起来很奇怪的现象——安装输出里这一行:

 Container board-migrate-1 Exited

Exited 在这里是好事。 它表示迁移跑完、正常退出。如果它变成 Exited (1),web 与 worker 根本不会被创建。而这恰好也是 06 章那套「升级失败自动回滚」的钩子:迁移没成功,整栈就不会起,所以不会出现「新代码配旧结构」的中间态。

四、五步跑完,看起来是什么样

一次完整的安装,输出长这样(节选):

==> 前置检查
==>    docker 29.8.2 / 可用磁盘 58376932KB
==> 校验制品完整性
==>    全部制品校验通过
==> 导入镜像(不访问外网)
==>    docker load < board__1.0.0.tar
Loaded image: board:1.0.0
==>    docker load < postgres__16-alpine.tar
Loaded image: postgres:16-alpine
==> 生成现场配置
==>    已写入 .../.env(权限 600),数据库密码为现场随机生成
==> 启动
 Container board-db-1 Started
 Container board-db-1 Healthy
 Container board-migrate-1 Exited
 Container board-web-1 Started
 Container board-worker-1 Started
==> 等待 web 就绪
==>    web 已就绪
==> 自检
  制品校验                       通过
  db                                 running / healthy
  web                                running / healthy
  worker                             running / healthy
  GET /healthz                       HTTP 200
  GET /readyz                        HTTP 200
  结构版本                       2

注意第 4 步那句话:「数据库密码为现场随机生成」。这不是一句宣传——密码在这一刻才存在,它不进镜像、不进交付包、不经过任何介质。生成用的也不是 openssl,而是 /dev/urandom:

SECRET="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"

理由和前一章那条「--pull never 不能省」是同一个思路:客户内网的最小化系统里不保证有 openssl,但 /dev/urandom 一定在。 交付脚本里每一个外部依赖,都要问一句「现场一定有它吗」。

再跑一遍会怎样? 这是客户最常做的一个动作(「刚才那个是不是没跑完,我再跑一次」),所以幂等性是设计目标而不是巧合:

第二遍的行为 读数
镜像已经在本地 已存在,跳过:board__1.0.0.tar
.env 已经存在 .env 已存在,保持不动(要被覆盖请先自行备份删除)
数据库密码 不变(仍是第一遍生成的那串)
数据 保留

这里有一条很容易做错的取舍:如果第二遍「顺手重新生成密钥」,那么所有已经存进去的数据都连不上了——因为没有进程还记着旧密码。所以这一步宁可「什么也不做」,也不做「看起来更干净」的事。

五、顺序对了,还是可能起不来

有两种失败与顺序无关,现场一定会遇到:

其一,制品在搬运途中坏了。 改动包里任意一个文件之后再跑:

env.example: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
[失败] 制品校验失败:镜像包与 MANIFEST 不一致。**不要继续安装** —— 用原始介质重新拷贝一次。

这条报错值得学的是它的位置:它在第 ② 步,也就是在任何副作用之前。所以「不要继续安装」不只是一句提醒,它是一个已经成立的事实——现场此刻还没有任何东西需要清理。

其二,端口被占。 客户机器上 8080 被占是常态,不是意外。这时起栈的结果是:

Error response from daemon: failed to set up container networking: driver failed programming
external connectivity on endpoint board3-web-1 (...): Bind for 0.0.0.0:8080 failed: port is already allocated

事后 docker compose ps 会告诉你:

db      running
worker  running

只有 web 没起来,db 与 worker 已经在跑了。 这就是第一节那张表里唯一会留下半成品的那一格。为什么偏偏是 web?因为只有它需要往宿主机上发布端口——db 与 worker 都只在内部网络里,没有对外的那一面。

这一格必须被单独记住:它不是「安装失败」,它是「安装成功了 4/5」。客户看到的现象是「网页打不开」,而实际状态是「后面两个进程已经在处理任务了」。修法是改 BOARD_HTTP_PORT 再起一次——注意是改一个数,不是重装。

动手:可观察结果

在起栈之前先把编排文件解析一遍,看它会向现场要什么:

docker compose config --variables
docker compose config --services

要观察三件事,而不是「看到两张表就算跑过」:

  1. 必填的那四个名字是哪些。 这四条就是现场必须准备的输入,少一个就会在解析期看到那段
    required variable ... is missing a value。
  2. 拓扑顺序是不是 db → migrate → web → worker。 这个顺序来自依赖声明,
    不是文件里的书写顺序——改一下 depends_on 再跑,它会跟着变。
  3. --services 与 --variables 都不需要 daemon 在跑。 它们是纯客户端解析,
    所以在任何一台装了 compose 的机器上都能先对一遍——这是现场唯一能「不碰生产就先验一遍」的动作。

完成标志:能不看笔记说出「五步里哪一步会留下半成品,以及为什么偏偏是那一步」。

故障注入

把 .env 改名(模拟现场忘了生成配置):

mv .env .env.bak
docker compose up -d --pull never

预期结果是一个容器都没有被创建,输出的是一串 required variable ... is missing a value,
而且每个用到那个变量的地方各报一条,不是一个变量只报一条。

再把 .env 改回来,故意删掉其中一行(比如 BOARD_DB_USER),再跑一次:预期报的是那一个名字。

这两次对比说明的是同一件事:缺参数是在解析期被挡住的,代价是零。 如果这段检查跑到启动期才做,
你要面对的就是第一节那张表里 ⑤ 那一格——一半的容器已经在跑。

生产边界

教学里这样 真实交付要换成 不换会出什么事
密码用 /dev/urandom 现场生成 按客户合规要求接密钥管理系统,或按流程登记密钥 密码只存在于那台机器上,交接与轮换都没有记录可查
.env 是普通文件(权限 600) 按现场要求评估:密钥是否需要单独存储、是否要有审计 一个有 root 的人能看到全部密钥,且没有任何痕迹
自检结论只打印在屏幕上 退出码被监控系统接走(selfcheck.sh 的退出码 = 失败条数) 没有人知道昨晚那次安装到底成没成
一套实例一套 .env 多实例并存时,实例名与端口必须成对分配并登记 第二个客户装进来时撞名、撞端口(05 章)
migrate 与 web 串在一条命令里 大规模现场可拆成显式的两个阶段,中间插入人工确认 迁移要跑很久时,「起来一个卡住的栈」比「等一会儿再起」更糟

自测题

  1. 五步里哪一步失败会留下半成品?为什么另外四步不会?
  2. ${VAR:?} 与 ${VAR:-default} 的差别是什么?为什么必填项要用前者?
  3. 安装输出里的 Container board-migrate-1 Exited 是成功还是失败?你凭什么判断?
  4. 为什么第二遍跑 install.sh 时不能重新生成数据库密码?重新生成之后现场会发生什么?
  5. 端口被占之后,db 与 worker 为什么已经在跑了?这种现象会让客户对故障的
    第一判断偏到哪里去?

现在能解释什么

进入 keel 阅读