1. 为什么需要自定义镜像:从拉取到构建的思维转变
很多人刚开始接触容器技术的时候,习惯性地从公共仓库拉取现成镜像,docker pull一条命令下去,MySQL、Redis、Nginx 全都有了,感觉特别方便。但真正到了实际项目里,你会发现现成的镜像总差那么点意思——要么版本不对,要么缺少业务需要的依赖包,要么配置文件得手动挂载一堆卷,部署五六台机器就得重复操作五六遍。这个时候,自定义镜像就不是一个可选项,而是一个必选项了。
我自己最早踩过的坑,是在一个图像处理的小项目上。当时直接用了一个公共的基础镜像,结果每次启动容器都要手动进去装几个系统级的依赖库,装完还得改环境变量,改完还得重启服务。一次两次还能忍,但每次更新代码重新部署都要来一遍,简直是在浪费生命。后来花了半个小时写了一个 Dockerfile,把这些重复劳动全部固化下来,从此部署就是一条docker build加一条docker run,干净利落。
这篇文章要聊的,就是怎么从一个最基础的 Dockerfile 开始,一步步构建出符合自己业务需求的自定义镜像。我会把指令的用法、构建的流程、常见的坑和排查思路都拆开来讲,不管你是刚接触容器的新手,还是已经用过一段时间但没系统整理过的开发者,应该都能从中找到可以直接拿来用的东西。
1.1 自定义镜像到底解决了什么问题
先把这个事情说清楚。自定义镜像的核心价值,是把环境配置的过程代码化、版本化、可复用化。你可以把它理解成一份"环境搭建的菜谱"——只要照着菜谱走,不管在谁的机器上,做出来的菜味道都一样。
具体来说,它解决了这么几个痛点。第一是环境一致性,开发机上跑得好好的服务,到了测试环境或者生产环境就出问题,十有八九是环境差异导致的,自定义镜像能把这个差异抹平。第二是部署效率,手动装环境可能要半小时,用镜像可能只需要几十秒。第三是版本管理,每次修改 Dockerfile 都相当于给环境打了一个快照,出了问题可以快速回滚到上一个版本。第四是知识沉淀,环境怎么搭的、依赖怎么装的,全部写在 Dockerfile 里,团队里任何人拿到这个文件都能复现出一样的环境,不用再靠口口相传。
1.2 自定义镜像的两种常见做法
实际工作中,构建自定义镜像主要有两种方式。一种是基于容器修改后提交,也就是先docker run启动一个基础容器,进去手动安装配置,然后用docker commit把容器保存成新镜像。这种方式上手快,适合临时试验,但缺点很明显——整个过程不可追溯,别人不知道你在这个容器里做了什么,而且镜像层会越来越臃肿,不利于维护。
另一种就是基于 Dockerfile 构建,把每一步操作写成指令,用docker build来执行。这是主流推荐的做法,也是这篇文章的重点。它的好处是每一步都清清楚楚,可以纳入版本控制,可以代码审查,可以自动化构建。我个人的建议是,除非是临时调试,否则一律用 Dockerfile,不要用 commit。
2. Dockerfile 核心指令逐个拆解
Dockerfile 本质上就是一个文本文件,里面是一行行的指令。Docker 引擎会按顺序读取这些指令,每执行一条就生成一个镜像层。理解每条指令的作用和适用场景,是写出高质量 Dockerfile 的前提。下面我把最常用的指令挨个讲一遍,重点说清楚"什么时候用"和"怎么用才不出问题"。
2.1 FROM:一切从基础镜像开始
FROM是 Dockerfile 的第一条有效指令(ARG可以放在它前面,这个后面说),用来指定基础镜像。你可以选择官方镜像,比如ubuntu、alpine、debian,也可以选择别人做好的镜像。
选择基础镜像有几个考量维度。体积方面,alpine系列通常只有几 MB,而ubuntu动辄几十上百 MB,如果对镜像大小敏感,优先考虑alpine。兼容性方面,alpine用的是 musl libc 而不是 glibc,某些依赖 glibc 的二进制程序在上面跑不起来,这时候就得换debian或ubuntu。生态方面,如果你用的语言有官方镜像,比如python:3.11-slim、node:20-alpine,直接用官方的通常比自己在通用系统上从头装要省事。
FROM python:3.11-slim这里有个小技巧,尽量指定具体的版本标签,不要用latest。latest看起来方便,但它会随着时间变化,今天构建的镜像和明天构建的可能就不一样了,这对可复现性是个灾难。
2.2 WORKDIR:设定工作目录
WORKDIR用来设置后续指令的工作目录。如果目录不存在,Docker 会自动创建。它的作用类似于cd,但比cd更可靠,因为它会影响后续所有RUN、CMD、ENTRYPOINT、COPY、ADD指令的执行位置。
WORKDIR /app我见过不少新手在RUN里写一堆cd /app && xxx,其实完全没必要,用WORKDIR设一次就行了。而且WORKDIR可以写多次,每次都是相对于上一条的路径,比如先WORKDIR /app再WORKDIR src,最终工作目录就是/app/src。
2.3 COPY 与 ADD:把文件放进镜像
COPY和ADD都用于把宿主机的文件复制到镜像里,但行为有区别。COPY就是纯粹的复制,语义清晰。ADD除了复制之外,还支持自动解压 tar 包和从 URL 下载文件,功能更多但也更容易让人困惑。
我的建议是优先用 COPY,除非你确实需要 ADD 的自动解压功能。原因很简单,ADD 的隐式行为容易造成意外,比如你复制一个 tar 包进去,本意是想保留这个包,结果它被自动解压了,这就很尴尬。
COPY requirements.txt . COPY . /app这里有个重要的优化点,后面讲构建缓存的时候会详细说——把不常变化的文件先复制,常变化的文件后复制,能大幅提升构建速度。
2.4 RUN:构建时执行命令
RUN是 Dockerfile 里用得最多的指令,用来在构建镜像时执行命令。每一条RUN都会在一个新的临时容器里执行,执行结果会被提交成一个新的镜像层。
写RUN有几个要点。第一,尽量合并命令,用&&把多条命令串起来,减少镜像层数。第二,及时清理缓存,比如apt-get装完包之后要rm -rf /var/lib/apt/lists/*,不然缓存会留在镜像里白白占空间。第三,使用非交互模式,装包时加上-y或者设置DEBIAN_FRONTEND=noninteractive,避免构建过程卡在交互提示上。
RUN apt-get update && \ apt-get install -y --no-install-recommends \ build-essential \ libgl1-mesa-glx && \ rm -rf /var/lib/apt/lists/*这个例子里的--no-install-recommends也值得说一下,它会跳过那些"推荐但非必需"的包,能显著减小镜像体积。
2.5 ENV 与 ARG:环境变量与构建参数
ENV设置的环境变量在构建时和容器运行时都有效,而ARG定义的变量只在构建时有效。这个区别很关键,很多人搞混。
ARG PYTHON_VERSION=3.11 ENV APP_HOME=/app ENV PYTHONUNBUFFERED=1ARG适合用来传递构建时的可变参数,比如版本号、下载地址。ENV适合设置应用运行需要的配置,比如路径、编码、时区。有个常见的用法是把ARG的值赋给ENV,这样构建参数就能传递到运行时。
注意:
ARG的值在镜像构建完成后不会保留在镜像的元数据里(除非你显式赋给 ENV),所以不要用 ARG 来传递敏感信息,它虽然不会留在最终镜像里,但在构建缓存和构建历史里可能被看到。
2.6 EXPOSE:声明端口
EXPOSE用来声明容器运行时监听的端口。需要强调的是,它只是一个声明,并不会真的把端口暴露出去。真正让端口可访问的是docker run时的-p参数。
EXPOSE 8000虽然它不直接起作用,但写上是个好习惯,相当于给镜像的使用者一个文档提示,告诉他们这个服务默认监听哪个端口。
2.7 CMD 与 ENTRYPOINT:容器启动时做什么
这两个指令决定了容器启动时执行什么命令,也是新手最容易混淆的地方。简单来说,ENTRYPOINT是容器的入口,CMD是默认参数。如果同时存在,CMD的内容会作为参数传给ENTRYPOINT。
ENTRYPOINT ["python", "app.py"] CMD ["--host", "0.0.0.0", "--port", "8000"]用docker run启动时,如果用户在镜像名后面加了参数,这些参数会覆盖CMD的内容,但不会覆盖ENTRYPOINT。这个特性让ENTRYPOINT适合定义"这个容器是干什么的",而CMD适合定义"默认怎么干"。
还有一个细节,这两个指令都有 exec 和 shell 两种写法。exec 写法是["可执行文件", "参数1", "参数2"],shell 写法是可执行文件 参数1 参数2。推荐用 exec 写法,因为 shell 写法会通过/bin/sh -c来执行,可能导致信号传递问题,容器收到停止信号时应用可能无法优雅退出。
3. 从零构建一个自定义镜像的完整实操
光讲指令有点干,我们直接上手做一个。假设我们要构建一个基于 Python 的图像处理服务镜像,这个服务依赖几个系统库和 Python 包,启动后监听 8000 端口。我会把每一步的思路和操作都写清楚,你可以跟着做一遍。
3.1 项目结构与文件准备
先规划一下目录结构。一个规范的构建目录大概长这样:
image-demo/ ├── Dockerfile ├── requirements.txt ├── app.py └── .dockerignoreDockerfile是构建脚本,requirements.txt是 Python 依赖清单,app.py是应用入口,.dockerignore用来排除不需要复制进镜像的文件。
.dockerignore这个文件很多人会忽略,但它其实很重要。构建时 Docker 会把整个目录(构建上下文)发送给守护进程,如果目录里有大文件、虚拟环境、Git 历史,会拖慢构建速度,还可能把不该进镜像的东西带进去。一个典型的.dockerignore长这样:
.git __pycache__ *.pyc .venv venv .env *.log3.2 编写 Dockerfile 的完整过程
下面是我为这个项目写的 Dockerfile,逐段解释。
FROM python:3.11-slim LABEL maintainer="dev@example.com" LABEL description="Image processing service" ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 WORKDIR /app RUN apt-get update && \ apt-get install -y --no-install-recommends \ libgl1-mesa-glx \ libglib2.0-0 && \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 ENTRYPOINT ["python", "app.py"] CMD ["--host", "0.0.0.0", "--port", "8000"]第一段FROM python:3.11-slim,选了 slim 版本,比完整版小很多,又比 alpine 兼容性好,适合这种需要编译依赖的场景。
第二段设置环境变量。PYTHONUNBUFFERED=1让 Python 的输出不缓冲,这样日志能实时看到,排查问题方便。PYTHONDONTWRITEBYTECODE=1禁止生成.pyc文件,保持镜像干净。PIP_NO_CACHE_DIR=1让 pip 不缓存下载的包,减小体积。
第三段装系统依赖。libgl1-mesa-glx和libglib2.0-0是图像处理库常见的系统依赖,不装的话运行时会报找不到共享库。装完立刻清理 apt 缓存,这是减小镜像体积的关键一步。
第四段先复制requirements.txt再安装依赖,这是为了利用构建缓存。只要requirements.txt没变,这一层就能复用缓存,不用每次重新下载安装。如果先复制全部代码再装依赖,那每次改一行代码都会导致依赖重装,构建时间会很长。
第五段复制剩余代码,然后声明端口和启动命令。
3.3 构建命令与参数详解
Dockerfile 写好了,接下来执行构建:
docker build -t image-service:v1 .-t给镜像打标签,格式是名称:版本。最后的.是构建上下文路径,表示当前目录。这个点不能省,它告诉 Docker 从哪里读取构建所需的文件。
构建过程中你会看到每一层的执行输出,类似这样:
[1/6] FROM docker.io/library/python:3.11-slim [2/6] RUN apt-get update && ... [3/6] COPY requirements.txt . [4/6] RUN pip install ... [5/6] COPY . . [6/6] ENTRYPOINT ["python", "app.py"]如果某一步失败,构建会停在那里,你可以根据错误信息定位问题。构建成功后,用docker images就能看到新镜像。
3.4 多阶段构建:把镜像体积压到极致
上面那个 Dockerfile 已经能用了,但如果你对镜像体积有更高要求,可以用多阶段构建。思路是在一个"构建阶段"里装编译工具、编译代码,然后把编译产物复制到一个干净的"运行阶段"镜像里,这样编译工具就不会留在最终镜像里。
FROM python:3.11-slim AS builder WORKDIR /build COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt FROM python:3.11-slim WORKDIR /app COPY --from=builder /root/.local /root/.local COPY . . ENV PATH=/root/.local/bin:$PATH EXPOSE 8000 ENTRYPOINT ["python", "app.py"]这个例子里,第一阶段把依赖装到用户目录,第二阶段只把装好的依赖复制过来,编译缓存、临时文件全部留在第一阶段被丢弃。对于编译型语言比如 Go、Rust,多阶段构建的效果更明显,能把镜像从几百 MB 压到几十 MB。
3.5 构建缓存机制与优化策略
构建缓存是提升效率的核心。Docker 在构建时会逐层检查,如果某一层的指令和上下文都没变,就直接用缓存,不再重新执行。一旦某一层失效,它后面的所有层都会失效。
理解这个机制之后,优化思路就清晰了:把变化频率低的指令放前面,变化频率高的放后面。依赖清单通常比业务代码稳定,所以先复制依赖清单装依赖,再复制代码。系统依赖比 Python 依赖更稳定,所以装系统依赖放在更前面。
如果你发现缓存该命中却没命中,可以用docker build --no-cache强制不用缓存重新构建,排除缓存干扰。反过来,如果构建时网络不稳定导致某一步失败,修好之后重新构建,前面的层会走缓存,不用从头再来。
4. 构建过程中的常见问题与排查实录
Dockerfile 写起来不难,但构建过程中遇到的各种报错才是真正消耗时间的地方。我把这些年遇到过的典型问题和排查思路整理出来,希望能帮你少走弯路。
4.1 构建失败的典型场景与定位方法
场景一:网络超时导致装包失败。这个最常见,尤其是在装系统包或者从包管理源下载依赖的时候。报错信息通常是Failed to fetch或者Connection timed out。排查思路是先确认网络是否正常,然后检查是不是源的问题。如果是构建环境网络受限,可以考虑配置镜像加速或者使用内网源。
场景二:找不到文件或目录。报错类似COPY failed: no source files were specified或者No such file or directory。这通常是构建上下文路径不对,或者.dockerignore把需要的文件排除了。排查方法是确认执行docker build的目录,以及.dockerignore的内容。
场景三:权限不足。报错Permission denied。可能是RUN里的命令需要 root 权限,但当前用户不是 root,或者复制的文件权限不对。可以在 Dockerfile 里用USER root切换,或者用chmod调整权限。
场景四:依赖版本冲突。装包时报版本不兼容。这种问题最隐蔽,因为同样的 Dockerfile 在不同时间构建可能结果不同。解决办法是锁定版本号,在requirements.txt或者包管理配置里写死具体版本。
4.2 镜像体积过大的排查与瘦身
镜像体积大是个常见问题,一个简单的 Python 服务镜像做到 1GB 以上并不稀奇。排查思路是先用docker history看看每一层占多大:
docker history image-service:v1输出会列出每一层的大小,哪一层特别大一眼就能看出来。常见的体积来源有这么几个:基础镜像本身太大、apt 缓存没清理、pip 缓存没清理、复制了不必要的文件、编译工具留在了最终镜像里。
对应的瘦身手段:换更小的基础镜像、装完包立刻清理缓存、用--no-install-recommends跳过非必需包、用.dockerignore排除无关文件、用多阶段构建丢弃编译工具。我实测下来,一个原本 1.2GB 的镜像,经过这几步优化能压到 300MB 左右。
4.3 容器启动后立即退出的排查思路
镜像构建成功,docker run一启动就退出,这也是高频问题。排查的第一步是看日志:
docker logs <容器ID>日志通常会告诉你原因。常见的有这么几种:启动命令写错了、应用依赖的服务没起来、端口被占用、配置文件路径不对、权限问题导致应用无法启动。
如果日志信息不够,可以用docker run -it --entrypoint /bin/bash image-service:v1覆盖入口,进到容器里手动执行启动命令,看看具体报什么错。这个技巧非常实用,相当于把容器变成一个可以交互调试的环境。
还有一个容易忽略的点是CMD和ENTRYPOINT的写法。如果用 shell 写法,应用可能收不到停止信号,导致docker stop时要等超时才被强制杀掉。用 exec 写法就能避免这个问题。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 构建时网络超时 | 网络受限或源不可达 | 检查网络和包源配置 | 配置加速源或换内网源 |
| COPY 找不到文件 | 上下文路径错误或被忽略 | 确认构建目录和 .dockerignore | 调整路径或修改忽略规则 |
| 镜像体积异常大 | 缓存未清理或基础镜像过大 | docker history 查看层大小 | 清理缓存、换小基础镜像、多阶段构建 |
| 容器启动即退出 | 启动命令错误或依赖缺失 | docker logs 查看日志 | 修正命令、补依赖、交互调试 |
| 端口无法访问 | 未映射端口或监听地址错误 | 检查 -p 参数和应用监听地址 | 加 -p 映射,监听 0.0.0.0 |
| 构建缓存不生效 | 上下文变化或指令顺序问题 | 观察构建输出哪些层命中缓存 | 调整指令顺序,必要时 --no-cache |
4.5 几个我踩过的坑和实操心得
第一个坑是关于COPY的。有一次我把COPY . .放在了装依赖之前,结果每次改代码都要重新装一遍依赖,构建时间从 30 秒变成了 5 分钟。后来把依赖清单单独复制先装依赖,问题就解决了。这个顺序问题看起来小,但对开发效率影响很大。
第二个坑是关于时区的。容器默认是 UTC 时间,应用里打印的日志时间比本地时间差 8 小时,排查问题时特别容易看错。解决办法是在 Dockerfile 里设置ENV TZ=Asia/Shanghai,或者装tzdata包并配置。
第三个坑是关于latest标签的。早期图省事用了FROM python:latest,结果某次构建突然失败,原因是基础镜像更新了,某个依赖的兼容性变了。从那以后我所有的 Dockerfile 都锁定具体版本号,再也没遇到过这种莫名其妙的问题。
第四个心得是关于分层策略的。把相关的命令合并到一条RUN里,不仅能减少层数,还能避免中间状态被固化。比如apt-get update和apt-get install必须放在同一条RUN里,如果分开写,update那层可能被缓存,导致install时用的是过期的包索引,装包就会失败。
5. 镜像构建之后的验证与使用
镜像构建出来只是第一步,怎么确认它真的能用、怎么把它用起来,同样值得说一说。
5.1 镜像验证的几种手段
构建完成后,先docker images确认镜像存在,记下它的 ID 和标签。然后可以用docker inspect查看镜像的详细信息,包括环境变量、入口命令、暴露端口、层信息等。
docker inspect image-service:v1接下来启动一个测试容器:
docker run -d --name test-svc -p 8000:8000 image-service:v1启动后用docker ps确认容器在运行,用docker logs test-svc看日志有没有报错,用curl localhost:8000测试服务是否正常响应。如果一切正常,说明镜像没问题。
如果服务没起来,就按前面说的排查思路,用交互模式进去手动调试。调试完记得把测试容器清理掉:
docker rm -f test-svc5.2 镜像的标签管理与版本策略
镜像标签管理是个容易被忽视但很重要的事情。我的习惯是每次构建都打两个标签,一个具体版本号,一个环境标签:
docker build -t image-service:v1.2.0 -t image-service:prod .版本号用于精确回滚,环境标签用于日常部署。这样部署脚本里用image-service:prod,需要回滚时指定image-service:v1.1.0就行。
如果团队有镜像仓库,构建完还要推送到仓库:
docker tag image-service:v1.2.0 registry.example.com/team/image-service:v1.2.0 docker push registry.example.com/team/image-service:v1.2.0推送之前记得先登录仓库,标签要符合仓库的命名规范。
5.3 把镜像用起来的几种方式
镜像构建好之后,使用方式很灵活。最简单的是直接docker run启动单容器。如果服务多了,可以用docker compose编排,把镜像、端口、卷、环境变量、依赖关系写在一个 YAML 文件里,一条命令启动整套服务。
再往上就是容器编排平台,把镜像推到仓库,由平台来调度和运行。不管用哪种方式,镜像都是最基础的交付单元,Dockerfile 写得好不好,直接决定了后续部署顺不顺。
我个人在实际操作中的体会是,Dockerfile 这东西,写出来能跑只是及格线,写得好不好要看三点:构建快不快、镜像小不小、别人能不能看懂。构建快靠缓存策略,镜像小靠分层和清理,别人能看懂靠注释和清晰的指令顺序。这三点做到了,这个 Dockerfile 就算合格了。
最后再分享一个小技巧,如果你不确定某条指令会产生什么效果,可以先用一个临时容器试一下,确认没问题再写进 Dockerfile。比如不确定某个包名对不对,先docker run -it python:3.11-slim bash进去apt-cache search一下,比反复构建试错要快得多。这个习惯养成之后,写 Dockerfile 的效率会高很多。