KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 一套代码、多个客户:配置注入与实例隔离 — keel 龙骨
这一章要回答的是:**同一份镜像、同一份编排文件,怎么给两个客户在同一台机器上各起一套, 而且谁也不会踩到谁? 答案不在于「多写一份配置」,而在于每一个会被多个实例共享的名字都要提前加前缀**, 以及配置必须能被现场自己读出来。
这一章要回答的是:同一份镜像、同一份编排文件,怎么给两个客户在同一台机器上各起一套,
而且谁也不会踩到谁? 答案不在于「多写一份配置」,而在于每一个会被多个实例共享的名字都要提前加前缀,
以及配置必须能被现场自己读出来。
一、同一台机器上的两套实例
先看事实。两台实例都起来之后,读数是这样的:
$ docker compose ls
NAME STATUS CONFIG FILES
board running(3) ...board-delivery-1.0.0\compose.yml
board2 running(3) ...board-delivery-1.0.0-b\compose.yml
$ docker ps
board-db-1 5432/tcp
board-web-1 0.0.0.0:8080->8080/tcp
board-worker-1 8080/tcp
board2-db-1 5432/tcp
board2-web-1 0.0.0.0:8090->8080/tcp
board2-worker-1 8080/tcp
六个容器、两个 compose 项目、两个不同的宿主机端口。它们用的是同一个镜像(board:1.0.0),跑的是同一份编排文件,两份 .env 之间只差三行:
BOARD_INSTANCE_NAME board / board2
BOARD_HTTP_PORT 8080 / 8090
BOARD_JOB_SECONDS 3 / 20
三条里前两条是隔离必需的,第三条不是——它是故意留的(后面第五节会说它为什么值钱)。
再看一眼那两个 5432/tcp:数据库没有映射到宿主机端口。 两套实例的 postgres 都在容器网络里各占一个 5432,但外面看不到任何一个。这不是巧合,是前一章那张图里 internal: true 的直接后果——能让多个实例并存的前提之一,就是有些东西根本不需要对外。
二、实例 = 资源名前缀 + 一份配置 + 一份数据
「实例」这个词在这一课里有一个精确的判据。展开成图:
flowchart TD
IMG["同一个镜像 board:1.0.0"] --> CA["现场目录 A"]
IMG --> CB["现场目录 B"]
CA --> EA["env A:BOARD_INSTANCE_NAME=board / BOARD_HTTP_PORT=8080 / BOARD_JOB_SECONDS=3"]
CB --> EB["env B:BOARD_INSTANCE_NAME=board2 / BOARD_HTTP_PORT=8090 / BOARD_JOB_SECONDS=20"]
EA --> PA["实例 board:board-db-1 / board-web-1 / board-worker-1"]
EB --> PB["实例 board2:board2-db-1 / board2-web-1 / board2-worker-1"]
PA --> VA["卷 board_pgdata / board_workdir,网络 board_data / board_edge"]
PB --> VB["卷 board2_pgdata / board2_workdir,网络 board2_data / board2_edge"]
EB -.->|"若两套配置的实例名写成同一个值:第二套接管第一套的卷"| VA
关键在那根把 BOARD_INSTANCE_NAME 变成一堆前缀的线。编排文件的第一行是:
name: ${BOARD_INSTANCE_NAME:-board}
name: 决定这套实例的资源前缀。 容器名、网络名、卷名都带上它——所以改这一个值,就换了一整套命名空间。这就是为什么两套实例能并存:它们在任何一张 Docker 的名单上都不撞名。
于是「实例」的判据可以写清楚了:
一个实例 = 一个资源名前缀 + 一份配置 + 一份数据。
三者里换掉任何一个,都等于换了一个实例;三者里少任何一样能被独立指认,多实例就不成立。
推论有三条,都在上图的读数里能看到:
- 数据必须跟着前缀走。
board_pgdata与board2_pgdata是两个卷。少了这个前缀,两个客户共用一份数据库——这是最严重的一类事故,而且它在安装时不会报错。 - 对外端口必须显式分配。
0.0.0.0:8080与0.0.0.0:8090。这两个数字不能靠默认值自动分开——必须有人去分配并记下来。 - 不需要对外的服务不要对外。 两个
5432/tcp都没有宿主机端口,所以第二个客户不会因为「5432 被占」而起不来。
三、配置凭什么不漂:让它自己读出来
多实例带来一个新问题:客户改了一个环境变量,到底生效了没有?
这个问题看起来可以靠「看配置文件」回答,但它答不了——「文件里写着」和「进程真的读到了」是两件事。两者之间隔着:改的是哪个文件、容器是不是重建过、这个变量有没有另一个来源覆盖它。
所以这套系统里有一个专门的角色用来回答它:
环境变量 当前生效值 来源
------------------------------------------------------------------------
BOARD_DB_HOST db 默认值
BOARD_DB_NAME board env
BOARD_DB_PASSWORD (23 chars) env
...
数据库连接(脱敏):host=db port=5432 dbname=board user=board password=(23 chars)
这张表有三处设计值得逐条读:
第一,表头第三列是「来源」,而且只有两种取值:env 与 默认值。 这一列是整张表的重点——它把「这个值是从哪来的」变成了一个可以直接读出来的事实。 客户问「我改的那个生效了吗」,答案就是看这一列:写着 env 表示进程读到了环境变量,写着默认值 表示没读到、用的是代码里的兜底。
第二,密钥只留长度与前两位。 BOARD_DB_PASSWORD 那一行显示的是 (23 chars)。原因很直接:自检日志本身不能变成泄漏面。客户会把这段输出截图发给我们,截图里的密码等于明文外泄。而「23 个字符」这个信息量已经够判断「密码是不是现场生成的那一个」。
第三,缺必填项时一次报全部,退出码是 2。 一个变量都不配时是这样:
{"ts": "...", "level": "error", "event": "config.invalid", "role": "config", "instance": "board",
"detail": "缺少必填环境变量:BOARD_DB_USER、BOARD_DB_PASSWORD"}
「一次报全」这个选择是为了减少往返。 现场没有我们的人,一次只报一个变量意味着每修一个就要跑一次、
每跑一次就要等一次沟通。而退出码 2 让它能被脚本直接接走——不需要人来读那句话。
四、YAML 里写的,和实际生效的不是一回事
第二个「配置漂移」的来源更隐蔽:编排文件里的值经过一层解析,未必长成你写的那个样子。
把编排文件解析成结构化读数,看两个字段:
| 服务 | command 是数组还是字符串 |
stop_grace_period |
|---|---|---|
db |
字符串(shell 形式,官方镜像自带) | 30s |
migrate |
数组(exec 形式) | 未设 |
web |
数组(exec 形式) | 15s |
worker |
数组(exec 形式) | 60s 被归一化成 1m0s |
两处值得停一下:
command的形态在同一个文件里就不统一。 四个服务里db是字符串,其余三个是数组。原因是db用的是官方镜像自带的启动方式(shell 形式),而另外三个是我们显式写的["python", "-m", "board", "..."]。这个差别在停机那一章会变成真实后果——shell 形式多套了一层进程,SIGTERM要先过它。60s变成了1m0s。 单位被归一化了。这意味着你不能靠字符串比对来判断「这个值有没有被改过」——要拿归一化之后的读数去比。
还有一处更值得记住的:内存限制的真相不在解析输出里。
解析输出里,worker 只有 deploy.resources.limits.memory = "536870912"——注意它是不带单位的字节串,而 mem_limit 这个字段根本没有出现。只看这一份输出,很容易得出「没设限」的结论。但去看运行中的容器:
board-worker-1 Memory=536870912 NanoCpus=1000000000
board-web-1 Memory=0 NanoCpus=0
只有 inspect 运行中的容器才知道真相。 而且这里还有一个语义提醒:512M 被归一化成 536870912,说明不带单位的数字会被按字节解释——写成 512 的话,差的是六个数量级。这一条不是本课的重点(资源预算见《Kubernetes 应用课》),但它在这里的用处是:「我声明了」和「它生效了」之间隔着一层解析,任何一层都可能和你的预期不同。
五、那三行差异,每一行都必须能被读出来
回到第一节那个读数:两套 .env 只差三行。这个「只差三行」只有在能读出来的前提下才是优点。
第一行与第二行的作用已经清楚。第三行——BOARD_JOB_SECONDS 从 3 改成 20——是这份对照里最值钱的一条:
它不是隔离必需的,它是为了让两套实例承担不同的实验条件。 一套用默认的 3 秒任务,另一套用 20 秒的长任务。06 章那组「停机窗口」的正反对照能成立,就靠这一行把「任务耗时」变成了一个可以拧的旋钮。
这说明了一件关于多实例的事:配置一旦被正确注入,它同时就变成了一个实验台。 同一份镜像、同一份编排,改变量就能造出不同行为的两套系统——这也是为什么「配置能被读出来」不只是排障需要,它是整门课的实验方法。
而在读实例这件事上,有一个具体的坑必须记住:
$ docker compose ls --format '{{.Name}}'
format value "{{.Name}}" could not be parsed: parsing failed
compose ls 不支持 Go 模板格式化,它只接受 table 与 json。要拿名字得用 -q。这个坑的危险之处不在报错本身,而在于它经常被写在命令替换里、并且 stderr 被丢掉:
INSTANCE="$(docker compose ls --format '{{.Name}}' 2>/dev/null)" # ← 静默的空值
这时你会得到一个空字符串,而空字符串传到后面可能是「没有实例在跑」这个结论。报错被丢掉之后,失败和「什么都没有」长得一模一样。
动手:可观察结果
先让这套实例把配置读给你看:
docker compose exec web python -m board config
docker compose ps
要观察三件事,而不是「看到一张表就算跑过」:
- 第三列「来源」全都有值,而且只有
env与默认值两种。 找一个你在.env里改过的变量,
看它是不是变成了env——这一步就是「我改的生效了吗」的答案。 - 密钥那一行显示的是长度,不是明文。 如果你能看到明文,说明这一行代码被改过。
docker compose ps里的容器名都带前缀。 把BOARD_INSTANCE_NAME改成别的值再起一次,
前缀会整体换掉——这就是「实例」这个概念的物理形态。
完成标志:能不看笔记说出「实例由哪三样东西构成,以及其中哪一样缺了会在安装期报错、哪一样缺了
要等出事才发现」。
故障注入
把两个客户的 .env 中的 BOARD_INSTANCE_NAME 改成同一个值(模拟现场复制粘贴时漏改这一行),然后起第二套:
docker compose up -d --pull never
预期结果是第二套实例根本没有独立存在:Docker 认为它就是第一套,容器不会新建,
而是「已经存在」;如果强行重建,它会接管第一套的资源——包括那份 pgdata 卷。
这个注入的关键在于:它在安装期不报错。 两个客户的 BOARD_INSTANCE_NAME 撞车是一个
「安装成功、数据错位」的故障,而它不是靠某一条命令能查出来的——它靠的是分配实例名时有人记一笔。
生产边界
| 教学里这样 | 真实交付要换成 | 不换会出什么事 |
|---|---|---|
手工改 .env,接口头分配实例名与端口 |
实例登记表 / 分配系统,装机前查一次重名与端口冲突 | 第二个客户装进来时接管第一个客户的数据卷 |
| 实例名只作为 Docker 资源前缀 | 实例名同时进日志字段、监控标签、备份文件名 | 出事时无法从那台机器上的产物判断「这是谁的」 |
| 一个客户一套实例 | 按现场评估:同一客户的开发/演示/生产要不要拆成多个实例 | 演示环境的重启会打断生产任务 |
配置靠 .env 文件注入 |
大现场可接配置中心;密钥走独立存储 | .env 里的密钥随现场目录一起被拷来拷去 |
| 默认值留在编排文件里 | 默认值也要进变更评审——默认值就是一套隐形配置 | 改一个默认值会同时改变所有没显式配置的实例 |
自测题
- 「实例」由哪三样构成?为什么「少任何一样能被独立指认」就会破坏多实例?
board config的「来源」列只有env与默认值两种取值,为什么这一列比「当前生效值」更重要?- 为什么解析输出里看起来「没设内存限制」,而运行中的容器却有限制?这件事的普遍教训是什么?
docker compose ls --format '{{.Name}}'报错时,什么样的写法会把它变成一个静默的空值?
这类错误为什么比报错本身更危险?- 把
BOARD_JOB_SECONDS从3改成20不是隔离必需的,为什么这一课还要保留这条差异?
现在能解释什么
- 「实例」这个概念的物理形态:一份前缀、一份配置、一份数据,以及三者各自缺失时的后果;
- 为什么隔离落到实处的关键是名字——资源名前缀、对外端口、以及不需要对外的东西不对外;
- 配置如何「自证」:来源列、密钥脱敏、缺项一次报全,三处各自在替谁省时间;
- 为什么「我声明了」和「它生效了」中间隔着一层解析,以及这一层的真相在哪里读;
- 那三行差异里为什么留了一行不是必需的行——它将撑起 06 章整章的对照实验。