KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

08 · 跨出容器:与相邻领域的四条交接缝 — keel 龙骨

这一章回答:容器自己用对了,为什么把它接到前端、后端、部署上时还是会坏?

这一章回答:容器自己用对了,为什么把它接到前端、后端、部署上时还是会坏?

前七章讲的都是"容器内部"的事——上下文、层、编排、配置边界、停机、排障。
但一个真实项目不是只有容器:它前面有前端、后面有数据库、外面有一层反向代理,
而且它最终要被发到一台你没摸过的机器上。

坏掉的地方,绝大多数不在这些领域的内部,而在它们之间的缝上。 每一边的人都觉得"我这块是对的",
因为每一块单独看确实是对的。

现场

一个项目在本地 docker compose up 全绿:前端能打开、接口能返回、数据库能连上。
打包成一整套,部署到一台新服务器上,出现了三个"本地从没见过"的现象:

① 首页返回 404 —— 但本地明明是好的
② 接口日志一直在刷 "connection refused" 连不上数据库
③ 日志时间比本地时间早 8 小时,按天统计的报表在凌晨那几小时会算错

三个现象,三种不同的缝。这一章逐个拆。

一、缝一:前端的构建产物,谁构建、放在哪

缝在哪:前端工程师交出来的是源码,而运行时要的是构建产物(dist/ 里那些 HTML/JS/CSS)。
这段转换在哪一步发生、产物最后躺在镜像的哪个路径下,是两个团队各管一半的事——中间那一段没人管。

两种做法,先看形态。

做法 A:本机构建,产物拷进镜像(镜像只负责"端着")

FROM nginx:alpine
COPY dist/ /usr/share/nginx/html/

做法 B:容器里构建(多阶段——构建阶段用完就扔)

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist/ /usr/share/nginx/html/

怎么选——判据只有一条:构建产物需不需要"可复现"?

场景 选 为什么
只在 CI 上发布、要能回溯"这一版到底由哪份源码产出" B 构建发生在镜像里,工具链版本也被固定了;换台机器结果一样
本地开发、或前端有独立发布流程、镜像只是"负责端着" A 更快,且不要求镜像里装 Node

交接缝上最常出的三个错:

  1. 产物目录名对不上。 前端默认产物是 dist/,但有些框架是 build/、.next/、out/。
    写成 COPY dist/ ... 而项目实际产出 build/,构建不会报错(COPY 找不到源目录时才报错,
    而在做法 B 里 /app/dist/ 确实存在——只是空的),结果是首页 404。这正是现场第 ① 条。
  2. .dockerignore 把产物排除了。 做法 A 里如果 .dockerignore 写了 dist,产物就传不进去。
  3. 静态托管和服务渲染混了。 nginx 只能端静态文件;如果前端是服务端渲染(Next.js 那类),
    它需要的是一个 Node 运行时在跑,不是 nginx。FROM nginx 起步就一定错。

这条缝的判据:问一句"运行时要的是源码还是产物?"。
要产物 → 必须先构建;谁构建、在哪构建,由"要不要可复现"决定。

二、缝二:监听地址——0.0.0.0 与 127.0.0.1

缝在哪:程序监听在哪个地址,是应用侧的决定;这个地址能不能被容器外看到,是容器网络侧的事。
两边各做对一半,合起来就是连不上。

它长什么样。 一个典型的"本地好、容器里不通":

本机直接跑:  python app.py          → curl localhost:8000  ✅
放进容器跑:  curl localhost:8000    → connection refused  ❌

原因是应用绑的是 127.0.0.1(回环地址)而不是 0.0.0.0(所有网卡):

绑定 127.0.0.1  →  只有"容器自己的回环"能连上
绑定 0.0.0.0    →  容器网络里的任何地址都能连上 → 端口映射才递得进来

这段"地址"要穿过四层才到得了外部,任何一层没接上,现象都是"连不上":

① 应用进程   监听 0.0.0.0:3000     ← 绑错这里,后面全白搭
② 容器网络   容器的 3000 端口
③ 编排网络   compose 里同一 network 上的服务用「服务名:3000」互访
④ 外部入口   反向代理 / 端口映射    ← 只在这一层做 8080→3000

判据:容器里的服务一律绑 0.0.0.0。绑 127.0.0.1 只在一种情况下有意义——
那个服务故意不希望被容器外面访问(比如只给同一容器内的边车进程用)。
在编排环境里,连"只给自己人访问"通常也应该靠网络隔离来做,而不是靠绑回环。

数据库连不上(现场第 ② 条)经常是这条缝的另一种形态:应用配置里写的是 localhost:5432,
但数据库在另一个容器里——对应用来说,localhost 指的是它自己,
而它自己容器里没有数据库。这时候要写的是服务名(Compose 网络里的那个名字),
不是 localhost。本地开发时数据库和程序都在宿主机上,所以 localhost 是对的——
同一个配置值,在两个环境里指向完全不同的东西。

三、缝三:端口是"宿主机和容器共享"的资源

缝在哪:容器里的端口和宿主机的端口,是两个命名空间里的东西;
ports: 是在两者之间架一段线的操作——而宿主机那一侧的端口是全局共享的。

它长什么样。 第二个服务想用同一个宿主机端口时:

Error: driver failed programming external connectivity:
Bind for 0.0.0.0:5432 failed: port is already allocated

关键区别(这两个词长得很像,作用完全不同):

写法 含义 谁受影响
expose: [5432] 只是声明"这个服务用 5432",供同一网络里的其他服务访问 容器间,不占宿主机端口
ports: ["5432:5432"] 把宿主机的 5432 接进去 占用宿主机端口,全局唯一

判据:只有"需要从宿主机外面访问"的服务才写 ports:。
数据库、消息队列、内部 API 之间互访——全都应该只走 expose + 服务名。
把每个服务的端口都映射到宿主机,等于把内部结构暴露成一套全局端口表,
除了更容易撞端口,也更容易被外面扫到。

四、缝四:时区

缝在哪:容器镜像有一个默认时区,宿主机有另一个,应用代码里可能还有第三个(写死的 Asia/Shanghai)。
日志、报表、定时任务一旦跨了这三者,就会出只在某些时刻才暴露的错。

它是什么。 多数基础镜像的默认时区是 UTC。 你的宿主机(比如在 UTC+8)上时间是 14:00,
容器里 date 出来是 06:00。

它长什么样。 现场第 ③ 条就是它的典型症状:

宿主机日志:2026-10-09 14:00:00
容器日志:  2026-10-09 06:00:00     ← 差 8 小时

三种处置,按代价从低到高:

做法 形态 代价 / 适用
容器内设时区 environment: TZ=Asia/Shanghai 最轻。需要镜像里装了时区数据,精简镜像可能没有
挂宿主机的时区文件 volumes: - /etc/localtime:/etc/localtime:ro 容器跟随宿主机。⚠️ 换一台宿主机结果就变
应用内统一用 UTC,展示层再转换 代码里的约定,不在配置里 最稳,但要求从一开始就这么写

判据:存储和计算一律用 UTC,只在"给人看"的那一层转成本地时间。
配置里的 TZ 只影响输出格式,不能靠它来修"存进去就是错的时间"——
那类问题改配置是修不好的。

时区这条缝值得多看一眼:它和前面三条不同——前面三条错了当场就报错(404、refused、already allocated),
时区错了往往几个月后才在某个凌晨的报表上暴露。所以它更依赖"一开始就定好",而不是"出问题再修"。

生产边界

缝 教学场景的做法 生产环境的差别
前端产物 单阶段 COPY dist/ 或本机构建 构建发生在 CI;产物带内容哈希;dist 进制品库而不进 git
监听地址 全部绑 0.0.0.0 同上;对外暴露面由反向代理与网络策略共同决定,不靠绑回环
端口 Compose 里只映射入口服务 编排系统里服务间靠服务发现互访;宿主机端口映射基本消失
时区 一个 TZ 环境变量或挂 localtime 存储统一 UTC 是被强制的约定;多区域部署时"本地时间"本身没有唯一定义

共同点:四条缝的"生产版"都不是加配置能解决的,都是在开始写的时候就定下来的约定。

动手

四条缝各自有一个"看得见"的验证动作(本地 Compose 环境即可):

# 缝一:确认产物真的进了镜像(应该列出 index.html 等文件,不是空的)
docker compose run --rm --entrypoint sh web -c 'ls -la /usr/share/nginx/html'

# 缝二:从另一个容器里访问目标服务(用服务名,不是 localhost)
docker compose exec api sh -c 'wget -qO- http://web:80/ | head -3'

# 缝三:看谁占了宿主机的哪个端口(读 PORTS 那一列,箭头左边是宿主机)
docker compose ps

# 缝四:对一下两边的时区
date && docker compose exec api date

四条里只要有一条的输出和你预期的不一样,就已经找到了一个缝。
把每条的命令和它的预期形状记下来——这四行在你接手别人的项目时同样是第一批要跑的命令。

故障注入

注入 预期现象 它演示的是哪条缝
把产物目录从 dist 改成 build,但 Dockerfile 仍写 COPY dist/ 构建成功,首页 404 缝一:错在交接,不在构建
把应用监听地址从 0.0.0.0 改成 127.0.0.1 本机直连正常,docker compose 里外部访问 refused 缝二:四层里的第一层错了,后面全白搭
把数据库的 ports: 改成 expose:,同时宿主上先占住 5432 去掉 ports 后不再报端口冲突;外部访问数据库则失败(符合预期) 缝三:区分"容器间"与"宿主机"
只给一个服务设 TZ=Asia/Shanghai,其余不设 同一件事在两条日志里的时间戳相差数小时 缝四:配置只影响输出,不影响存储

注意每一条的"预期现象"里都有一个反直觉点——尤其是缝一:构建成功、页面 404。
这类"成功路径上不报错"的缝,才是跨域时真正花时间的地方。

自测

  1. 前端构建产物没进镜像,为什么构建不报错?
  2. 应用在容器里绑 127.0.0.1 会怎样?为什么绑定回环在编排环境里是个坏主意?
  3. expose 与 ports 各自的可见范围是什么?为什么数据库不该写 ports?
  4. 容器日志比本地时间早 8 小时。改 TZ 能修好吗?什么情况下能、什么情况下不能?
  5. 为什么说这四条缝"本地全绿、上线才炸"?它们和前面七章的哪几章是同一类问题?

现在能解释什么

课程收束

八章走完,这门课实际上只立了三根柱子:

  1. 边界:上下文、镜像层、运行时;进程命名空间、网络命名空间。
    判断任何问题的第一步是问"这条信息 / 这个动作在哪一侧"——第 08 章那四条缝,
    全都是这句话的具体形态。
  2. 时机:层缓存的失效链、停止的三段时序、健康检查与依赖条件的等待。
    配置里写的顺序与时间,比配置里的值更容易出错。
  3. 证据口径:docker compose config 展开实际生效值;OOMKilled 分流 137;
    能分清"我验证过"与"我读到的"。

其余的都是这三根柱子在具体场景里的应用。带着它们去看下一门课的 K8s,
你会发现换掉的只是名词——连"缝"都还在,只是换了名字。

进入 keel 阅读