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,内核直接拒绝。区别在于:

分清这两者,是这一章后面所有判断的前提。把它画出来:

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

这张图要带走两件事:

  1. internal: true 不是一句注释,它是一条内核层面的规则。 客户的内网合规检查常常就查这一条——「数据库不能被从外面直接连到」。
  2. 只有 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

看清楚这里发生了什么:

唯一能让真相排在第一位的方法,是明确禁止「拉」这个动作。 下一节讲怎么做。

三、搬运只有一条路:随包直送

回到第一章那个结论:把镜像送进内网只有三条路,前两条走不通。所以真正要设计的不是「选哪条」,而是这条唯一的路怎么走稳。

动作本身只有一个来回:

# 我们这边:把镜像从本地 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

要观察三件事,而不是「有输出就算跑过」:

  1. board:1.0.0 与 postgres:16-alpine 这两个名字都在。 少任何一个,
    后面不管怎么调编排文件都不会起来。
  2. docker compose config --services 能列出服务名(它只做客户端解析,不需要 daemon 在跑)。
    这四行是拓扑顺序,04 章会解释它的意义。
  3. 两个镜像都不是从网上拉来的——如果你是在有外网的机器上做这个练习,这一步看着没差别;
    把网络断掉再跑一次,才看得出「本地有」和「能拉到」有什么区别。

完成标志:能不看笔记说出「包里的制品有哪两样、各自为什么不能少」。

故障注入

把 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 只保证没路由 配合现场网络策略(防火墙、代理白名单)一起核 「有路由但被拦」与「没路由」的失败形态不同,排障会走偏

自测题

  1. Network is unreachable 与 timed out 的区别是什么?为什么这个区别决定了
    「现场下一步该查什么」?
  2. 为什么镜像文件名里用 __ 而不是 :?这个约定除了绕开文件系统限制,
    还顺手让 install.sh 的哪一步变得简单?
  3. 用一句话说清为什么 --pull never 在离线场景里不是优化而是默认。
  4. 交付包 160M,包内那个应用镜像 tar 有多大?为什么它不能压?
  5. 客户说「我起不来,报的是 resolve reference 失败」。你让他先跑哪一条命令,
    能在一秒内把答案缩到「包漏了一个镜像」这个可能上?

现在能解释什么

进入 keel 阅读