本地 docker run 跑得好好的服务,推到服务器就报 exec format error、not found,或进程一起来就退。答案几乎都在一处:镜像不是"打包好的文件夹",而是一叠只读层加一份元数据。搞清这叠层怎么叠、何时失效、谁决定体积,构建慢、镜像大、跑不起来这三件事就同时可解释。
1. 分层结构与写时复制
镜像由若干只读层(layer)堆叠,容器启动时在最上面加一层可写层。读文件从上往下找第一个命中的层;写文件不改原层,而是把该文件复制到可写层再改,即写时复制(Copy-on-Write)。所以"容器里删了大文件,镜像没变小"、"改了镜像里配置,原层文件还在"都属正常。
层用内容哈希标识(sha256:...),同一份内容可被多个镜像共享。docker images 的 SIZE 是逻辑大小,docker system df 才是实际占用。
docker image history nginx:alpine
docker image inspect nginx:alpine --format '{{json .RootFS.Layers}}'
docker system df -v
| 概念 | 是什么 | 常见误解 |
|---|---|---|
| 只读层 | 每条 Dockerfile 指令产生的 diff | 以为改一层只影响那一层 |
| 容器可写层 | 运行时新写的文件 | 以为写进容器等于写进镜像 |
| 层哈希 | 内容寻址的 ID | 以为与提交顺序无关——父层变了它也变 |
| 共享层 | 多镜像共用的相同层 | 以为删一个镜像能省一整份空间 |
最关键的一条规则:某层哈希变了,它和它之后的所有层全部失效。这就是"改一行源码导致重装依赖"的根因,后续缓存优化都是它的推论。
2. 指令顺序为什么决定缓存命中
构建时每条指令算一个缓存键,键由"父层 ID + 指令内容"组成。命中直接复用,不命中就重跑并让后续全部重跑。
# 差:任意源码改动都打穿依赖安装
COPY . /app
RUN npm install --omit=dev
# 好:依赖清单先拷,源码后拷
COPY package.json package-lock.json /app/
RUN npm ci --omit=dev
COPY src/ /app/src/
| 指令 | 缓存键包含什么 | 最容易被什么打穿 |
|---|---|---|
FROM |
基础镜像 digest | 基础镜像被重推,latest 悄悄换版本 |
RUN |
父层 ID + 命令字符串 | 命令改一个空格、包管理器拉了新版本 |
COPY |
父层 ID + 被拷文件内容哈希 | 源码改动;.dockerignore 写漏 |
ADD |
同 COPY,另含解压与远程下载 |
远程 URL 内容变化不进哈希 |
ENV / ARG |
键值或使用它的指令 | 改变量值,后续层全失效 |
排序原则一句话:变化频率低的放上面——系统依赖与运行时 → 依赖清单 → 源码 → 构建命令。
3. 构建上下文与 .dockerignore
docker build . 里的 . 不是"代码目录",而是构建上下文:客户端先把整个上下文打包交给构建器。把两个 G 的 node_modules、.git、日志送进去,代价是每次构建都搬一遍,且它们对 COPY . . 完全可见。
# .dockerignore
.git
node_modules
dist
*.log
| 写法 | 匹配范围 | 注意 |
|---|---|---|
node_modules |
任意层级的同名项 | 根路径与子路径都命中 |
/dist |
仅上下文根下的 dist | 前导斜杠锚定根 |
**/tmp |
任意层级 | 依赖 glob 实现 |
!README.md |
反选重新包含 | 顺序敏感,写在排除之后 |
.dockerignore 与 .gitignore 不是同一份文件,Docker 不读 .gitignore;当成同一份是"镜像里莫名多了测试数据"的常见来源。
4. 多阶段构建
编译型语言的镜像通常不需要编译器。多阶段构建让每个 FROM 成为独立阶段,用 COPY --from= 只把产物搬进最终镜像。
FROM node:20-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production TZ=Asia/Shanghai
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
USER node
CMD ["node", "dist/server.js"]
| 做法 | 镜像里有什么 | 体积量级 |
|---|---|---|
| 单阶段 + 完整 SDK | 编译器、源码、缓存目录 | 最大,常按 GB 计 |
| 多阶段 + 完整运行时 | 运行时 + 产物 | 数百 MB |
| 多阶段 + slim / distroless | 精简运行时 + 产物 | 最小,后者无 shell |
体积随语言与依赖树波动很大,按你的依赖树实测 docker images 核对,别照抄别人的数字。
5. 基础镜像四类与 glibc/musl 的坑
| 类别 | 例子 | 体积 | 有 shell | 主要代价 |
|---|---|---|---|---|
| 完整发行版 | node:20、python:3.12 |
大 | 有 | 体积与攻击面最大,兼容性最好 |
| 精简版 | node:20-slim、debian:bookworm-slim |
中 | 有 | 部分系统包要自己补 |
| Alpine | node:20-alpine |
小 | 有(busybox) | musl libc,glibc 二进制不兼容 |
| distroless | gcr.io/distroless/nodejs20 |
最小 | 无 | 不能 exec 进去排查 |
差异根子在 libc:Debian/Ubuntu 系用 glibc,Alpine 用 musl。预编译原生扩展(node-gyp 产物、带 .so 的 wheel、cgo 包)按 glibc 编译,搬到 Alpine 就报 Error loading shared library 或 not found——后者最坑,文件明明在,缺的是动态链接器。
| 报错 | 真实原因 | 处理方向 |
|---|---|---|
not found 但文件存在 |
缺 musl 动态链接器 | 换 Debian slim 系,或改静态编译 |
Error loading shared library |
glibc 二进制跑在 musl 上 | 同上 |
exec format error |
架构不匹配(amd64 vs arm64) | --platform 指定或多架构构建 |
选择策略:业务镜像优先 slim;依赖纯净且追求体积再上 Alpine;distroless 用于不需进容器排错的场景。
6. 体积排查、COPY 与 ADD、ARG 与 ENV
先量再改:docker history --no-trunc --human myapp:1.0 看哪条指令贡献多少字节,dive myapp:1.0 --ci 看每层新增与冗余文件。
# COPY 与 ADD:能用 COPY 就别用 ADD
COPY ./config.yml /app/config.yml # 本地拷贝,缓存键只含内容
ADD app.tar.gz /app/ # 自动解压 tar,行为隐式
ADD https://example.com/x.sh /app/ # 远程下载:不入哈希、不校验
# ARG vs ENV
ARG NODE_ENV=production # 仅构建期可见
ENV APP_HOME=/app TZ=Asia/Shanghai # 进入运行时环境
RUN echo "$NODE_ENV" > /tmp/b.txt # 只有 ARG 在这里可用
ARG 作用域到使用它的 RUN 为止,不进最终镜像的环境变量;ENV 写进镜像配置,docker inspect 可见。敏感值别用 ARG 传——它留在构建历史里。
6.1 三件常被漏掉的配置
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
RUN useradd -r -u 10001 -m appuser && chown -R appuser /app
USER appuser
| 配置 | 不做的后果 | 做法 |
|---|---|---|
| 时区 | 默认 UTC,日志与业务时间差 8 小时 | 装 tzdata + /etc/localtime 软链,并设 TZ |
| locale | 中文排序/编码异常,Python 报 UnicodeEncodeError |
LANG=C.UTF-8,必要时 locale-gen |
| 非 root 用户 | 进程以 root 跑,挂载目录属主全是 root | useradd + USER,提前 chown 工作目录 |
隐蔽变体:只声明 ENV TZ 而镜像里没有 tzdata,Java 或 Go 仍按 UTC 算。
6.2 最小验证流程
改完 Dockerfile 别急着推,先在本地按顺序验证四件事:
docker build -t myapp:dev . # 1. 构建能否通过缓存复用
docker run --rm myapp:dev id # 2. 进程是否非 root
docker run --rm myapp:dev date # 3. 时区是否正确
docker image history myapp:dev --no-trunc | head # 4. 有无意料之外的大层
四步都过再谈推送与部署;第 2、3 步是最容易被"构建成功"掩盖的两个配置错误。
7. 标签策略与推送拉取
docker build -t registry.example.com/team/myapp:1.4.2 .
docker push registry.example.com/team/myapp:1.4.2
docker pull registry.example.com/team/myapp@sha256:abc123
| 引用方式 | 是否可变 | 适用场景 |
|---|---|---|
myapp:latest |
可变,本地已有则不拉 | 本地试用;部署禁用 |
myapp:1.4.2 |
约定不变,但可被重推覆盖 | 常规发布,配合版本纪律 |
myapp@sha256:... |
不可变 | 生产部署、审计、回滚定位 |
"latest 拉不到最新"不是 bug:不带 --pull 时本地已有该标签就不访问仓库。固定 digest 的好处是回滚目标明确——标签可能被重推覆盖,digest 不会。镜像加速器只作用于拉取侧,配在 /etc/docker/daemon.json 的 registry-mirrors,改完重启 dockerd。
8. 排错表
| 现象 | 根因 | 处理 |
|---|---|---|
| 改一行代码重装全部依赖 | COPY . . 写在 RUN install 之前 |
依赖清单先单独 COPY |
| 服务器拉到的还是旧镜像 | 用 latest 且未 --pull |
用固定 tag 或 digest,部署前显式 pull |
镜像混进 node_modules、.git |
没有 .dockerignore,或只写了 .gitignore |
补文件并确认 COPY 范围 |
| 容器时间早 8 小时 | 缺 tzdata,TZ 未生效 |
装 tzdata 并建软链 |
| 挂载目录属主是 root | 未设 USER |
建非 root 用户后 chown |
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 需要进容器排查线上问题 | 保留 slim/完整版 | 无 shell 时 exec 进去也没东西可用 |
| 依赖含大量预编译 glibc 二进制 | 用 Debian slim | 绕开 musl 的成本高于省下的体积 |
| 每次构建依赖都在变 | 私有包代理/镜像仓库缓存 | 层缓存救不了每次都变的依赖树 |
核心是先量再改:docker history 一行就能看出大头是基础镜像还是某条 RUN。
10. 自测
- 为什么改了一层之后,后面的层都会失效?
COPY . .放在RUN npm ci之前,代价具体体现在哪一步?.dockerignore与.gitignore的关系是什么?- 多阶段构建真正省下的是哪部分体积?
- Alpine 里文件明明存在却报
not found,缺的是什么? ARG与ENV的作用域差别,以及为什么敏感值不该用ARG传?