搞Docker的,迟早都要面对Dockerfile。很多人一开始用docker run跑现成镜像,感觉挺顺利,但一旦需要定制环境、打包自己的应用,就懵了:镜像到底是怎么“长”出来的?为什么别人的镜像几百MB,我照着写就几个GB?CMD 和 ENTRYPOINT 到底有什么区别?如果你卡在这些问题上,那这篇就是给你写的。
Dockerfile 是 Docker 的构建脚本,用一系列声明式指令告诉 Docker 如何一步步组装出镜像。它解决的痛点是“环境一致性”和“可复现性”——只要有一份 Dockerfile,任何机器上都能构建出完全一样的镜像,再也不用在部署文档里写十几条手工命令,每条还带着“按回车之后等两分钟”。这文章面向已经会基本容器操作、但对构建镜像还比较模糊的入门读者,我会从底层设计思路拆起,讲清楚每条指令背后的逻辑,再带你把一个完整构建流程跑通,最后聊聊我踩过几次坑之后总结的排查方法和优化技巧。
1. 整体设计思路:Dockerfile 在 Docker 体系里到底扮演什么角色
1.1 镜像不是“装好的一坨文件”,而是一层一层“叠”出来的
想理解 Dockerfile,先得理解镜像的分层结构。打个比方:镜像就像千层蛋糕,每一层都是只读的,Dockerfile 里的每一条指令,基本都会生成一个新层。构建时按顺序执行指令,一层一层往上叠,最终叠加出来的就是你要的镜像。
为什么要分层?因为可以复用。本地如果已经有一个包含基础系统文件和常用工具的层,另一个镜像也要用到相同的基础环境,就可以直接复用,不用重新下载。这也是为什么相同基础镜像的容器启动极快、占空间也更小。分层还有一个隐藏的好处:拉取镜像时可以做增量下载,仓库里某个镜像更新了几层,你重新拉取时只需要下载变化的部分。
理解了分层,再看 Dockerfile 就清楚很多:它本质上是用一条条指令描述“每一层要做什么”。比如RUN apt-get install xxx就是新建一层,在这一层里执行安装命令;COPY app.py /app/又建一层,把文件放进去。每一层的产物都会被记录下来,构建出完整镜像。
1.2 为什么强烈建议用 Dockerfile,而不是 commit 制作镜像
有一类新手会这么干:先起一个容器,进去手动装一堆东西,改完文件之后docker commit成一个新镜像。这种方式的坑极其隐蔽。首先是不可复现:容器里装了哪些包、改过哪些配置、甚至误删了什么文件,完全靠记忆,你不在旁边记录,换个人根本不知道这个镜像里发生过什么。其次是镜像会迅速膨胀:容器运行过程中产生的临时文件、日志、进程残留,全都会被 commit 进去,几轮操作下来镜像轻松翻倍。
Dockerfile 解决的正是这个问题。它把“如何构建镜像”变成了一串可读、可审查、可版本管理的文本,整个构建过程是确定的、可重复的。有回同事在项目里紧急发版,直接 commit 了一个镜像,结果第二天想复现同一环境,发现连他自己都回忆不起来往里加了什么。后面我把流程改成 Dockerfile,构建脚本入库,问题再没出现过。
1.3 指令的“构建期”和“运行期”之分
有一个很重要的概念容易忽略:Dockerfile 里的指令分两类,一类在“构建时”执行,一类会影响“运行时”。
比如RUN,它是在构建镜像时执行的,所有命令的结果会被固化在镜像层里。而CMD和ENTRYPOINT不参与构建过程,只是写入镜像的配置元数据,等docker run启动容器时才生效。理解这个区别的意义在于:如果你把启动命令写进了RUN,它只会执行一次并留下结果,容器启动时根本不会执行它。
我见过不少人用RUN python app.py这种方式启动应用,结果构建时确实打印了日志,但docker run一执行就报“container exited immediately”,因为启动命令根本没被保存。这就是混淆了构建期和运行期导致的典型问题。
2. Dockerfile 核心指令拆解:每条指令背后的设计逻辑与实操要点
2.1 FROM:一切镜像的起点
FROM指定基础镜像,这是 Dockerfile 里必须出现在第一条的指令。这意味着你的镜像是在某个已有镜像之上叠加出来的,而不是凭空构建。
基础镜像的选择有讲究。官方镜像、带-alpine标签的镜像、-slim标签的镜像,体积差很多。以常见的 Linux 发行版基础镜像为例:普通版可能几百MB,slim版去掉了很多用不到的工具,体积能降不少,Alpine 则更小,但底层用的 C 库与常规发行版不同,某些编译好的二进制可能不兼容。
我在选择基础镜像时一般遵循这样几条原则:官方优先,优先选择维护活跃的官方镜像,不为了追求“冷门小镜像”给自己挖坑;体积其次,能选默认版本就不选带一堆额外工具的版本;安全最后把关,镜像里包越少,潜在漏洞面越小。有回为了追求极致体积选了一个非常精简的极端小镜像,结果应用需要的某个系统库在里面不存在,手动编译整整折腾了一下午,后来还是换回常规 slim 版本,问题瞬间消失。体积确实重要,但“能跑起来”永远是第一位的。
2.2 RUN:构建时执行命令,但别把它当万能工具
RUN是使用频率最高的指令,负责在构建过程中执行命令,比如安装软件、创建目录、调整配置。常见写法有两种:
RUN apt-get update && apt-get install -y curl RUN ["curl", "-L", "https://example.com/file.tar.gz", "-o", "/tmp/file.tar.gz"]我强烈推荐第一种shell形式(直接写命令字符串),它更易读,也方便用&&串联命令。第二种exec形式在某些场景下有用,但参数解析方式不一样,容易踩引号和变量展开的坑。
写RUN时最核心的经验是:能用&&串联就串联。因为每写一个RUN就会多生成一个镜像层,层太多不仅让镜像臃肿,构建时的每一步缓存判断也更耗时。比如这样:
RUN apt-get update \ && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/*就是典型的“三条命令合成一层”的写法,最后顺手把 apt 的缓存列表清掉,能有效减小镜像体积。
RUN还有一个容易忽略的坑:环境变量和 Shell 行为。默认的shell形式用的是/bin/sh -c,在执行复杂脚本时可能和你在本地用的 Bash 行为不一致。如果遇到变量展开的问题,可以显式切换:
SHELL ["/bin/bash", "-c"] RUN source /root/.profile && echo $SOME_VAR2.3 WORKDIR 与 COPY/ADD:组织文件和目录的正确姿势
WORKDIR 是所有人最容易忽视、却最值得养成的习惯。它设置默认的工作目录,后续的 RUN、CMD、ENTRYPOINT、COPY、ADD 指令都会基于这个目录执行。
WORKDIR /app COPY . .不写 WORKDIR 会怎样?如果直接RUN cd /app && ...,每一条命令都要手动切目录,非常啰嗦,而且万一某条命令忘了切,文件就散落在默认目录里了。更关键的是,容器启动时默认的工作目录也会跟着变,如果你的应用读取的配置是相对路径,不设 WORKDIR 会出现“容器内找不到文件”这种莫名其妙的问题。
我习惯把 WORKDIR 放在 Dockerfile 靠前的位置,所有后续操作都围绕它展开,清晰又省心。
COPY 和 ADD 的作用都是把文件从构建上下文复制到镜像里。ADD 额外支持 URL 下载和解压 tar 包,听起来很香,但坑也在这:用 ADD 拉取远程文件,一旦这个文件更新了,构建缓存不会自动刷新,容易悄悄用上旧版本;自动解压可能覆盖掉隔离目录结构,也容易造成失控。所以日常场景,我永远优先用 COPY,只有明确需要解压本地 tar 包的场景才用 ADD。
写 COPY 时还有一条容易犯错的细节:目标路径是绝对路径时,之前的 WORKDIR 不生效;相对路径时,会相对于 WORKDIR 拼接。自己写 Dockerfile 时还好,一旦阅读别人的文件,这个逻辑一乱,很容易就会困惑。
2.4 ENV 与 ARG:两种变量的分工
ENV 设置环境变量,镜像里所有后续指令以及最终运行的容器都会继承。ARG 只存在于构建过程中,镜像构建完成后就不保留(除非它作为 ENV 的默认值被固化下来)。
ARG APP_VERSION=1.0.0 ENV APP_VERSION=$APP_VERSION RUN curl -L -o app.tar.gz https://example.com/download/${APP_VERSION}/app.tar.gz这个组合很常用:ARG 允许外部通过--build-arg传入变量,ENV 则把它固化到镜像里,应用运行时可以读取到。
用 ENV 时有一个必须注意的坑:不能存秘密信息。构建过程中你可能需要用到某个密码,把它写进 ENV 看起来方便,但这个变量会留在镜像的配置元数据里,任何拿到镜像的人都能通过docker history看到。后面我会专门讲怎么处理构建时的敏感信息。
2.5 EXPOSE 与 USER:通信口和权限边界的设定
EXPOSE 声明容器运行时会监听的端口。注意,它只是声明性的,不会真的让外部访问到容器端口。真正映射端口的是docker run -p或docker-compose里的 ports 配置。EXPOSE 存在的意义在于文档化和镜像自我描述,使用docker run -P(大写)时,会随机映射到宿主机端口,但前提是镜像里已经用 EXPOSE 声明过。
USER 指令决定容器中以哪个用户运行。默认情况下,容器以 root 身份运行,这有安全隐患。一旦容器被攻破,攻击者就直接拿到了 root 权限。更好的做法是建一个专用用户来跑应用:
RUN useradd --create-home --user-group myuser USER myuser这样应用以普通用户身份运行,即使出现漏洞,也被限制在用户权限之内。我在模拟项目X里特意测试过两种跑法,切换到非 root 用户后,应用功能完全没受影响,但安全边界却明确了很多。
2.6 CMD 与 ENTRYPOINT:容器启动的“入口”设计
CMD 和 ENTRYPOINT 通常让人混淆。简单说,CMD 是默认的启动命令,如果docker run时带上了其他参数,CMD 会被覆盖;ENTRYPOINT 是容器的入口命令,docker run后面的参数会被追加到 ENTRYPOINT 后面,而不是替换它。
两者配合会有非常舒适的体验。比如经常能看到这样的镜像:
ENTRYPOINT ["python", "app.py"] CMD ["--port=8080"]启动时如果不带任何参数,则默认执行python app.py --port=8080;如果带了参数,比如--port=9090,CMD 被覆盖,最终的参数就变成了python app.py --port=9090。
但很多人忽略的是:ENTRYPOINT 写 shell 形式时,CMD 里的参数拼接方式会变,容易导致引号、变量展开出问题。我的经验是,如果两者都要用,统一用 exec 形式加方括号,最省心。
还有一个常见坑:ENTRYPOINT 最后没有exec前缀。假如你的入口脚本是这样写的:
ENTRYPOINT ["sh", "/start.sh"]如果 start.sh 最后执行的是用&或后台方式启动进程,容器主进程很可能是 shell 而不是你的应用,Docker 发送的停止信号 shell 接收不到,容器就会“卡在停止中”很久。解决办法是在脚本末尾用exec xxx启动程序,让当前 shell 进程被程序替换掉,这样容器信号就能直接送到进程了。这个细节我实测下来非常关键,生产环境里很多“容器停不掉”的诡异现场,根子都在这里。
3. 完整构建流程实操:从上下文到缓存再到镜像
3.1 构建上下文与 .dockerignore:第一道也是最重要的防线
docker build执行时,Docker 会把当前目录(你可以通过最后那个.指定)作为构建上下文打包发送给 Docker 守护进程。即使你的 Dockerfile 里只是COPY了其中一个小目录,整个上下文也会被完整传输。
这就是很多构建慢、镜像大的隐藏根源。如果项目里有 node_modules、大型数据文件、临时产物,全都塞进上下文,不仅构建时消耗大量网络和磁盘,不小心还会把这些不该进镜像的东西 COPY 进去。
.dockerignore文件就是用来排除这些内容的。格式和.gitignore类似:
node_modules *.log .git temp/ *.md我在第一次接触 Docker 构建时,没写这个文件,直接把整个项目目录打过去构建,光是传输就花了十分钟,镜像体积多了几百MB。后来加上 .dockerignore,构建时间肉眼可见地降了下来。写完 Dockerfile 之后,第一时间配好 .dockerignore,这个习惯值得从第一天开始养成。
3.2 一个完整示例:从零构建一个 Web 服务镜像
下面是一份完整且有针对性的 Dockerfile,我以 Python 应用为例,但思路完全适用于其他语言:
# 阶段一:构建阶段 FROM python:3.11-slim AS builder WORKDIR /build COPY requirements.txt . RUN pip install --user -r requirements.txt # 阶段二:运行阶段 FROM python:3.11-slim WORKDIR /app COPY --from=builder /root/.local /root/.local COPY app.py . COPY config/ ./config/ ENV PYTHONUNBUFFERED=1 EXPOSE 8000 USER nobody ENTRYPOINT ["python", "app.py"] CMD ["--host=0.0.0.0", "--port=8000"]这份文件里的几个细节都值得说明。
第一,构建阶段用了多阶段构建(后面专门讲),保证了运行镜像里不残留编译工具链,只保留程序运行所需的部分。
第二,ENV PYTHONUNBUFFERED=1强制 Python 不缓冲输出,不然容器日志会攒在缓冲区里迟迟看不到,排查问题时会非常痛苦。这个对任何 Web 服务都适用。
第三,USER nobody明显降低了容器权限。有人可能会担心权限不够导致应用写不了文件,但应用本来就应该把需要持久化的数据挂到宿主机,容器内部用只读文件系统反而是更安全的方案。
构建命令很简单:
docker build -t myapp:1.0.0 .其中-t指定镜像名和标签,最后一个.就是构建上下文目录。构建完成之后用:
docker run -p 8000:8000 myapp:1.0.0就能把容器跑起来了。
3.3 分层缓存:为什么你的构建忽快忽慢,缓存是如何失效的
Dockerfile 的每一层构建都可能利用缓存:如果这一层的输入和之前构建时完全一致,Docker 会直接复用之前的层,跳过执行。这个机制极大加速了迭代开发,但也是一个微妙的细节来源。
缓存判断的依据是:前一层的镜像 ID 没变,且本条指令本身没有变化(RUN 指令还会检查它依赖的文件是否变化)。所以一个常见经验是:把少变化的内容放在 Dockerfile 靠前,多变化的内容放在靠后。
举个例子,一个 Python 项目如果这么写:
COPY . /app RUN pip install -r /app/requirements.txt每次改任何源码,重新构建时 COPY 层会失效,后面的 pip install 层也全部失效,每次都重新安装依赖。而这样写:
COPY requirements.txt /app/ RUN pip install -r /app/requirements.txt COPY . /app只要 requirements.txt 没变,依赖安装层就永远复用缓存,哪怕源码文件改了一百遍,构建依旧秒级完成。这个顺序问题,是我见过的最多的优化收益点,没有之一。
但也别过度依赖缓存。有些依赖源(比如系统包仓库的元数据)会更新,顽固的层缓存可能导致安装到的包不是最新版本。如果你明确需要强制忽略缓存,构建命令加--no-cache即可。
4. 常见问题与排查技巧实录:那些文档里不会写的血泪经验
4.1 CMD 被覆盖与 ENTRYPOINT 失效的经典困惑
有回同事在群里喊:“我这容器启动就退出了,CMD 明明写了python app.py,为什么没执行?”我让他把启动命令发出来,结果是:
docker run myapp:1.0 -d --name app1问题就在这:docker run后面接的第一段参数不是选项而是命令时,它会整个覆盖 CMD。比如docker run myapp:1.0 bash,那么镜像里的 CMD 就完全不生效了。上面的命令里-d --name app1是选项,没有覆盖命令,理论上不该有问题,但类似问题是高频发生的:有些人会习惯性在docker run后面加/bin/bash,结果进去后发现 shell 是起来了,程序没起。这时就知道是 CMD 被覆盖了。
更稳妥的方案是用 ENTRYPOINT 承载启动命令,这样即使 CMD 被覆盖,也只是覆盖参数,不会覆盖进程本身。如果你希望容器一定要执行某个进程,那 ENTRYPOINT 是比 CMD 更合适的入口。
4.2 构建上下文过大,或者把敏感文件带进了镜像
这种情况我排查过好几次。用户明明只 COPY 了一个配置文件,镜像体积却大得离谱,用docker history一看,某个中间层体积异常大。多半是构建上下文太大,或者 Dockerfile 里不知什么时候 COPY 了整个项目目录,把带有密钥文件、数据库备份的目录带进了镜像。
排查方法很直接:
docker history myapp:1.0.0输出里能看到每一层的体积,如果某层是几十MB甚至几百MB,基本就是它搞的鬼。对着检查那一层对应的指令,是 COPY 了整个上下文?还是 RUN 里下载了文件但没清理?找到之后要么在 .dockerignore 里排除,要么在 RUN 末尾顺手清理掉临时文件。
关于敏感信息,尤其注意:绝不要把私钥、密码、API Token 固化进 ENV 或 COPY 进镜像。构建时需要用敏感信息怎么办?合法的做法是 BuildKit 的--secret机制:
RUN --mount=type=secret,id=my_token \ curl -H "Authorization: Bearer $(cat /run/secrets/my_token)" ...构建时:
docker build --secret id=my_token,src=./token.txt -t myapp:1.0.0 .这种方式下,秘密只在 RUN 指令执行期间临时挂载,不会残留在镜像层里。这是我处理必须用密文构建的场景时的首选方案。
4.3 镜像体积过大的定位与压缩思路
体积过大的原因,通常能归成这几类:基础镜像本身太大;RUN 安装了大量工具且没清理缓存;COPY 了不应包含的大文件;构建依赖被完整保留在运行镜像里。
一个很实用的暂停点是,善于用docker history和docker system df看体积分布。手动做多阶段构建之前,可以先试无脑去除所有 apt 缓存:
RUN apt-get update \ && apt-get install -y xxx \ && rm -rf /var/lib/apt/lists/*如果是 Python 应用,可以摘掉__pycache__:
RUN find /app -type f -name "*.pyc" -delete如果是因为构建工具链占空间,那就直接用多阶段构建,几乎是一劳永逸的解法。
4.4 构建失败后的定位思路:逐层拆解与分步验证
构建过程是顺序执行的,哪一步报错,通常错误信息都会指向对应的 RUN 指令。但很多 RUN 指令里是一长串&&串联的命令,报错后不好确认是哪条命令出的问题。
我的经验是分两步排查。第一步,临时把出错的 RUN 拆成多条 RUN,逐条确认哪条失败,同时可以保留中间的镜像层供检查。第二步,报错信息不清晰时,可以启动失败指令所在的层做交互式调试:
docker run -it --rm <之前的镜像ID> /bin/bash在这个临时容器里手动执行你怀疑出错的命令,可以看到完整的报错上下文,还能尝试修改命令参数,直到本地跑通了再合并回 Dockerfile。这个方法比一次次重复docker build然后全靠猜要高效得多。
5. 进阶优化:多阶段构建、安全实践与工程化打磨
5.1 多阶段构建:把编译环境和运行环境彻底分离
多阶段构建是我在优化镜像体积时用得最多的手段,没有之一。它的核心思路很简单:用一阶段装好所有编译工具和依赖,把编译产物或依赖文件复制到第二个精简阶段,运行镜像只保留最终需要的内容,所有的编译器和中间文件全部被丢弃。
拿一个 Go 应用举例子:
# 构建阶段 FROM golang:1.22 AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o app . # 运行阶段 FROM gcr.io/distroless/base WORKDIR /app COPY --from=builder /build/app . EXPOSE 8080 ENTRYPOINT ["./app"]构建阶段里 Go 工具链、源码、依赖全都在,但最终镜像只包含一个编译好的二进制。运行时阶段没有包管理器、没有 shell,攻击面大幅缩小,体积也常常从几百MB降到几十MB甚至十几MB。
使用多阶段的时候要注意一点:第一个阶段的名字(AS builder)是为了在第二阶段里用COPY --from=builder引用它。如果你不需要引用,也可以不给阶段取名,直接按顺序使用最后一个阶段。具体选择看团队习惯,我一般在复杂项目里给每个阶段起名,可读性好很多。
5.2 安全实践:非 root 用户、依赖锁定与镜像扫描
除了前面聊过的USER nobody和 secrets 管理,还有几个安全细节值得养成习惯。
依赖锁定。项目里如果有依赖清单文件(如package-lock.json、requirements.txt固定版本号、go.sum),必须放进锁文件。不锁版本,构建时就会拉到依赖源里最新版本,今天是新功能,明天可能是破坏性变更,甚至可能引入有漏洞的版本。
镜像扫描。镜像构建完成后,用合规的容器镜像扫描工具检查已知漏洞。启动刚构建的镜像之前跑一次扫描,能让你对镜像里存在的常见安全暴露有个总览。扫描出来有高危漏洞时,优先升级基础镜像版本而不是盲目堆补丁,基础镜像升级往往连同底层系统库一起修复了。
还要留意镜像标签。线上尽量用不可变标签(比如版本号、提交哈希)而不是latest。latest是个漂浮标签,今天构建的镜像和三个月后构建的镜像可能完全是两个东西,回滚、审计都没法做。
5.3 工程化打磨:多镜像复用与统一基础层
当项目里存在多个服务、多份 Dockerfile 时,维护压力会逐渐浮上来。比如每个服务的镜像都要装同一个日志工具,于是每份 Dockerfile 里都粘贴了一段相同的 RUN 命令。这种复制粘贴看着是小事,但一旦那个工具的版本更新,你就要去改所有文件,漏掉一个就是环境差异。
比较顺手的方案是,把所有服务共享的基础环境抽取成一个自定义基础镜像,后续服务的 Dockerfile 用FROM引用它。例如:
FROM registry/internal-base:1.4这个基础镜像里放统一的 CA 证书、时区设置、常用调试工具、数据目录约定。更新基础环境时,只需要构建一次基础镜像,再把服务的FROM标签一升级,全链路就同步了。几轮迭代之后,你会明显感觉到这种做法的价值。
6. 写在后面的一点个人体会
从一开始只会写三行 Dockerfile,到后来能根据项目特性设计多阶段构建流程,这个过程里踩过的坑多得数不过来。有一次折腾一个复杂应用的镜像,改了几十次缓存顺序和指令层级,最后构建出来体积完美,但启动时发现应用读不到配置文件,罪魁祸首是WORKDIR的默认路径和COPY的目标路径没对齐,这个教训让我之后写任何 Dockerfile 都会提前把目录结构画清楚。
还有一个小建议:给每个基础镜像或通用 Dockerfile 维护一个 README 段落,把关键设计决策写下来,比如为什么用这个基础镜像、为什么CMD参数不能随便改、为什么某些层不能乱调整顺序。这东西在当下看似多余,三个月后你想动这个文件时,它比任何注释都管用。
Dockerfile 就是这么一套东西——初看指令不多,但每一条的选择都影响着最终镜像的体积、速度、安全性和可维护性。把它当成构建系统的“源码”来管理,认真对待每一条指令,你会发现自己做的镜像,质量和稳定性都能往上走一个台阶。