2604 字
约 8 分钟
13
S15-B07 · 容器镜像入门:分层、构建缓存与体积控制

本地 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. 自测

  1. 为什么改了一层之后,后面的层都会失效?
  2. COPY . . 放在 RUN npm ci 之前,代价具体体现在哪一步?
  3. .dockerignore 与 .gitignore 的关系是什么?
  4. 多阶段构建真正省下的是哪部分体积?
  5. Alpine 里文件明明存在却报 not found,缺的是什么?
  6. ARG 与 ENV 的作用域差别,以及为什么敏感值不该用 ARG 传?
S15-B07 · 容器镜像入门:分层、构建缓存与体积控制
https://www.clxhxhhr.top/posts/6112/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。