资讯动态

Dockerfile 入门详解:从核心指令到多阶段构建实践

发布时间:2026/10/21 4:45:05 来源:尧图企业网站定制
1. 从零认识Dockerfile它到底是什么很久没写这么基础的入门文章了最近团队里来了几个新人第一次接触容器化部署看到项目根目录下放着一个叫Dockerfile的文件完全不知道该怎么下手。我跟他们说Dockerfile 本质上就是一张菜谱而 Docker 镜像就是按这张菜谱做出来的成品菜。你按菜谱一步步操作最后端上桌的那道菜就是可以运行、可以分享、可以直接端到其他环境里开吃的镜像。先把这个概念拆透。一个典型到不能再典型的 Dockerfile 长这样FROM node:20-alpine WORKDIR /app COPY package.json . RUN npm install COPY . . EXPOSE 3000 CMD [npm, start]你看着它就像是看到了别人已经写好的项目部署说明。每一行都是以一个大写指令开头的FROM指定基础镜像RUN执行构建命令CMD定义容器启动时做的事情。Docker 构建工具会从上到下一行一行地去读、去执行最终产出一个不可变、可复用的镜像文件。这个文件解决了什么问题说直白点它把我的项目在什么环境里跑、怎么装依赖、怎么启动这件事从某一个人的脑子里或某个长期失修的内部文档里变成了一份版本控制下的、任意环境都能复现的文本。以前你接手别人项目最怕的就是在我机器上好好的到你机器上就打不开。用了 Dockerfile这种问题基本能根除掉——只要环境是 Linux 内核的容器运行时构建结果几乎是一模一样的。适合谁来读这篇文章如果你刚刚开始接触 Docker不知道 Dockerfile 里那些指令到底是什么意思或者你已经开始用 Docker 但每次都是抄别人的 Dockerfile改两行就跑了那这篇文章就是写给你的。我会从指令讲起再给一个前端项目和一个后端的实际例子最后把构建部署阶段最常见的坑一个个拉出来说清楚。2. Dockerfile 的基本结构与核心指令拆解2.1 Dockerfile 的执行机制从上到下分层构建要理解 Dockerfile不能只背指令得先理解它底层的执行机制。Docker 构建镜像的过程是逐行执行指令、每一行都可能生成一个新的镜像层。这么说吧每一行指令的执行结果都会被 Docker 记录成一个快照也就是层layer。下一行指令在上一层的基础上再修改、再记录。当你最终拉取镜像时这些层会按顺序叠加起来最终形成一个完整的、可运行的根文件系统。这个机制有两个非常重要的推论修改 Dockerfile 中靠后的部分不会影响到靠前已经构建过的层。Docker 会缓存那些没变过的层直接复用这样重新构建的时候会特别快。如果你改了靠前的层那它后面的所有层都会失效必须全部重新构建。这就是为什么经验丰富的开发者总是把不容易变的东西比如依赖锁定文件放在 Dockerfile 靠前的位置把经常变的东西比如源码放在靠后位置。这有点像是你在拍一部连续剧第一集定下来的主角和世界观设定后面每一集都会继承。如果第一集你就把主角换了后面所有剧情都得重拍。2.2 从最基础的指令讲起我把最常见的 Dockerfile 指令分成四类一类一组地来看。第一组镜像从哪里来FROM是所有 Dockerfile 的第一条指令它指定你的镜像在哪个基础镜像之上构建。这相当于盖房子打地基地基决定了房子能盖多高、能扛多大风。FROM ubuntu:22.04 FROM node:20-alpine FROM python:3.11-slim选择官方镜像时要注意 tag 的选择。alpine系列的镜像非常小比如 node:20-alpine 只有几十兆因为是基于精简的 Linux 发行版很多东西都被裁掉了。slim系列是基于完整发行版瘦身的兼容性更好一点但体积会稍大。如果你的项目没有依赖特殊的原生编译库优先用alpine系列镜像更小磁盘占用低传输也快。第二组信息声明与环境设置LABEL用来打标签相当于给镜像写元数据比如维护者、项目名称、版本号。ENV设置环境变量容器启动后可以直接读取。LABEL maintaineryour_emailexample.com ENV APP_ENVproduction ENV TZAsia/Shanghai关于ENV有个细节要注意它设置的变量是同时存在于构建阶段和容器运行阶段的。如果你只想在构建阶段使用一个临时变量用ARG它是构建时专用的参数不会出现在最终的容器里。两者的区别就像是买房时的装修预算和买房后的每月生活费——装修预算只在你装修那段时间有意义生活费则长期存在。第三组核心操作指令RUN在构建镜像的过程中执行命令常见于安装依赖、编译代码。COPY把本地的文件复制进镜像里。ADD比COPY更高级一点可以自动解压本地 tar 包还支持从 URL 复制文件。WORKDIR设置工作目录之后的所有指令都会在这个目录下执行。如果不写默认是在镜像根目录很容易造成路径混乱。我给你看一个实际例子。假设你有一个 Python 项目依赖文件是requirements.txt源码在src/目录下。一个合理的 Dockerfile 是FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ . CMD [python, main.py]注意看COPY requirements.txt .和COPY src/ .分了两步。如果它们合并成一步COPY . .那就相当于把依赖安装这一步的输入范围扩大到了整个项目目录。只要任何源码文件变动了重新构建时依赖都需要重新安装那构建效率就非常差了。分开拷依赖文件不变就能直接命中 Docker 构建缓存构建时间从几十秒直接降到一两秒。第四组运行时配置与启动命令EXPOSE声明容器运行时监听的端口。注意它只是一个声明不是真的把宿主机的端口映射到容器里真正映射要靠运行容器时加-p 宿主机端口:容器端口。CMD容器启动时执行的默认命令。一个 Dockerfile 里只能有一个CMD写了多条只有最后一条生效。ENTRYPOINT跟CMD类似也是定义启动命令但区别在于CMD可以被运行容器时的命令行参数覆盖ENTRYPOINT不会。简单记忆法CMD是默认值——用户给新参数就覆盖它ENTRYPOINT是固定项——用户给的参数会作为它的追加参数。ENTRYPOINT [python, main.py] CMD [--host0.0.0.0, --port8080]这种写法的效果是容器启动时默认执行python main.py --host0.0.0.0 --port8080。如果你运行容器时额外加了--debug最终执行的就是python main.py --host0.0.0.0 --port8080 --debug。两种指令配合使用逻辑非常清晰入口固定参数可变更。3. 前端项目实战举例一个 React 应用的镜像制作前面的指令都认识了直接上实战。以 React 项目为例走一遍从源码到最终镜像的完整流程。3.1 自带构建工具模式最直白的流程如果项目是直接用 Vite 或 CRA 构建的最自然的方式就是先构建再把构建产物放到静态服务器里。FROM node:20-alpine AS builder WORKDIR /build COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --frombuilder /build/dist /usr/share/nginx/html EXPOSE 80 CMD [nginx, -g, daemon off;]这里有两个关键点要展开说。第一多阶段构建。FROM node:20-alpine AS builder给这个阶段取了个名字叫 builder。第一阶段是用 Node 环境装依赖、执行构建第二阶段换了一个完全不同的基础镜像——Nginx 静态服务器然后把第一阶段构建好的产物用COPY --frombuilder拷过来。这样最终镜像里只有打包后的静态文件和一个轻量的 Nginx完全没有 Node 运行时的体积负担。第二npm ci和npm install的区别。npm ci是严格按照package-lock.json安装依赖的速度快、结果可复现非常适配容器构建这种每次都要从零开始的场景。npm install则有可能因为各种语义化版本规则装到跟 lock 文件不一致的版本。所以只要你的项目提交了 lock 文件就应该用npm ci。一个典型的前端镜像产出对比可以是这样构建方式最终镜像体积参考说明单阶段Node 直接启动服务1GB 以上镜像里包含 node_modules 和全部源码巨大单阶段安装 Nginx 手动拷贝500MB 左右Nginx 安装过程留在了镜像层里多阶段构建产物拷贝50MB 左右只有静态资源和轻量版 Nginx3.2 前后端一体化模式用 Nginx 反代后端接口如果前端项目还需要访问后端 API常见做法是在 Nginx 容器里同时配置静态页面托管和反向代理这样生产环境只需要启动这一个容器前端静态资源和后端接口的转发都在同一个进程里完成。配置文件中需要加入一段反向代理规则server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend-service:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }然后在 Dockerfile 里把这段配置复制进去FROM nginx:1.25-alpine COPY --frombuilder /build/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80这里要特别注意proxy_pass后面的 URL 写法。如果写了http://backend-service:8080/api/末尾带斜杠匹配到的/api/xxx请求会被去掉/api/前缀转发到后端的就是/xxx。如果没有末尾斜杠那么完整的/api/xxx会被直接传给后端。这个细节特别容易踩坑我见过不止一次因为多了一个斜杠导致接口路径全部 404。backend-service也不是随手写的在 Docker Compose 或 K8s 里它需要跟后端服务的服务名完全一致Docker 内置 DNS 才能解析到对应容器 IP。3.3 前端构建的缓存优化技巧对于前端项目重新构建镜像最频繁的场景就是交了新的前端代码。如果你每次都把整个项目拷进镜像里重新装依赖你会发现一次构建两分钟起步。但如果把COPY package.json package-lock.json ./和RUN npm ci放在前面只要依赖文件没变这两步就直接走缓存只有COPY . .和RUN npm run build会真正执行构建时间会大幅缩短。再进一步你可以在.dockerignore文件里把node_modules、dist、.git等目录排除掉。这样可以避免它们被当作上下文发送到 Docker 构建环境中既加快了传输也避免了一些奇怪的缓存问题。node_modules dist .git *.log .DS_Store4. 后端项目实战举例一个 Python API 服务的容器化后端项目的 Dockerfile 跟前端思路不太一样它更关心依赖管理、运行环境稳定性和多环境配置。下面用一个 FastAPI 项目作为例子。4.1 基础方案用 pip 管理依赖假设项目结构是这样的. ├── Dockerfile ├── requirements.txt └── app ├── main.py └── routersDockerfile 可以这么写FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]--no-cache-dir这个参数很重要它告诉 pip 不要在本机缓存下载的包文件。那些缓存如果留在镜像里会白白增加镜像体积而且在容器里它们几乎没有任何复用价值。4.2 用 Poetry 或 uv 时的 Dockerfile 写法现在很多 Python 项目已经切换到 Poetry 或 uv 管理依赖它们的 Dockerfile 写起来会有些差异。以 Poetry 为例一个比较规范的写法是FROM python:3.11-slim AS builder WORKDIR /app ENV POETRY_HOME/opt/poetry ENV POETRY_VIRTUALENVS_CREATEfalse RUN pip install poetry1.8.3 COPY pyproject.toml poetry.lock ./ RUN poetry install --no-root --no-dev FROM python:3.11-slim WORKDIR /app COPY --frombuilder /usr/local/lib/python3.11/site-packages ./site-packages-copy COPY . . ENV PYTHONPATH/app/site-packages-copy EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这里用到了多阶段构建第一阶段里创建好完整的 Python 依赖环境第二阶段只把 site-packages 目录拷贝过去源码直接 COPY 进去。这种方式比直接把 poetry 留在最终镜像里要干净得多。4.3 后端容器化的几个容易踩的深坑第一个大坑是时区。基础镜像默认时区基本是 UTC如果你在代码里用了datetime.now()去获取当前时间它在容器里取到的是 UTC 时间。正确做法是在 Dockerfile 里显式设置时区ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone第二个大坑是权限。很多基础镜像默认以root用户运行这在高危环境比如生产 K8s 集群里是个安全风险。最佳实践是创建一个非 root 用户然后切换到它来运行进程RUN useradd --create-home appuser USER appuser第三个大坑是信号处理。容器在停止时Docker 会向主进程发送 SIGTERM 信号如果你用 shell 脚本包裹了启动命令或者用了不正确的 PID 1 进程信号可能没法正确传递到你的应用进程里导致停止容器时进程被强杀数据丢失或连接损坏。使用exec形式JSON 数组形式的CMD和ENTRYPOINT能保证你的应用直接成为 PID 1信号传递是正常的。5. 进阶技巧与多阶段构建原理详解5.1 为什么要用多阶段构建给你算一笔账很多人觉得多阶段构建是高级玩法是给项目变大以后才需要用的。但其实它非常朴素核心目的只有一个最终镜像里只保留运行所需要的东西不保留构建时需要的东西。拿一个 Go 项目举例。构建 Go 二进制文件的时候需要完整的 Go 工具链、可能还需要 GCC 之类的编译依赖这些加起来少说也有几百兆。但二进制文件本身也就十几兆甚至几兆运行它只需要一个空的基础镜像就够了。多阶段构建的写法FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -o /app/server . FROM scratch COPY --frombuilder /app/server /server ENTRYPOINT [/server]第一阶段的 Go 镜像可能有一两百兆但第二阶段从scratch开始——scratch是一个完全空白的镜像里面连 shell 都没有。最后拷贝进去一个二进制文件最终镜像可能只有十几兆。这个思路可以类比为你请装修队来装修装修队带着一卡车工具进来了但搬走后留下的应该是整洁的房子而不是连工具和装修废料都留在这。多阶段构建就是在搬走后帮你把那些工具和废料清走的关键步骤。5.2 ARG 与 ENV 的协作使用在开发环境、测试环境和生产环境需要不同配置参数时ARG和ENV的组合非常好用。ARG APP_ENVproduction ENV APP_ENV${APP_ENV}这样你构建的时候可以传入不同的参数docker build --build-arg APP_ENVtest -t myapp:test . docker build --build-arg APP_ENVproduction -t myapp:prod .ARG在镜像里是临时存在的不会留在最终镜像的可执行环境里ENV则不一样它是运行时的环境变量应用可以读取到。这个组合的实际语义是构建期传参运行期生效。5.3 HEALTHCHECK给容器加一个体检容器是否活着和容器是否真的能提供服务是两回事。你可能见过某个容器虽然进程还在但已经阻塞到完全处理不了请求的僵尸状态。给镜像加一个HEALTHCHECK指令让 Docker 定期去检查应用的健康状态是一个很容易被忽略但又很重要的技巧。HEALTHCHECK --interval30s --timeout3s --start-period5s --retries3 \ CMD curl -f http://localhost:8000/health || exit 1每个参数都值得解释一下--interval是每隔多久检查一次--timeout是允许单次检查的超时时间--start-period是容器刚启动时给的宽限期因为应用可能还在启动中--retries是连续失败多少次后才把容器标记为 unhealthy。如果基础镜像里没有curl你可以换用wget或者用一个更轻量的办法——直接用 Python 或 Node 去请求健康检查接口HEALTHCHECK CMD [python, -c, import urllib.request; urllib.request.urlopen(http://localhost:8000/health)]6. 构建常见问题与排查技巧实录6.1 构建时网络超时RUN pip install或者RUN npm ci这种在线安装依赖的指令特别容易因为网络原因失败。你在本地电脑上也许三秒钟就装好了但 Docker 构建环境可能是另一套网络。解决办法有几种按推荐程度排序使用国内的镜像源。以 npm 为例在 Dockerfile 里加一行RUN npm config set registry https://registry.npmmirror.compip 则用-i参数指定镜像源。基础镜像选择距离更近的镜像仓库这一步属于容器运行时的范畴但也能减少拉取失败的概率。对于特别不稳定的场景可以写一个重试脚本包裹下载命令。6.2 权限报错Permission denied新手最容易遇到的情况是COPY了本地的文件进容器然后用 root 之外的用户运行时发现应用没有权限读取文件。解决办法有两个方向一是在 COPY 之前用RUN chmod修改文件权限二是在运行时用VOLUME或挂载文件时手动指定权限。另一个权限场景是用 root 用户构建后生成了 root 属主的文件切到非 root 用户运行应用时这些文件再也没法写入。这时候要在 Dockerfile 里先把目录归属改掉RUN chown -R appuser:appuser /app/data6.3 构建成功但容器一启动就退出最常见的场景是 CMD 写错了。你要区分两种情况如果你的 CMD 是[npm, start]控制台可能一闪而过容器退了多半是npm start本身失败了如果你的 CMD 是[nginx, -g, daemon off;]这类前台命令容器会一直运行退了就代表 nginx 启动失败。排查方法很简单先看日志docker logs container_name然后交互式地进去手动执行命令把启动过程一步步拆开docker run -it --rm myimage /bin/sh在 shell 里手动运行启动命令看报的什么错改完再重新构建。多数情况下是环境变量没配、依赖缺失或者工作目录不对。6.4 镜像体积异常大瘦身怎么瘦第一步用docker history 镜像名查看每一层的大小。第二步定位体积大户通常就是那几种基础镜像本身就大、源文件整个拷贝进去了、依赖以调试模式安装、缓存的包文件留在镜像内。瘦身手段按见效程度排序改用alpine或slim基础镜像多阶段构建只把运行产物拷进最终镜像RUN合并且清理缓存比如apt-get install之后立即执行rm -rf /var/lib/apt/lists/*加.dockerignore防止无关文件被打包进构建上下文。7. 我的实践经验与心法总结写了无数个 Dockerfile、踩了无数个坑之后我慢慢总结出几条朴素的经验分享给刚上手的你。第一Dockerfile 要像代码一样追求可读性和可维护性。你写的每一行都可能会被团队里的另一个人看到、修改。把环境变量集中放在文件头部把阶段划分清楚把自定义脚本封装成一个单独的.sh文件再拷进去这些看似微小的习惯在项目复杂后收益极大。第二用最具体的方式指定版本。FROM node:20-alpine里除了主版本号什么都没锁。严格来说你应当写成FROM node:20.11.1-alpine这样的全版本号。依赖管理器npm、pip最好也锁定具体版本。镜像构建的结果要可复现如果每次拉到的依赖包版本都不同最终构建出来的镜像行为自然也可能不同。第三写完 Dockerfile 不要着急构建生产镜像先本地跑一遍再优化。我有一次图省事直接把一个需要编译原生模块的 Python 项目用 slim 镜像构建结果运行时报缺少编译工具浪费了一整个下午。后来我习惯在 Dockerfile 里把基础镜像选成完整版先把功能跑通再考虑按需替换成瘦身体系这比一上来就极限优化要稳妥得多。最后要养成用 docker compose 来管理环境变量的习惯。镜像本身应该尽量做到不同环境下代码完全一样只通过环境变量做行为差异。这样测试环境的镜像理论上可以直接部署到生产环境只是配置变了而已。否则如果你的 Dockerfile 里写死了某个环境的配置那这份镜像到了另一套环境里就很难不折腾。从最早只能用别人构建好的现成镜像到自己手写 Dockerfile 完成了一次完整的部署上线这中间能学到的远不止那几行指令。当你把这个文件写顺了很多之前困扰你的环境问题、部署问题、甚至团队协作问题都会慢慢变得不那么棘手。希望这些经验能帮你少走点弯路。

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

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

免费获取报价 →
↑