ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

docker-compose build属性详解:从context到多阶段构建的完整指南

docker-compose build属性详解:从context到多阶段构建的完整指南 我的 docker-compose 文件属性系列写到第 14 篇终于轮到build这个属性。前面讲镜像讲得再多终归要用自己的 Dockerfile 把服务跑起来build就是 compose 文件里驱动本地构建的那把钥匙。很多新手刚接触 docker-compose 时总把build当成一个简单的路径字段写完就算完事但实际用下来它牵扯到 context 路径、Dockerfile 位置、构建参数、多阶段构建阶段选择这一整套逻辑。这篇我把build彻底掰开揉碎从属性定义到实战配置再到踩坑记录一次讲透。build最直接的用途是把当前项目的源码目录变成可运行的镜像然后像普通image一样被 compose 管理起来。它非常适合需要本地开发联调、自研镜像、持续集成构建副本的场景。如果你是只跑现成镜像、从来不碰 Dockerfile 的人这篇可以先收藏等你要定制镜像时再翻出来对照着做。1. build 属性到底在管什么1.1 先说清楚 image 和 build 的区别docker-compose 的服务定义里镜像来源有两种写法直接用image: nginx:1.25或者用build: ./my-app。这两者的核心区别是image告诉 compose“去 Registry 拉这个镜像”或者“用本机已有的这个镜像”而build告诉 compose“你先把当前目录构建成一个镜像再用这个镜像创建容器”。打个比方image是去超市买半成品菜加热就能吃build是自己从洗菜切菜开始做。compose 只负责调度它不看菜是怎么切的只关心最终能不能端上桌。所以当你对一个服务同时写了build和image时compose 会执行构建然后把构建产物显式打上image里写的名字。这个组合非常实用后面实战部分细说。标准的最小build配置长这样services: web: build: ./web ports: - 8080:80这里./web是构建上下文目录里面必须放一个 Dockerfile。执行docker compose up时compose 会自动先 build 再启动容器。你不需要先手动docker buildcompose 替你包办了。1.2 为什么会有 build 这个属性在真实项目里团队可能维护着几个互相依赖的服务。行虽然可以用现成镜像将就但只要涉及到业务代码就必须把代码打进镜像里否则每次改代码都要手动重装依赖。compose 里的build让整套流程可以一键化一个docker compose up --build前端、后端、中间件全部按依赖顺序构建并启动。这在本地开发和多环境部署中特别有用。很多人容易把build理解成“只是封装了 docker build 命令”这种理解太粗糙了。compose 对build的处理还包含了服务间的依赖顺序。比如后端服务依赖数据库先启动那你写depends_on后compose 会先确认数据库服务就绪再进行后端构建。虽然严格说容器的就绪检测和构建顺序不完全等价但 compose 的语义至少保证了启动顺序的确定性。1.3 build 在编排中的定位build是服务级别属性只在services下的某个服务内部使用。它不是顶层字段不能像version那样放在文件最外层。一个 compose 文件里可以有多个服务同时使用build每个服务之间独立构建依赖关系通过depends_on、links或网络配置来管理。有次我见过有人把build写在services顶层导致整个文件校验失败。compose 文件的结构层级是顶层的services- 具体服务名 - 该服务的配置项。build一定是服务配置项而不是服务列表本身。这听起来基础但实际工作里这类拼写错误并不罕见。2. build 属性的配置项清单与选型逻辑build既可以写成简短的字符串形式也可以展开成字典形式。字符串形式适合只有 Dockerfile 且文件名就是Dockerfile、没有额外参数的简单场景。一旦涉及指定 Dockerfile 文件名、传构建参数、选择多阶段构建目标就必须用字典形式。services: web: build: context: ./web dockerfile: Dockerfile.prod args: - APP_ENVproduction target: final-stage下面把字典形式下的核心子字段逐个讲清楚。2.1 context构建上下文的起点context指定构建上下文路径。很多人以为context是 Dockerfile 所在目录其实不对。准确说context是 Docker 守护进程能看到的文件集合的根目录Dockerfile 里的COPY、ADD指令只能引用这个目录内的文件。如果 Dockerfile 放在子目录而构建上下文想包含更多文件得这样写build: context: . # 整个项目目录作为上下文 dockerfile: ops/Dockerfile.api # Dockerfile 在 ops 子目录里context可以写相对路径compose 会基于 compose 文件所在目录来解析这一点要注意。比如 compose 文件在/project/docker-compose.ymlcontext: ./backend实际指向/project/backend。如果执行 compose 命令时换了一个工作目录路径依然以 compose 文件为准而不是以终端当前目录为准。这看起来理所当然但恰恰是我见过最频繁的路径问题来源。2.2 dockerfile定制 Dockerfile 文件名dockerfile字段指定在 context 中使用的 Dockerfile 文件名。默认情况下 compose 会查找名为Dockerfile的文件。但实际项目经常有多个 Dockerfile 并存比如Dockerfile.dev、Dockerfile.prod或按组件拆分。此这时候就必须显式指定。build: context: ./backend dockerfile: Dockerfile.dev设置后Docker 守护进程会去./backend/Dockerfile.dev读取构建指令。这里有一个坑dockerfile路径是基于context的不是基于 compose 文件。也就是说dockerfile: ops/Dockerfile.api表示context/ops/Dockerfile.api如果写成了context: .而你希望从 ops 子目录读取那就没问题如果 context 已经指向 backend再写dockerfile: ops/Dockerfile.api就会去backend/ops下找很容易报 “Cannot locate specified Dockerfile”。排查这类问题先记住这条规则。2.3 args构建参数的默认值与传递args对应 Dockerfile 里的ARG指令用来在构建阶段传递变量。常见用途包括指定镜像源、注入环境标识、设置版本号。写法支持两种格式列表形式的- KEYVALUE以及映射形式的KEY: VALUE。build: context: . args: - NODE_ENVproduction - REGISTRY_MIRRORhttps://registry.npmmirror.com等价写法build: context: . args: NODE_ENV: production REGISTRY_MIRROR: https://registry.npmmirror.com注意args 里的键不需要在 Dockerfile 中提前声明也可以传进去但只有 Dockerfile 里有对应ARG指令的值才真正参与构建。Dockerfile 里这样接住ARG APP_ENVdev RUN echo 当前构建环境 $APP_ENV另外args值可以引用 compose 文件里的环境变量这在多环境下非常方便build: context: . args: VERSION: ${APP_VERSION:-1.0.0}当 shell 里没有APP_VERSION时构建参数会回落为1.0.0。用这个技巧一套 compose 文件就能应对测试、预发、生产多个场景而不需要复制多份文件。2.4 target多阶段构建的定点输出多阶段 Dockerfile 是现在的主流写法核心是一个 Dockerfile 里多次使用FROM。最典型的场景是前端项目先用 node 镜像把源码编译成静态文件再把编译产物复制到 nginx 镜像里。最终产物体积小、攻击面小构建和运行职责分离。FROM node:18-alpine AS frontend-builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --fromfrontend-builder /app/dist /usr/share/nginx/html EXPOSE 80 CMD [nginx, -g, daemon off;]如果我们只想构建中间某个阶段的任务比如只验证前端能不能编译通过、生成 build 产物但不想最终镜像就把target指向那个阶段名build: context: ./frontend target: frontend-builder构建完成后你可以使用docker run临时进入该镜像检查产物或者把它作为测试容器跑一跑。target的精髓在于默认情况马上构建最终阶段但你不需要整个依赖链时它可以帮你省去很多时间和磁盘空间。2.5 其他子字段labels、cache_from、networkbuild的子字段不止这几个还有若干在特定场景下很有用的配置子字段作用典型场景labels给生成的镜像打标签记录构建元数据、用于镜像管理平台识别cache_from指定从哪些镜像里复用层缓存CI 中使用远程 Registry 已有镜像加速构建network设置构建时容器使用的网络模式使用host网络访问私有源或特殊网络环境extra_hosts构建时注入 hosts 映射访问内网域名或开发机映射shm_size设置构建容器共享内存大小遇到/dev/shm空间不足的编译任务platforms指定构建的目标平台交叉构建 arm64 或 amd64 镜像cache_from在 CI 里值得重点关注。举个例子你的 CI 每次跑docker compose build如果本地没有任何缓存就得从头拉基础镜像、装依赖。但如果你提前把上一次构建好的镜像推到 Registry然后cache_from指向那个镜像Docker 就能复用它内部的缓存层。虽然不能保证 100% 全部命中但通常能明显减少安装依赖的时间。配置很简单build: context: . cache_from: - registry.example.com/project/backend:latest2.6 速查表build 常用参数对照为了查阅方便我把最常见子字段整理成表。实际写 compose 时可以先对照这张表确认自己漏了哪项子字段格式默认值必须context字符串无必填是dockerfile字符串Dockerfile否args列表或映射无否target字符串Dockerfile 最后一个阶段否labels映射无否cache_from列表无否network字符串bridge否shm_size字符串64MB否3. 实战拆解一个全栈项目的 build 配置理论说了不少现在进入实际演练。我拿一个常见的前后端分离项目做例子前端 Vite Vue后端 FastAPI外加一个 Redis。目录结构如下project/ ├── frontend/ │ ├── Dockerfile │ └── src/... ├── backend/ │ ├── Dockerfile │ └── app/ ├── docker-compose.yml └── .env3.1 前端和后端的 Dockerfile 设计前端采用多阶段构建第一阶段用 node 镜像执行npm run build第二阶段用 nginx 托管静态文件FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --frombuilder /app/dist /usr/share/nginx/html EXPOSE 80 CMD [nginx, -g, daemon off;]后端 FastAPI 相对简单直接以 Python 镜像为基础安装依赖后启动服务FROM python:3.12-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]两个 Dockerfile 差异点值得说明前端有编译阶段必须用多阶段构建后端只做依赖安装和代码复制单阶段足够。你的项目复杂度更高时可以按需加上target和args。3.2 在 docker-compose.yml 里写 build打开docker-compose.ymlservices: frontend: build: context: ./frontend args: - VITE_API_BASE/api image: demo/frontend:latest ports: - 8080:80 depends_on: - backend backend: build: context: ./backend target: runtime image: demo/backend:latest ports: - 8000:8000 environment: - REDIS_HOSTredis depends_on: - redis redis: image: redis:7-alpine ports: - 6379:6379这里有三个关键点第一前端构建时传入了VITE_API_BASE/api这个参数会被 Vite 构建过程读取把请求的 base URL 编译进静态资源。因为args只存在于构建阶段最终 nginx 镜像里不会保存这个 ARG运行时拿不到这是符合预期的。第二backend同时写了build和image。这样做的好处是显而易见的本地构建出的镜像会固定叫demo/backend:latest而不是 compose 自动生成的随机名字project-backend。固定镜像名方便后续docker compose push推送到自定义 Registry也方便本地排查时直接docker run demo/backend:latest bash进去调试。第三frontend的depends_on: backend能保证后端服务先被编排启动再由前端接管。这里说的“先启动”是指同一批次内顺序不涉及健康检查如果需要更严格的等待就得上depends_on: condition和健康检查这里不展开。3.3 一键构建与启动的命令配置写完最常用的命令组合如下# 只构建不启动 docker compose build # 构建并后台启动 docker compose up --build -d # 拉取依赖的基础镜像并构建忽略缓存 docker compose build --no-cache # 检查合并后的最终配置确认格式和路径 docker compose configdocker compose config是我极力推荐的命令。它会把 .env 和 override 文件合并后的内容完整打印出来还会校验 schema。路径对不对、参数传没传、变量是否解析一眼就能看出来。很多时候你以为 compose 读的是这份 yml但其实它合并了别的文件用config命令一看便知。构建成功后用docker image ls能看到两个镜像demo/frontend:latest和demo/backend:latest。如果只想构建前端不碰后端docker compose build frontend注意服务名跟在build后面不是跟在镜像名后面。这个命令在微服务项目里特别有用几十个服务不可能每次全部构建一遍。3.4 初始化阶段与增量构建的行为差异第一次执行docker compose build时每个服务都会从基础镜像开始完整构建。docker-compose 会把步骤按 Dockerfile 顺序执行并生成缓存层。第二次再构建时只要 Dockerfile 没变、依赖文件没变它就会命中缓存速度会快很多。但有一个容易忽略的点构建上下文中的文件只要有任何变动即使 Dockerfile 没变相关层缓存也会失效。比如前端项目每次构建都会生成新的dist目录而如果你的 Dockerfile 里不小心有COPY . .那么所有源码变动都会穿透依赖层缓存。所以编排 Dockerfile 时要尽量把“变动频率低的指令”放在前面把“变动频率高的指令”放在后面。最简单的实践是把COPY package*.json .和npm ci放在源码复制之前。后端同理先把requirements.txt复制进去并执行pip install再 COPY 业务代码。4. build 常见问题与排错实测build看似简单实际踩坑的姿势非常多。我挑了几个高频问题结合报错信息给出定位思路和解决办法。下面这些不包括基础网络范畴的问题网络问题已经讲烂了不再赘述。4.1 找不到 Dockerfile报错信息通常长这样Cannot locate specified Dockerfile: Dockerfile failed to solve: rpc error: code Unknown desc failed to solve with frontend dockerfile.v0: failed to read dockerfile: open Dockerfile: no such file or directory这类错误九成是dockerfile子字段的路径写错了把自己绕进了错误的目录。记住前面反复强调的规则dockerfile是相对于context的不是相对于 compose 文件的。假设项目结构如下project/ ├── docker-compose.yml └── services/ └── api/ └── Dockerfile.api正确的配置是services: api: build: context: ./services/api dockerfile: Dockerfile.api如果把context写成.dockerfile写services/api/Dockerfile.api语义就变了因为 Docker 会把 context 根目录与 dockerfile 路径拼接成./services/api/Dockerfile.api这时 context 根目录已经是./那么实际读取的是project/services/api/Dockerfile.api那其实也能读到。这里的坑在于COPY指令的上下文范围也随之改变大概率会报找不到源码文件。所以不要从 compose 文件的位置去推算 Dockerfile要站在构建上下文的角度去推算。4.2 context 路径不存在failed to read dockerfile: open /project/nonexist/Dockerfile: no such file or directorycontext 路径写错是新手高发问题。compose 里相对路径的基准点是 compose 文件所在目录。如果你在项目根目录执行docker compose -f deploy/ci.yml build那么所有相对路径都基于deploy/目录而不是终端当前目录。这个细节特别容易害人因为本地执行时 compose 文件通常就在当前目录下一旦换了-f参数指定文件位置路径规则没变但结果天差地别。我的习惯是业务越复杂越偏向用绝对路径或基于 repo root 的固定相对路径减少歧义。4.3 ARG 没生效构建环境变量丢失场景你已经写了args但在 Dockerfile 里RUN echo $MY_ARG打出来的还是空值。原因大概率是 Dockerfile 里漏写ARG MY_ARG。ARG指令必须在使用前声明它不像镜像环境变量那样可以隐式继承一段作用域。FROM node:18-alpine ARG MY_ARGdefault RUN echo value is $MY_ARG特别注意多阶段构建里每个阶段都有自己的 ARG 作用域。如果在第一个阶段声明了ARG MY_ARG第二个阶段想用必须重新声明。这个规则经常被忽略因为它不像环境变量那样可以自动继承全局。4.4 构建缓存导致“改动没生效”有时候你改了前端源码但重新构建后 nginx 镜像里的文件还是旧的。第一反应是“缓存出问题了”。但我要泼一盆冷水绝大多数情况下这不是缓存问题而是 Dockerfile 里的COPY指令没覆盖到对应路径或者你的 compose 里build和image同时存在然后后续docker run又基于旧镜像手动建的容器。排查时可以分两步先docker compose build frontend --no-cache构建一次看产物再用docker run --rm demo/frontend:latest ls /usr/share/nginx/html实际检查文件内容。如果新构建的镜像里文件是新的说明容器启动时加载的镜像是旧的检查容器实例如果新镜像里文件也是旧的说明COPY路径写错。用这个思路绝大多数“缓存失效”的悬案都能在十分钟内找到真凶。4.5 构建时网络超时依赖装不上docker compose build backend在pip install阶段卡了很久最后报TimeoutError。常见原因是基础镜像使用了国外源或者 Docker 构建时默认网络桥接不通。对此有三个经验级解法一是换基础镜像或配置镜像加速器比如 pip 仓库配清华源、npm 配 npmmirror或 apt 配国内镜像。二是给 build 加network: host让构建时容器共享宿主机网络通常能规避 NAT 层的连接问题。build: context: ./backend network: host但要注意host 网络模式会让构建环境直接暴露在宿主机网络空间有安全风险仅建议在隔离的开发机上使用。三是检查 Docker daemon 本身的 DNS 配置若宿主机能解析域名而容器不行大概率/etc/docker/daemon.json里没配 DNS可以临时加{ dns: [8.8.8.8] }4.6 build args 里的密钥泄露问题这个我必须单独拿出来提醒。build args并不是安全的传密方式。虽然构建成功后容器环境里看不到 ARG 的值但镜像历史里记录着完整构建参数。执行docker history或拉取远端镜像后任何能访问镜像的人都能看到。比如下面这种写法和裸奔没区别build: context: . args: PRIVATE_KEY: sk-xxxxx正确做法是密钥通过 secrets 机制或者运行时以环境变量注入不要把生产密钥塞进 build args。构建时需要的私有源 Registry 密码可以使用 Docker BuildKit 的--mounttypesecret方案compose 里则配合secrets属性避免留在缓存层里。这个细节在安全审计时会被重点检查。4.7 构建日志的快速定位习惯构建失败时我会先滚动到最后 50 行日志找报错关键字因为关键错误通常在最底部中间全是 Redis 缓存生产的 “CACHED” 行容易干扰判断。用docker compose build 21 | tail -n 50做一次粗略过滤再针对性搜索error、failed、timeout、cannot。遇到 “CACHED” 相关的提到却没有报错说明构建其实成功了问题在启动阶段或网络层。这个习惯帮我省了不少时间。5. 进阶玩法与实战心得5.1 把 build 玩出“环境矩阵”说到“一套 compose 文件对应多套环境”args和.env的组合可以做得很优雅。以docker-compose.yml为骨架通过读取 shell 环境变量为构建注入参数兼顾多环境差异化。.env文件内容可以这样写APP_ENVdev VERSION2.0.0-rc1docker-compose.yml里这样接services: api: build: context: ./api args: APP_ENV: ${APP_ENV:-dev} VERSION: ${VERSION:-1.0.0}这样当你临时用APP_ENVproduction VERSION2.1.0 docker compose up --build -d部署时构建参数会动态切换无需改任何 yml 文件。如果你还想玩得更花一点还可以配合 override 文件docker-compose.prod.yml里只覆盖args的映射值与基础文件合并执行。这是大型项目里常见的配置管理手段。5.2 与 CI/CD 集成构建和推送分离本地把docker compose build作为一键构建工具是一回事CI 里跑又是一回事。我的建议是 CI 中分三步先docker compose config --quiet校验配置再docker compose build构建全部服务最后docker compose push把结果推送到 Registry。docker compose config --quiet docker compose build docker compose push这里docker compose push依赖服务配置里同时写了image。如果你只写了build、没写imagecompose 会使用自动生成的镜像名推送这种名字很难管理。所以凡是准备推送到远程 Registry 的服务我都建议显式加上image字段。CI 构建另一个大坑是缓存。临时 CI 机器上每次 clean 环境没有本地缓存构建会特别慢。此时cache_from的价值就体现出来了让 compose 先从 Registry 拉取上次构建的镜像作为缓存来源。配合--with-registry-auth参数若使用私仓效果更佳。5.3 BuildKit 与平台构建的现代化配置从 Docker 23 和 compose v2 开始BuildKit 已成为默认构建模式COMPOSE_DOCKER_CLI_BUILD老环境变量也不再需要设置。BuildKit 带来的收益中最直观的是并发执行构建阶段、更好的缓存挂载和密钥支持。对于跨平台构建需求compose 的build也支持基于docker buildx的能力但需要在docker-compose.yml里显式声明platformsservices: api: build: context: ./api platforms: - linux/amd64 - linux/arm64实际使用中我提醒一点跨平台构建需要 Docker daemon 开启buildx的qemu模拟并不是随便写个platforms就能跑。本地机器跑多平台构建时通常会碰到 “exec user process caused: exec format error” 或 “no matching manifest”原因就是缺少对应平台的基础镜像或模拟器。推荐先在 x86 开发机上构建并 push再用docker buildx的多平台命令处理compose 的platforms更像是对外声明而不是本地万能药。5.4 从 build 到运行联动 watch 的取舍新版本 compose 还支持docker compose watch可以在代码变更时自动重建并重启服务配合build使用可以实现类似本地热更新的体验。不过我不建议把所有业务都交给 watch它的性能和稳定性还没到可以取代完整调试工具的程度。开发环境偶尔用一用可以生产环境还是走正规的镜像构建和滚动发布流程。我的常用法式是日常开发只构建一次镜像代码改动用 bind mount 同步目录实现热重载避免反复构建只有依赖变化或发布前才执行完整的docker compose build。这样既稳妥又节省了大量重复构建时间。bind mount 和镜像里的代码是两套来源所以在联调阶段要时刻记住“当前容器里跑的到底是挂载目录的代码还是镜像里的代码”别被误导。5.5 一个值得坚持的镜像体积洁癖不要把本机里的无关文件塞进 build context。context 越大镜像层就越大构建也越慢。.dockerignore在这个语境下非常关键。比如前端 node_modules 动辄几百兆你如果不排除它一个COPY . .就会把依赖全部打进镜像层不仅体积膨胀后续每次源码改动还会连带 dependency 层一起失效。项目根目录project下的.dockerignore示例**/node_modules **/dist **/.git **/*.log **/.DS_Store写清楚 .dockerignore 后构建上下文体积大幅压缩build 速度和镜像仓库占用都会明显优化。这个习惯的养成越早越好后面线上跑起来再返工成本高得多。关于 build 的最后一句话我自己的经验是build这个属性入门容易精通难。它表面上是“告诉 compose 去哪构建”实际上牵扯的路径解析、缓存策略、构建参数、多阶段构建和安全实践每一项都能单独写一篇。真正出问题时多数不是编排语法问题而是对构建上下文和 Dockerfile 执行模型的理解偏差。把今天的配置技巧保存下来下次遇到镜像构建慢、缓存失效、Dockerfile 找不到这些bug时对照着快速过一遍大概率能少走弯路。后面如果我碰到更有意思的build watch或 BuildKit 缓存编排案例还会继续在这个系列里更新。
返回列表