ARTICLE DETAIL

资讯详情

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

Docker Compose 文件详解:从 docker run 到声明式服务编排

Docker Compose 文件详解:从 docker run 到声明式服务编排 最早接触 docker-compose 文件是在我被一串串docker run命令折磨到崩溃的时候。当时要部署一个内部工具依赖 MySQL、Redis 和一个定时任务服务三个容器分别启动还得手动指定--link、--network写出来的部署脚本又长又脆换个服务器就报错。后来我把整套依赖整理成一个docker-compose.yml一条docker compose up -d全部拉起才真正理解了为什么社区里几乎所有开源项目都会附带这个文件——它不是一个命令缩写工具而是整套应用的声明式清单。这篇内容围绕 docker-compose 文件本身展开适合三种人看刚接触 Docker、还在用多条docker run拼服务的人已经在抄 compose 文件但经常改错、报错不知道怎么排查的人以及想把本地开发环境和交付部署方案统一起来的人。我会从文件结构、字段语义、环境变量、持久化、排错链路到进阶用法逐个拆解把我实际踩过的坑和长期养成的习惯都放进去。1. 为什么 docker-compose 文件的关键不是省事而是搭关系很多人第一次看到 docker-compose 文件直觉反应是这不就是把几条 docker run 命令合并了吗真去用之后才会发现它的价值完全不在少打几个字。1.1 单条 docker run 无法表达的容器关系一个 Web 应用如果只依赖一个数据库单条docker run勉强能扛住docker run -d --name web \ -p 8080:8080 \ -e DB_HOSTlocalhost \ myapp:latest但现实里服务很少这么简单。数据库要持久化要挂 volumeRedis 要做缓存要设 maxmemoryWeb 服务和定时任务要能互相访问所有容器要处于同一网络。于是命令开始膨胀docker run -d --name mysql \ -v mysql-data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORDxxx \ mysql:8.0 docker run -d --name redis \ -v redis-data:/data \ redis:7-alpine docker run -d --name web \ -p 8080:8080 \ --network myapp-net \ -e DB_HOSTmysql -e REDIS_HOSTredis \ myapp:latest三条命令之间没有任何绑定关系MySQL 没起来 Web 也照常启动然后在日志里反复报连接失败。这里的问题已经不是命令太长而是容器之间的关系完全靠人的记忆维护。谁依赖谁、谁访问谁、网络叫什么名字全部隐式散落在命令里。1.2 compose 文件的本质应用拓扑的显式声明docker-compose 文件把上面那条隐式关系链变成了一份可读、可审计、可版本管理的显式文档。同一套依赖被描述成一个整体每个服务之间是平级关系通过服务名互相访问网络、存储、端口映射、环境变量都各自归类。我习惯把 compose 文件戏称为应用的家谱version是家谱格式版本services是家庭成员volumes和networks是家庭资产depends_on是成员之间的先后次序。这套表达天然贴近部署时真正关心的东西而不是贴近 Docker 底层的实现方式。1.3 Compose V2 与 docker-compose 命令的关系这里有个绕不开的术语问题。老教程里经常看到docker-compose带连字符那是独立的 Python 工具。Docker 官方后来把 Compose 集成进了 Docker CLI命令变成docker compose带空格也就是 Compose V2。两者读取的文件格式基本一致都是docker-compose.yml或compose.yaml。我现在的习惯是只要 Docker 版本不是太老一律用docker compose。真遇到某些自动化脚本还在用docker-compose命令的我会在脚本里做兼容处理而不是在 compose 文件上做区分因为文件本身是通用的。1.4 文件带来的工程化红利当部署方案落成一个文件Git 就能跟踪它的变更记录同事 review 的就是一段结构化的声明而不是一串命令 diff换新环境时只需要把文件和应用代码一起拉下来执行出问题时可以直接docker compose config看解析结果、docker compose logs看日志。这些都是脚本时代的部署方式给不了的。2. 一张 compose 文件的骨架拆解顶级字段与 services 核心键不管文件多复杂顶级结构始终就那几块。先把骨架认识清楚再去填细节出错概率会大幅下降。2.1 顶级字段version、services、volumes、networks、configs、secrets一个典型的 docker-compose 文件长这样services: web: image: nginx:alpine volumes: web-data: networks: app-net:字段清单如下services必填定义所有应用服务的核心区。volumes声明命名卷。服务里通过volumes键引用这里定义的名字或者直接使用匿名卷、绑定挂载。networks声明自定义网络。不写的话 Compose 会自动创建默认网络。configs、secrets用于配置文件与敏感信息的注入更适合 Docker Swarm 语境单机 Compose 场景用得少但新版 Compose 也支持。version历史遗留字段。早期 Compose 必须写比如version: 3.8。Compose V2 已经不再要求这个字段不写默认按最新规范解析写了对新旧版本兼容性也无明显影响。我新写的文件一律不写 version 字段。2.2 services 下的核心键以及它们对应 docker run 的哪个参数services 下面每个服务是一个 map每个键都有明确的语义。我做一个对照表你看完就知道为什么 compose 文件能替代那一堆命令参数compose 键说明对应的 docker run 参数image指定镜像镜像名:tagbuild指定构建上下文和 Dockerfiledocker buildcontainer_name固定容器名--namedepends_on服务启动顺序无直接对应脚本里靠循环等待restart重启策略--restartenvironment容器内环境变量-eenv_file从文件加载环境变量--env-fileports端口映射-pvolumes挂载卷或宿主机目录-vcommand覆盖镜像默认 CMD镜像 CMD 的覆盖entrypoint覆盖镜像默认 ENTRYPOINT--entrypointnetworks归属哪些自定义网络--networkhealthcheck容器健康检查--health-cmd等depends_on.condition依赖健康状态再启动无直接对应对照完之后你会发现compose 文件的每个键几乎都能在 docker run 参数里找到影子但它提供了一种声明式组织方式而不是命令式拼接。2.3 image、build 与二者同时出现时的行为image和build可以同时出现在一个服务里很多人搞不清含义。行为规则是存在build时会先构建镜像构建完打上image指定的标签如果本地已有同名镜像且没有--build标志则直接复用本地镜像。services: api: build: context: ./api dockerfile: Dockerfile image: myapp-api:latestbuild.context表示构建上下文目录dockerfile默认取该目录下的Dockerfile。这里最容易踩的坑是COPY 指令的路径是相对于 context 的不是相对于 compose 文件位置的。比如 context 是./apiDockerfile 里写COPY . /app实际复制的是./api目录下的全部内容。2.4 container_name 的代价container_name可以直接指定容器名看起来方便但它有一个隐藏代价一个名字只能对应一个容器。一旦 compose 项目里写了固定容器名这个项目就无法docker compose up --scale扩容出多个实例其他项目也不能再使用同名容器。我在本地开发时不排斥用container_name但交付到 CI 或部署环境时一律去掉让 Compose 自动生成带项目名前缀的容器名避免环境之间互相冲突。2.5 depends_on 的真实语义与健康检查配合depends_on控制的是启动顺序不是可用状态。默认情况下它只保证依赖服务的容器先启动不保证服务内部进程已经就绪。MySQL 容器启动了但 MySQL 进程可能还在初始化这时候 Web 服务照样连不上。要真正解决这个问题需要给依赖服务加健康检查然后把 depends_on 升级为条件等待services: db: image: mysql:8.0 healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 5s timeout: 3s retries: 10 web: build: ./app depends_on: db: condition: service_healthy这段配置的意思是db 服务必须被健康检查判定为 healthy 后web 才会被创建启动。实现上由 Compose 轮询健康状态完成比用sleep 10这种硬编码等待可靠得多。3. 环境变量、数据持久化与配置注入最容易写错的三个区域如果说骨架决定 compose 文件长什么样那环境变量、卷和配置注入这三个区域决定它能不能在真实环境里跑起来。这三个领域也是我见过出错最多的地方。3.1 environment、env_file 和 .env 是三个完全不同的层面初学者最大的混淆点是搞不清environment、env_file、.env之间的关系。它们的用途可以这样区分environment直接定义容器内部运行时的环境变量等价于 docker run 的-e。env_file从宿主机某个文件读取键值对批量注入容器内部运行时的环境变量等价于--env-file。.env文件位于项目目录下用于compose 文件本身解析过程中的变量替换默认不会自动注入容器。也就是说环境变量分两个层面一个是给 compose 文件模板用的变量一个是给容器进程用的变量。compose 文件里写${DB_PASSWORD}时是在文件解析阶段替换这种替换可以读.env而environment键下写的键值对才是容器内实际拿到的环境变量。更具体地说即使你建立了.env文件并写了DB_PASSWORD123456容器内进程默认不会通过os.environ拿到DB_PASSWORD这个变量。想让容器内拿到它要么在environment里显式写入DB_PASSWORD: ${DB_PASSWORD}要么用env_file指向这个文件。3.2 变量替换语法默认值、必填校验与转义compose 文件解析阶段支持的变量替换语法有几种我实际用得最多的是默认值和错误拦截services: web: image: nginx:${NGINX_TAG:-alpine} environment: LOG_LEVEL: ${LOG_LEVEL:-info} ADMIN_EMAIL: ${ADMIN_EMAIL:?必须在.env中配置ADMIN_EMAIL}${VAR:-default}VAR 为空或未设置时使用默认值。${VAR-default}VAR 未设置时使用默认值显式设置为空字符串时则保留空值。${VAR:?error message}VAR 未设置或为空时直接报错并显示自定义提示信息。转义场景下要注意$符号的处理。容器内环境的变量如果由 compose 统一管理且值里包含$比如密码是pa$$word在 compose 文件里需要写成$$才会被正确解析成单个$。$$提供的是字面量美元符号绕开变量替换流程。3.3 named volume 与 bind mount 的选型逻辑数据持久化的核心是volumes键下的配置。两种方式虽然都能把数据留在宿主机但语义和使用场景完全不同对比维度named volumebind mount宿主机路径Docker 管理的/var/lib/docker/volumes/name/_data用户指定的任意路径文件可见性对宿主机用户不直观需要通过容器查看宿主机直接可见方便编辑备份方式docker run --rm -v volume:/volume ...或卷驱动备份直接复制目录适合场景数据库数据、缓存数据等数据资产配置目录、代码目录、日志目录实际选型我遵循一个简单标准需要 Docker 全权管理生命周期的数据用 named volume需要在宿主机直接读写、实时查看的文件用 bind mount。比如 PostgreSQL 存储目录一定用 named volumeNginx 配置文件一定用 bind mount。services: db: image: postgres:16 volumes: - pg-data:/var/lib/postgresql/data nginx: image: nginx:alpine volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro volumes: pg-data:注意:ro后缀把配置目录设为只读防止容器内部误改宿主机的配置文件。3.4 配置注入与 secrets 的现代做法传统做法里配置文件注入就是 bind mount把宿主机目录挂进容器。这个方案直白、容易理解至今仍然够用。但如果你在写新项目可以看看configs这个顶级字段它允许你在 compose 文件里直接嵌入配置文件内容或引用外部文件挂载到容器指定路径services: web: image: myapp:latest configs: - source: app_config target: /etc/myapp/config.yaml configs: app_config: file: ./config.yamlsecrets 与之类似专门用于敏感信息区别在于 secrets 会以 tmpfs 挂载而不是写入可写层安全性上更讲究。单机场景我不太依赖 secrets敏感信息更多交给部署平台的密钥管理能力处理比如 CI/CD 里的 Secret 机制compose 文件里只留${VAR}占位。4. 从零写一个可运行的 Compose 文件一个完整案例与启动细节结合前面所有字段下面给一个完整的、能直接落地的 docker-compose 文件示例用 Python Web 应用作为业务服务搭配 PostgreSQL 和 Redis。4.1 完整的 docker-compose.yml 示例services: api: build: context: ./backend dockerfile: Dockerfile image: myapp-api:latest ports: - 8080:8080 environment: APP_ENV: ${APP_ENV:-development} DB_HOST: db REDIS_HOST: redis env_file: - .env depends_on: db: condition: service_healthy redis: condition: service_started restart: unless-stopped volumes: - ./backend/logs:/app/logs worker: build: context: ./backend dockerfile: Dockerfile.worker image: myapp-worker:latest command: [python, worker.py] environment: DB_HOST: db REDIS_HOST: redis depends_on: db: condition: service_healthy restart: unless-stopped db: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-myapp} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?需要在.env中配置POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB:-myapp} volumes: - pg-data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER:-myapp} -d ${POSTGRES_DB:-myapp}] interval: 5s timeout: 3s retries: 10 restart: unless-stopped redis: image: redis:7-alpine command: [redis-server, --appendonly, yes] volumes: - redis-data:/data restart: unless-stopped volumes: pg-data: redis-data:这个文件里包含了几处值得强调的设计api 和 worker 都基于./backend构建但用不同 Dockerfile。这样代码共享产出两个职责不同的镜像。db 配了 healthcheckapi 用service_healthy等它redis 只要求容器启动即可业务能自行容忍 Redis 短暂不可用。POSTGRES_PASSWORD用了:?语法没配好环境变量时 Compose 直接报错绝不让数据库带着弱密码或空密码启动。restart: unless-stopped适合长期运行的业务服务手动停止的容器不会自动被拉起其余的异常退出都会重启。4.2 启动前必做的一件事docker compose config我把docker compose config当做写文件的语法检查器。它会把 compose 文件解析后的完整结果打印出来所有变量替换、默认值生效、字段合并都会在此时可见。启动任何服务前先跑一次docker compose config如果输出结构符合预期再执行docker compose up -d --build--build表示启动前先构建镜像。对本地开发来说代码改了之后重新执行这一条即可。后续不需要构建时可以直接docker compose up -d。4.3 服务间通信服务名就是 DNS 名Compose 会为项目自动创建默认网络所有声明在这个 compose 文件里的服务都会加入该网络并注册以服务名为域名的 DNS 记录。因此在 api 容器里连接数据库的主机地址就是db连接 Redis 就是redis不需要去查宿主机 IP 或容器 IP。这也是我特别强调的一点只要通过 compose 文件组织服务容器内的连接地址一律写服务名。如果代码里写死了localhost或某个容器 IP迁移环境时就会出问题用服务名整个部署可以在任何一台机器上无缝复现。4.4 常用运维命令与 down -v 的危险性按频率排序我常用的命令如下docker compose ps # 查看当前项目服务状态 docker compose logs -f api # 跟踪 api 服务日志 docker compose exec api sh # 进入容器执行命令 docker compose top # 查看每个服务的进程列表 docker compose down # 停止并删除容器与默认网络保留卷这里必须重点警告docker compose down -v-v会同时删除 compose 文件里声明的所有 named volume。一旦执行PostgreSQL 数据、Redis 持久化数据全部消失。我见过不止一次因为手快丢了数据库的情况。日常清理用不带-v的docker compose down只有在确定要销毁所有数据时才加-v。5. 常见 fail 复盘按排查链路列出的高频坑这一部分是我真正想分享的干货。下面每个问题都是我在实践中反复踩过、帮别人排过的典型。5.1 YAML 缩进错误导致的服务解析失败报错形式通常是yaml: line 6: mapping values are not allowed in this context services: api: image: nginx:alpine这里image写在了api的层级之外导致结构错误。这类问题的排查链路非常简单先docker compose configYAML 解析错误会直接定位到行号修完之后再看下一处。我见过很多神秘报错到最后都是缩进或空格层级问题所以养成先跑 config 再排查其他问题的顺序能省掉大量时间。5.2 ports 端口冲突Error response from daemon: driver failed programming external connectivity on endpoint xxx: Bind for 0.0.0.0:3306 failed: port is already allocated排查顺序一般是先docker compose ps确认是不是之前启动的容器还占着端口再ss -lntp | grep 3306看宿主机上是谁监听了这个端口如果确认是旧容器残留docker rm -f清理如果是其他进程占用就改 compose 文件的端口映射。遇到过最隐蔽的一种情况是宿主机上还有 Docker 自身的 bridge 网络占用了端口映射段。处理手段是换到高位端口范围比如 18080 映射 8080减少冲突概率。5.3 container_name 冲突导致旧容器被替代写了固定容器名之后如果项目在另一台机器或另一个 compose 项目重名会出现报错Error response from daemon: Conflict. The container name /xxx is already in use by container ...排查链路docker ps -a找到同名退出或残留的容器确认无用后docker rm -f再重新docker compose up -d。根治办法是在交付文件中移除container_name让 Compose 自动加上项目名前缀天然避免重名。5.4 服务启动后立即退出的排查链路容器一直处于 Exited 状态看日志可能只有一行docker compose logs api这种情况先看退出码0 说明进程主动结束通常是没有前台进程在运行非 0 说明程序崩溃。exit code 0最常见的原因是基础镜像是一次性镜像比如纯 alpine 不带任何服务进程加command: [sleep, infinity]可以暂时保活exit code 1则先看应用报错日志。另一种非常隐蔽的情况是entrypoint被 compose 里的command覆盖后启动命令预期不符合镜像默认入口设计导致进程秒退。这时候用docker compose exec进不去因为容器已经退出需要先用一个临时覆盖命令启动来排查docker compose run --rm api sh这会进入一个新的临时容器手动执行应用启动命令能直观看到当时进程为何退出。5.5 bind mount 相对路径的基准目录compose 文件里的相对路径基准目录是compose 文件所在的目录不是当前执行docker compose命令的目录。这个认知错误非常普遍很多人在项目顶层写volumes: - ./data:/app/data然后跑到项目的backend/子目录执行docker compose -f ../docker-compose.yml up结果挂载的宿主目录是backend/data不是预期里的项目根data排查半天。解决方式是统一在 compose 文件所在目录执行命令或所有相对路径都写成以该文件目录为基准的明确路径。5.6 服务名解析不通两个服务之间用服务名访问却报 DNS 错误最常见原因是某个服务里显式指定了network_mode或加入了另一个自定义网络导致它不在 Compose 默认网络中。排查用docker network inspect 项目名_default docker inspect 容器名 | grep NetworkMode另外如果服务里声明了networks但没把默认网络算进去其他服务也无法通过服务名访问它。简单规则要么所有服务都不写 networks全部走默认网络要么每个服务都显式列出参与的所有网络。5.7 volume 被占用导致无法删除Error response from daemon: unable to remove volume ... volume is in use排查链路先docker ps -a --filter volume卷名找到还在引用该卷的容器即便容器是 Exited 状态也仍然占用确认无用后删容器再删卷。清理命令docker ps -a | grep 卷名 docker rm 相关容器ID docker volume rm 卷名6. 进阶多文件合并、profiles 与健康检查组合基础能力扎实之后compose 文件还有一些进阶玩法能让它适应更复杂的工程环境。6.1 多文件覆盖base 与 override 的拆分Compose 支持用-f指定多个文件后面的文件对前面的内容做增量覆盖。Docker 官方还规定了默认行为如果项目目录存在docker-compose.override.ymldocker compose up会自动加载它用于本地开发的配置覆盖。我实际项目里常见的组合是docker-compose.yml放基础服务定义docker-compose.override.yml放本地才需要的端口映射和代码卷挂载。这样同一个文件库既能支撑本地开发又能用于服务器部署docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d多文件叠加时数组字段会合并map 字段会覆盖。端口映射这类列表尽量只在其中一个文件里定义避免两个文件重复定义端口导致意外冲突。6.2 profiles让文件里的服务按场景出场profiles让同一个 compose 文件可以定义默认不启动的服务只有显式启用对应 profile 时才拉起。我通常把监控面板、管理后台、一次性数据迁移任务放进 profilesservices: adminer: image: adminer:latest profiles: [tools] ports: - 8081:8080启动时加上 profiledocker compose --profile tools up -d这样日常docker compose up -d只会启动默认服务需要排查数据库时再临时把adminer拉起来不用为它单独维护一个 compose 文件。6.3 healthcheck 是依赖可用性的最终解法前面提过condition: service_healthy实际项目中可以把这套组合用在所有关键依赖上。Redis、PostgreSQL、应用服务各自定义真实可用的健康检查上游服务再依赖健康状态启动。这样整个应用栈的自愈能力和启动顺序都变得非常稳定。健康检查的命令要贴近服务的真实能力。pg_isready比pg_isready -h localhost更严谨因为默认检查的是本地 socket有时候容器里 socket 和 TCP 状态并不一致容易误判。Redis 用redis-cli ping返回 PONG 才是真可用。6.4 资源限制与 labels 管理对本地或单机部署来说给服务加上资源限制很有必要防止某个容器失控吃掉整台机器资源deploy: resources: limits: cpus: 0.50 memory: 512M单机 Compose 环境下新版引擎能识别 deploy 下的资源限制字段并执行某些旧版本会忽略。旧式写法是mem_limit和cpus两者作用目标相同但字段来源不同。我的建议是如果 Dockers 版本支持 Compose V2优先用deploy.resources.limits。labels字段可以给容器打上自定义元数据常用于日志采集、监控过滤services: api: labels: app.team: backend logging.level: info记住 labels 在服务下的写法和使用docker inspect查看时的 key 格式差异排错或采集配置时容易踩。最后再分享两个长期积累的小习惯。第一个新建项目写 compose 文件前先想清楚三件事哪些数据必须持久化哪些端口必须暴露给宿主机服务之间通过什么网络互相访问。三件事想通了文件框架基本就成型了。第二个所有 compose 文件都先过一遍docker compose config再执行任何 up 或 build 操作能挡住至少一半的低级错误。这套流程用习惯了compose 文件就不再是项目里的玄学配置而是整个部署方案里最可靠的一块基石。
返回列表