从 docker run 到 Docker Compose:多容器编排实战与排障指南
2026/9/19 1:45:34 网站建设 项目流程

我见过有人把项目里所有容器的启动命令一句句堆进 shell 脚本,每加一个服务就往里追加一行 docker run,最后脚本长到没人敢动;也见过同事在新环境部署时对着聊天记录一条条找参数,敲到第三条才发现端口映射写错了。多容器编排听起来玄乎,其实核心就一句话:别再手动重复敲 docker run,交给 Docker Compose 用一份声明式配置来统一管理。

这篇文章不聊高深的调度原理,就聊怎么把"三条 docker run"变成"一份 docker-compose.yml",再顺带把日志查看、健康检查、数据卷、网络这些多容器场景必然遇到的问题讲透。适合刚学完 docker run、正在被多容器互相踩坑折磨的新手,也适合想把项目部署方式整理得更规范、更好复现的开发者。

1. 从三条 docker run 说起:多容器编排到底难在哪

在讲 Compose 之前,先还原一个最典型的场景,你会发现很多项目就是从这个场景开始失控的。

1.1 一个典型项目的真实场景

假设你有一个博客应用,架构不复杂:Nginx 反代前端、后端服务、Redis 做缓存、MySQL 存数据。为了省事,先只算三个核心容器:后端、Redis、MySQL。按照官方文档和"能用就行"的原则,你会敲出类似这样的命令:

docker run -d --name redis-cache -p 6379:6379 redis:7-alpine docker run -d --name mysql-db -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=secret \ -e MYSQL_DATABASE=blog \ mysql:8.0 docker run -d --name blog-app -p 8080:8080 \ --link redis-cache --link mysql-db \ -e REDIS_ADDR=redis-cache:6379 \ -e MYSQL_ADDR=mysql-db:3306 \ blog-app:v1

每条命令单独看都不复杂:-d后台运行,--name给容器起名,-p映射端口,-e注入环境变量,--link让容器之间能访问。但三条命令放在一起,问题就来了。

--link其实已经是被官方标记为遗留的机制,它靠往容器的/etc/hosts里写条目来解析对方地址,而且只在容器启动时生效。你在这个例子中还能用,是因为你手动控制了启动顺序。一旦服务多起来,--link的依赖关系会变成一团乱麻。真正稳定可靠的方案是让所有容器加入同一个用户自定义网络,用服务名做 DNS 解析——这正是 Compose 默认做的事情,后面实操部分会展开。

1.2 手工命令的三个致命问题

第一,启动顺序全凭记忆。MySQL 容器起来不等于 MySQL 进程已经准备好接受连接。在--link方案下,后端容器如果比 MySQL 先启动,或者 MySQL 还没初始化完成,后端就会连接失败。你可能见过很多应用带重试逻辑,但并不是所有程序都有,于是"先启动谁、后启动谁"这件事就成了部署环节最大的隐性负担。

第二,参数分散且不可复现。三条命令里的端口、密码、网络配置、镜像版本散落在不同的地方。你今天在本机敲一遍没问题,三个星期后在新服务器上再部署一遍,可能就发现密码忘了、端口换过了、镜像 tag 拉不到了。你没法保证两次部署用的是同一套配置,这才是比"敲命令麻烦"更致命的问题——部署结果不可控。

第三,修改配置必须删除重建。docker run的语义是"创建并启动一个新的容器",它不允许你修改已存在容器的端口映射或环境变量。也就是说,每次调整参数,你得先docker rm -f把旧容器干掉,再重新敲一遍完整命令。配置稍微复杂一点,这条命令就会变成一长串没法看的文本,复制粘贴时漏一个参数,排查半天。

1.3 那些容易被忽略的 docker run 细节

除了上面三个大问题,docker run本身的不少细节也会在实战中埋雷。

--rm参数就很有迷惑性。它的作用是容器退出时自动删除容器文件系统,适合临时跑任务,比如拉一个测试镜像执行完就走。但如果某个服务本身需要长期驻留,你误加了--rm,一旦进程异常退出,容器连同日志一起被清掉,现场都没得看。我见过有人在调试时用docker run --rm -d --name hermes hermes-image,以为这样既能后台跑又能自动清理,结果容器一崩日志全没了,调试变成盲人摸象。

容器名冲突也是高频坑。同一个宿主机上,容器名是唯一的。你要是之前已经跑过一个hermes,下次再执行docker run -d --name hermes ...,会直接报Conflict. The container name "/hermes" is already in use。解决办法只有docker rm或者换名字,多容器多项目混在一起时,起名和清理就变成了负担。

再就是日志。docker run前台运行时日志直接刷在终端上,所以你第一次体验觉得很直观。一旦加上-d后台运行,终端什么都看不到,想看日志只能docker logs <容器名>。三个容器你就得开三个终端窗口分别看,日志一多,定位问题全靠肉眼比对时间戳。后面我会讲docker compose logs是怎么把这件事变简单的。

2. Docker Compose 的设计思路:把命令变成声明式配置

Docker Compose 不是什么玄乎的调度平台,它本质上做了一件看起来很笨但极其有效的事:把docker run的参数原封不动地写进一个 YAML 文件,然后由它来负责创建网络、按依赖启动容器、管理生命周期。

2.1 从命令参数到配置字段的映射

只要你熟悉docker run的常用参数,写 Compose 文件基本没有学习成本,因为就是一一对应:

docker run 参数Compose 字段作用
-d无需配置,up -d本身即后台后台运行
--namecontainer_name指定容器名
-pports端口映射
-eenvironment环境变量
-vvolumes数据卷挂载
--networknetworks自定义网络
--restartrestart重启策略
--link不需要,服务名自动解析容器间访问

这个映射关系非常重要。很多人看到 Compose 文件第一反应是"又要学一套新语法",实际你只要把它当成"用 YAML 写 docker run 参数",心理包袱就卸掉一半。剩下的,只是 Compose 帮你多做了一些约定俗成的事情。

最典型的一个约定是:同一个 Compose 文件里的所有服务,默认会被放进同一个自定义网络。在这个网络里,每个服务都可以直接用服务名作为主机名访问其他服务,不需要知道对方的 IP。这就彻底替代了--link的活,而且网络是动态创建的,容器重建后 IP 变了也不影响互相访问。

2.2 docker-compose.yml 的核心结构

现代版本的 Docker Compose(V2 已集成到 docker CLI,直接执行docker compose即可)推荐使用不带version字段的 Compose Specification 格式。一个最小可用的文件长这样:

services: redis: image: redis:7-alpine app: image: my-app:v1 ports: - "8080:8080" environment: REDIS_ADDR: redis:6379

顶层只有services,下面每个键就是一个服务。services.redis声明了一个名为 redis 的服务,字段和docker run参数一一对应。两个服务在同一个 Compose 网络里,所以app访问redis只需要把地址写成redis:6379,而不是localhost:6379,更不需要查 IP。

除了services,还有两个顶层字段你会经常用到:networksvolumes。它们的作用是声明式地定义需要的网络和数据卷。不用的时候 Compose 会使用默认网络,但如果你有多个 Compose 项目需要互通,或者需要挂载命名卷做持久化,就会用到这两个字段。命名卷的声明方式也很简单:

volumes: mysql-data:

声明之后,服务里就可以引用它:

services: mysql: image: mysql:8.0 volumes: - mysql-data:/var/lib/mysql

这里mysql-data:/var/lib/mysql的语义是:把名为mysql-data的卷挂载到容器的/var/lib/mysql目录。卷不存在时 Compose 会自动创建,docker compose down也不会删除它,数据就安全了。

2.3 depends_on 的真实效果与限制

depends_on是 Compose 里最容易被误解的字段,新手几乎都会在这里踩坑。它的字面意思是"依赖某个服务",于是很多人以为加上它就能保证"被依赖的服务已经完全就绪"。

实际上,默认的depends_on只控制启动顺序,不控制就绪状态。比如:

services: app: depends_on: - mysql

Compose 的保证只是"先启动 mysql,再启动 app"。但 mysql 容器启动成功,只代表容器进程起来了,不代表 mysqld 已经初始化完成、可以接受连接。app 容器可能仍然在 mysql 还没就绪时就尝试连接,然后报错退出。

标准解法是给被依赖的服务加上healthcheck,然后让depends_on带条件:

services: mysql: image: mysql:8.0 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-pmysql123"] interval: 5s timeout: 3s retries: 10 app: depends_on: mysql: condition: service_healthy

这样 Compose 会在 mysql 通过健康检查后才启动 app。这个细节值得反复强调:只要是用 Compose 编排互相依赖的服务,就要养成"healthcheck + condition"的习惯,否则depends_on就是一个看起来有用、实战中经常害你的假保证。

3. 实操:一个 Web 应用 + Redis + MySQL 的完整编排

理论讲完,直接进入实战。这次我们把开头那三条docker run重写成一份 Compose 文件,顺便把网络、卷、健康检查这些该有的东西都补齐。

3.1 目标与目录结构

假设项目是一个后端应用blog-app,依赖 Redis 和 MySQL。目录结构如下:

my-blog/ ├── docker-compose.yml └── .env

.env文件用来放密码这类敏感配置,避免直接写死在 YAML 里。Compose 会自动读取同目录下的.env,并用${变量名}的语法在 YAML 中引用。这样一份配置文件可以轻松适配多套环境:开发、测试、生产只靠换.env即可。

3.2 编写 docker-compose.yml

下面是一份可以直接落地的配置,我逐段解释关键点:

services: redis: image: redis:7-alpine container_name: blog-redis restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s timeout: 3s retries: 5 mysql: image: mysql:8.0 container_name: blog-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: blog volumes: - mysql-data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"] interval: 5s timeout: 3s retries: 10 app: build: . image: blog-app:v1 container_name: blog-app restart: unless-stopped ports: - "8080:8080" environment: REDIS_ADDR: redis:6379 REDIS_PASSWORD: ${REDIS_PASSWORD} MYSQL_ADDR: mysql:3306 MYSQL_USER: root MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD} depends_on: mysql: condition: service_healthy redis: condition: service_healthy volumes: redis-data: mysql-data:

Redis 这段用了command给 redis-server 加了访问密码。注意redis-cli -a ${REDIS_PASSWORD} ping里的密码来自.env,健康检查就是向 Redis 发一个 ping,能收到 PONG 就算健康。

MySQL 这段配置了 root 密码和初始数据库。MYSQL_DATABASE: blog会在容器首次初始化时自动创建名为 blog 的数据库。mysqladmin ping -h localhost -p${MYSQL_ROOT_PASSWORD}是判断 MySQL 是否就绪的标准做法,注意-p和密码之间没有空格。

app 服务的关键在environment里,REDIS_ADDR写的是redis:6379MYSQL_ADDR写的是mysql:3306,直接用服务名访问,而不是用localhost。因为在 Compose 网络里,localhost指的是容器自己,而不是其他服务。很多新手在这卡半天,原因就是没理解容器网络和宿主机网络的差异。

最后用depends_on加上condition: service_healthy,保证 app 只在 Redis 和 MySQL 都健康后才启动。这份配置已经把开头三条命令的所有功能覆盖了,还额外解决了顺序、就绪探测、密码集中管理的问题。

3.3 启动、验证与常用命令

my-blog目录下执行:

docker compose up -d

-d表示后台运行,等价于给每条docker run-d。首次执行会先拉取镜像,之后每次启动基本秒级完成。

验证状态用:

docker compose ps

这个命令会列出所有服务,显示容器名、状态和端口映射。配合健康检查,你会看到类似healthy的状态,一眼就知道三个容器是否正常。

查看日志用:

docker compose logs -f app

-f是 follow 模式,效果类似tail -f,可以实时盯着某个服务的输出。后面第 4 节还会详细展开日志这块。

需要进入容器排查时用:

docker compose exec app sh

这条命令等价于docker exec -it blog-app sh,但通过服务名定位容器,不用记容器名。

停止并删除所有容器用:

docker compose down

注意down默认只删除容器和默认网络,不会删除数据卷。这是好事,Redis 和 MySQL 的数据都还在。如果你想连数据一起清掉,才需要docker compose down -v,这个命令很危险,生产环境务必慎用。

3.4 网络与数据卷的细节

这份配置只把 app 的 8080 端口暴露给了宿主机,Redis 和 MySQL 都没有ports映射。这是有意为之:外部只能访问到应用,数据库和缓存待在 Compose 内部网络里,只有 app 能访问它们。相比三条docker run把 Redis、MySQL 端口全都暴露出去的写法,攻击面小得多。

数据持久化靠的是两个命名卷redis-datamysql-data。它们由 Compose 管理,存放路径在宿主机的 Docker 数据目录下。你不需要关心具体位置,只要知道:容器删了、重建了,数据还在。这是生产环境最基础的数据安全保证。

如果你需要把宿主机的配置文件传给容器,可以用 bind mount,写法是./config:/app/config,冒号左边是宿主机路径,右边是容器路径。开发和调试阶段 bind mount 很实用,改完代码直接生效,不用重新构建镜像。但生产环境我一般建议优先用命名卷,迁移和备份都更省心。

4. 日志、健康检查与真实排障经验

到了这一节,聊聊实战中最消耗时间的东西:日志排查和稳定性。多容器场景下,日志和健康检查不是可选项,而是必需品。

4.1 用 docker compose logs 管理多容器日志

单容器时代,docker logs <容器名>凑合能用。多容器时代,三个容器三个终端窗口,日志互相穿插,定位问题全靠肉眼。Compose 把这件事收敛成了一条命令:

docker compose logs -f --tail=100

这条命令把当前项目所有服务的日志实时打印出来,并且每种服务会自动标注前缀,比如app-1 |mysql-1 |,一眼就能看出日志属于谁。--tail=100表示只显示每个服务最后 100 行,避免刷屏。

只想看某个服务时:

docker compose logs -f app

日志里关键字太多,可以先过滤再找:

docker compose logs app | grep "ERROR"

这个组合在线上排障时几乎是标准操作。先 grep 出错误行,再根据时间戳回到对应日志上下文,定位速度比开三个窗口人工比对快得多。

这里给新手一个建议:如果docker compose logs app什么都看不到,先确认容器是不是根本没起来。执行docker compose ps看状态,如果是Exit 1,再用docker compose logs app看启动错误。很多"日志为空"的情况,本质是容器在启动阶段就崩了,根本没走到写日志那一步。

4.2 资源限制与健康检查的重要性

Compose 支持用deploy.resources.limits给容器设置资源上限,虽然它在单机docker compose up下的语义和在 Swarm 模式下略有差异,但内存、CPU 限制在单机也有效:

services: app: deploy: resources: limits: cpus: "0.5" memory: 512M

这个配置把 app 容器的 CPU 限制在半核、内存限制在 512MB。资源限制的意义在于:某个容器发生内存泄漏时,不会拖垮宿主机上的其他容器。我在实际项目里就遇到过 redis 缓存被大量冷数据打满、内存上涨后把整台机器拖挂的情况,后来给每个容器都加了上限,问题立刻隔离。

健康检查前面已经提过,这里再强调一下它是多容器编排的基石。容器存活不代表服务可用。MySQL 容器在启动、初始化、恢复数据期间,进程虽然活着,但还不能接受业务请求。如果不做健康检查,下游服务会在这段时间内反复连接失败。healthcheck的四个参数很好记:

  • test:探测命令,在容器内执行,退出码为 0 即健康。
  • interval:每隔多久探测一次。
  • timeout:单次探测超时时间。
  • retries:连续失败多少次判定为不健康。

配合restart: unless-stopped,容器在异常退出或探测失败后会自动重启,很多偶发问题可以被系统自动消化掉,不用人半夜爬起来处理。

4.3 常见问题速查表

整理了我在项目里最常遇到的几个问题,按"现象 → 原因 → 解法"列成表格,方便直接对号入座。

问题现象可能原因解决办法
app 连接 MySQL 报 Connection refusedMySQL 容器未就绪给 MySQL 加 healthcheck,depends_oncondition: service_healthy
app 连不上 Redis,地址用的是 localhost容器内 localhost 指向自身改成服务名,如redis:6379
端口被占用,容器启动失败宿主机端口已被其他进程占用修改ports映射,或用lsof -i :8080查占用
容器名冲突报 Conflict同名容器已存在docker rm -f <name>或去掉container_name
改了 YAML 配置但容器不更新Compose 未强制重建使用docker compose up -d --force-recreate
数据突然消失误执行了down -v卷已删除无法找回,务必慎用-v
容器总是自动重启,状态是 restarting启动即崩溃,或者健康检查持续失败docker compose logs,修复后手动restart
镜像版本不对,行为诡异用了latesttag 拉到新版镜像固定镜像 tag,如mysql:8.0.36

这张表基本覆盖了我接触过的多数 Compose 事故。其中"容器名冲突"和"漏了-v导致数据删除"是最高频的两类,前者影响心情,后者影响职业生涯,务必小心。

4.4 真实项目里攒下的避坑清单

除了速查表,还有一些经验是文档里不会专门写的,单独列出来供参考。

不要把所有服务的端口都映射到宿主机。容器之间通过 Compose 网络可以直接访问,端口只需要暴露给真正需要访问的外部调用方。数据库、缓存、消息队列这些内部服务,映射出去就是给自己添堵。

环境变量里的密码不要写死在 YAML 文件里。用.env文件配合${VAR}引用,同时把.env加进.gitignore。如果项目用 git 管理,一定不要把这个文件提交到仓库。真不小心提交了,赶紧轮换密码,别抱侥幸心理。

镜像 tag 尽量固定,不要用latestlatest的含义是"最新",今天能跑不代表三个月后还能跑。一旦上游发布新版本,你重新部署时可能拉到不兼容的镜像,服务就莫名挂了。固定 tag 虽然麻烦一点,但部署结果可复现,出了事能对版本负责。

写配置前先用docker compose config验证一下。这个命令会解析你的 YAML 和.env,把最终展开的配置打印出来。语法错误、变量没定义、端口格式不对,它都会警告你。在up之前先跑一下它,等于给配置做一次静态检查。

还有一个关于docker run --rm的使用习惯:我一般只在临时调试和跑一次性任务时用它,比如docker run --rm -v "$(pwd)":/data alpine ls /data。凡是需要长期运行的容器,一律走 Compose 并用restart策略管理,绝不混用。这两种模式混在一起,最容易出现"容器不见了但数据还在/数据没了"的认知混乱。

5. Compose 不只是玩具:真实项目的编排范本

Compose 的能力边界经常被低估。很多人以为它只是开发环境的小工具,实际上大量开源项目的生产级部署都直接使用 Compose,它撑起了单机部署的半壁江山。

5.1 很多开源项目都自带现成的 Compose 配置

与其自己从零琢磨怎么编排,不如多看看成熟项目的 Compose 文件,这是学习速度最快的路径。

以私有镜像仓库 Harbor 为例,官方提供的部署方式就是下载一个包含docker-compose.yml的安装包,改一下配置文件里的域名、密码和存储路径,然后docker compose up -d。一个完整的镜像仓库服务,内部包含了 Web 管理端、数据库、Redis、日志收集等多个组件,全都由一套 Compose 文件编排起来。你用docker compose ps能清楚看到每个组件的状态,用docker compose logs能看到统一日志。这就是多容器编排在真实生产环境中的典型形态——你不需要知道每个组件是怎么互相通信的,Compose 文件已经把这一切声明好了。

媒体服务器 Jellyfin 也一样,官方文档直接给出 Compose 配置,把媒体目录、配置目录、端口映射写得清清楚楚。你复制过去改一下路径就能跑起来。这类项目的好处是文件经过大量用户验证,你可以直接把它当作最佳实践模板来学习:别人是怎么处理卷挂载的、怎么配置环境的、怎么声明健康检查的。

还有一类值得关注的是近年流行的各类向量数据库服务,比如 Qdrant 的 standalone 模式,官方示例通常就是让你用docker run -d先跑一个单机实例做验证,但真正用于生产部署时,官方同样提供了 Compose 文件。你会发现一个规律:任何需要多个组件配合的服务,最终都会收敛到 Compose 或者更高层的编排工具上。单容器用docker run足够,多容器必须靠编排。

5.2 Compose 的边界:什么时候该上更重的平台

Compose 不是万能的,它的定位是单机多容器编排。当你的服务需要部署到多台机器、需要自动扩容、需要滚动更新而不中断服务时,Compose 就力不从心了。那时候该考虑的是 Kubernetes 或 Docker Swarm 这类容器编排平台。

我个人的判断标准很简单:项目容器数量在几个到十几个,跑在单台服务器或少量服务器上,用 Compose 就足够;如果应用需要根据流量自动伸缩、需要跨多台机器的服务发现和负载均衡,再考虑上 K8s 这种重型平台。强行在小项目里上 K8s,运维成本反而高过收益。

最后分享一个我自己的习惯:无论项目多小,只要容器数量超过一个,我一定会停下来先写一份docker-compose.yml,哪怕只是临时调试。原因很简单:手动docker run是"一次性思维",这次敲完下次还得重来;写成 Compose 文件才是"资产化",配置、网络、依赖关系全部固化成代码,能提交、能审查、能复现。等你三个月后再回来看这个项目,那份 compose 文件比任何聊天记录和 shell 脚本都靠谱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询