ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Dockerfile 编写与镜像构建实战:从入门到体积优化

Dockerfile 编写与镜像构建实战:从入门到体积优化

1. 为什么做镜像要选Dockerfile

上一篇文章里我详细讲过容器的生命周期管理,包括启动、停止、删除这些高频操作。评论区有不少朋友留言问同一个问题:我现在容器里装了一堆环境、放了一堆代码,能不能直接把当前这个容器“另存为”成镜像,下次直接用?

当然可以,docker commit确实能做到,但这里必须先泼一盆冷水:生产环境中用 commit 生成镜像,基本属于灾难现场。原因我在实操中踩过几次之后就彻底明白了——commit 会把容器运行时产生的临时文件、缓存、历史命令记录全部带进去,镜像体积会像滚雪球一样越来越大,而且你不知道这个镜像具体经历了哪些变更,相当于拿到一个来历不明的“黑箱”,没法审阅、没法复现、也没法自动化。

Dockerfile 解决的就是这个问题。它是一个纯文本文件,里面写的是“如何一步步把镜像构造出来”的指令集。你写完它,一条docker build命令就能构建出完全可复现的镜像。镜像怎么来的、装了什么、改了什么配置,全部有据可查。

本文是 Docker 系列第三篇,前面两篇分别讲了基础命令和容器生命周期管理,这一篇就围绕 Dockerfile 编辑和镜像制作展开。篇幅会比较长,因为我把指令基础、完整实操、体积优化和问题排查都揉进来了。建议你打开终端跟着敲一遍,光看是学不会 Docker 的。

适合谁来读:刚学会docker run还不太明白镜像怎么来的新手,或者已经在写 Dockerfile 但总觉得构建过程不顺畅、镜像体积控制不住的开发者。

2. 先把 Dockerfile 的核心指令吃透

2.1 前端基础指令:FROM、LABEL、ENV、ARG

一条 Dockerfile 开头只写一个FROM,它声明你以哪个镜像作为基础。这可不是随便写的,它直接决定了最终镜像的操作系统底子、软件包管理器和系统调用接口。

FROM ubuntu:22.04

这里我选了 Ubuntu 22.04,因为它稳定、软件源齐全。如果你只想跑 Node 程序,可以直接FROM node:20-alpine,Alpine 版本只有几 MB,适合追求极致体积的场景。基础镜像选错了,后面所有层都会受影响,这是最需要花时间斟酌的一步。

LABEL是元数据声明,相当于给镜像贴标签。构建出来的镜像在docker inspect里能看到这些信息,常用于标注维护者、版本号、文档地址:

LABEL maintainer="dev@example.com" LABEL version="1.0.0" LABEL description="用户服务 API 镜像"

ENV比 LABEL 实用得多,它设置的是容器运行时能读取的环境变量。需要注意一点,ENV 既影响构建过程,也会写进镜像的配置里,容器启动时会自动带上。

ENV APP_HOME=/app ENV NODE_ENV=production

ARG是构建参数,和 ENV 很像,但只在构建阶段有效,不会写进最终镜像的配置。一个经典用法是给基础镜像版本做参数化:

ARG UBUNTU_VERSION=22.04 FROM ubuntu:${UBUNTU_VERSION}

注意:ARG 必须在 FROM 之前声明才能在 FROM 中使用,如果 FROM 后面还需要用到同一个 ARG,需要再声明一次。这个细节特别容易踩坑,我第一次用的时候就被“Argument is not set”的报错卡了半天。

2.2 文件操作指令:COPY 和 ADD 到底用哪个

COPY 和 ADD 都能把宿主机文件复制进镜像,但两者侧重点完全不同。

COPY 只有“复制文件”这一个功能,行为最简单直白,最不容易出问题:

COPY package.json /app/package.json COPY ./server/ /app/server/

ADD 在 COPY 基础上多了两个高级功能:自动解压 tar 包、支持远程 URL 下载。听起来很香,但实际生产环境我几乎不用 ADD 的这两个功能,原因很简单:

自动解压听起来方便,但一旦基础镜像的 tar 版本和宿主机不一致,解压出来的文件权限、属主就可能出问题。远程下载更是隐患——镜像构建过程依赖外部网络,如果那个文件源临时挂了,你的整个构建流程就得断供。更稳妥的做法是先用curl或wget下载再 COPY 进去,虽然多写一行命令,但可控制性强得多。

另外一个必须提的点是.dockerignore文件。Docker 构建时会先扫描当前目录,把上下文全部打包发送给 daemon,如果你没写.dockerignore,node_modules、.git、__pycache__这些文件夹都会被塞进构建上下文里,不仅拖慢构建速度,甚至还可能造成 COPY 复制进来一堆不该有的文件。我的经验是,任何项目开工第一时间就写好它:

node_modules .git .gitignore *.log .DS_Store dist

2.3 RUN 指令的正确姿势

RUN 是最常用的指令,它在构建阶段执行命令,并在当前镜像层之上生成一个新层。很多人喜欢把它当 shell 来写,一条命令装一个包:

RUN apt-get update RUN apt-get install -y vim

这种做法在 Docker 里是大忌。因为 Dockerfile 每一行都会产生一个新的镜像层,虽然层数多不直接等于体积大,但管理混乱、构建缓存失效概率高。正确的姿势是把逻辑上属于同一环节的操作合并成一条 RUN:

RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ vim \ && rm -rf /var/lib/apt/lists/*

注意到末尾的rm -rf /var/lib/apt/lists/*尤其重要。apt-get update 和 install 会在镜像里留下软件包索引文件,这些文件在容器运行阶段完全没用,但会白白增加镜像体积。一个几 MB 的索引看似不多,积少成多就不可忽视了。

至于包管理器,如果你用 apt 作为主要安装方式,务必加上--no-install-recommends。这个参数会阻止 apt 安装推荐的附加包,实测下来能节省三四成的最终镜像体积,代价是你需要手动补齐真正需要的依赖。

2.4 WORKDIR 和 USER:这两个指令常被忽略

WORKDIR 设置的是工作目录。很多人习惯写RUN cd /app && ...,但这样只在当前 RUN 内有效,下一行指令又回到根目录了。用 WORKDIR 一次设置,后面所有 RUN、CMD、COPY、ENTRYPOINT 都会以此为基准目录:

WORKDIR /app COPY package.json . RUN npm install COPY . .

这段代码等价于“先进入 /app,再复制文件,再执行安装”,逻辑非常顺。

USER 指定后续指令和容器运行时使用的用户。生产环境我强烈建议不要用 root 跑应用,因为一旦应用被入侵,攻击者拿到的是容器内最高权限,虽然还有层隔离,但风险面明显更大。最佳做法是创建专用低权限用户:

RUN groupadd -r appuser && useradd -r -g appuser appuser USER appuser

提醒:USER 设置之后,前面的 WORKDIR 和文件权限必须让这个用户能正常访问。如果文件是 root 创建的,普通用户读不了,应用启动时就会各种没有权限的报错。建议在创建完用户之后,用chown -R appuser:appuser /app把目录属主改掉。

2.5 CMD 和 ENTRYPOINT 的区别,我尽量讲明白

这两个指令的判断我给很多人讲过一次又一次,因为它们确实容易混淆,而且弄错了镜像就不能按预期方式运行。

最核心的区别一句话:ENTRYPOINT 定义容器的主进程命令,CMD 定义的是默认参数,或者说 CMD 是可以被覆盖的默认值。

举个例子:

ENTRYPOINT ["python", "app.py"] CMD ["--port", "8080"]

这样构建出来的镜像,docker run myimage会执行python app.py --port 8080。如果你想在运行时换端口,直接docker run myimage --port 9090,CMD 部分就被覆盖了,但 ENTRYPOINT 完全不受影响。

反过来的场景是:你只写 CMD 不写 ENTRYPOINT。这时候你去docker run,镜像就跑 CMD 里写的命令。但注意,如果启动时你在命令末尾加了任何参数,CMD 整个被替换,比如:

CMD ["nginx", "-g", "daemon off;"]

执行docker run myimage nginx -t就会用nginx -t完全替换掉 CMD,导致容器没有前台进程直接退出。

我的经验汇总成一个判断口诀:想固定“跑什么命令”用 ENTRYPOINT,想允许“改什么参数”用 CMD。两者可以配合使用,实际部署中我也更多采用这种组合方式。

2.6 EXPOSE 的真相

EXPOSE 声明容器对外监听哪些端口。注意,它其实只是文档性质的声明,并不会真正把端口映射到宿主机。只有 docker run 时用-p或-P才会产生实际映射。但就算这样,我依然坚持每个镜像都写 EXPOSE,好处一是见名知意,二是使用-P批量映射时不至于漏掉端口。

EXPOSE 8080/tcp

3. 手把手写一个完整的 Dockerfile

3.1 场景设定:一个 Node.js 服务

为了让整个流程更直观,我以一个 Node.js 的服务为例。这是一个很典型的场景:代码跑在 Node 20 上,通过 npm 安装依赖,对外暴露 3000 端口,生产模式启动。

先把项目里需要的文件准备好,结构大概长这样:

myapp/ ├── .dockerignore ├── package.json ├── package-lock.json └── src/ └── index.js

为了演示完整流程,我把package.json写简单些:

{ "name": "myapp", "version": "1.0.0", "main": "src/index.js", "scripts": { "start": "node src/index.js" }, "dependencies": { "express": "^4.18.2" } }

src/index.js就只写一个最简单的请求处理,能启动、能响应就够了:

const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('hello, dockerfile'); }); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`server listening on ${port}`); });

3.2 第一版 Dockerfile

第一版先追求“能跑”,逻辑清晰优先,后面再优化体积。

FROM node:20-alpine # 设置维护信息和环境变量 LABEL maintainer="dev@example.com" LABEL version="1.0.0" ENV NODE_ENV=production ENV APP_HOME=/app # 创建应用目录并作为工作目录 WORKDIR /app # 单独复制 package 文件,利用层缓存加速构建 COPY package.json ./ COPY package-lock.json ./ # 安装生产依赖 RUN npm install --omit=dev --registry=https://registry.npmjs.org/ # 复制业务代码 COPY src/ ./src/ # 创建非 root 用户 RUN addgroup -g 1001 -S appuser \ && adduser -u 1001 -S appuser -G appuser \ && chown -R appuser:appuser /app USER appuser EXPOSE 3000 CMD ["npm", "start"]

这段 Dockerfile 我逐行讲一下为什么这么写。

npm 安装放在拷贝代码之前,这是构建缓存用的经典套路。package.json 和 package-lock.json 不常变化,而业务代码每次改动都很频繁。如果先把所有代码 COPY 进去再 npm install,任何一行代码改动都会让 npm install 那层缓存失效,每次都重新下载全部依赖,构建时间会崩溃。

--omit=dev告诉 npm 只装生产环境依赖,开发依赖全部跳过,这样镜像里的 node_modules 体积能小很多。开发依赖通常在本地开发时用,容器里跑生产环境根本不需要。

换源--registry=https://registry.npmjs.org/是防止某些环境默认源慢导致安装超时。你如果在内网或者更快的镜像环境,也可以换成自己访问最快的那个源。

创建用户那两行,addgroup -S表示系统组,-u 1001是固定 UID,这个固定很重要——不同构建环境下 UID 一致,后续如果你要挂载宿主机数据卷,权限对齐会简单不少。

3.3 构建镜像

在项目根目录执行:

docker build -t myapp:1.0.0 .

-t指定镜像名和标签,我习惯用项目名:版本号的格式。最后的.是构建上下文路径,指的是当前目录。

构建过程中会看到一堆输出,每一条都对应一个 Dockerfile 指令:

[+] Building 15.2s (12/12) FINISHED => [internal] load build definition from Dockerfile => [internal] load package.json => [2/5] WORKDIR /app => [3/5] COPY package.json ./package.json => [4/5] RUN npm install --omit=dev ... => [5/5] COPY src/ ./src/ => exporting layers => writing image myapp:1.0.0

观察到第 5 层是 COPY src,这层对应的就是本地代码。如果你改了一行代码重新构建,npm install 那层的缓存依然命中,构建时间会明显缩短。

3.4 验证镜像

构建完了先看看基本信息:

docker images myapp

输出会列出大小、TAG、IMAGE ID。然后试跑:

docker run -d -p 3000:3000 --name myapp-test myapp:1.0.0

-d后台运行,-p把容器的 3000 端口映射到宿主机 3000 端口。接着用 curl 验证:

curl http://localhost:3000/

如果一切正常,会看到响应就是代码里那行hello, dockerfile。之后再检查下容器日志:

docker logs myapp-test

能看到server listening on 3000,镜像制作到这个程度已经可以拿去部署了。

4. 构建太快和太慢的玄机:构建缓存机制

Dockerfile 的每条指令都会生成一个镜像层,Docker 构建时会复用以前的层,只要这条指令本身没有变化。判断“有没有变化”依据的是指令内容以及 COPY/ADD 的文件内容。

这个机制有个很实际的陷阱:npm install 生成的结果受 package-lock.json 影响,但不会受 registry 源影响。如果你改了 source 文件,COPY src之后的所有层都会重建,但之前的 package-lock 和 npm install 层可以直接命中缓存。

我在团队里给新手讲构建流程时,通常会这样总结缓存命中的优先级:

  1. 越少变化的指令要写在 Dockerfile 越靠前的位置
  2. COPY 的文件内容有无变化会被精确检测,连换行符都逃不掉
  3. RUN 指令如果没有改变,构建时直接复用缓存,不重新执行
  4. 如果某层缓存失效,它之后的所有层缓存都会跟着失效

顺带一提,显式清理缓存的一种方法是在构建命令里加--no-cache:

docker build --no-cache -t myapp:1.0.0 .

但只有在你确定需要完全重新构建时才用,否则日常构建不要加,毕竟缓存是加速的利器。

5. 镜像体积控制实操:从 500MB 压到 100MB 的关键策略

5.1 多阶段构建:一个 Dockerfile 干两件事

单阶段构建最容易出现的场景是,为了编译一个 C 程序,装了一整套 gcc、make、cmake,最终镜像里全是编译工具链。即使编译完了,工具链也不会被自动清理。

多阶段构建的思路是:在一个临时镜像里完成编译,再把编译产物 COPY 到干净的运行镜像里。临时镜像用什么工具链都不影响最终结果,运行镜像保持极简。

给上面的 Node 项目再加一个编译步骤作为例子,比如我们有一个 TypeScript 需要编译成 JS:

# 阶段一:编译 FROM node:20-alpine AS builder WORKDIR /build COPY package.json ./ COPY package-lock.json ./ RUN npm install COPY tsconfig.json ./ COPY src/ ./src/ RUN npx tsc --project tsconfig.json # 阶段二:运行 FROM node:20-alpine ENV NODE_ENV=production WORKDIR /app COPY package.json ./ COPY package-lock.json ./ RUN npm install --omit=dev COPY --from=builder /build/dist/ ./dist/ RUN addgroup -g 1001 -S appuser \ && adduser -u 1001 -S appuser -G appuser \ && chown -R appuser:appuser /app USER appuser EXPOSE 3000 CMD ["node", "dist/index.js"]

核心是AS builder给第一个阶段取了个名字,第二个阶段通过COPY --from=builder从那个阶段里取文件。最终镜像里只有运行环境和编译结果,编译工具、中间文件全部不属于最终产物。实测下来,这种写法的镜像体积往往只有单阶段构建的三分之一到二分之一。

5.2 选择合适的基础镜像

基础镜像对体积的影响比大多数人想象的还大。同样跑 Node 程序,node:20的体积接近 1000MB,而node:20-alpine只有 170MB 左右。这个差距源于 Alpine 采用了 musl libc 替代 glibc,以及精简的包管理器。

但省体积不等于无脑选 Alpine。有些二进制工具是针对 glibc 编译的,在 Alpine 上会报 “not found”,比如某些 node 原生模块。我遇到过好几次这类问题,最后乖乖换回 Debian 基础镜像。选择标准是:先保证能稳定运行,再考虑体积优化。能跑 Alpine 就选 Alpine,跑不了就用 debian 的 slim 版本。

5.3 清理不必要的文件

和前面 apt 的例子一样,任何包管理器安装都会产生缓存文件。npm 的缓存目录是/root/.npm,如果不清理会占几十 MB:

RUN npm install --omit=dev \ && npm cache clean --force

还有一种隐藏体积来源是apk add默认的缓存,在 Alpine 里可以用:

RUN apk add --no-cache curl

--no-cache直接不要本地缓存索引,效果等同于装完再删缓存。

5.4 用 docker history 看体积分布

镜像体积到底卡在哪,空想是没用的,要用工具看:

docker history myapp:1.0.0

每条记录对应一层,最右边显示该层增加的字节数。哪个 RUN 或 COPY 撑大了体积,一眼就能定位。我优化镜像体积的流程是:先 history 查看分布,再用多阶段构建拆掉编译层和运行层,最后针对每一层清理缓存文件。

6. Dockerfile 写完怎么调试:常见问题和排查实录

6.1 构建报错汇总速查表

报错信息产生原因解决办法
COPY failed: file not found in build context源文件路径写错,或未在构建上下文目录中检查 Dockerfile 所在目录和构建命令末尾的上下文路径
exec: "npm": executable file not found基础镜像里没有安装该命令,比如从 node 镜像换成了 ubuntu在基础镜像中先安装 node,或改回合适的镜像
failed to solve: rpc error: ...网络问题导致拉取基础镜像失败多试几次,或配置镜像加速源
/bin/sh: apt-get not found基础镜像是 Alpine,包管理器是 apk 不是 apt-get改用 apk add,或更换为 Ubuntu/Debian 镜像
no space left on device磁盘空间不足,Docker 默认数据目录满了执行docker system prune清理无用数据

6.2 容器启动后秒退怎么办

容器启动后立刻退出,先把日志调出来看:

docker logs myapp-test

如果没有日志或者日志看不出问题,用非退出模式启动:

docker run -it --rm myapp:1.0.0 sh

-it分配交互终端,--rm退出后自动移除容器,sh覆盖 Dockerfile 里的 CMD 直接给你一个 shell。进去之后手动执行 CMD 里的命令,比如npm start,看看真正的报错是什么。这种调试方式比反复改 Dockerfile 重新构建高效得多。

6.3 时区问题

容器默认时区是 UTC,日志和业务时间显示会和北京时间差 8 小时。这个问题在算法部署后特别容易造成运维混乱。简单粗暴的做法是设置环境变量配合安装时区数据:

ENV TZ=Asia/Shanghai RUN apk add --no-cache tzdata

不同基础镜像安装时区包的命令不一样,Alpine 用 apk,Ubuntu 用 apt。装完tzdata之后 TZ 环境变量才会生效。

6.4 端口不通的排查路径

镜像跑起来了,但外部访问不到。按这个顺序排查:

  1. 确认进程是否还在:docker ps
  2. 确认端口映射是否正确:docker port myapp-test
  3. 确认应用监听的是哪个网卡和端口:进入容器执行netstat -tlnp
  4. 确认防火墙是否放行:检查宿主机安全组和 iptables

90% 的情况是第三步出错:应用监听的是 127.0.0.1,而这个地址只在容器内部可访问,宿主机的端口映射目标地址不能用它。代码里应该监听0.0.0.0而不是127.0.0.1。

7. 从一个能跑镜像到一个好镜像:我踩过的坑

最后一个部分,分享几个不是在文档里能直接学到的实操细节。

第一个坑是 COPY 目录的权限问题。Dockerfile 里执行 COPY 时,文件的属主是 root,而不是你在后面通过 USER 切换的那个用户,所以我总是会在创建完用户之后补一句chown -R。忘了这一步的后果就是应用启动时报 EACCES 权限错误,看起来和代码无关,排查起来很耗时间。

第二个坑是 shell 格式和 exec 格式的选择。写 CMD 和 ENTRYPOINT 时,有数组写法和普通字符串写法两种:

CMD ["npm", "start"] ENV VAR=value

数组格式是推荐的,因为它是 exec 执行方式,进程会成为 PID 1,能够正确接收信号。字符串格式在内部会以/bin/sh -c方式执行,信号的传递链路会变长,容器优雅停机就可能会出问题。生产环境的镜像一定要用数组格式。

第三个坑是构建上下文别放太大。docker build默认会把命令末尾指定的路径全部打成 tar 包发给 daemon,你放在项目根目录里的几百兆数据文件,虽然 Dockerfile 里没有引用,也一样会被上传。配合好.dockerignore可以规避这个问题,但更大的项目我建议把构建目录单独整理干净。

最后一个提醒是关于镜像版本管理。镜像 tag 最好是唯一的版本号加 commit hash,用docker tag打标签时要清楚每个 tag 指向哪个具体镜像,避免出现多环境部署时一不小心覆盖生产镜像的故障。

估计到这里你已经可以独立写一个像样的 Dockerfile 了。从最小可用到不断削减体积,这个过程本身就是在理解运行环境和构建过程的边界。下一步如果想深入,可以试试给同一个项目写不同环境的多阶段构建,或者自己做一个基础镜像模板,这些实战经验才能真正把 Docker 沉淀成自己的技能。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进