资讯动态

Docker自定义镜像构建指南:从Dockerfile指令到多阶段构建优化

发布时间:2026/10/22 21:22:10 来源:尧图企业网站定制
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_FRONTENDnoninteractive避免构建过程卡在交互提示上。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_VERSION3.11 ENV APP_HOME/app ENV PYTHONUNBUFFERED1ARG适合用来传递构建时的可变参数比如版本号、下载地址。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 maintainerdevexample.com LABEL descriptionImage processing service ENV PYTHONUNBUFFERED1 \ PYTHONDONTWRITEBYTECODE1 \ PIP_NO_CACHE_DIR1 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 兼容性好适合这种需要编译依赖的场景。第二段设置环境变量。PYTHONUNBUFFERED1让 Python 的输出不缓冲这样日志能实时看到排查问题方便。PYTHONDONTWRITEBYTECODE1禁止生成.pyc文件保持镜像干净。PIP_NO_CACHE_DIR1让 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 --frombuilder /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-cache4.5 几个我踩过的坑和实操心得第一个坑是关于COPY的。有一次我把COPY . .放在了装依赖之前结果每次改代码都要重新装一遍依赖构建时间从 30 秒变成了 5 分钟。后来把依赖清单单独复制先装依赖问题就解决了。这个顺序问题看起来小但对开发效率影响很大。第二个坑是关于时区的。容器默认是 UTC 时间应用里打印的日志时间比本地时间差 8 小时排查问题时特别容易看错。解决办法是在 Dockerfile 里设置ENV TZAsia/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 的效率会高很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑