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 的名单上都不撞名。

于是「实例」的判据可以写清楚了:

一个实例 = 一个资源名前缀 + 一份配置 + 一份数据。
三者里换掉任何一个,都等于换了一个实例;三者里少任何一样能被独立指认,多实例就不成立。

推论有三条,都在上图的读数里能看到:

  1. 数据必须跟着前缀走。 board_pgdata 与 board2_pgdata 是两个卷。少了这个前缀,两个客户共用一份数据库——这是最严重的一类事故,而且它在安装时不会报错。
  2. 对外端口必须显式分配。 0.0.0.0:8080 与 0.0.0.0:8090。这两个数字不能靠默认值自动分开——必须有人去分配并记下来。
  3. 不需要对外的服务不要对外。 两个 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

两处值得停一下:

还有一处更值得记住的:内存限制的真相不在解析输出里。

解析输出里,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

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

  1. 第三列「来源」全都有值,而且只有 env 与 默认值 两种。 找一个你在 .env 里改过的变量,
    看它是不是变成了 env——这一步就是「我改的生效了吗」的答案。
  2. 密钥那一行显示的是长度,不是明文。 如果你能看到明文,说明这一行代码被改过。
  3. 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 里的密钥随现场目录一起被拷来拷去
默认值留在编排文件里 默认值也要进变更评审——默认值就是一套隐形配置 改一个默认值会同时改变所有没显式配置的实例

自测题

  1. 「实例」由哪三样构成?为什么「少任何一样能被独立指认」就会破坏多实例?
  2. board config 的「来源」列只有 env 与 默认值 两种取值,为什么这一列比「当前生效值」更重要?
  3. 为什么解析输出里看起来「没设内存限制」,而运行中的容器却有限制?这件事的普遍教训是什么?
  4. docker compose ls --format '{{.Name}}' 报错时,什么样的写法会把它变成一个静默的空值?
    这类错误为什么比报错本身更危险?
  5. 把 BOARD_JOB_SECONDS 从 3 改成 20 不是隔离必需的,为什么这一课还要保留这条差异?

现在能解释什么

进入 keel 阅读