...
Back

本地能构建,别处都不行:一次 Dockerfile 复盘

我们花了一整天没能把一个 monorepo 容器化。每一次失败都是同一个失败换了张错误信息的皮:构建上下文里没有我们以为有的东西。

本地能构建,别处都不行:一次 Dockerfile 复盘

本地能构建,别处都不行 🐳

那天的提交记录不太好看。连着十六个提交,每一个都是同一句话的变体:修一下,让 Dockerfile 找到它找不到的那个东西。调整 COPY 顺序。合并 COPY。从 .dockerignore 里删一条。最后放弃,直接 COPY . .

值得写下来,是因为这通乱撞只有一个原因,而这个原因是结构性的,谈不上什么技巧。


容器构建不是"在某个目录里构建"

你在本地跑 bun run build 时,进程能看见整个文件系统。它能从所在的包里向上走。它能顺着软链接进到同级的 workspace。它能读到比你告诉它的那个位置高两层的配置文件。

你跑 docker build 时,以上全都不成立。守护进程拿到的是一个构建上下文 —— 由你的目录打包成的 tar,减去 .dockerignore 排除的部分 —— 而构建过程只能看见你从这个 tar 里显式拷出来的东西,并且只能按你拷贝的顺序看见。

我们那十六次失败全是同一个 bug:构建需要的某个东西,要么根本不在上下文里,要么在需要它的时刻还没被拷进来。错误信息却千差万别。turbo.json not found。模块解析失败。某个 workspace 包解析成了空。看上去像四个独立问题,其实是一个。


monorepo 会让它具体地更糟

单包仓库里,最朴素的 Dockerfile 就能跑通,所以没人学到这一课。monorepo 里它立刻就坏,因为让 monorepo 有用的那件事 —— 包与包之间经由 workspace 根互相引用 —— 恰好就是构建上下文最乐意从中间砍断的那件事。

我们踩到的三个具体陷阱:

根配置不是可选项。 turbo.json、根 package.json、lockfile、workspace 通配,都住在顶层。如果你的 .dockerignore 写得太狠 —— 我们有一条忽略构建产物的规则,顺带匹配上了 turbo.json 所在的位置 —— 编排器就消失了,而报错会去怪下游三步远的另一个东西。

拷贝顺序同时决定缓存行为和正确性。 标准做法是先拷清单文件、装依赖、再拷源码,这样改一行源码不会重装依赖。这是对的,但在 monorepo 里,"清单文件"意味着每一个 workspace 的 package.json,而且要在各自正确的嵌套路径上,外加根部那几个。漏一个,安装会悄无声息地产出一棵和你本地不一样的依赖树。

COPY . . 能让它跑通,也能让它变慢。 这是一个正当的调试步骤 —— 它能证明问题出在上下文而不是你的代码。但它不是终点。我们短暂地上过线,然后又退了回来,因为它把整个仓库塞进一个每次编辑都会失效的层。


我们改了什么

修法是:别再猜上下文里有什么,直接去看。

# 实际发给守护进程的到底是什么?
docker build --no-cache --progress=plain -t probe . 2>&1 | head -40
 
# 我以为拷进去的那个文件,真的在镜像里吗?
docker build -t probe . && docker run --rm probe ls -la /app
 
# 是哪条 .dockerignore 规则吃掉了我的文件?
#   最快的答案是把整个文件注释掉再构建一次。

最后那条在猜了一整天之后,两分钟就给出了答案。吃掉 turbo.json 的那条规则,压根不是为它写的;它是几年前为别的东西写的,只是碰巧匹配上了。


同一个错误的运行时版本

构建通过之后,同一类错误会转移到运行阶段,所以这两半值得连起来读。

Next.js 的 standalone 输出正是为解决这个而存在的 —— 它会追踪服务端真正需要哪些文件,然后产出一棵最小的树。但它产在一个特定路径上,而其中的服务端是一个朴素的 Node 入口,不是你的 package 脚本。我们在 standalone 构建上跑 bun run start 浪费了时间(它做的事和看上去不一样),又在把 CMD 指到输出目录错误的嵌套层级上浪费了更多。

两条能省掉全部时间的规则:

  1. 直接启动 standalone 的服务端,用构建真正产出的那个路径。别经由某个在裁剪过的树里解析结果不同的 package 脚本。
  2. 构建时变量和运行时变量是两回事。 任何要被内联进客户端产物的值,必须在构建期间就存在。在部署里设置已经太晚了 —— 那个值是一小时前被烘进去的,或者没有。

通用形状

如果本地能构建而容器里失败,第一个该检验的假设不是"容器配错了",而是:

构建上下文里没有我以为有的东西,或者此刻还没有。

这条几乎覆盖全部情况。检验它的成本是一次 docker run ... ls。我们在第十七次尝试时做了它。第一次做就该成了。