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
要观察三件事,而不是「看到两张表就算跑过」:
- 必填的那四个名字是哪些。 这四条就是现场必须准备的输入,少一个就会在解析期看到那段
required variable ... is missing a value。 - 拓扑顺序是不是
db → migrate → web → worker。 这个顺序来自依赖声明,
不是文件里的书写顺序——改一下depends_on再跑,它会跟着变。 --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 串在一条命令里 |
大规模现场可拆成显式的两个阶段,中间插入人工确认 | 迁移要跑很久时,「起来一个卡住的栈」比「等一会儿再起」更糟 |
自测题
- 五步里哪一步失败会留下半成品?为什么另外四步不会?
${VAR:?}与${VAR:-default}的差别是什么?为什么必填项要用前者?- 安装输出里的
Container board-migrate-1 Exited是成功还是失败?你凭什么判断? - 为什么第二遍跑
install.sh时不能重新生成数据库密码?重新生成之后现场会发生什么? - 端口被占之后,
db与worker为什么已经在跑了?这种现象会让客户对故障的
第一判断偏到哪里去?
现在能解释什么
- 一次安装由哪五步构成,以及每一步失败时现场处于什么状态;
- 为什么「缺参数」要在解析期就失败,以及这对现场清理成本意味着什么;
db → migrate → web → worker这个顺序由哪根线撑着,以及migrate的退出码为什么是它的全部产物;- 现场配置在哪一刻诞生、为什么它不经过介质、为什么第二遍不能重生成;
- 「半启动」这个状态长什么样,以及它会把客户的第一判断带偏到哪里——
它的排查入口在 07 章。