本节摘要:服务(service)、网络(network)、卷(volume)是 Compose 的三个基本构建块,配置(config 与环境变量)负责给它们喂参数,项目(project)是这一切的容器。本节用"配方卡"隐喻逐个讲透这五个概念,并给出项目与服务的层级关系、服务间通信与数据持久化的完整示例,帮你在动手写文件之前先把词汇表背下来。
做饭的人都知道,菜谱的写法是"主料、辅料、步骤"。Compose 文件的结构和菜谱惊人地像:主料是镜像,辅料是环境变量和挂载,步骤则交给命令去执行。我第一次接触 Compose 时,最大的障碍不是语法,而是脑子里的模型不对——老想着"每个容器怎么配",而 Compose 的思考单位是"服务"。模型换过来之后,一切就顺了。
这一节不写太多代码,先把五个词讲透。它们分别是:服务(service)、网络(network)、卷(volume)、配置(config 与环境变量)、项目(project)。后面的每一章、每一个模板,都是这五个词的排列组合。
服务是 Compose 的基本构建块。一个服务代表一类容器:它声明用哪个镜像、要不要构建、映射哪些端口、挂哪些卷、设哪些环境变量。注意用词——"一类"而不是"一个"。同一个服务可以通过 up 的 --scale 参数启动多个容器副本,每个副本是独立的容器实例,但配置来自同一段服务定义。
先看一个真实的例子。下面的服务配置定义一个 PostgreSQL 数据库,这是本书模板里出场率最高的服务之一:
services: db: image: postgres:13 ports: - "5432:5432" volumes: - db_data:/var/lib/postgresql/data environment: POSTGRES_USER: myuser POSTGRES_PASSWORD: mypassword volumes: db_data:
逐行拆开看:image 指定 postgres:13 这个具体标签,而不是 latest,这保证了每次部署拉到的镜像内容一致;ports 把宿主机 5432 端口映射到容器 5432 端口,这样你本机的数据库客户端就能连进去;volumes 把名为 db_data 的卷挂到容器里的数据目录,PostgreSQL 的数据写进卷而不是容器层;environment 里的 POSTGRES_USER 和 POSTGRES_PASSWORD 是官方镜像约定的初始化变量,首次启动时用来创建用户和库。
一个服务能配的键不止这些。image 与 build 二选一或组合使用,前者拉现成镜像,后者从 Dockerfile 构建;command 覆盖镜像默认启动命令;restart 定义重启策略,always 表示退出后总是拉起;expose 只暴露端口给同网络的其他服务,不发布到宿主机——这层区别新手常踩,后面 1.3 节会细讲。
再举一个偏开发的例子,把服务的"容器模板"属性看得更清楚:
services: app: image: python:3.9-slim-buster working_dir: /app volumes: - ./app:/app command: python app.py environment: - FLASK_APP=app.py
image 指定 Python 基础镜像,working_dir 把容器工作目录切到 /app,绑定挂载把本机代码目录原样带进去,command 覆盖镜像默认命令直接跑 python app.py,environment 里声明 Flask 要读的 FLASK_APP。这四行组合起来的效果是:本机改代码、容器里即时生效,Flask 开发服务器的热重载全程无缝。注意 working_dir 与 volumes 的配合——挂载路径和容器内路径必须对得上,写错一个斜杠,代码就"挂"不进去,容器启动时报找不到模块。
项目(project)是比服务更高一层的概念:一份 Compose 文件定义一个项目,项目里可以有很多服务。默认情况下,项目名就是 Compose 文件所在目录的目录名,容器名则按"项目名_服务名_序号"的规则生成。比如在 myapp 目录下启动 web 服务,容器名一般是 myapp_web_1。
为什么要这一层?因为 Docker 里容器、网络、卷的名字是全局命名空间,两个项目都用 db 这个名字就会撞车。项目名前缀天然隔离了不同应用的资源,这也是同一台机器上可以并存多套应用的原因。后面 1.4 节的 ps 命令输出里,你会看到带前缀的容器名,那就是项目层级在起作用。
单容器时代,容器之间通信要手动 link 或者查 IP,麻烦且脆弱。Compose 的做法是给项目建一张网:默认创建一个名为 default 的桥接网络,所有服务自动加入,服务之间直接用服务名当主机名访问。web 服务里写 DATABASE_URL=postgres://user:password@db:5432/database,这个 db 不是魔法,是 Compose 默认网络提供的 DNS 解析。
services: web: image: nginx:latest ports: - "80:80" networks: - frontend - backend app: image: python:3.9-slim-buster networks: - backend networks: frontend: driver: bridge backend: driver: bridge
上面这个示例把服务分进两张网:web 同时加入 frontend 和 backend,app 只进 backend。这样 app 访问不到 web 暴露的端口以外的资源,而 web 可以找 app——典型的"网关在中间、后端藏起来"拓扑。
网络驱动有三个常用选项:bridge 是默认驱动,在宿主机上建私有网段;overlay 用于 Swarm 集群的多主机网络;host 让容器直接共享宿主机的网络命名空间,性能最好但牺牲了隔离。单机部署 99% 的情况用默认 bridge 就够了。
网络机制里还藏着一个高频疑问:服务名为什么能当主机名?答案在 Compose 创建网络时内置的 DNS 解析:项目里每个服务加入网络时,都会用服务名注册一个解析记录,容器里访问 db 会被解析到 db 服务的容器 IP。这套机制是 Compose 多服务协作的根基——web 里写 DATABASE_URL 指向 db,app 里写 REDIS_HOST 指向 redis,全靠它。反过来也提醒一件事:服务名起好了就别随便改,改一次名字,所有引用它的环境变量、配置都要跟着改,漏一处就断一路。
⚠️ 端口暴露与端口映射是两回事:ports 把端口映射到宿主机,外网可访问;expose 只是"告诉 Compose 这个服务监听哪些端口",仅供同网络的其他服务访问。数据库这类服务通常只 expose 不映射,避免把 5432 裸奔到公网。
💡 四个概念的记忆口诀:服务想"用什么镜像跑什么进程",网络想"谁和谁能说话",卷想"什么东西不能丢",配置想"哪些值会变"。四个问题对号入座,绝大多数 Compose 文件都能一眼看穿。
容器是即用即弃的:删除容器,容器层里写入的数据跟着消失。数据库、上传文件、日志这些必须留存的,就得放进卷。卷有两种:命名卷(named volume)由 Docker 管理,数据存在 Docker 的数据目录里,推荐给数据库用;绑定挂载(bind mount)把宿主机的一个目录直接挂进容器,两边实时同步,适合开发期改代码热更新。
services: db: image: postgres:13 volumes: - db_data:/var/lib/postgresql/data # 命名卷 - ./data:/app/data # 绑定挂载 volumes: db_data:
判断标准很简单:要 Docker 帮你管生命周期和备份的,用命名卷;要跟宿主机文件直接打交道的,用绑定挂载。开发环境我倾向绑定挂载改代码,生产环境一律命名卷。
挂载还有一个常被忽略的维度:读写权限。冒号后面加 :ro 可以把挂载设为只读,比如 ./nginx.conf:/etc/nginx/nginx.conf:ro,容器想改也改不动。配置文件、证书这类只读素材建议一律 :ro,既能防容器误写,也能在排查"文件怎么被改了"时缩小怀疑范围。绑定挂载还有个权限坑:容器内进程以非 root 用户运行时,对宿主机挂进来的目录可能没有写权限,报 Permission denied。解决思路是在 Compose 里指定 user,或者调整宿主机目录属主,别一上来就 chmod 777 图省事。
镜像里烧死的配置没法改,环境变量就是容器外的"调节旋钮"。Compose 文件里可以直接写死,更常见的做法是用 ${变量名} 引用外部值,让同一份文件在不同环境复用。引用来源有两个:同目录的 .env 文件,或者命令行前缀。
# docker-compose.yml services: web: image: nginx:${NGINX_VERSION} ports: - "${PORT}:80" environment: - APP_NAME=${APP_NAME}
# .env NGINX_VERSION=latest PORT=8080 APP_NAME=MyWebApp
运行 docker compose up -d 时,Compose 自动读取同目录 .env 文件,把变量填进文件。想临时换值,命令行直接覆盖:APP_NAME=AnotherApp docker compose up -d。注意 .env 只对 Compose 文件本身的插值生效,不会自动传进容器;要传进容器,得在服务的 environment 里显式声明。
当一组变量要整体喂给容器时,还有 env_file 这个键:它把指定文件里的所有键值对一次性注入容器的环境变量,适合存放数据库连接、第三方密钥这类成堆的配置。这里存在一条容易绕晕的规则链:Compose 文件插值读 .env,容器环境变量来自 environment 与 env_file,两者同名时 environment 优先,命令行前缀只影响插值那一层。先记结论:敏感值进 .env 或 env_file,显式要暴露给应用的变量写进 environment,三层各司其职,别混着用。
多个服务共享同一段配置时,复制粘贴会埋下隐患——改一处忘另一处。extends 关键字允许一个服务继承另一个 Compose 文件里某个服务的配置:
# base.yml services: base_web: image: nginx:latest restart: always # docker-compose.yml services: web: extends: file: base.yml service: base_web ports: - "80:80"
web 服务继承 base_web 的 image 和 restart,再叠加自己的 ports。继承的键会被本文件的同名键覆盖,这点和 CSS 的层叠思路一致。团队里维护一套公共基础文件,各项目只写差异部分,这是 Compose 少有人用但很值钱的技巧。

上面的图把两种模式摆在一起:左边是 docker run 的单容器孤岛,容器靠端口映射暴露,服务间依赖靠人肉顺序;右边是 Compose 编排的项目,web、app、db 三个服务挂在同一张项目网络里,服务名互相解析,卷负责持久化,一条 up 命令按依赖顺序全部拉起。
| 概念 | 在配方里的角色 | 声明位置 | 常见配置键 |
|---|---|---|---|
| 服务 service | 一行主料 | services 块下的每个键 | image、build、ports、environment |
| 网络 network | 共用厨房台面 | networks 块 | driver、external、ipam |
| 卷 volume | 冰箱与储藏室 | volumes 块或服务内联 | driver、driver_opts |
| 配置 config | 可调参数 | 环境变量、.env、configs 块 | environment、env_file、file |
| 项目 project | 整桌菜 | 一份 Compose 文件 | 目录名即项目名 |
理解到这,词汇表就算背完了。下一步,我们把工具装到机器上——1.2 节讲三种安装方式和新旧版本的取舍,装错版本是新手第一道坎,值得认真读。