2. 容器化基础与Docker实战

容器镜像不是一个“装在盒子里的操作系统”,而是由一组只读层叠加而成的文件系统快照,再配合一份描述启动方式的元数据。理解这一点,是写好 Dockerfile 的前提。Docker 使用联合文件系统(如 overlay2)把多个只读层挂载成一个统一视图,容器启动时再在最上方加一个可写层。因此,镜像层是共享且不可变的:相同的基础层在多个镜像之间只存一份,这也是容器比虚拟机轻量的关键原因。

一个镜像的引用通常写成 仓库地址/命名空间/镜像名:标签,例如 gcr.io/my-project/api:v1.2.0。标签只是人类可读的别名,真正决定内容的是摘要(digest),形如 sha256:...。在生产交付中,用 digest 固定镜像比用 latest 更可靠,因为 latest 会随推送而变化,导致“同一标签、不同内容”的不可复现问题。

Dockerfile 的构建逻辑

Dockerfile 是一份按顺序执行的构建指令清单。每条会产生文件系统变化的指令(如 RUN、COPY、ADD)都会生成一个新层。Docker 在构建时会复用缓存:只要某条指令的输入(指令文本、上下文文件内容)与上次一致,就直接使用缓存层。因此,把变化频率低的指令放前面、变化频率高的放后面,能显著加快构建。

下面是一个典型的 Node.js 服务 Dockerfile 示例,注意依赖安装与源码拷贝的先后顺序:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]

这里 COPY package*.json ./ 与 RUN npm ci 被单独提前,是为了让“依赖不变、只改业务代码”的日常提交能命中依赖层缓存,避免每次重新下载全部依赖。npm ci 依据 package-lock.json 精确安装,比 npm install 更适合可复现构建。

多阶段构建:把编译环境和运行环境分开

很多语言需要编译工具链,但运行时并不需要它们。多阶段构建允许在一个 Dockerfile 中定义多个 FROM 阶段,只把最终产物拷贝到精简的运行阶段,从而同时缩小体积和攻击面。

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

构建阶段使用完整的 Go 工具链,运行阶段只保留一个静态二进制和 distroless 基础镜像。最终镜像可能只有十几 MB,而单阶段构建往往超过 800 MB。注意 --from=builder 只拷贝需要的产物,编译缓存、源码和工具链都不会进入最终镜像。

多阶段构建与镜像层结构 构建阶段 builder golang:1.22(约 800MB) FROM golang:1.22 AS builder COPY go.mod go.sum → go mod download COPY . . → go build -o /out/app 产物:/out/app(静态二进制) 编译缓存 / 源码 / 工具链 不进入最终镜像 COPY --from=builder 运行阶段 final distroless/static(约 15MB) FROM distroless/static-debian12 COPY --from=builder /out/app /app USER nonroot:nonroot ENTRYPOINT ["/app"] 最终镜像:仅二进制 + 最小根文件系统 体积小、无 shell、攻击面低 镜像 = 只读层叠加 + 元数据;容器启动时在最上方追加可写层 基础层 依赖层 应用层 可写层

镜像优化的几个实际抓手

优化不是盲目追求最小体积,而是在体积、构建速度、可调试性和安全性之间取平衡。常用手段包括:选择更小的基础镜像(alpine、distroless、scratch);合并 RUN 并在同一层内清理缓存,例如 apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/*;使用 .dockerignore 排除 node_modules、.git、构建产物等无关文件,避免它们进入构建上下文并破坏缓存;固定基础镜像的 digest 或精确版本标签。

需要提醒的是,alpine 使用 musl libc,某些依赖 glibc 的二进制可能无法直接运行;distroless 镜像没有 shell,无法 docker exec 进去排查,调试时需临时换用带 shell 的镜像。这些取舍要在项目早期就明确。

本地运行与调试

构建完成后,用 docker build -t api:v1 . 打标签,再用 docker run --rm -p 8080:8080 api:v1 启动。若容器立即退出,先用 docker ps -a 找到容器 ID,再执行 docker logs <id> 查看标准输出与错误。常见原因包括:CMD 指向的进程前台运行失败、端口未监听、缺少环境变量。

需要交互排查时,可以覆盖入口进入 shell:docker run --rm -it --entrypoint sh api:v1。若镜像基于 distroless,则改用 docker run --rm -it --entrypoint /app api:v1 直接观察程序行为。挂载本地目录做热更新也很实用:docker run --rm -p 8080:8080 -v $(pwd):/app api:v1,但要注意挂载会覆盖镜像内同路径内容,依赖目录需单独用匿名卷保护。

调试顺序建议:先看 docker logs 判断进程是否启动,再用 docker inspect 检查端口、环境变量和挂载,最后才进入容器内部排查文件与依赖。多数“容器起不来”的问题在日志阶段就能定位。

一份可复用的构建检查清单

检查项判断标准常见问题
基础镜像版本固定,非 latest标签漂移导致构建结果不一致
层顺序依赖安装先于源码拷贝改一行代码就重装全部依赖
构建上下文存在 .dockerignorenode_modules、.git 被打进镜像
运行用户非 root容器内进程拥有过高权限
最终体积多阶段构建后明显下降编译工具链残留在运行镜像
启动验证docker logs 无报错,端口可访问CMD 非前台进程导致容器退出

把镜像当作“可复现的交付物”来对待:每次构建都应有确定的输入(基础镜像 digest、锁文件、源码提交),产出确定的镜像 digest。这样在后续推送到 Artifact Registry、部署到 GKE 或 Cloud Run 时,才能做到“构建一次、处处运行”,也为回滚和审计留下可靠依据。