我把 Next.js 容器的启动命令改成 Bun 时,Dockerfile 里仍然保留了 tini:
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bun", "server.js"]第一反应很容易是:容器里只有一个 Bun 进程,为什么还要多套一层?
tini 不负责守护进程、负载均衡或自动重启,也不会让 Bun 跑得更快。它只处理一个很窄、但经常被忽略的问题:容器里的 PID 1 和普通 Linux 进程不完全一样。
容器的主进程就是 PID 1
Docker 容器的生命周期跟主进程绑定。主进程退出,容器就结束;docker stop 也会先向主进程发送停止信号。
如果直接写:
CMD ["bun", "server.js"]那么 Bun 就是容器里的 PID 1。
Docker 的 docker run 文档说明,PID 1 在 Linux 中受到特殊对待:如果程序没有为某个信号安装处理器,内核不会完全按照普通进程的默认动作处理它。主程序还要承担回收孤儿子进程的责任。
这些差异平时很难看见,往往到容器停止变慢、发布超时或进程表积累异常时才出现。
tini 做的两件事
Tini 项目 README对职责描述得很克制:启动一个子进程,等待它退出,同时转发信号并回收僵尸进程。
放到当前容器里,进程树是:
PID 1 /sbin/tini -- bun server.js
└─ bun server.js当 Docker 向 PID 1 发送 SIGTERM 时,tini 把信号转给 Bun。Bun 退出后,tini 使用子进程的退出码结束,Docker 因而能得到真实状态。
如果 Bun 或某个 npm 依赖创建了子进程,子进程结束后没有被原父进程正确 wait(),它会暂时成为僵尸进程。孤儿后代重新挂到 PID 1 后,tini 会负责回收,避免 PID 表长期累积。
僵尸进程不是后台进程
“僵尸”这个词听起来像还有程序在后台运行,其实相反。进程已经结束,内存和文件描述符通常也释放了,只剩一条退出状态,等待父进程读取。
少量僵尸不会立刻拖慢应用。问题在于 PID 数量有限,如果应用反复创建子进程又一直不回收,残留条目可能不断增加。
Next.js 博客平时不会频繁 spawn 子进程,出现这个问题的概率不高。我仍然保留 tini,因为基础镜像增加的成本很小,而依赖以后是否会创建子进程并不总是由应用代码决定。
exec form 仍然必须使用
有了 tini,不代表 Dockerfile 可以写成 shell form:
CMD bun server.jsDockerfile 参考文档说明,shell form 会先启动 /bin/sh -c,实际程序成为 shell 的子进程,shell 默认不会替你转发所有信号。
正确写法使用 JSON 数组:
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bun", "server.js"]-- 表示 tini 自己的选项到此结束,后面都是要启动的程序和参数。
如果使用自己的 entrypoint shell 脚本,最后也应该用 exec 替换 shell:
exec bun server.js否则 tini 转发给直接子进程的信号会先到 shell,仍然可能卡在中间。
为什么不用 docker run --init
Docker 支持:
docker run --init IMAGE这个选项会让 Docker 提供一个轻量 init 作为 PID 1,同样解决信号和僵尸回收问题。对临时容器来说,它很方便。
我把 tini 写进镜像,是为了让行为成为镜像的一部分:
- 本地
docker run不必记额外参数。 - Compose、面板和其他部署工具得到同一进程结构。
- 镜像自己声明需要怎样启动,不依赖宿主机命令习惯。
如果部署平台已经统一为所有容器注入 init,就没有必要再内置一份。两种方式选一种即可。
退出码 143 不代表应用崩溃
迁移后的容器曾经在启动十几秒后消失。日志只有:
Next.js ready没有异常,也没有 OOM。检查容器状态后看到:
ExitCode=143
OOMKilled=false143 等于 128 + 15,其中 15 是 SIGTERM。后来确认是 WSL 环境停止了 Docker,tini 正常把信号转给 Bun,Bun 随之退出。
这段经历反而证明了信号链路是通的。看到 143 应该先查谁发送了停止信号、Docker daemon 是否退出、部署平台是否触发滚动更新,而不是先认定 Bun 崩溃。
Tini 也支持把特定退出码重新映射为 0,例如 -e 143。我没有这样做,因为容器被外部终止仍然是有意义的状态,隐藏它会让诊断更难。
tini 是不是绝对必要
不是。一个正确处理信号、不会产生未回收子进程的程序,可以直接作为 PID 1。Docker 的 exec form 已经避免了额外 shell,Bun 也能正常运行这种容器。
我保留 tini 的理由是工程上的:Next.js 和 npm 依赖会变化,容器需要可靠接收停止信号,而 tini 的职责单一、行为成熟。它是一层便宜的保险,不是运行 Bun 的前置条件。
完整 Docker 迁移过程见 Next.js 16 博客完整迁移到 Bun 1.4。镜像分层和推送体积则单独记录在 Next.js standalone Docker 镜像分层优化。