KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 没有外网,怎么把制品搬进去 — keel 龙骨
这一章要拆掉一个几乎所有人都会默认的假设:「需要的时候去仓库拉一下」。 在外网环境里它是免费的,在内网里它连失败的方式都很不友好—— 它不报「没有网络」,它挂在那里等超时。这一章把这条假设替换成一个明确的动作。
这一章要拆掉一个几乎所有人都会默认的假设:「需要的时候去仓库拉一下」。
在外网环境里它是免费的,在内网里它连失败的方式都很不友好——
它不报「没有网络」,它挂在那里等超时。这一章把这条假设替换成一个明确的动作。
一、先证明「没有出口」是真的
所有关于私有化的讨论都建立在一句话上:那台机器到不了外面。这句话不能靠听说,要能量出来。
先量网络本身。这套编排把网络分成两个:
| 网络 | 配置 | 读出来的值 |
|---|---|---|
数据面 board_data |
声明里写了 internal: true |
internal=true driver=bridge |
接入面 board_edge |
声明是空对象 {} |
internal=false driver=bridge |
docker network inspect board_data --format '{{.Name}}: internal={{.Internal}} driver={{.Driver}}'
docker network inspect board_edge --format '{{.Name}}: internal={{.Internal}} driver={{.Driver}}'
再量一次「从里面出去会怎样」。在只挂在 data 网络上的 worker 容器里试着连一次外网:
连不上: OSError [Errno 101] Network is unreachable
这句话的形状比它的内容更重要。 注意它不是 timed out,也不是 Connection refused——它是 Network is unreachable,内核直接拒绝。区别在于:
- 超时意味着「包发出去了,没人应答」——这是网络故障,
重试、换 DNS、加代理都可能有用; unreachable意味着「根本没有路由可走」——这是配置事实,
再怎么重试都是同一个结果。
分清这两者,是这一章后面所有判断的前提。把它画出来:
flowchart TD
subgraph DATA["data 网络(internal=true,没有出口)"]
DB["db 容器"]
MG["migrate 一次性进程"]
WK["worker 容器"]
end
subgraph EDGE["edge 网络(internal=false)"]
E1["web 容器对外的那一侧"]
end
WEB["web 容器"] --> E1
WEB --> DB
E1 --> PORT["宿主机端口 BOARD_HTTP_PORT"]
WK -.->|"尝试出网:内核直接拒绝,Errno 101 Network is unreachable"| NET["外网"]
DB -.->|"数据库同样没有出口"| NET
这张图要带走两件事:
internal: true不是一句注释,它是一条内核层面的规则。 客户的内网合规检查常常就查这一条——「数据库不能被从外面直接连到」。- 只有 web 挂在接入面上。 worker 没有端口、也没有出口;它唯一能谈的对象是同一个
data网络里的db。这条约束在后面几章会反复出现:凡是需要一个服务「出去拿点什么」的设计,在这个形态里都不成立。
二、「能拉」这个假设是怎么塌的
既然没有出口,那「拉不到」应该是常态。但真正麻烦的是:在有外网的机器上,这条假设看起来永远是成立的。
构建镜像的时候遇到过这样一次:
unexpected status from HEAD request to http://hub-mirror.c.163.com/v2/library/python/manifests/3.12-slim: 502 Bad Gateway
重试一次就成了。在外网环境里,这件事的结论仅仅是「网络抖了一下」。但它真正说明的是:「从仓库拉」这个动作本身有前提——仓库要活着、镜像源要能解析、链路上每一跳都不能挂。这三条里任何一条不成立,pull 就会失败,而它失败的方式是超时。
把这个失败放到客户内网里,它会变成最坏的一种形态。假设镜像没从包里导进来,直接起栈:
Image board:1.0.0 Pulling
Image board:1.0.0 Error unknown: failed to resolve reference "docker.io/library/board:1.0.0":
unexpected status from HEAD request to http://hub-mirror.c.163.com/...: 502 Bad Gateway
Error response from daemon: No such image: postgres:16-alpine
Error response from daemon: No such image: board:1.0.0
看清楚这里发生了什么:
- 报告里出现了
hub-mirror.c.163.com——客户机器根本连不到这个地址,这条报错是「取不到」的转述,不是事实本身; - 前面是一段等待(解析 + 连接 + 重试),它耗掉的时间是最贵的东西——现场那几分钟里没人知道该干什么;
- 三条报错里,只有最后两条是真相:
No such image。前面那条failed to resolve reference是噪音。
唯一能让真相排在第一位的方法,是明确禁止「拉」这个动作。 下一节讲怎么做。
三、搬运只有一条路:随包直送
回到第一章那个结论:把镜像送进内网只有三条路,前两条走不通。所以真正要设计的不是「选哪条」,而是这条唯一的路怎么走稳。
动作本身只有一个来回:
# 我们这边:把镜像从本地 daemon 导出成一个 tar
docker save --output images/board__1.0.0.tar board:1.0.0
# 客户现场:把这个 tar 导回本地 daemon
docker load --input images/board__1.0.0.tar
围绕这个来回,有三条约定值得逐个说清:
约定一:文件名里用 __ 代替冒号。
board:1.0.0 里的冒号不能进文件名——Windows 会把它当驱动器分隔符,某些文件系统会把它当扩展名分隔符。而「用 U 盘在两台机器之间搬包」这件事里,文件名被改写的概率不低。所以约定写成 名字__标签.tar,导入时再换回冒号:
docker image inspect "$(echo "${name%.tar}" | sed 's/__/:/')" >/dev/null 2>&1
这一行同时做了第二件事:先看这个镜像在不在。在的话直接跳过——这是后面 04 章「幂等」的地基。
约定二:包里必须有数据库镜像。
这是这类交付最常见的漏项。应用镜像准备得再完整,客户机器上如果没有 postgres,compose 一样会去 pull,然后在没有出口的网络里挂到超时——就是上一节那段报错。所以打包脚本里两行 docker save 缺一不可:
docker save --output "${BUNDLE}/images/board__${VERSION}.tar" "${APP_IMAGE}"
docker save --output "${BUNDLE}/images/postgres__${DB_IMAGE##*:}.tar" "${DB_IMAGE}"
约定三:包内不压缩,包外压一层。
docker load 吃的是 docker save 的原样输出,所以包内的 .tar 是未压缩的(应用镜像那一个约 215MB)。压缩只做一层——外面的 board-delivery-1.0.0.tar.gz,最终 160M。
这一条有个副作用必须提前知道:解开包的那一刻,磁盘上会先落下一个未压缩的 tar。 这就是 install.sh 前置检查里那条磁盘判断存在的理由:
AVAIL_KB="$(df -Pk "${HERE}" | awk 'NR==2 {print $4}')"
[ "${AVAIL_KB}" -gt 2097152 ] || warn "可用磁盘不足 2GB,镜像导入可能失败。"
四、让失败变成一句能懂的话
--pull never 这一个开关,把上一节那段三行报错压成一句话。同一场景(镜像没导入)加上它之后:
service:db:1 Error response from daemon: No such image: postgres:16-alpine
Error response from daemon: No such image: postgres:16-alpine (退出码 1)
对比一下两条路径:
不加 --pull never |
加 --pull never |
|
|---|---|---|
| 现场看到的第一句 | Image board:1.0.0 Pulling |
No such image: postgres:16-alpine |
| 消耗的时间 | 一段 DNS / 代理等待 | 立刻 |
| 指向的原因 | failed to resolve reference → 像是「网络问题」 |
直接是「这个镜像不在本地」 |
| 现场会做的下一步 | 找网络、找代理、找我们 | 去 images/ 里查包是不是漏了那个 tar |
它不是可选优化,它是离线场景的正确默认。 理由很简单:在没有出口的网络里,pull 这个动作永远不可能成功,所以「让它先试一次」的价值恒为零,而它带来的噪音恒大于零。
同样出于这个考虑,install.sh 明确不做两件事:不问任何交互问题(现场可能只有一台跳板机、没有 TTY),不自动拉取任何镜像(拉取只会挂在这里等超时)。
动手:可观察结果
在起栈之前,先确认本机到底有没有这两个镜像——这是现场最该先做的一步:
docker image ls --format '{{.Repository}}:{{.Tag}}'
docker compose config --services
要观察三件事,而不是「有输出就算跑过」:
board:1.0.0与postgres:16-alpine这两个名字都在。 少任何一个,
后面不管怎么调编排文件都不会起来。docker compose config --services能列出服务名(它只做客户端解析,不需要 daemon 在跑)。
这四行是拓扑顺序,04 章会解释它的意义。- 两个镜像都不是从网上拉来的——如果你是在有外网的机器上做这个练习,这一步看着没差别;
把网络断掉再跑一次,才看得出「本地有」和「能拉到」有什么区别。
完成标志:能不看笔记说出「包里的制品有哪两样、各自为什么不能少」。
故障注入
把 images/ 下的数据库镜像 tar 移走(模拟打包时漏了它),然后起栈:
docker compose up -d --pull never
预期结果是立刻退出,输出里有一句 No such image: postgres:16-alpine,退出码为 1。
再把它换成不加 --pull never 重跑一次,对比两次的输出:这一次会先打印 Pulling,然后经过一段等待,
最后才落到 failed to resolve reference。两次的根因完全相同,但第二次的报错把读者往「网络问题」上带。
这一条值得停一下,因为它是整个私有化交付里最容易踩的一类坑:报错信息指向的不是根因。
现场只有客户的人,他看到的第一个词决定了他接下来十分钟在查什么。
生产边界
| 教学里这样 | 真实交付要换成 | 不换会出什么事 |
|---|---|---|
| 靠 U 盘在机器之间搬包 | 按现场选定搬运通道(摆渡机 / 内网文件服务 / 刻录介质),并固定校验动作 | 160M 的包在多次中转里被截断,而没有人会在中途校验它 |
| 包内只有两个镜像 | 按现场规模评估:是否要带离线 Docker Engine 安装包、是否要带数据库扩展镜像 | 客户机器上根本没有 docker 时,整个包无法开始安装 |
docker save 全量导出 |
按现场带宽评估增量传输(基础层不变时只搬变化的那一层) | 每次升级都搬一个完整 tar,升级窗口被无谓地拉长 |
| 单架构镜像 | 按客户机器的 CPU 架构分别构建 | docker load 成功、一跑就 exec format error |
internal: true 只保证没路由 |
配合现场网络策略(防火墙、代理白名单)一起核 | 「有路由但被拦」与「没路由」的失败形态不同,排障会走偏 |
自测题
Network is unreachable与timed out的区别是什么?为什么这个区别决定了
「现场下一步该查什么」?- 为什么镜像文件名里用
__而不是:?这个约定除了绕开文件系统限制,
还顺手让install.sh的哪一步变得简单? - 用一句话说清为什么
--pull never在离线场景里不是优化而是默认。 - 交付包 160M,包内那个应用镜像 tar 有多大?为什么它不能压?
- 客户说「我起不来,报的是 resolve reference 失败」。你让他先跑哪一条命令,
能在一秒内把答案缩到「包漏了一个镜像」这个可能上?
现在能解释什么
internal: true在内核层面意味着什么,以及它为什么会被合规检查盯上;- 为什么「能拉到」在内网里是一个不成立的假设,以及它失败时会把读者带向哪里;
- 把镜像搬进内网的那个来回由哪三条约定固定,每条各自在防什么;
- 一个开关(
--pull never)如何把一段误导性报错压成一句能立刻行动的话; - 包里为什么必须有数据库镜像——它的缺失要到 04 章才会以「半启动」的形式再出现一次。