你有没有遇到过这种情况:明明在本地跑得好好的应用,一到同事电脑上就各种报错;或者上线时照着文档一步步操作,结果部署出来的环境和测试环境完全两个样。如果你正在被这类问题折磨,说明你该认真学一下 Dockerfile 了。
这是 Docker 系列的第三篇,前两篇我们把 Docker 的基本概念、镜像与容器的关系、常用操作命令过了一遍,从这篇开始进入真正核心的环节:Dockerfile 编辑和镜像制作。Dockerfile 就是镜像的"菜谱",它把你应用需要的操作系统、依赖库、运行环境、启动命令全部用一段文本描述清楚,Docker 照着这份菜谱就能百分百复现出一模一样的环境。
这篇文章适合已经会跑docker run、docker ps这类基础命令、但还没系统写过 Dockerfile 的开发者。我会从设计思路讲起,逐个拆解核心指令,再用一个完整的实战案例演示多阶段构建,最后整理我踩过的坑和排查经验。看完之后,你应该能独立写出规范、体积可控、可维护的 Dockerfile。
1. 动手之前:先把设计思路理清楚
1.1 为什么不用 docker commit 做镜像
很多刚接触 Docker 的同学会有个惯性思维:先在容器里把环境折腾好,再执行docker commit把当前状态保存成镜像,感觉这样也能达到"复用环境"的目的。我在早期也这么干过,但实际用下来,这套流程只能应付本地临时测试,真放到团队协作或生产环境里就是灾难。
docker commit生成的镜像是个"黑盒"。它记录了容器文件系统的变更,但你无法从镜像本身得知当初执行过哪些命令、装过哪些包、改过哪些配置。一旦镜像出了问题,排查只能靠猜。更麻烦的是,这种镜像完全不可复现——换个时间、换个机器、换个操作顺序,commit 出来的镜像内容可能就不一样了。
还有个很现实的问题:容器运行期间会有大量临时文件、日志、缓存写入,这些都会被原封不动地打进镜像里,导致镜像体积虚胖。我见过有人 commit 出来的镜像比原始镜像大了好几倍,里面全是垃圾数据。
Dockerfile 的声明式写法则完全不同。它把环境构建的每一步都显式记录下来,任何人拿到这份文件都能构建出几乎一致的镜像。文件本身就是文档,可以直接走代码评审,出问题能回溯,配合 CI/CD 流水线还能实现完全自动化。这才是在生产环境里真正靠得住的方案。我把两种方式的差异整理成了表格,方便你对比着看:
| 对比维度 | docker commit | Dockerfile 构建 |
|---|---|---|
| 可复现性 | 不可复现,依赖手工操作 | 完全可复现,文件即代码 |
| 可审查性 | 无记录,黑盒 | 每条指令透明可查 |
| 体积控制 | 难以控制,包含临时文件 | 指令清晰,易于优化 |
| 团队协作 | 无法评审 diff | 可走 Git 版本管理 |
| 自动化集成 | 不适合 CI/CD | 天然适配流水线 |
1.2 镜像分层机制:理解 Dockerfile 的基石
要写好 Dockerfile,必须先理解镜像的分层机制,这是 Docker 一切设计的基础。
镜像不是一块完整的大文件,而是由若干只读层叠加而成的。每层对应 Dockerfile 里的一条指令。当构造镜像时,Docker 会从上到下逐条执行 Dockerfile 中的指令,每一条指令都会在原有层之上创建一个新的层。容器运行时则是在这些只读层之上再加一个可写层,你对容器文件的修改都发生在这个可写层。
这种分层设计最大价值在于缓存和复用。如果某条指令没有发生变化,Docker 会直接复用之前的缓存层,跳过构建过程。比如你改了代码文件重新 build 镜像,只要基础镜像层没变,依赖安装层没变,Docker 就会直接从文件拷贝那一步开始重新生成新的层,前面全部用缓存,构建速度会快很多。
理解分层还能帮你搞清楚为什么有些镜像优化手段是有效的。最典型的就是合并 RUN 指令——每条 RUN 都会产生一层,层越多镜像越大,把多个操作合并到一条 RUN 里能有效减少层数量。我在后面的优化章节会展开讲细节,这里你只需要先建立起"指令 ⇒ 层 ⇒ 体积与缓存"这条认知链。
1.3 Dockerfile 的基本结构长什么样
剥开所有细节,一个标准 Dockerfile 通常由四类指令组成:
- 基础镜像声明,用
FROM指定基于哪个镜像构建。 - 元数据设置,比如
MAINTAINER(已弃用)、LABEL。 - 文件系统操作,比如
COPY、ADD、RUN、WORKDIR这些改变容器内文件和环境的指令。 - 运行时配置,比如
ENV、EXPOSE、CMD、ENTRYPOINT,定义容器启动时的行为。
看一个最简结构示例:
# 指定基础镜像 FROM python:3.12-slim # 设置工作目录 WORKDIR /app # 拷贝依赖文件并安装 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 拷贝项目代码 COPY . . # 声明容器监听端口 EXPOSE 8000 # 容器启动命令 CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]这个 Dockerfile 虽然简单,但五脏俱全。尤其注意COPY requirements.txt .和COPY . .分开写,这背后有缓存优化的考量,后面我会专门解释。指令格式上,Dockerfile 不区分大小写,但惯例是大写指令名,参数跟在后面。#开头的行是注释,建议在关键步骤写清楚注释,Dockerfile 本身就是最好的运维文档。
2. 核心指令逐条拆解:每个选择都有它的理由
2.1 FROM:起点决定终点
FROM是 Dockerfile 的第一条指令,它指定了构建所基于的镜像。这个看似简单的选择,其实决定了你整个镜像的体积、安全性和兼容性。
你当然可以直接用ubuntu:22.04或者centos:7,但生产环境里我更推荐从官方精简镜像开始。最常见的几个选择是:
alpine:体积极小,通常只有 5MB 左右,包管理器是 apk。但要注意它使用的是 musl libc 而不是 glibc,某些依赖原生编译的软件可能不兼容。如果你用的是 Python、Node 这类有大量二进制扩展的场景,遇到module not found或段错误时,多半就要怀疑是 musl 的锅。slim系列:比如python:3.12-slim、node:20-slim,基于 debian 裁剪,保留了 glibc 和 apt 包管理器,兼容性好,体积也不算大。distroless:不含包管理器、不含 shell,只包含运行所需的库文件,体积和安全表现都很好,但排查问题不方便,适合对镜像体积和安全性有极致要求的场景。
还有一个原则:基础镜像的 tag 要写明确,别用latest。latest是个会漂移的指针,今天构建和明天构建可能拿到不同内容,这会导致不可复现。我建议至少锁定大版本号,比如python:3.12-slim,要做到完全可复现的话,就锁定到具体 digest(镜像摘要)。
2.2 RUN:每条指令都会产生新层
RUN用来在镜像构建过程中执行命令,安装依赖、编译代码、修改文件都靠它。它是 Dockerfile 里出现频率最高的指令之一,也是最容易写出"肥胖镜"N的起点。
RUN 的两种写法:
# shell 形式:等价于 /bin/sh -c "apt-get update" RUN apt-get update # exec 形式:直接执行,不经过 shell RUN ["apt-get", "update"]默认的 shell 形式和直接在终端里敲命令差不多,可以做变量展开、管道、重定向。但要注意,每条 RUN 都会产生一个新的镜像层,如果层太多,镜像体积会非常可观。更关键的是,只有执行了清理操作,那些临时文件才会被"留在"这一层里。举个例子:
# 反面教材 RUN apt-get update RUN apt-get install -y curl wget RUN rm -rf /var/lib/apt/lists/*这段代码看着没毛病,但apt-get update产生的缓存文件已经写进了第一层,第三条rm虽然删掉了当前层的文件,却无法删除已存在于下方层的数据。正确的做法是把它们合并成一条 RUN:
RUN apt-get update && apt-get install -y curl wget \ && rm -rf /var/lib/apt/lists/*这样 update、install、清理在同一个层内完成,缓存文件不会被固化到镜像里。这里有个我早期的教训:装包时图省事,把 apt、pip 的缓存文件留在镜像里,构建出来一个 1GB+ 的镜像,后来强制清理才降到 400MB 左右。
2.3 COPY 与 ADD:拷贝文件的门道
COPY和ADD都可以把文件从构建上下文拷进镜像,很多人以为没区别,实际上差异不小。
ADD额外支持从 URL 拉取文件,还能自动解压本地 tar 包。听起来很强大,但这也意味着它的行为更不可控。比如你想拷贝一个名为archive.tar.gz的文件到容器里,结果它被自动解压了,你反而要额外处理。出于安全和可预测性的考虑,官方文档也明确建议:大部分场景使用COPY,只有明确需要自动解压 tar 包时才使用ADD。
COPY 还有个很重要的特性:当被拷贝文件的校验和发生变化时,Docker 会判定这一层及之后的所有层缓存失效。这是一个经常被人忽略的行为。回到前面简单示例中的顺序:
COPY requirements.txt . RUN pip install -r requirements.txt COPY . .为什么要把依赖清单单独先拷贝过去,而不是COPY . .一次搞定?因为项目代码是你改动最频繁的东西,如果把所有文件一次性拷进去,任何文件一改,后面安装依赖的缓存层就全部失效了。把requirements.txt单独拷贝并安装依赖,只要依赖没有变化,就算你的代码改了,Docker 也能直接命中依赖安装的缓存层,重建镜像的时间会大幅缩短。这个细节在依赖很多的业务项目里,能节省大量构建时间。
再强调一个容易踩坑的点:COPY的源路径是相对于构建上下文的,而不是相对于 Dockerfile 所在目录。构建上下文是执行docker build时指定的目录(通常是命令末尾的.)。如果你把文件放在 Dockerfile 所在目录之外,用COPY ../something .是行不通的,因为 Docker 能看到的内容仅限于上下文目录。
2.4 CMD 与 ENTRYPOINT:启动命令的正确姿势
CMD和ENTRYPOINT是 Dockerfile 里最容易让人迷糊的两个指令。我在各种技术群里见过太多人问"我明明写了 CMD,为什么没执行"。先记住一句话:CMD提供默认参数,可以被docker run后面的命令覆盖;ENTRYPOINT定义容器的主进程,不容易被覆盖。
用表格来对比两者的行为差异会直观很多:
| 场景 | 只有 CMD | 只有 ENTRYPOINT | ENTRYPOINT + CMD |
|---|---|---|---|
| 容器启动 | 执行 CMD 内容 | 执行 ENTRYPOINT 内容 | 执行 ENTRYPOINT,CMD 作为参数附加 |
| docker run 带命令 | 覆盖 CMD | 作为参数传给 ENTRYPOINT | 覆盖 CMD,不影响 ENTRYPOINT |
| 典型场景 | 提供默认启动命令 | 固定主进程 | 固定主进程且支持追加参数 |
最佳实践是组合使用:ENTRYPOINT固化不变量,CMD提供可变量。比如一个自定义的启动脚本:
ENTRYPOINT ["docker-entrypoint.sh"] CMD ["--help"]容器启动时默认执行docker-entrypoint.sh --help,用户可以通过docker run image --version覆盖掉--help参数,灵活性很高。Nginx 官方镜像就是这么干的。
还有个细节值得单独提醒:CMD 的推荐写法是 exec 形式(JSON 数组),也就是CMD ["nginx", "-g", "daemon off;"],而不是 shell 形式CMD nginx -g "daemon off;"。这两种写法在信号处理上有本质区别。shell 形式实际上会启动/bin/sh -c "nginx ...",你发给容器的 SIGTERM 信号会被 shell 进程接收,而不是直接发给 nginx,这会导致容器停机变慢、甚至产生僵尸进程。exec 形式则让 nginx 直接作为 1 号进程运行,信号能被正确处理,优雅停机才有保障。
2.5 ENV 与 ARG:环境变量的两种生命周期
ENV和ARG都跟变量相关,但作用域截然不同。ENV设置的环境变量不仅存在于构建过程,还会被固化到镜像里,容器运行时也能通过env命令看到;ARG则仅仅存在于构建过程,不会进入最终镜像。
什么时候用ARG?典型场景是构建时传入参数。比如:
ARG VERSION=1.0.0 RUN echo "building version $VERSION"构建时通过--build-arg VERSION=2.0.0覆盖默认值。注意ARG的默认值写法是ARG 变量名=值,不加引号。
什么时候用ENV?当你需要运行时也读取这个变量时。比如 Java 应用的 JVM 参数:
ENV JAVA_OPTS="-Xms256m -Xmx512m" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]这里JAVA_OPTS必须用ENV,因为运行时还需要读取。有一点要注意:ENV的值会被固化到镜像的配置信息里,所以不要把密码、Token 这类敏感信息写进ENV,镜像一旦被推送到仓库,等于密钥公开。
WORKDIR跟前面两个关联度高,顺手一起说了。它设置工作目录,影响后续所有指令的执行路径,目录不存在时会自动创建。我建议每个 Dockerfile 都明确设置WORKDIR,不要依赖默认的/目录,否则代码和文件散落在根目录,很难维护。
2.6 USER 与 EXPOSE、VOLUME、HEALTHCHECK:容易被忽略的细节
这几个指令单独用起来都不复杂,但能体现一个 Dockerfile 写得好不好。
USER指定容器内执行命令的用户。默认情况下容器以 root 用户运行,这在安全上是很大的隐患——一旦应用被攻破,攻击者拿到的是容器内的 root 权限。规范的做法是在镜像里创建专用用户:
RUN groupadd -r appuser && useradd -r -g appuser appuser USER appuserEXPOSE只是声明容器监听哪些端口,它是一个元数据性质的指令,并不会真正打开端口。实际端口映射还是靠docker run -p完成。它的价值在于文档化和帮助 Docker 进行端口发现,所以别指望写完EXPOSE 8080就能从外面访问了。
VOLUME用于声明匿名卷挂载点。它把路径标记为外部挂载点,这样即使运行容器时没有显式指定-v,数据也会写入匿名卷而不是容器可写层。这个机制能有效防止运行时产生的日志、临时文件撑大容器可写层。但要注意:一旦在 Dockerfile 里写了VOLUME,后续的 RUN 指令对那个目录的写入就会被丢弃,所以VOLUME一般放在文件操作的偏后面。
HEALTHCHECK定义健康检查命令,让 Docker 定期检查容器是否正常:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8080/ || exit 1加了它之后,docker ps会显示容器的健康状态,编排工具也会据此决定是否重启容器。我在生产环境中强烈建议配置 HEALTHCHECK,它能把很多故障消灭在"用户感知到"之前。
3. 完整实战:用多阶段构建做一个规范镜像
3.1 为什么需要多阶段构建
先看一个现实问题:构建一个 Java 应用需要完整的 JDK 和 Maven 环境,光基础镜像就 300MB 以上;但运行这个应用只需要一个 JRE 运行时,200MB 都不到。如果用同一个镜像既编译又运行,那凭空多出来的几百兆体积就要长期跟着镜像走,拉取、传输、存储成本都会增加。
多阶段构建的思路是:在同一个 Dockerfile 里定义多个 FROM,先用包含完整工具链的镜像去编译、打包,再把产物拷贝到一个精简的运行镜像里。最终产出的镜像是最后那个阶段,前面所有阶段都会丢弃。这样既保证了构建能力,又让最终的运行镜像体积最小化。这套方案特别适合 Java、Go、Node 这类有编译构建过程的应用,是我最推荐的镜像制作方式。
3.2 实战案例:Java 应用镜像制作
我以最常见的 Java Spring Boot 应用为例,完整演示一遍。假设项目名是某订单服务,结构大概是:
order-service/ ├── pom.xml └── src/ └── main/ └── java/Dockerfile 写在项目根目录,内容如下:
# 第一阶段:编译打包 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行裁剪 FROM eclipse-temurin:17-jre WORKDIR /app RUN groupadd -r appuser && useradd -r -g appuser appuser COPY --from=builder /build/target/order-service-*.jar app.jar USER appuser EXPOSE 8080 ENV JAVA_OPTS="-Xms256m -Xmx512m" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]这个 Dockerfile 里有几个细节值得反复咀嚼:
AS builder给第一个阶段起了名字,后面的COPY --from=builder才能引用到它。- 先
COPY pom.xml再执行mvn dependency:go-offline,这样依赖下载的层可以独立缓存。源代码一改,只要 pom 没变,依赖层就不会重新构建,每次能省 1-2 分钟。 - 第二阶段选择了精简运行镜像,并且创建了非 root 用户
appuser来运行应用。 - 构建阶段用的是 Maven 官方镜像,里面自带 JDK;运行阶段换成了只带 JRE 的镜像,体积差异非常可观。
3.3 构建、运行与产物验证
构建镜像的命令:
docker build -t order-service:v1.0 .如果构建过程中报错想看清楚每一行的输出,可以加上--progress=plain参数,Docker 会输出完整日志而不是默认的交互式视图:
docker build --progress=plain -t order-service:v1.0 .这个参数在 CI/CD 环境里尤其有用,因为流水线里没有 TTY,默认的进度展示会变成一堆乱码,--progress=plain能输出干净的逐行日志,方便排查问题。
构建完成后,用docker images看一下镜像大小。我实测的场景里,直接用一个包含 JDK 和 Maven 仓库的大镜像做运行镜像,体积能到 800MB 以上;换成多阶段构建之后,最终镜像只有不到 200MB。这个差距在生产环境意味着拉取时间从几分钟缩短到十几秒,效果立竿见影。
运行并验证:
docker run -d -p 8080:8080 --name order-demo order-service:v1.0 docker logs order-demo curl http://localhost:8080/actuator/health看到健康检查返回{"status":"UP"}就说明容器正常起来了。这里如果容器启动后立刻退出,优先看日志,大概率是启动命令写错、端口监听不对,或者依赖没打包进去。
3.4 多阶段构建的通用套路
上面是 Java 场景,但多阶段构建的思想可以迁移到任何有编译、打包步骤的应用上:
- 前端应用:第一阶段用
node镜像执行npm install && npm run build,第二阶段用nginx镜像把构建产物拷贝到 nginx 的 html 目录。 - Go 应用:第一阶段用
golang镜像交叉编译出静态二进制,第二阶段用scratch(空镜像)或distroless直接运行二进制,体积能压到 10MB 以内。 - Python 应用:如果不需要源码编译,可以单阶段完成;如果有 C 扩展需要编译,第一阶段安装构建工具编译依赖,第二阶段从第一阶段拷贝编译好的
.so文件。
有个使用技巧:多阶段构建时可以用--target指定只构建到某个阶段。调试阶段只想跑一下编译看结果,不想完整产出最终镜像,就可以docker build --target builder -t order-tmp .,节省时间。
4. 构建效率和镜像体积的优化实践
4.1 先管住构建上下文
很多人在构建时踩过一个莫名其妙的坑:docker build的执行时间特别长,甚至卡住不动,看起来像是 Docker 卡死了。其实很可能是你的构建上下文太大了。
构建上下文是docker build命令末尾那个路径下的所有文件。Docker 客户端会把整个上下文打包发送给 Docker 守护进程,然后守护进程在上下文中解析 Dockerfile 并执行构建。如果你在项目根目录执行docker build .,那么这个目录里所有文件都会被打包进去,包括node_modules、.git、target这些动辄几百 MB 甚至几个 GB 的目录。
正确的做法是在项目根目录创建.dockerignore文件,语法和.gitignore一样,把不需要的文件排除在外:
.git node_modules target *.log Dockerfile .dockerignore我在实际项目里见过没写.dockerignore导致构建上下文好几百 MB 的情况,每次构建光上传上下文就消耗了大部分时间。管好上下文,是提高构建速度的第一步,也是最容易被忽略的一步。
4.2 构建缓存的命中与失效规律
Docker 构建缓存遵循"指令级复用"的规则:当某条指令的输入没有发生变化,它就复用历史构建结果。具体来说:
FROM指令:基础镜像的引用(tag 或 digest)不变则命中。RUN指令:指令字符串本身不变且前面的层未变化,则命中缓存,不看它在容器里执行后产生的结果。COPY/ADD:源文件的校验和(checksum)发生变化时,该层和后续所有层缓存失效。
这个规律带来几个实操上的推论。想让缓存更高效,就把变化频繁的操作放在 Dockerfile 靠后的位置。依赖安装在依赖声明文件之后,代码拷贝放在最末尾,这是最经典的调序策略。相反,如果你把COPY . .写在最前面,那后面所有层基本每次都重建,缓存形同虚设。
还有两种强制控制缓存的方式。构建时用--no-cache可以完全禁用缓存,适合排查"缓存导致的陈旧产物"问题;--cache-from可以复用远程镜像的缓存层,在 CI 场景里常配合使用,不过这个配置较复杂,初学者了解即可。
4.3 精简镜像的几条硬规则
把镜像体积压下来,既有直接的经济效益,也有部署速度上的隐形收益。我总结几条经过验证的规则:
第一,尽可能选更小的基础镜像。同样的 Python 应用,python:3.12和python:3.12-slim体积差出一倍还多,运行表现差异不大。有些极简场景可以用基于 alpine 的镜像,能再小一半,但要小心前面提到的 musl 兼容性问题。
第二,合并 RUN 指令。每条 RUN 都会产生一个永久层,层之间的"删除"并不会真正省空间。把相关的 apt/pip/apk 操作写进同一条 RUN,并在同一层内清理缓存。比如:
RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*注意加了--no-install-recommends参数,apt 就不会安装那些推荐的但通常没用的包,能省不少空间。
第三,不装调试工具。很多人在镜像里装 vim、curl、net-tools,方便容器出问题时进去排查。但这种做法得不偿失,工具本身增加体积和攻击面。生产镜像应该保持最小化,排查问题用docker exec配合镜像里已有的工具,或者挂载工具容器进去,而不是把所有东西都塞进应用镜像。
第四,多阶段构建。这已经是现代构建的标准做法,前面专门讲过,这里不再重复。一句话总结:编译环境只需要存在于构建过程中,不需要进入最终成品。
5. 常见问题与排查实录
5.1 典型报错速查表
我把这几年做镜像时遇到的各种问题整理成了一个速查表,记录在下面。每一条都是实际踩过坑之后总结的,优先排查表中内容能省掉一大半时间:
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
exec format error | 基础镜像架构和应用二进制架构不匹配 | 用--platform指定平台,或重新编译对应架构的二进制 |
no such file or directory | 启动命令引用的文件路径不对 | 检查 WORKDIR 和 COPY 目标路径,确认一致 |
standard_init_linux.go: exec user process caused: permission denied | 启动脚本没有执行权限 | 构建时RUN chmod +x script.sh |
| 构建上下文太大,build 很慢 | 没有.dockerignore,把依赖目录打包了 | 补写.dockerignore,排除不用文件 |
docker build提示找不到文件 | COPY 源路径超出上下文范围 | COPY 只能读取构建上下文内的文件 |
| 容器启动几秒后退出 | CMD/ENTRYPOINT 写错,或应用本身启动失败 | 用docker logs查看报错,逐步排查 |
| 构建没有缓存、每次都全量 | 源码在 Dockerfile 里位置太靠前 | 把变化最少的内容放前面,代码放最后 |
| 域名解析失败 | 容器内缺少 DNS 配置 | 给docker run加--dns参数 |
| 时区不对 | 基础镜像默认 UTC | 构建时设置ENV TZ=Asia/Shanghai并安装 tzdata |
| 非 root 用户运行时没权限写文件 | 应用目录属主是 root | 构建阶段显式chown给应用用户 |
5.2 排查用的工具和思路
遇到镜像构建或运行问题时,我一般按下面的步骤排查,效率比较高。
第一步,看历史记录。docker history是分析镜像构成最好的起点。执行docker history order-service:v1.0能看到每一层是什么指令创建的、大小是多少。某层体积异常大,目标就锁定到那条指令上。看到中间有历史遗留的敏感信息,也能靠它追根溯源。
第二步,看完整构建日志。构建失败时不要只看最后几行报错,往前翻往往能找到真正失败的那一步。用docker build --progress=plain重新跑一次,把日志逐行看过去,多半能定位到具体是哪条命令退出码非 0。
第三步,进容器里面探。容器能启动但行为不对,就用docker exec -it <容器ID> sh进到容器里手动执行命令查看。注意很多精简镜像连sh都没有(比如 distroless),这时可以临时用一个包含工具链的镜像重新构建一版来调试,或者用docker cp把排障工具拷进去。
第四步,临时改 Dockerfile 验证假设。排查问题不要靠猜,每次只改一处,重新构建,观察结果。我见过有人为了改一个环境变量,一次性改了三个地方,结果验证时根本分不清是哪个改动生效的。一次一改,是排查复杂问题最起码的纪律。
5.3 构建安全:这些红线别碰
镜像安全问题在生产环境尤为敏感,有几点我强烈建议你遵守:
不要在镜像里存放敏感信息。数据库密码、API Token、私钥这些绝对不能写进 Dockerfile、ENV 或者被 COPY 进去。镜像一旦推送公共仓库,等于公开了你的密钥。正确做法是用构建时的密文管理工具(比如 Docker 的 secret 功能)或者运行时从外部挂载。
不要用 root 运行应用。前面提过USER appuser这个实践,再强调一次。攻击者如果打穿应用,拿到 root 权限就意味着容器内他可以为所欲为,即使有隔离机制,风险也远比非 root 账户大得多。
依赖漏洞要及时更新。基础镜像和依赖包都有各自的 CVE 漏洞,要养成定期升级的习惯。现在很多仓库都集成扫描工具,能自动检测镜像中的高风险组件。我的建议是每次构建时都拉取最新的补丁版本基础镜像,别一直用几个月前甚至一年前的老版本。
Dockerfile 本身也要评审。它看起来只是几行文字,但定义了生产环境的全部。我在团队里推行过一个简单的评审清单:基础镜像是否锁定版本、是否非 root 运行、是否有 HEALTHCHECK、是否用了--no-install-recommends、敏感信息是否泄漏到 ENV。每次合并前过一遍清单,很多隐患在源头就被拦住了。
最后分享一点个人经验:写 Dockerfile 没有一步到位的完美方案,它是在"构建速度、镜像体积、安全基线、可维护性"之间不断做权衡的过程。你现在拿到的任何一份优秀 Dockerfile 都经历过无数轮迭代,所以不用追求第一版就是最优解,重要的是养成"构建完回头看一遍体积和层级"的习惯。下次遇到镜像问题,你可以先翻一眼docker history,答案往往就藏在那里。