- Published on
Dockerfile 里反复出现的错误 — 以 root 运行、PID 1,以及留在镜像里的密钥
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 构建能过、只在生产环境炸掉的那些东西
- 以 root 运行 — 谁都不指定的话就是 0 号
- latest 标签与可复现性
- ADD 与 COPY — 默认就该用 COPY
- PID 1 这个位置 — SIGTERM 为什么到不了应用
- 留在镜像历史里的密钥
- HEALTHCHECK、攻击面,以及文件属主
- 结语 — Dockerfile 是一份执行契约
引言 — 构建能过、只在生产环境炸掉的那些东西
Dockerfile 的问题在构建阶段几乎不会暴露。镜像造出来了,容器起来了,本地跑得也很好。问题出在之后。
每次部署时 docker stop 都会恰好等满 10 秒再强制终止容器,退出码打出来的是 137 而不是 143。那 10 秒里正在处理的请求就那样被掐断。几个月后,进程表里堆了几千个僵尸,安全扫描则从镜像历史里挖出了部署用的令牌。
本文从原因一侧而不是症状一侧来看这些问题。大多数用两三行 Dockerfile 就能解决,但如果不知道为什么要这么做,下一个项目里还会原样重演。
以 root 运行 — 谁都不指定的话就是 0 号
没有使用 USER 的镜像会以 UID 0 运行。
docker run --rm node:22-bookworm-slim id
uid=0(root) gid=0(root) groups=0(root)
"容器是隔离的,所以里面是 root 也无所谓"这种说法流传了很久,但在默认设置下,容器里的 root 和宿主机的 root 是同一个 UID。只要没开用户命名空间,一旦把宿主机文件系统挂成卷,这件事立刻就原形毕露。
docker run --rm -v /etc:/host-etc alpine:3.20 \
sh -c 'echo "# injected" >> /host-etc/hosts && tail -1 /host-etc/hosts'
# injected
在容器里改掉了宿主机的 /etc/hosts。这上面再加一个运行时漏洞,逃逸就等于直接拿到宿主机 root 权限。
修法是固定的。建一个用户,把需要的路径的属主给它,再用 USER 切换过去。
FROM node:22-bookworm-slim
WORKDIR /app
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --shell /usr/sbin/nologin --create-home app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
USER 10001:10001
EXPOSE 8080
CMD ["node", "dist/server.js"]
用数字 UID 而不是名字是刻意的。Kubernetes 的 runAsNonRoot 在镜像配置里的 USER 值是名字时,无法判断它是不是 root,于是会拒绝这个 Pod。
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
写成 USER app 再套上面这份配置,你会看到这样的事件。
Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root
换成非特权用户之后就打不开 1024 以下的端口了。这里也可以选择加上 NET_BIND_SERVICE capability,但把应用端口改成 8080、在服务层暴露成 80 要简单得多。
latest 标签与可复现性
FROM node:22 很方便,但它随时间指向不同的东西。今天的构建和下周的构建会落在不同的基础镜像上,出故障时你追不出"上周还好好的"到底是怎么回事。
docker pull node:22-bookworm-slim
docker inspect --format '{{index .RepoDigests 0}}' node:22-bookworm-slim
node@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31
用摘要固定住,无论什么时候构建都能保证是同一个基础镜像。
FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31
这里常见的反驳是"那就打不上安全补丁了"。这话说得对,所以更新这件事不交给人,交给机器人。Dependabot 或 Renovate 会开一个提升摘要的 PR,CI 验证过那个组合之后再合并。这就是自动流进来和经过验证再进来的区别。
应用镜像的标签也一样。用 myapp:latest 部署,就没有可回滚的对象。用提交 SHA 作为标签,需要的话再额外挂一个 latest,这个程度才算安全。
ADD 与 COPY — 默认就该用 COPY
这两条指令看着像,但 ADD 干的事多得多。而那些多出来的行为,大部分都是问题。
ADD 会自动解开本地的 tar 归档。
ADD release.tar.gz /opt/app/
这一行不是复制 release.tar.gz,而是把它解压。要是归档里含有 ../ 路径或者符号链接,文件就会被写到意料之外的位置。如果本来只想原样放着这个文件,那应该用 COPY。
ADD 还接受 URL。
ADD https://example.com/tool.tar.gz /tmp/
RUN tar -xzf /tmp/tool.tar.gz -C /opt && rm /tmp/tool.tar.gz
没有校验和验证,压缩包留在层里,而且在后面的 RUN 里删掉体积也不会变。需要远程文件的话,在 RUN 里面下载、验证、删除更合适。
RUN set -eux \
&& curl -fsSL -o /tmp/tool.tar.gz https://example.com/tool-1.4.2.tar.gz \
&& echo "3f0a91c2e4d7b5a8f16c9d02e3b7a41f8c5d29e06b3a7f1c4d8e2b0a95f3c716 /tmp/tool.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/tool.tar.gz -C /opt \
&& rm -f /tmp/tool.tar.gz
ADD 站得住脚的场景只有两个:有意去解开本地的 tar,以及在 BuildKit 上直接拉取 git 仓库。除此之外永远是 COPY。
PID 1 这个位置 — SIGTERM 为什么到不了应用
最昂贵的错误就在这里。症状是每次部署都会重演的 10 秒延迟。
FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
CMD npm start
docker run -d --name shellform demo:shell
time docker stop shellform
docker inspect -f '{{.State.ExitCode}}' shellform
shellform
real 0m10.412s
137
10 秒是 Docker 默认的终止宽限期,137 是 SIGKILL。如果是正常退出,本该立刻结束并给出 143(SIGTERM)。
原因有两层。用 shell form 写的 CMD npm start,实际执行的是 /bin/sh -c "npm start"。于是 PID 1 变成了 shell。而 PID 1 在内核里受到特殊对待:没有显式注册处理函数的信号,其默认动作不会生效。也就是说,SIGTERM 来了也只是被忽略。
这里常流传的解释是"shell 不会把信号传给子进程",这只对了一半。Debian 的 dash 和 alpine 的 busybox ash 会做一个优化:如果 sh -c 后面只有一条简单命令,就不 fork 而直接用 exec 替换自身。这样 shell 就消失了,目标进程成为 PID 1。问题在于这个优化是有条件的。命令里一旦出现管道、&&、变量展开或重定向,shell 就会留下来。依赖 shell 的行为,总有一天会悄无声息地坏掉。
而第二层更常见。npm、yarn、pnpm 这类包装器,即便 shell 被 exec 替换掉,它们自身也还是会拉起子进程的管理者。npm 长期以来都不把 SIGTERM 转发给子进程。所以就算去掉了 shell,应用照样收不到信号。
修法是改用 exec form,并把包装器摘掉。
CMD ["node", "dist/server.js"]
docker run -d --name execform demo:exec
time docker stop execform
docker inspect -f '{{.State.ExitCode}}' execform
execform
real 0m0.284s
143
应用一侧也需要接收信号并做清理的代码。Docker 只负责投递信号,不会替你完成优雅退出。
const server = app.listen(8080)
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
server.close(() => process.exit(0))
setTimeout(() => process.exit(1), 15000).unref()
})
}
确实需要入口脚本时,最后一行必须用 exec。少了它,脚本就会作为 PID 1 留下来,同样的问题会再现。
#!/bin/sh
set -e
./wait-for-db.sh
exec "$@"
COPY entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/server.js"]
终止宽限期也要一并调整。给一个需要 15 秒的应用留着 10 秒的默认值,每次都会被截断。
docker run -d --stop-timeout 30 api:v312
# Kubernetes
spec:
terminationGracePeriodSeconds: 30
PID 1 的另一项职责 — 回收僵尸进程
修好了信号传递,并不意味着 PID 1 的问题就结束了。在 Linux 上,失去父进程的进程会被 PID 1 收养,而 PID 1 承担着用 wait 收走它退出状态的责任。init 系统会做这件事。Node 或 Python 进程不会。
如果应用在容器里频繁创建子进程,僵尸就会堆积。
docker exec api ps -eo pid,stat,comm | grep -c 'Z'
1847
进程表一满,新进程的创建就会失败,其症状会在应用日志里表现为 fork: Resource temporarily unavailable。
解法是把一个真正的 init 放到 1 号位。最简单的办法是 Docker 的内建选项。
docker run -d --init api:v312
docker exec api ps -eo pid,comm
PID COMMAND
1 docker-init
7 node
想放进镜像本身的话,就用 tini。
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]
Kubernetes 没有与 --init 对应的 Pod 选项,所以只能在镜像一侧处理。不过如果是不创建子进程的单进程服务,其实没必要。先确认它会不会拉起子进程,再决定就行。
留在镜像历史里的密钥
要从私有仓库拉包,构建过程中就需要令牌。这里有两种常用做法,而它们都是错的。
# 反模式 1:ENV
ENV NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ
RUN npm ci
# 反模式 2:ARG
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
&& npm ci \
&& rm ~/.npmrc
第一种会永久留在镜像配置里。
docker inspect -f '{{json .Config.Env}}' bad:v1 | tr ',' '\n'
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
"NODE_VERSION=22.14.0"
"NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ"]
第二种虽然删掉了 ~/.npmrc,但正如上一节所见,它在下层原样留着,而在经典构建器上连历史里也会留着。
docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ
如果已经推到了镜像仓库,就该把那个令牌视为已经泄露并作废掉。删掉镜像是挽回不了的。
正确的做法是 BuildKit 的 secret mount。密钥只在对应的 RUN 执行期间以 tmpfs 挂载,不会被写进层里。
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > /tmp/npmrc
docker buildx build --secret id=npmrc,src=/tmp/npmrc -t api:v312 .
shred -u /tmp/npmrc
也可以直接从环境变量传进去。
docker buildx build --secret id=npmtoken,env=NPM_TOKEN -t api:v312 .
RUN \
NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci --omit=dev
如果需要拉取私有 git 仓库,还可以转发 SSH agent。
RUN \
git clone git@github.com:org/private-lib.git /opt/lib
docker buildx build --ssh default -t api:v312 .
验证要变成习惯。推送之前把历史和配置扫一遍,大部分事故就被筛掉了。
docker history --no-trunc api:v312 | grep -Ei 'token|secret|password|key='
docker inspect -f '{{json .Config.Env}}' api:v312
HEALTHCHECK、攻击面,以及文件属主
剩下的这些条目单看都很小,但凑在一起会改变运维质量。
HEALTHCHECK 把健康判定的标准埋进镜像里。
HEALTHCHECK \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
这里有两个误解。第一,健康检查失败时 docker run 并不会重启容器。只是把状态标成 unhealthy,自动处置是 Swarm 或编排器的职责。第二,Kubernetes 完全忽略镜像里的 HEALTHCHECK。在 Pod 里必须另行定义 livenessProbe 和 readinessProbe。而且 distroless 镜像里既没有 wget 也没有 curl,所以更好的做法是一并放进一个用于健康检查的小二进制,或者在应用里做一个状态检查子命令。
攻击面说的是最终镜像里还剩下什么。构建时用到的 curl、git、gcc 如果留在运行时镜像里,等于把工具亲手递给了入侵者。用多阶段构建分开是第一答案,做不到的话至少要收窄安装范围。
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
单靠 --no-install-recommends,在 Debian 系上就能少掉几十 MB 和几十个软件包。
文件属主和体积直接相关。先复制再改属主这种写法,会把整批文件重新写进一个新层。
# 反模式:层会翻倍
COPY . /app
RUN chown -R app:app /app
COPY . /app 286MB
RUN chown -R app:app 286MB
用 --chown 的话,属主在复制的同时就定下来了,所以只有一层。
COPY . /app
如果以只读根文件系统运行,就只把需要写入的路径单独开出来。
docker run -d --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /run:rw,noexec,nosuid,size=8m \
api:v312
| 错误 | 表现出的症状 | 修法 |
|---|---|---|
未指定 USER | 容器以 root 运行,违反 k8s 策略 | 用数字 UID 写 USER 10001:10001 |
FROM node:22 | 昨天的构建和今天的构建不一样 | 固定摘要 + Renovate |
滥用 ADD | 归档自动解压、无验证的下载 | COPY 或在 RUN 里做校验和验证 |
shell form 的 CMD | docker stop 要 10 秒,退出码 137 | JSON 数组的 exec form |
用包装器启动(npm start) | SIGTERM 到不了应用 | 直接执行运行时二进制 |
| 没有 init | 僵尸堆积、fork 失败 | --init 或 tini |
用 ENV/ARG 传令牌 | 在 docker history 里明文暴露 | RUN --mount=type=secret |
RUN chown -R | 镜像体积翻倍 | COPY --chown |
| 依赖 HEALTHCHECK | 在 k8s 上被忽略、毫无作用 | liveness/readiness 探针 |
结语 — Dockerfile 是一份执行契约
本文的这些条目看着彼此不同,根却是同一个。Dockerfile 不是一个把文件收集起来的脚本,而是写明这个进程以谁的身份、用什么权限、如何启动又如何结束的契约书。
评审一份新的 Dockerfile 时要看的是五行。FROM 有没有固定,USER 有没有用数字 UID 指定,CMD 和 ENTRYPOINT 是不是 JSON 数组,密钥有没有通过 ARG 或 ENV 进来,以及最终阶段里有没有残留构建工具。光是让这五条通过,生产环境里遇到的事故就会消失大半。