最近给朋友排查一个 Docker Compose 部署 MySQL 的问题,他敲下 docker compose up -d 之后,终端里直接弹出一句 cannot start docker compose application. reason: compose [start] exit status,整个服务瞬间趴窝。这种报错在 Docker 生态里很常见,但又特别抽象,因为它不会明说是哪行配置错了、哪个镜像没拉下来,还是宿主机端口被占了。很多刚开始接触 Docker Compose 的人,看到这串英文就慌了。
这篇文章是我从安装 Docker Compose、编写 compose.yaml、实战部署 MySQL,到排查各种 up -d 报错的完整记录。就算你现在连 docker compose 和 docker-compose 的区别都分不清,也可以照着走一遍。目标就一个:让多容器应用变成“一个 YAML 文件 + 一条命令”,能启动、能持久化、能排查。
1. 为什么我建议用 Compose 管理容器
先说个最常见的场景。你要跑一个 Web 应用:MySQL 存数据、Redis 做缓存、后端服务处理业务逻辑、前端用 Nginx 托管静态文件。如果全靠 docker run 去一个个启动容器,最要命的不是命令长,而是参数得靠脑子记。端口映射漏了、环境变量写错、卷路径不对、容器名称冲突,任何一个细节出问题,服务都起不来。等到容器多了,网络怎么互通、启动顺序怎么控制,纯命令行会让人抓狂。
Compose 解决的就是这个编排问题:把整个应用的多容器结构写进一个 compose.yaml(旧版本叫 docker-compose.yml),然后一条 docker compose up -d 拉起来。所有配置都变成代码,可以提交到 Git 仓库。同事把代码克隆下来,不需要问“你当时启动 MySQL 用的什么参数”,直接看 YAML 文件就能复现整套环境。
我自己的感受是,Compose 特别适合三类场景。第一,本地开发:一个项目要依赖 MySQL、Redis、RabbitMQ 这种中间件,用 Compose 拉起来,比在本机一个个安装服务省心太多,版本也不会和系统环境冲突。第二,CI/CD 里的测试环境:跑完就 docker compose down,环境立刻干净,不污染宿主机。第三,中小型项目的单机部署:一台服务器上用 Compose 管着几个容器,日志、网络、持久化都有明确约定,维护成本比直接裸跑容器低很多。
这里也得把边界说清楚。大规模集群调度、自动伸缩、跨节点容灾,那是 Kubernetes 的领地,Compose 不该碰。我见过有团队把 Compose 当 K8s 用,一台机器强行塞十几个服务,数据目录和网络全搅在一起,最后维护成本飙升。这不是工具不行,是选错了工具。工具选型这件事,清楚边界比追求功能多更重要。
1.1 Compose V2 和 V1 到底该用哪个
现在网上大量旧教程还在教 docker-compose(带横杠),那是 V1 时代的命令。V1 是 Python 写的独立程序,后来官方用 Go 语言重写成 V2,并把它作为 Docker CLI 的一个子命令集成进去,命令变成了 docker compose(中间是空格)。V2 启动速度更快,对新语法支持更完整,而且官方已经停止 V1 的主线维护。新项目不用犹豫,直接用 V2。
怎么判断自己系统里是哪个版本?很简单:执行 docker compose version 能输出版本号,说明已经在用 V2;如果只有 docker-compose version 有效,那就是 V1。我建议别两个命令混着用。同一个项目今天用 docker-compose up,明天用 docker compose up,Compose 默认的项目名、容器前缀都可能不一样,很容易把容器状态搞乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:安装和版本选择
写配置之前,先把环境弄利索。很多人卡在第一步其实就是 Docker Compose 没装对,尤其是 Linux 服务器上,不同发行版安装方式差别不小。下面按场景拆开说。
2.1 先把 Docker 装好
Compose 不是一个独立运行的软件,它依赖 Docker 守护进程。所以前提是 Docker Engine 已经正常工作。在 Linux 上可以用 docker run --rm hello-world 验证。如果你刚装完 Docker,跑这个命令能正常输出 Hello from Docker,说明守护进程没问题。macOS 和 Windows 上直接用 Docker Desktop,它已经内置了 Compose V2 插件,不需要额外安装任何东西。
2.2 Linux 下三种常见安装方式
Linux 的安装方式主要看发行版和网络条件。我给三种我实测过的方式,按推荐程度排序。
| 方式 | 适用场景 | 示例命令 | 备注 |
|---|---|---|---|
| 发行版插件包 | Debian/Ubuntu/Fedora 等主流发行版 | sudo apt install docker-compose-plugin |
跟随系统包管理器更新,最省事 |
| 独立二进制 | 老旧发行版或 CentOS | 从 GitHub Release 下载 | 手动维护版本,适合长期固定版本 |
| Docker Desktop | macOS / Windows | 安装 Docker Desktop | 自带插件,无需任何额外配置 |
第一种方式在 Ubuntu 上最常用。装好 Docker Engine 之后执行 sudo apt-get update && sudo apt-get install docker-compose-plugin,装完用 docker compose version 验证。有些新的发行版把包名改成了 docker-compose-v2,如果你搜不到 docker-compose-plugin,可以试这个包名。
独立二进制方式的命令如下:
bash复制sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose version
注意下载地址里的版本号要替换成实际存在的版本,建议去 GitHub Releases 页面看最新的稳定版本号。国内服务器如果下载慢,可以找发行版的插件包,这是我在实际项目里最省时间的选择。
2.3 别忽略版本兼容性
Compose 也会随着版本演进出现语法差异。比如新版支持 depends_on 里的 condition: service_healthy,旧版本就不认识;再比如顶层 version 字段在新版本里已经标记为 deprecated,官方建议直接不写。我通常在 compose.yaml 里不写 version,因为 V2 会忽略它,写多了反而容易被旧教程误导。
验证版本是否可用的最快方法是执行:
bash复制docker compose version
如果输出了类似 Docker Compose version v2.24.0 的信息,就可以放心进入下一节了。
3. compose.yaml 是怎么组织的
安装只是热身,真正的核心不是命令,而是那个 YAML 文件。很多人以为 Compose 不过是把 docker run 参数搬进文件,这个理解太浅了。它其实是一个带有依赖关系和应用边界的描述文件,我习惯先在心里把它分成三层。
3.1 三个顶层块:services、networks、volumes
一份合格的 compose.yaml 至少会用到三个顶层关键字。
services 定义要启动的容器服务,每个服务可以有镜像、构建上下文、端口、环境变量、卷等。可以把它理解成“一台容器的完整描述”。
networks 定义服务之间的网络拓扑。Compose 默认会创建一个桥接网络,所有服务都在同一个网络里,互相可以通过服务名访问。如果你不写这个块,Compose 也会自动创建默认网络,但我建议显式写出来,因为后面加服务、加隔离策略时一切尽在掌握。
volumes 定义数据卷。数据卷是容器持久化的关键。容器是临时性的,一删就没了,但卷里的数据会保留。MySQL 这类有状态服务,必须把数据目录挂到卷或本机目录上,否则 docker compose down 之后数据就丢了。
这样拆分之后,写文件的心态会完全不同:你不再是在抄参数,而是在描述“谁跑什么服务、能访问谁、数据放哪里”。
3.2 services 里的高频指令
yaml复制services:
mysql:
image: mysql:8.0
container_name: my-mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root123456
volumes:
- mysql_data:/var/lib/mysql
networks:
- app-net
image 就是镜像名,container_name 是自定义容器名称,不写的话 Compose 会按“项目名_服务名”自动生成。restart 设置重启策略,unless-stopped 意味着除非手动 stop,否则容器退出会自动重启,生产环境我一般都用这个策略而不是默认的 no。
ports 是端口映射,规则是“宿主机端口:容器端口”。这里有个坑:一定要加引号。YAML 解析器会把 3306:3306 当成时间比例,不加引号在部分情况下会出现类型转换问题,实测下来加引号最稳妥。
environment 是环境变量,不同镜像有各自规定的关键变量。MySQL 镜像读取 MYSQL_ROOT_PASSWORD,PostgreSQL 镜像读取 POSTGRES_PASSWORD,Redis 则一般不设密码。这属于镜像自身的约定,动手之前先去 Docker Hub 看镜像说明。
3.3 .env 文件把敏感信息拆出去
生产中我建议把密码这类信息放到 .env 文件里,然后在 YAML 里用 ${变量名} 引用。注意 .env 文件和 compose.yaml 放在同一目录,否则用 --env-file 指定路径。比如在 .env 里写:
code复制MYSQL_ROOT_PASSWORD=root123456
MYSQL_DATABASE=shop
YAML 里引用:
yaml复制 mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
好处显而易见:一套 compose.yaml 可以通过切换不同的 .env 文件适配开发、测试、生产环境,敏感信息也不会直接进 Git。记得在 .gitignore 里把 .env 忽略掉。
3.4 网络和依赖关系
服务之间通过服务名互相访问,这是 Compose 的默认行为。比如 PHP 后端要连接 MySQL,数据库地址就写 mysql,不用写 IP。这个设计极大简化了配置,也解释了为什么部署 MySQL 时,“127.0.0.1”在容器里连不上——“127.0.0.1”指的是容器自身,而不是宿主机。跨容器访问要用服务名或自定义网络别名。
depends_on 控制启动顺序,但要小心:默认的 depends_on 只保证“先启动”,不保证“已经可用”。MySQL 容器要完成初始化才能对外提供连接,后端服务早一步起来,照样连不上。所以建议配合 healthcheck 使用,让后端等待 MySQL 健康后再启动。下面实战部分会写完整配置。
4. 实战:用 Compose 部署 MySQL 完整流程
理论讲完,直接上一套我亲测可用的配置。这次的目标是:部署一个 MySQL 8.0,数据持久化,自动建一个业务库和专用账号,指定 utf8mb4 字符集,再加一个 phpMyAdmin 方便调试。这套组合拳在本地开发场景非常常见。
4.1 需求先理清楚再动手
动手写 YAML 之前,先花三十秒把需求列出来:
- 数据库版本用 MySQL 8.0,因为 utf8mb4 默认支持比较好
- 数据目录必须持久化,也就是容器删了、服务重启了,数据不能丢
- 需要初始化和业务表,不能每次启动都手工执行 SQL
- 端口映射要暴露到宿主机 3306,方便本地客户端连接
- 顺便加一个可视化工具,开发阶段连命令行都省了
需求清晰之后写文件就顺理成章。别上来就抄模板,抄完不知道为什么这样写,遇到报错就完全不会排。
4.2 完整 compose.yaml 长这样
yaml复制services:
mysql:
image: mysql:8.0
container_name: my-mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: shop
MYSQL_USER: shop_user
MYSQL_PASSWORD: shop_pass_2024
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
- ./mysql-init:/docker-entrypoint-initdb.d
networks:
- app-net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot123456"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
phpmyadmin:
image: phpmyadmin/phpmyadmin:latest
container_name: my-phpmyadmin
restart: unless-stopped
ports:
- "8080:80"
environment:
PMA_HOST: mysql
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: root123456
depends_on:
mysql:
condition: service_healthy
networks:
- app-net
networks:
app-net:
driver: bridge
volumes:
mysql_data:
重点关注几个点。
MYSQL_ROOT_PASSWORD 是 root 密码,MYSQL_DATABASE 会自动创建一个数据库,MYSQL_USER 和 MYSQL_PASSWORD 会创建一个专用账号并授予该库的权限。这套机制是 MySQL 官方镜像提供的初始化逻辑,省去了手动执行建库建号语句。
command 字段用来追加 MySQL 启动参数,这里指定了字符集 utf8mb4 和排序规则 utf8mb4_unicode_ci。中文字符存进去不乱码,靠的就是这个。注意 MySQL 8.0 默认字符集已经是 utf8mb4,但显式写出来更稳妥。
./mysql-init:/docker-entrypoint-initdb.d 是初始化脚本目录。镜像启动时会按文件名字母顺序执行这个目录下的 .sql、.sh 文件。首次初始化时用,之后不会再执行。这个目录我在实战里用得非常多,建表、初始数据、测试数据全丢进去,一套环境拉起来数据就齐了。
healthcheck 是健康检查,用 mysqladmin ping 探测 MySQL 是否真的可用。后面 phpMyAdmin 的 depends_on 指定 condition: service_healthy,意思是等 MySQL 健康了再启动,避免 phpMyAdmin 先起来却连不上数据库。
4.3 启动、检查、连库
在 compose.yaml 所在目录执行:
bash复制docker compose up -d
第一次会拉取 mysql 8.0 和 phpmyadmin 两个镜像,耐心等一会儿。启动后看状态:
bash复制docker compose ps -a
正常情况下 STATUS 那列应该显示 Up (healthy) 或至少 Up。接下来验证数据库是否真正可用:
bash复制docker exec my-mysql mysql -uroot -proot123456 -e "select version();"
能输出版本号,说明数据库服务正常。再用本机的 MySQL 客户端或者直接执行:
bash复制mysql -h127.0.0.1 -P3306 -ushop_user -pshop_pass_2024 shop
能连进去说明端口映射、账号权限都正常。打开浏览器访问 http://localhost:8080,输入 root/root123456 就能看到 phpMyAdmin 界面,开发调试会方便不少。
4.4 初始化脚本内容示例
假设 mysql-init 目录下放一个 01_init.sql:
sql复制CREATE TABLE IF NOT EXISTS users (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO users (name) VALUES ('test_user');
首次 docker compose up 时这个脚本会自动执行。下次如果改了脚本内容想重新初始化,得先把数据卷删掉(docker compose down -v)再启动,因为初始化只发生在数据目录为空时。这个小细节很多人踩坑,我先写在这里。
5. 启动报错排查清单
Compose 用多了,总会碰到报错。这里把我在实际项目里遇到的高频问题和排错顺序整理出来。
5.1 先看懂那串英文报错
回到开头朋友遇到的报错:
code复制cannot start docker compose application. reason: compose [start] exit status
这句话的字面意思是 Compose 试图启动应用,但某个服务启动失败,退出了。exit status 后面没接具体退出码,所以它更像一个“汇总错误”。遇到它的第一反应不应该是盯着这句话反复看,而是马上看细节。具体怎么做,我放在 5.3 的三板斧方法论里,这里先记住一个原则:Compose 报错信息往往不是根本原因,要顺着它找到具体服务,再看服务的日志。
5.2 docker compose up -d 报错典型原因
端口占用是最常见的。报错一般长这样:
code复制Error response from daemon: driver failed programming external connectivity on endpoint my-mysql
bind: address already in use
意思是 3306 端口已经被宿主机上的进程占了。排查命令:
bash复制ss -lntp | grep 3306
找到占用进程,要么停掉,要么把 compose.yaml 里的宿主机端口改成别的,比如 "3307:3306"。注意冒号左侧是宿主机端口,右侧是容器端口,需要清楚哪边能改。
其次是镜像拉取失败。报错里一般会出现 failed to resolve reference 或者 connection refused。可以手动 docker pull mysql:8.0 先验证网络和镜像库是否可访问。拉不下来就检查 DNS、镜像加速器配置。这里我不仔细展开,因为不同环境的镜像加速配置差异比较大。
再一个是数据卷权限问题。MySQL 容器启动后立刻退出,日志里出现:
code复制chown: changing ownership of '/var/lib/mysql/': Permission denied
这种情况多发生在宿主机开启了 SELinux,或者目录以 root 身份创建但容器进程用户权限不够。我通常的处理方式是把数据卷挂到带正确权限的目录,或者关闭 SELinux 对 docker volume 的干涉。在学校环境或者测试机上也可以直接给目录开权限,但生产环境还是要按照安全规范来。
5.3 排查问题三板斧:config、ps、logs
遇到任何 Compose 启动问题,我都是按固定顺序排查,效率最高,也最不容易漏。
第一板斧:docker compose config。它会读取 compose.yaml、.env 和所有 override 文件,把最终解析结果打印出来。很多语法错误、变量引用错误在这一步就能暴露。比如环境变量没定义,config 里会显示空值,立刻能定位到是 .env 拼写错了还是文件位置不对。
第二板斧:docker compose ps -a。这里能看到每个服务的容器状态和退出原因。如果某个服务状态是 Exit 1,那问题基本锁定在这个服务上。
第三板斧:docker compose logs --tail=200 服务名。只看自己关心的那部分日志,别上来就刷全量,容易淹没关键信息。MySQL 的日志如果出现 Access denied 说明账号密码或认证插件有问题,如果是 Can't start server 说明数据目录损坏或权限不对。
这套三板斧我基本天天用,比直接去看 Docker 守护进程日志快得多。
5.4 常见问题速查表
| 现象 | 原因 | 处理方法 |
|---|---|---|
compose [start] exit status |
某个服务启动失败,被 dependent 服务卡住 | 按三板斧逐层定位,重点看退出服务的日志 |
bind: address already in use |
宿主机端口被占用 | ss -lntp 查占用,换端口或停进程 |
Permission denied 后退出 |
数据卷权限或 SELinux 干涉 | 调整目录权限,或挂载带正确 owner 的目录 |
failed to resolve reference |
镜像不存在或网络不通 | 先 docker pull 验证,换镜像源 |
| depends_on 提示 condition 不支持 | Compose 版本过旧 | 升级到 V2 版本 |
| 容器能起但外部连不上 | 端口映射未生效 | docker compose ps 看端口绑定情况,检查防火墙 |
MYSQL_ROOT_HOST 问题导致远程无法连接 |
MySQL root 默认只允许 localhost | compose 里加 MYSQL_ROOT_HOST: '%' |
这些坑我几乎都踩过一遍,写出来是希望大家少走弯路。尤其是初次部署 MySQL 时遇到“容器起来了,但 Navicat 连不上”的情况,十有八九是没设 MYSQL_ROOT_HOST,root 默认只允许容器内的 localhost 连接。
6. 日常运维和升级改造经验
部署不是终点,日常维护才是大头。
6.1 配置变更后怎么应用
改了 compose.yaml 之后,执行 docker compose up -d 即可,Compose 会判断哪些服务需要重建,增量应用变更。如果需要强制重新创建容器,加 --force-recreate。注意 docker compose restart 只是重启现有容器,不会重新读取镜像和配置的变更,这是一个高频误区。
更安全的方式是先把镜像更新拉到本地,再应用配置:
bash复制docker compose pull
docker compose up -d
这样可以保证重启时用的是最新镜像,避免出现“配置变了但镜像还是旧的”这种奇怪状态。
6.2 数据备份和卷清理
MySQL 的数据虽然写在卷里,但这个卷本身绑定在宿主机上,换服务器、跑 docker 清理时很容易误删。我习惯定期在容器内执行 mysqldump 备份:
bash复制docker exec my-mysql sh -c 'exec mysqldump -uroot -proot123456 --all-databases' > backup.sql
注意 -p 和密码之间不要加空格,这是 mysqldump 的老规矩。备份文件放到宿主机,和 volume 解耦,就算容器卷被删了,也能恢复数据。
清理时务必搞清楚命令区别:docker compose down 只删除容器和网络,保留卷;docker compose down -v 会把卷一起删掉,数据就没了。生产环境手滑执行 down -v 的后果很严重,我的建议是在命令里永远不要轻易加 -v,除非你明确知道这份数据已经不需要了。
6.3 多环境配置和扩展
一套 compose.yaml 想同时适配开发、测试、生产,可以用 override 文件机制。默认文件叫 compose.yaml,开发环境可以加一个 compose.dev.yaml,生产环境加 compose.prod.yaml,然后按需指定:
bash复制docker compose -f compose.yaml -f compose.prod.yaml up -d
后一个文件里的同名配置会覆盖前一个文件。比如开发环境端口映射到 3306,生产环境端口映射到 13306,不需要复制两份完整文件,只需要在 override 文件里写差异部分。
6.4 我的几点实在建议
最后聊几句实在话。Compose 的入门门槛其实很低,但它是一门“经验学科”,很多问题只有实际部署过一遍才知道。我个人体会比较深的有三点:第一,永远把数据持久化放在第一位,有状态服务删卷等于删数据,思想上要重视;第二,不要贪多求全,一开始就用几十个服务来练手,反而容易在编排上卡住,先用 MySQL + Redis 这样的小组合上手;第三,凡是涉及容器的操作,多留个心眼,像 down -v、system prune -a 这类毁灭性命令,敲下去之前先备份。
把这些摸熟之后,你会发现 Docker Compose 其实是一个性价比极高的工具,它把你从“记参数、敲命令”的泥潭里拉出来,让你能更专注地写业务代码。后面如果再遇到 cannot start docker compose application 这种报错,你已经知道该怎么顺着日志找到真正的原因了。我自己在项目中体会最深的就是:报错别慌,先看服务状态,再看日志,大部分问题都是配置或权限层面的,三板斧过一遍,思路自然就通了。
