1. 从三条命令说起:多容器编排到底在解决什么问题
1.1 手动 docker run 的三重痛点
先说个场景:你在本地开发一个 Web 应用,后端要连 Redis,还要挂一个 MySQL。这个时候最朴素的做法就是开三个终端,分别把三条 docker run 敲进去:
docker run -d --name web -p 8080:80 --link redis:redis my-web-image docker run -d --name redis -p 6379:6379 -v redis-data:/data redis:7-alpine docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:8.0第一次敲还行,第二次也能忍,到第三次你就会发现一个很现实的问题:这些命令越来越长,长得连换行都不知道该怎么断。而且容器一多,你要记住每个容器的端口、数据卷、网络别名、环境变量,稍一疏忽就漏了某个-v,等重启之后数据丢了你才意识到问题出在哪。
这还不是最头疼的。更麻烦的是依赖关系。比如你的 Web 服务依赖 Redis 已经就绪,可是裸用 docker run 的时候,容器之间的启动顺序完全靠人肉把控。今天你先启动了 Web 再启动 Redis,应用启动时连不上缓存,直接崩了。虽然大多数应用有重试机制,但这种不确定性在开发环境里特别耗神。
再加上你还要管理自定义网络。为了让两个容器能用容器名互相访问,你得手动docker network create my-network,然后每条命令后面再加一个--network my-network。一条命令里塞这么多参数,已经不是在写命令了,是在练记忆力。
1.2 Docker Compose 到底是什么,又不是什么
Docker Compose 的核心思路一句话就能说清:把一堆docker run参数转换成一份声明式的 YAML 配置文件,然后用docker compose up -d一条命令全部拉起来。
它不是一个新的容器运行时,也不是 Docker 的替代品。它本质上是一个"编排工具",负责解释那份 YAML 文件,然后帮你把启动、网络、数据卷、依赖关系这些底层操作全部做了。你可以把它理解成装修房屋时的施工图纸:docker run是现场跟工人说"这里放个沙发、那里放个茶几",Compose 则是把每件家具的型号、位置、摆放顺序全部画在一张图纸上,工人照着图纸施工就行。
这份配置文件通常叫docker-compose.yml,放在项目的根目录。里面定义了你需要哪些服务、用哪个镜像、暴露哪些端口、挂载哪些数据卷、加入哪个网络。以后不管是你自己还是同事,只要拿到这份文件,在装有 Docker 的机器上执行docker compose up -d,就能得到一个完全一致的环境。
我见过很多人分不清 Compose 和 Kubernetes(K8s)的区别。简单说,Compose 处理的是"一台机器上的多容器编排",适合开发环境、小型生产部署、家庭服务器、单体应用拆分的场景;K8s 则是面向多台机器的大规模集群调度。如果你还没有几十个服务要管理,用 Compose 就对了,没必要一上来就搬一套 K8s 集群回来自己维护。
1.3 适合谁、不适合谁:先想清楚再上车
Docker Compose 最典型的适用人群有这几类:
- 本地开发需要同时跑多个中间件(Redis、MySQL、RabbitMQ、MongoDB)的后端开发者
- 需要在服务器上部署一套完整应用(前端 Nginx + 后端 API + 数据库 + 缓存)的运维工程师
- 想在自己 NAS 或家庭服务器上跑一堆自建服务(比如相册、博客、监控面板)的爱好者
- 用 CI/CD 流水线做自动化测试,需要临时拉起一组依赖服务的测试工程师
那有没有不适合用 Compose 的场景?有。如果你的项目只有一个容器,只敲一条docker run就能搞定,那就没必要刻意引入 Compose。另外,如果你的服务需要根据流量自动扩容缩容、需要跨多台机器调度,那就是 K8s 或 Docker Swarm 的领域了,Compose 帮不了你。
不过我个人还是建议:只要你的服务数量超过一个,或者你发现自己经常要重复输入同一串 docker run 参数,就值得把 Compose 用起来。一次迁移成本并不高,收益却是长期的。
2. 核心概念拆解:docker-compose.yml 里有什么
2.1 services:组成应用的每一块砖
services是 docker-compose.yml 里最核心的字段,它定义了应用由哪些容器组成。每个 service 对应一个镜像或一个构建上下文,你可以理解成"一类容器"。
一个最基本的 service 定义长这样:
services: redis: image: redis:7-alpine container_name: my-redis ports: - "6379:6379" volumes: - redis-data:/data这里面redis是服务名,在 Compose 内部网络里,其他容器可以直接用redis这个主机名访问它。container_name是可选的,不写的话 Compose 会自动生成一个"项目名-服务名-序号"形式的容器名。ports是端口映射,"6379:6379"表示把容器的 6379 端口映射到宿主机的 6379 端口。
需要注意的是,service 不一定要用现成镜像,也可以从 Dockerfile 构建:
services: web: build: . ports: - "8080:80"这里的build: .表示使用当前目录下的 Dockerfile 构建出镜像再运行容器。你还可以指定build的context和dockerfile字段来精确控制构建路径。
2.2 networks:容器之间怎么通信
默认情况下,执行docker compose up时 Compose 会为整个项目自动创建一个网络,项目里的所有 service 都会加入这个网络。在同一个网络里,容器之间可以互相通过服务名访问,完全不需要知道对方的 IP 地址。
这个设计非常实用。比如 Web 服务要连接 Redis,你在代码里配置连接地址时只需要填redis:6379,而不需要填localhost:6379或某个具体的 IP。因为 Compose 内置的 DNS 会把服务名解析成对应容器的 IP。
你可能会问,为什么不直接把 Redis 的端口映射到宿主机,然后在 Web 服务里连localhost:6379?在开发环境你可以这么干,但在生产环境或者多网络环境里会有问题:端口映射会暴露到宿主机上,别人也能直接访问你的 Redis;而且一旦端口被占用,整个环境就起不来了。
所以 Compose 更推荐的做法是:需要对外提供服务(比如 Web 的 80 端口、数据库的 3306 端口)才用ports映射到宿主机;服务之间的内部调用,直接通过服务名走内置网络,不做端口映射。这样既安全又干净。
2.3 volumes:数据不丢的关键
容器本身是临时的,重建容器之后里面的数据就没了。所以凡是需要持久化的数据,比如数据库文件、上传的图片、日志文件,都应该挂载到 volume 或宿主机目录里。
Compose 里的 volume 有两种写法,一种是具名卷:
services: mysql: image: mysql:8.0 volumes: - mysql-data:/var/lib/mysql volumes: mysql-data:这里mysql-data是一个具名卷,Docker 会在宿主机上创建一个受管目录来存储数据。即使你docker compose down删掉了容器和网络,数据依然保留在卷里,下次up的时候重新挂载就能恢复。
另一种是 bind mount,直接映射宿主机目录:
services: nginx: image: nginx:alpine volumes: - ./html:/usr/share/nginx/html这种方式适合开发场景,本地改了文件,容器里立刻生效,不用重新构建镜像。但要注意目录权限问题,尤其是以非 root 用户运行的容器,经常会出现"容器内写不了宿主机目录"的错误。
2.4 depends_on、restart 与 env_file:几个容易被忽视的配置
depends_on用来声明服务之间的启动依赖关系:
services: web: image: my-web depends_on: - redis这个配置告诉 Compose:启动web之前,先启动redis。但请注意,它只保证"先启动",不保证"已经就绪"。也就是说,redis容器启动了,可 Redis 进程可能还没初始化完,web就已经开始连它了。
想解决"就绪"问题,可以配合 healthcheck 使用,这种写法在一些新版本 Compose 里也支持:
services: web: image: my-web depends_on: redis: condition: service_healthy redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5这样 Compose 就会等 Redis 的 healthcheck 通过之后才启动 Web,可靠性高很多。我在后面讲生产部署的时候会再展开。
restart是容器的重启策略,常见值有no、always、on-failure、unless-stopped。生产环境里我一般给常驻服务配unless-stopped或always,这样服务器重启或者容器异常退出后,Docker 会自动把它拉起来。
env_file可以把环境变量拆到独立文件里,避免把密码、密钥直接写进 docker-compose.yml。比如:
services: app: image: my-app env_file: - .env.env文件里一行一个KEY=VALUE,注意不要提交到 Git 仓库,尤其是生产环境的密钥。
2.5 为什么 Compose 比一条条 docker run 更稳
从工程角度说,Compose 最大的价值是"可重复性"。手工敲命令的时候,每个人对参数的记忆和理解都不一样,A 说多加一个-e,B 说忘了一个--link,最后环境之间差别越来越大。而 Compose 把环境定义固化成了代码,团队协作时,只要共享一份 docker-compose.yml,每个人拉起来的环境就是一样的。
另一个价值是操作粒度。一条条 docker run 管的是单个容器,Compose 管的是"一组服务"。docker compose up -d把一组全部拉起来,docker compose ps看到的是全组状态,docker compose logs -f跟的是全组日志,docker compose down把全组按依赖顺序全部停掉。这种"整组操作"的体验,用 docker run 是完全没有的。
3. 完整实战:用 Docker Compose 编排一个 Web + Redis 应用
3.1 预先想好的目录结构和镜像选型
为了让你能直接照着做,我设计一个最常见的场景:一个简单的访问计数服务,Web 后端用 Python Flask 写,数据存到 Redis 里。整个项目目录结构这样安排:
compose-demo/ ├── app/ │ ├── app.py │ ├── requirements.txt │ └── Dockerfile ├── docker-compose.yml └── .env镜像选型上,Web 服务用自己的 Dockerfile 构建,基础镜像用python:3.11-slim,Redis 直接用官方redis:7-alpine。为什么用 alpine?因为它镜像体积小、启动快,作为 Redis 这种单一服务的运行环境很合适。
app.py 的代码故意写得很简单:
import os import redis from flask import Flask app = Flask(__name__) r = redis.Redis(host="redis", port=6379, db=0) @app.route("/") def index(): count = r.incr("hits") return f"Hello Docker Compose! This is visit #{count}."注意代码里连接 Redis 的主机名是redis,这个就是 Compose 网络里服务名解析的结果,而不是localhost。
requirements.txt:
flask==3.0.3 redis==5.0.4Dockerfile:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY app.py . EXPOSE 5000 CMD ["python", "app.py"]3.2 第一个 docker-compose.yml 长这样
在项目根目录创建docker-compose.yml:
services: web: build: ./app container_name: compose-demo-web ports: - "8080:5000" environment: - REDIS_HOST=redis depends_on: redis: condition: service_healthy networks: - demo-network restart: unless-stopped redis: image: redis:7-alpine container_name: compose-demo-redis volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 networks: - demo-network restart: unless-stopped volumes: redis-data: networks: demo-network:这个文件我给每个字段加了实际含义,一条条说:
web服务通过build: ./app从源码构建镜像。Docker 会先在当前目录找 Dockerfile,构建完成之后用这个镜像启动容器。ports把容器的 5000 端口映射到宿主机 8080,这样浏览器访问localhost:8080就能看到页面。environment里设置了REDIS_HOST=redis,这其实是给忘了改代码的情况留一个兜底。你完全可以在代码里直接读这个环境变量,而不是硬编码主机名。redis服务挂载了redis-data卷到/data,这是 Redis 持久化数据的默认目录。healthcheck用redis-cli ping检查 Redis 是否真正就绪。Web 的depends_on.condition: service_healthy表示只有 Redis 健康检查通过后,才会启动 Web。restart: unless-stopped保证容器异常退出后能自动恢复。
3.3 启动、查看状态、伸缩与停止:日常操作全流程
写完配置文件之后,执行:
docker compose up -d-d表示后台运行。如果你不写-d,日志会直接打印在当前终端,按 Ctrl+C 会同时停掉所有容器,这适合调试场景。第一次执行时,Docker 会自动构建 Web 镜像并拉取 Redis 镜像,耐心等一会儿。
启动完成后看状态:
docker compose ps正常情况下你会看到两个容器都在运行,一个是 web,一个是 redis。接着打开浏览器访问http://localhost:8080,每刷新一次页面,计数加一,说明 Web 和 Redis 之间的链路是通的。
如果想看日志,用:
docker compose logs -f-f是 follow 模式,类似tail -f。也可以只跟某个服务:
docker compose logs -f web当你想停掉整套环境时,区分两个命令非常关键:
docker compose stop docker compose downstop只停容器,数据卷、网络都还在,下次docker compose start可以快速恢复。down会删除容器和网络,但具名卷默认还在,数据不会丢。如果你想连卷一起清掉,用docker compose down -v,这个命令请慎用,因为会把 redis-data 里的数据全删掉。
3.4 网络细节:为什么在容器里可以访问 redis 这个名字
我见过不少刚开始用 Compose 的人卡在这里:明明 Redis 的端口没有映射到宿主机,为什么 Web 容器里能用redis:6379连上?
答案就是 Compose 自动创建的那个网络。你可以在宿主机上执行:
docker network ls会看到一个compose-demo_demo-network这样的网络。然后:
docker network inspect compose-demo_demo-network能看到这个网络里挂着的两个容器以及它们的 IP。当 Web 容器尝试访问redis这个主机名时,Docker 内置 DNS 会把它解析成 redis 容器在这个网络里的 IP,所以不需要走宿主机的端口映射。
这个机制在生产环境特别有用。你不需要把数据库的端口暴露到公网,因为只有同一个自定义网络里的服务才能互相访问。默认网络之外的主机根本连不上数据库,安全风险直接降了一个量级。
4. 生产环境部署时的编排经验:别把开发配置直接照搬
4.1 设置 restart policy 前先想清楚
restart: always看起来省心,但生产环境并不建议无脑用。如果容器因为配置错误、密码错误等原因反复崩溃,restart 策略会一直尝试重启,造成日志刷屏甚至宿主机负载升高。
更稳妥的做法是区分场景:
- 数据库、Redis、消息队列这类基础设施,可以用
unless-stopped,它在 Docker 守护进程启动时自动拉起,但如果你手动 stop 了容器,它不会自动再拉起。 - 应用服务如果希望崩溃后自动恢复,可以配
on-failure:5这样的策略,只对非零退出码重启,最多重试 5 次。 - 批量任务类容器,比如定时跑脚本、数据迁移任务,一般不要配 restart,跑完就退出,配了反而会陷入重启循环。
我个人在生产服务器上通常用unless-stopped,它比always多了一个好处:手动 stop 时不会被守护进程重新拉起来,方便维护。
4.2 健康检查与依赖排序:depends_on 不是万能的
前面提到的depends_on: condition: service_healthy是一个比较稳妥的依赖方案,但有个前提:依赖的服务必须配置了 healthcheck。如果没配,Compose 会直接认为该服务已经就绪,然后启动依赖它的服务。
在老的 docker-compose 版本里,depends_on没有condition选项,只有简单的启动顺序控制,很多人因此踩过坑。所以如果你的生产环境用到depends_on,确认一下 Compose 版本至少是 2.x。执行docker compose version查看版本号。
健康检查本身也要设计合理。比如 Redis 的健康检查用redis-cli ping,返回 PONG 才算通过。MySQL 的检查则可以用mysqladmin ping -h localhost。注意健康检查的interval不要太短,5 秒是一个合理的默认值;如果中间件启动很慢,retries要多给一点,设置 10 次甚至更多都不过分。
4.3 日志、资源限制与 .env 的实战用法
容器日志如果不控制,时间长了会占满磁盘。Compose 支持配置 logging 驱动:
services: app: image: my-app logging: driver: "json-file" options: max-size: "10m" max-file: "3"这样单份日志最大 10MB,保留 3 份,超过自动轮转。这套配置在生产环境几乎是必须的,否则跑一个月的服务,日志可能轻松涨到几个 GB。
资源限制也很重要。不想让某个服务把宿主机 CPU 或内存吃满,可以这样写:
services: app: image: my-app deploy: resources: limits: cpus: "0.5" memory: 512M这里限制了 app 最多用 0.5 个 CPU 核心和 512MB 内存。注意deploy字段在 Compose v2 里对单机部署是部分支持的,实测下来限制内存和 CPU 是有效的。
.env文件我建议在生产环境必须用起来。把容易变化的值单独拎出来,比如端口号、版本号、密码,避免每次修改都去改 docker-compose.yml:
# .env APP_PORT=8080 REDIS_IMAGE=redis:7-alpine MYSQL_ROOT_PASSWORD=change-medocker-compose.yml 里通过${APP_PORT}引用:
services: web: build: ./app ports: - "${APP_PORT}:5000"这样做的好处是,不同环境(测试、生产)可以各自维护一套 .env,而配置文件本身完全不用改。
4.4 常见生产环境组合:Redis、RabbitMQ、数据库一起上
实际生产部署里,很少只有两个服务。我以一个典型业务后端为例,给出一个组合配置风格的片段:
services: api: build: ./api depends_on: mysql: condition: service_healthy redis: condition: service_healthy rabbitmq: condition: service_healthy ports: - "8000:8000" env_file: - .env worker: build: ./api command: celery -A tasks worker --loglevel=info depends_on: - rabbitmq env_file: - .env mysql: image: mysql:8.0 volumes: - mysql-data:/var/lib/mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_USER: ${MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD} healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10 redis: image: redis:7-alpine volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 rabbitmq: image: rabbitmq:3.12-management hostname: rabbitmq environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS} volumes: - rabbitmq-data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "ping"] interval: 5s timeout: 3s retries: 10 volumes: mysql-data: redis-data: rabbitmq-data:几个注意点:
worker和api用同一个镜像,但通过command覆盖启动命令,省去再写一个 Dockerfile 的麻烦。- RabbitMQ 需要设置
hostname,否则集群模式下节点名会随机变化,容易出问题。 - MySQL 初次启动会执行初始化脚本,耗时较长,健康检查的
retries一定要给足。 - 所有敏感值全部通过 .env 注入,docker-compose.yml 里不出现明文密码。
这套组合能覆盖大多数中小型项目的部署需求。如果你要部署的应用本身就带回调、队列、缓存这类机制,这个模板基本可以直接改改用。
5. 常见问题与排查技巧实录
5.1 cannot connect to the docker daemon 是怎么回事
这个报错信息几乎每个 Docker 用户都见过:
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?遇到这个报错,先别慌,按顺序排查:
第一,确认 Docker 服务是否在运行。Linux 上执行:
systemctl status docker如果没在运行,启动它:
sudo systemctl start docker如果你用的是 CentOS 或某些最小化安装的系统,Docker 服务默认不会开机自启,记得执行sudo systemctl enable docker。
第二,检查当前用户是否有权限访问 Docker socket。如果是刚装完 Docker,你的用户可能不在docker用户组里,普通用户执行 docker 命令就会报这个错。解决办法:
sudo usermod -aG docker $USER然后退出重新登录终端,让组权限生效。
第三,比较隐蔽的是 Docker daemon 启动失败。这种情况需要看 daemon 的日志:
journalctl -u docker --since today常见原因包括:daemon.json 配置了无效的镜像加速地址、磁盘空间不足、iptables 规则被其他软件覆盖等。我在一台服务器上遇到过因为磁盘写满导致 docker daemon 起不来的情况,清理完/var/lib/docker下的日志和悬空镜像之后才恢复正常。
5.2 镜像拉取慢或失败怎么处理
国内服务器拉取 Docker Hub 镜像经常遇到超时或速度极慢的情况。最直接的办法是配置镜像加速器,修改/etc/docker/daemon.json:
{ "registry-mirrors": [ "https://docker.mirrors.ustc.edu.cn" ] }改完之后重启 Docker:
sudo systemctl restart docker注意不同加速器的可用性和速度会有波动,建议多试几个,留下实际速度最快的一个。如果你在公司内网,可能还有内网私有的镜像仓库,配置方式是在镜像地址前加上仓库地址,比如registry.example.com/library/redis:7-alpine。
另一个经验是:如果 push 或 pull 超时,可以设置更长的超时时间,但这只能在 Docker 客户端的配置里部分调整,总体而言还是加速器更直接。
5.3 端口冲突、容器启动顺序、Compose 命令版本差异
端口冲突是非常经典的问题。启动时报这个错:
Error starting userland proxy: listen tcp4 0.0.0.0:8080: bind: address already in use说明 8080 端口已经被占用了。先用下面的命令看是谁占了:
ss -lntp | grep 8080找到占用进程后,要么停掉它,要么把 Compose 里的宿主机端口改掉。
容器启动顺序的问题前面提过,再强调一遍:depends_on只是启动顺序,不是就绪等待。如果确认 order 没问题但还是连不上,优先检查依赖服务的健康检查是否真的通过了。可以用:
docker compose ps看健康状态那一列是不是healthy。
还有个很容易踩的坑是命令版本差异。老版本的 Compose 命令是docker-compose(带横线),新版本是docker compose(带空格)。如果你的系统里只装了旧版或者只装了新版,命令就用对。查看版本:
docker compose version如果提示找不到这个命令,说明你的 Docker 版本太老或者没有安装 Compose 插件。CentOS 上可以用:
sudo yum install -y docker-compose-pluginUbuntu/Debian 上通常安装docker-compose-v2包,或者直接用 Docker 官方脚本安装完 Docker 后就自带 Compose v2。
5.4 从日志快速定位问题的三板斧
容器起不来的时候,第一步永远是看日志,而不是反复重启:
docker compose logs --tail=100--tail=100表示只看最后 100 行。如果日志显示某个服务连接不上,比如redis.exceptions.ConnectionError,说明可能是 Redis 没起来或者网络不通。这时再看:
docker compose ps确认所有容器的状态。如果有容器显示 Restarting,多半是启动后马上崩了,日志里会有具体原因。
有时候单看容器日志不够,还需要看容器的详细配置:
docker compose exec web env这个命令可以直接在运行中的容器里执行环境变量查看,确认环境配置对不对。更多时候需要直接进容器里去手动试:
docker compose exec redis redis-cli ping如果返回 PONG,说明 Redis 正常。再去 Web 容器里试试网络连通性:
docker compose exec web ping redis如果 ping 不通,优先检查是不是自定义网络配置有问题,或者容器没有加入同一个网络。
6. 一点个人体会
Docker Compose 用到现在,最让我省心的不是省了那几条命令,而是环境的"确定性"。以前项目换人维护,光是把开发环境跑起来就要折腾半天,大家装的依赖版本还不一样,问题多到怀疑人生。现在拿到项目第一件事就是docker compose up -d,整套环境一拉就起来,团队成员之间的结构差异几乎为零。
最后分享一个小实践:我会把docker compose down && docker compose up -d --build写成一个部署脚本,配合代码仓库里的版本标签,实现一键发布。每次发布前先docker compose pull拉取新镜像,再up -d重建容器,整个过程不超过 30 秒。这套流程简单可靠,比很多复杂的发布系统都顺手。
Compose 的上手门槛不高,但能玩出的深度不低。建议你先从两三个服务的组合开始,跑通之后再加健康检查、资源限制、日志轮转这些生产必备配置。等哪天你发现自己已经在用脚本管理十几条 docker run 的时候,回头看这份 docker-compose.yml,会觉得当初的迁移决定做得太值了。