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 |
交接缝上最常出的三个错:
- 产物目录名对不上。 前端默认产物是
dist/,但有些框架是build/、.next/、out/。
写成COPY dist/ ...而项目实际产出build/,构建不会报错(COPY找不到源目录时才报错,
而在做法 B 里/app/dist/确实存在——只是空的),结果是首页 404。这正是现场第 ① 条。 .dockerignore把产物排除了。 做法 A 里如果.dockerignore写了dist,产物就传不进去。- 静态托管和服务渲染混了。
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。
这类"成功路径上不报错"的缝,才是跨域时真正花时间的地方。
自测
- 前端构建产物没进镜像,为什么构建不报错?
- 应用在容器里绑
127.0.0.1会怎样?为什么绑定回环在编排环境里是个坏主意? expose与ports各自的可见范围是什么?为什么数据库不该写ports?- 容器日志比本地时间早 8 小时。改
TZ能修好吗?什么情况下能、什么情况下不能? - 为什么说这四条缝"本地全绿、上线才炸"?它们和前面七章的哪几章是同一类问题?
现在能解释什么
- 跨域失败绝大多数发生在缝上,而缝的特点是:两边各自看都是对的。
- 前端产物那条缝的判据是"运行时要源码还是产物";决定性错误是目录名对不上却构建成功。
- 服务绑
0.0.0.0不是"更开放",是"能被这一层的网络看到";绑回环等于把自己关在容器里。 expose管容器间、ports占宿主机——只有入口服务才该写ports。- 时区是四条缝里唯一不会当场报错的一条;它的正解是"存储用 UTC",不是"配一个 TZ"。
- 这四条缝和第 04–06 章是同一类问题:配置里写的"位置"和"名字",比配置里的"值"更容易出错。
课程收束
八章走完,这门课实际上只立了三根柱子:
- 边界:上下文、镜像层、运行时;进程命名空间、网络命名空间。
判断任何问题的第一步是问"这条信息 / 这个动作在哪一侧"——第 08 章那四条缝,
全都是这句话的具体形态。 - 时机:层缓存的失效链、停止的三段时序、健康检查与依赖条件的等待。
配置里写的顺序与时间,比配置里的值更容易出错。 - 证据口径:
docker compose config展开实际生效值;OOMKilled分流 137;
能分清"我验证过"与"我读到的"。
其余的都是这三根柱子在具体场景里的应用。带着它们去看下一门课的 K8s,
你会发现换掉的只是名词——连"缝"都还在,只是换了名字。