接触Docker这几年,最常用的一套流程就是:写需求、改代码、提交代码、等流水线跑完、拉镜像、部署、验证。这中间有一环特别关键,就是生成镜像的源头——Dockerfile。很多人觉得Dockerfile就是几条指令拼在一起,能跑就行,但真正用下来,你会发现它决定了镜像的大小、构建的速度、出问题之后好不好排查。本专题就来把这个Dockerfile从头到尾掰开揉碎了讲清楚,不讲花哨的,只讲实际工作中天天要用的东西。
这套内容适合谁看?适合刚接触容器、想把镜像写明白的同学,也适合已经写了几个月Dockerfile但总觉得哪里不对的开发者。我会把每条核心指令的用法、为什么这么用、以及我在实际项目里踩过的坑一起写出来。你不光能照着写,还能理解背后的机制。
1. 你写的每一行,都在构建一个“层”
1.1 Dockerfile到底是干什么的
Dockerfile是一份构建说明书,它不像我们平时写的脚本那样从上到下执行完了就结束了,而是每一条指令都会生成一个所谓的“层”。这个“层”你可以理解成一张透明胶片,一张一张叠起来,最终变成一个完整的可写文件系统。所以镜像不是一个大而全的压缩包,而是一摞只读层的集合。
这个设计最大的好处是复用。比如你在开发环境基于同一个基础镜像写了十个不同服务的Dockerfile,只要前面那几层的基础依赖指令是一样的,重新构建时Docker就会直接复用缓存层,只有后面不同的部分才会重新生成。这也是为什么团队内部通常不会每个人都去拉一遍全量依赖,而是把公共镜像推到一个内部仓库,原因就在这里。
不少新手最初会误以为Dockerfile跟普通的安装脚本差不多,用commit的方式直接把容器保存为新镜像。手动commit不是不能用,但它不够“可追溯”。你在容器里敲了哪些命令、改了什么文件,最后记录在镜像里,别人拿到这个新镜像完全不知道它里面装了什么依赖、调整过哪些参数。而Dockerfile是明文的,通过它你能精确还原构建过程。这就是为什么要用文件来描述镜像,而不是靠人工操作容器然后另存。
1.2 分层机制对写文件的要求
因为分层机制的存在,写Dockerfile时有几个默认的习惯一定要养成。一个习惯是“尽量少的指令数”,但不是强行把所有内容压缩成一条命令,而是要把属于同一个逻辑层的操作放在同一条RUN中。另一个习惯是“每条指令尽量稳定”,因为只要一条RUN指令的命令字符串变了,这一层缓存就会失效,后面所有层都得重新构建。
还有一点容易被忽略:层虽然是复用的,但每一层都会留下“历史记录”,比如你用RUN命令往系统里安装了一些工具包,即使后面一条RUN命令又把它们删掉了,前面那一层里它们依然存在于镜像历史中,镜像体型并不会变小。这一点后面讲多阶段构建时还会重点提到。
理解层的存在,是理解后面所有指令选择和顺序排布的基础。什么时候用COPY,什么时候用ADD,为什么说ENV会在层里留下环境变量记录,这些都跟层模型直接相关。
2. Dockerfile指令拆解:每条都有讲究
2.1 FROM是地基,地基决定一切
几乎所有Dockerfile都以FROM开始。它指定了我们构建的基础镜像,也就是新镜像的地基。选基础镜像不是随便挑一个latest就行,这里有两个方向需要权衡:一个是体积,一个是完整度。
以常见的Linux发行版镜像为例:
alpine:几MB大小,包管理器是apk,很多服务需要额外编译兼容层,比如有些依赖glibc的二进制程序在alpine上跑不了,因为alpine默认用的是musl libc。debian系:体积大一些,但包管理更成熟,生态兼容性更好。distroless:专门为了当运行环境做出来的,里面没有shell、没有包管理器,只有你的应用和它需要的运行时文件,安全性和体积表现都不错,但排查问题时要进去敲命令就不太方便了。
我个人的经验是:如果是做内部服务,优先用带完整调试工具的基础镜像,比如debian系的slim版本;如果是构建对外分发、要严格控制镜像体积的产物,可以上多阶段构建,最终运行镜像用distroless或alpine。千万别在还没搞清楚应用依赖的情况下,盲目地追求一个几十MB的镜像,结果应用在alpine上运行出现各种兼容性问题,排查起来更费时间。
2.2 RUN:把层合并好,依赖才干净
RUN指令负责在构建过程中执行命令,主要有两种写法:
# shell形式 RUN apt-get update && apt-get install -y curl # exec形式 RUN ["apt-get", "update"]shell形式会经过/bin/sh -c来执行,exec形式则直接执行指定的命令,不经过shell,因此在解析环境变量、通配符等情况上会有差异。
我建议在安装系统依赖时把多个操作控制在同一条RUN里,用&&连接。原因很简单:如果你写成两条RUN,第一条执行完apt更新,第二条执行安装,那缓存命中时,第二条因为第一条的缓存更新而白跑一遍。更关键的是,如果你在第一条RUN里安装了软件包,第二条里执行apt清理缓存,文件虽然被删掉了,但它们占用的空间早就留在上一层里了。
RUN apt-get update \ && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ && rm -rf /var/lib/apt/lists/*注意到这里我用了一个--no-install-recommends参数,它可以避免安装推荐的非必要包,省下不少体积。后面的rm -rf /var/lib/apt/lists/*是清理包管理器的索引缓存,这条操作很重要,因为apt的源列表在构建时会下载不少索引文件,不清理的话它们会成为镜像里的垃圾文件。
2.3 WORKDIR:别在路径上偷懒
WORKDIR用来指定当前的工作目录,类似在shell里执行cd,但它比cd强的地方在于:如果目录不存在,它会自动创建;而且它不只是影响后续的RUN指令,还影响CMD、ENTRYPOINT、COPY这些指令的默认路径。
举一个踩过坑的例子:某次我在容器里写了一个启动脚本,脚本里写的是相对路径./logs/app.log。结果在本地测试一切正常,一到容器里就报找不到目录。后来才查出是启动时的工作目录跟预期不一致,因为Dockerfile里根本没有设置WORKDIR,默认落在了根目录。从那以后我每个项目都会明确设置WORKDIR,绝不依赖镜像默认的目录。
WORKDIR /app2.4 COPY与ADD:用COPY,少用ADD
COPY和ADD都可以把本地文件复制到镜像中。ADD比COPY多了一些额外功能,比如它支持从URL直接下载文件到镜像,也支持把本地的tar包自动解压到指定目录。
听起来很方便,但我不建议日常使用这些额外功能。原因有三点:
- 从URL下载文件到镜像里,会把网络请求放进构建过程,网络一抖动或对方接口慢,整个构建就会卡住,而且下载下来的文件版本不可控,不利于镜像可复现。
- 自动解压tar包是一种隐式行为,别人看你的Dockerfile时并不能一眼看出解压逻辑。
- ADD对于URL下载带过来的文件属性、权限、时间戳等处理方式不够透明,容易带来一些意想不到的问题。
因此,最稳妥的做法永远是:
COPY ./app /app如果你需要下载远程文件,先在本地下载好再COPY进去,或者用RUN配合curl下载,这样逻辑更清晰,后续排查也方便。
2.5 ENV与ARG:构建参数和环境变量别混了
ENV和ARG长得像,但作用时机完全不同。
ARG只在构建过程中有效,比如用于传递需要安装的包版本号、镜像标签等,构建结束后在容器里是看不到的。ENV会同时影响构建阶段和容器运行阶段,它会把变量写进镜像的环境变量配置里。
我在实践中的一个习惯是:需要外部传入的版本号等构建参数用ARG,需要写入运行环境供应用读取的配置用ENV。把两者分开,语义就清晰了,也避免在最终容器里残留一堆只对构建有用、却有可能暴露内部信息的变量。
ARG APP_VERSION=1.0.0 ENV APP_HOME=/app ENV JAVA_OPTS="-Xms256m -Xmx512m"还有一个细节:ENV设置的关键值最好固定下来。例如很多服务镜像喜欢把ENV LANG=C.UTF-8写进去,这个值不随构建参数变,它对整个运行环境起稳定作用,值得放前面。
2.6 CMD与ENTRYPOINT:同一个侧重点
CMD和ENTRYPOINT都用来指定容器启动时执行的命令,但设计理念不同。简单说:
CMD是“默认值”,当用户执行docker run时没有追加任何命令,就会使用CMD指定的命令;如果追加了命令,CMD会被覆盖。ENTRYPOINT是“固定入口”,无论你有没有在docker run后面追加参数,都会执行ENTRYPOINT声明的命令,追加的参数会作为参数传递给ENTRYPOINT。
一个非常普遍的配合是:ENTRYPOINT写启动主程序,CMD写默认参数。这样别人运行你的镜像时,想加参数可以直接加在镜像名后面,不需要重写整个命令。
ENTRYPOINT ["java", "-jar", "/app/app.jar"] CMD ["--spring.profiles.active=prod"]运行:
docker run myapp --spring.profiles.active=dev实际执行的就是java -jar /app/app.jar --spring.profiles.active=dev。这样镜像的启动语义清晰,使用者也可以灵活覆盖默认配置。
需要特别注意的是JSON数组的exec形式,使用它的时候命令不会自动经过shell解析,所以像$HOME这种变量的展开不会生效,你必须显式写成["/bin/sh", "-c", "java -jar $HOME/app.jar"]才能拿到变量。这是新手最容易翻车的地方之一。
2.7 EXPOSE与VOLUME:声明用途,而不是真正开端口
EXPOSE只起文档化作用,它告诉使用镜像的人“这个镜像里的服务默认会监听这些端口”,但并不会真的打开主机的端口。真正把端口对外暴露出用的是docker run -p或docker compose里的ports配置。所以不要指望写了EXPOSE,宿主机就能访问容器端口。
VOLUME也不是一个简单的目录占位,它会在容器运行时创建一个匿名卷挂载点。如果你在镜像里声明了VOLUME /var/log/app,那么即使后面你没有手动指定-v参数,Docker也会为它创建一个匿名卷,这个好处是数据不会因为容器删除而直接丢失,但它也会让一些测试场景变得麻烦,比如临时容器退出,匿名卷还在,占着磁盘空间。因此我建议,在Dockerfile里只在真正需要保留数据的位置声明VOLUME,一般的临时目录不要滥用。
3. 实操攻坚:从“能跑”到“跑得好”的三版演进
3.1 V1版本:先跑通再说
假设现在有一个基于Python写的小型Web服务,依赖只有两个包,需要clone代码、安装依赖、启动服务。很多人第一版会写成这样:
FROM python:3.9-slim WORKDIR /app COPY . . RUN pip install -r requirements.txt CMD ["python", "app.py"]这个版本功能上没问题,构建也能通过,但它有几个隐患:
- 把整个项目目录全部COPY进镜像,本地那些不需要的文件比如缓存目录、测试数据、git目录全都一起进去了。
- 依赖安装发生在源码复制之后,意味着每次改代码重新构建时,pip安装依赖那一层缓存一定会失效,因为COPY层的记录总是变化。
- 没有设置PYTHONPATH或默认环境,后续扩展容易出问题。
这种“能跑”的阶段,适合本地做快速验证,但不适合直接往生产环境推。
3.2 V2版本:利用缓存,先装依赖再复制代码
第二个版本的核心思路是:把变动频率低的依赖层放在前面,把变动频率高的源码层放在后面。
FROM python:3.9-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["python", "app.py"]这里把COPY requirements.txt .作为一次独立COPY,单独复制依赖清单文件,触发一次依赖安装层。因为requirements.txt一般不会频繁变动,所以后面开发时每次改业务代码,重新构建都能命中pip install这一层的缓存,速度大大提升。
同时设置了PIP_NO_CACHE_DIR=1,让pip不保留下载缓存,减小镜像体积,而且指定了PYTHONUNBUFFERED=1,确保Python的日志输出不会被缓冲丢失,这个在容器里非常重要。如果不设置,应用print了内容,但你用docker logs查看时可能发现日志迟迟不出现,因为Python默认的stdout是带缓冲的,当buffered模式检测到不是终端环境时,只会在缓冲满了或程序退出时刷新。有了这个环境变量,日志就能实时往外吐。
3.3 V3版本:多阶段构建,只保留运行时需要的文件
对于Python这类解释型语言,可能看不出太大的体积差距,但对于Golang、Java、Node这类需要构建出产物的场景,多阶段构建的优势非常明显。
# 第一阶段:构建环境 FROM golang:1.21-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o app . # 第二阶段:运行环境 FROM alpine:3.19 RUN apk add --no-cache ca-certificates WORKDIR /app COPY --from=builder /build/app . EXPOSE 8080 ENTRYPOINT ["./app"]第一阶段负责下载依赖并完成编译,第二阶段只需要把编译好的二进制文件拷贝过来,再加上运行所需的依赖(这里是ca-certificates)即可。最终镜像不包含Go编译器、不包含源码、不包含包管理缓存,体积可以非常小。
多阶段构建的关键点在于AS指定阶段名,以及COPY --from=builder这个语法。我在实际项目里看到过一种用法:有人在同一个Dockerfile里写了三个阶段,第一个阶段负责程序编译,第二个阶段负责前端资源构建,最后把两边的产物合在一起,放到运行镜像中。这种方式把多个构建任务的产物集合到一起,非常实用。
当前后端构建结果需要合并时,典型的写法是:
FROM node:18-alpine AS frontend WORKDIR /build COPY frontend/package*.json ./ RUN npm install COPY frontend/ . RUN npm run build FROM golang:1.21-alpine AS backend WORKDIR /build COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ . RUN CGO_ENABLED=0 go build -o server . FROM alpine:3.19 WORKDIR /app RUN apk add --no-cache ca-certificates COPY --from=backend /build/server . COPY --from=frontend /build/dist ./static ENTRYPOINT ["./server"]这样最终跑起来的是一个纯二进制服务,所有静态资源都已经打包进镜像,整个过程全自动,极大降低了部署链路中不一致的风险。
4. 构建优化细节:让Dockerfile更丝滑
4.1 层缓存的利用策略
Docker构建时的缓存机制是:对于一条COPY指令,如果源文件内容没有变化,则直接从缓存中取;对于一条RUN指令,如果它的命令字符串没变,并且它的父层缓存没有失效,则也直接命中缓存。
这个机制最重要的启示是:把变化少的步骤放前面,把变化多的步骤放后面。常见的步骤顺序是:
- 安装系统依赖
- 拷贝依赖锁定文件
- 安装应用依赖
- 拷贝源码
- 编译或启动相关操作
但有些团队会把构建所需的环境变量全部放在最上面,比如设置了某个版本号给后续RUN用的ARG,这样每次改版本号时,由于ARG变了,这一层以及后面的所有层都会重建。这个现象有它的合理性:版本号变了本来就希望重新构建。但如果你设置的是像ENV JAVA_OPTS这种运行期参数,它其实不影响构建产物,你却把它放在所有COPY之前,那后续任何构建阶段改动也会让它重建,白白浪费缓存。因此,ENV也要放在“它真正影响到的指令”前面,而不是统一堆在文件顶部。
4.2 .dockerignore:被忽略但非常重要
前面说V1版本把整个项目目录COPY进去,要避免这个,除了精心设计COPY路径之外,还需要.dockerignore文件。它类似于.gitignore,作用是让Docker在把上下文打包发送给构建进程时,主动跳过不需要的文件。
一个典型的.dockerignore:
.git .gitignore node_modules venv dist *.md docker-compose.yml这个文件的影响比你想象中还要大。因为Docker构建时会先打包整个上下文,你在项目根目录下有多少文件,即使Dockerfile里没用到,它们也会被遍历。一个大项目里如果存在误传的日志、大数据集或node_modules,光是把上下文发给构建进程这一步就能卡掉你几分钟时间。所以从一开始就应该维护好一份.dockerignore。
4.3 减少镜像体积的三个习惯
除了多阶段构建和清理缓存,我常用的还有三个习惯:
- 使用
--no-install-recommends安装系统包里尽量少的非必要依赖。 - 把日志文件、临时文件,在RUN阶段内用完就删。
- 尽量使用更小的基础镜像,但要先保证兼容性。
在Debian系基础镜像上,很多人习惯先执行apt-get update,然后安装、清理,这三步一定要在一个RUN里完成,不然清理动作就发生在下一层,上一层的文件仍然不会被真正去除。
RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/*这里我把apt-get update、install、rm -rf通过&&串成一条,即使中间某一步失败,也不会留下半成品层,逻辑上更安全。
4.4 安全细节:换用户、最小权限
容器里默认是用root用户运行的,但生产实践中,让应用进程使用普通用户运行可以降低安全风险。基础镜像里有些自带普通用户,比如node镜像里有一个node用户,或者我们自己创建:
RUN groupadd -r app && useradd -r -g app app USER app这样做的意义在于:即使容器被攻击者通过应用漏洞拿到了shell,他也没有root权限,能做的事情受到很大限制。
USER指令一旦设置,后续的RUN、CMD、ENTRYPOINT都会以该用户执行,所以在设置USER之前先完成所有需要root权限的安装操作。还要注意一点:设置USER之后,如果应用要写文件到某个目录,必须提前把该目录的所有权交给该用户,否则会遇到权限拒绝的错误。
RUN mkdir -p /app/data && chown -R app:app /app4.5 HEALTHCHECK:镜像自报健康状态
HEALTHCHECK指令定义了一个健康检查命令,Docker会根据这个命令返回的结果来判断容器是否健康。这在编排平台中特别有用,它可以让调度器知道某个容器是不是真的准备好接收流量。
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1这段命令每30秒检查一次/health接口,如果三次连续失败,容器会被标记为unhealthy。我在本地测试时,经常会用--start-period调大一点,因为服务启动阶段往往需要加载数据或初始化连接,这个阶段还没准备好,过早的检查反而会产生误报。
5. 常见问题与排查技巧实录
5.1 构建阶段乌鸦乱飞:缓存失效、找不到文件、没权限
构建时报错的情形五花八门,但归纳起来,最多见的就这几个类别。
找不到文件:多半是WORKDIR不是你以为的位置,或者COPY源路径相对于build context的路径写错了。Dockerfile里的COPY路径是相对于构建上下文的,不是相对于Dockerfile文件所在目录的。比如你通常在项目根目录执行docker build -f docker/Dockerfile .,这个.代表上下文根目录,那么COPY路径就要从上下文根目录开始写,哪怕Dockerfile在docker子目录里,也不能写COPY docker/config ./这样多余的层级。
apt源404:很多国内环境里直接使用默认源容易出现连接超时。解决方案是在RUN步骤中替换成可用的镜像源,但要注意镜像源地址不要写死到业务代码里,毕竟不同团队网络环境不一样。更好的做法是把源配置作为构建参数,用ARG传入。
没有权限:集中在把宿主机的文件复制进容器后,文件的所有者、权限位不符合容器内预期。可以用--chown参数在COPY时直接指定所有权:
COPY --chown=app:app ./dist /app/dist5.2 容器启动秒退:不是build的问题,是run的问题
最常见的现象是docker run之后,容器立刻退出。这通常是因为前台进程执行完就结束了。很多新手会在这里懵,觉得镜像都构建成功了,怎么容器跑不起来?
这里有一个根本观念要纠正:容器不是虚拟机,它里面没有常驻的系统服务进程。如果你启动的命令是/bin/bash,而你又没有用-it保持交互,bash会立刻读到EOF然后退出,容器随之结束。所以,CMD和ENTRYPOINT里写的必须是前台运行的进程。如果你有多个服务要同时启动,不要靠nohup、&把它们放到后台,而是应该用一个脚本来拉起它们并等待它们,或者用专门的进程管理工具作为前台进程运行。
排查这类问题时,最快的方法是用docker logs <容器名>查看输出,很多情况下应用日志已经把原因打印出来了,比如缺依赖、端口被占、连不上数据库等。
5.3 镜像太大,怎么科学地“减肥”
先说明一点,不要一味地追求小体积,安全稳定优先,体积是最后考虑的事情。但如果确实需要控制体量,可以这样查:
- 用
docker history <镜像名>看每一层的大小,找到哪些层最占空间。 - 用
docker system df查看本地的镜像、容器、数据卷占用情况。 - 确认是不是有重复的构建缓存:
docker builder prune可以清理构建缓存。
实际工作中,我见过一个Go服务,编译后二进制不到20MB,但最终镜像却有1.2GB,原因就是有人直接在golang:1.20基础镜像上运行编译好的程序,把整个Go工具链都塞进了运行时。切换到多阶段构建后,镜像立刻缩小到30MB左右。这种案例是优化镜像体积最典型、也最直观的说明。
5.4 构建太慢,先从缓存下手
构建速度慢,先问几个问题:
- 是不是每次构建前面所有层都重建了?如果是,检查是不是ARG或ENV放在了文件顶部,导致每条指令的缓存都失效。
- 是不是COPY了不需要的大文件?检查
.dockerignore。 - 是不是网络环境导致拉基础镜像、安装依赖特别慢?看看是否用了合适的镜像源、缓存代理。
基础镜像的拉取如果每次都走官方仓库,在部分网络环境下确实可能非常慢。解决办法是让团队内部维护一个镜像仓库,构建人员先统一拉取再指定tag。
6. 最后分享两个我在实际项目里反复用到的习惯
第一,每次写Dockerfile都会在注释里留下维护者和意图说明。什么镜像、什么版本、基于哪个基础镜像、为了处理什么问题而加的层,这些信息写得越清楚,后续排障就越省心。镜像几个月没有人动过之后,你回头再看,如果没有注释,只能靠一层层猜。
第二,强烈建议把Dockerfile纳入代码分支管理,并配合CI流水线做镜像扫描。构建完镜像后,先扫描一下漏洞,再打tag推送仓库。这样任何一个已知的高危漏洞都能在进入部署环境之前暴露出来。有些团队的流程是构建完成后直接推生产私仓,中间根本没有镜像扫描这一环,等到出了安全问题,再从头查镜像历史,代价就大了。
第三,每次调整指令优先级时,不要只凭感觉。你的改动可能会让某几层缓存失效,可以用docker build --progress=plain观察每一步的真实耗时。如果发现改完之后每次构建都全量重来,那就要反思是不是把变化频率太高的变量放在了太靠前的位置。
Dockerfile是一项“看一遍觉得会了,写多了才发现全是细节”的技能。上面这些指令、规范和踩坑记录,是我在若干项目里反复滚过之后总结出来的。如果你刚开始写Dockerfile,就按这个顺序一步步来,先别追求最优,先把流程跑通,再逐步打磨体积和速度,后面你会发现自己越来越不想回到那种“手动进容器改文件再commit”的日子了。