1. 项目概览:为什么需要 Docker Compose
先聊一个很多新手都会经历的痛点:第一次玩 Docker 时,用 docker run 拉个 Nginx、跑个 MySQL 感觉还挺爽,一条命令加几个参数就能把容器拉起来。但当你真正开始做一个稍微完整点的项目,比如一个 Web 应用要配数据库、配缓存、配队列,再挂几个辅助服务,问题马上就来了——你需要在终端里反复敲十几个 docker run,每个命令里都要写端口映射、数据卷挂载、环境变量、网络配置。敲错一个参数,容器起不来,排查半天还不知道错在哪。这还只是部署阶段,等你要把项目交给别人、或者换一台机器重新部署,那种“一个服务一条命令”的原始方式,基本等于灾难。
Docker Compose 解决的就是这个痛点。简单说,它是一个多容器编排工具,用一套声明式的配置文件(默认叫 docker-compose.yml)把整个应用的服务栈一次性定义清楚,然后用一条 docker-compose up -d 把全部服务拉起来,再一条 docker-compose down 全部停掉。你不用再关心哪个容器先启动、哪个端口映射到哪、哪个网络要共享,这些都在 YAML 文件里写明白。
这一篇我会先讲 Docker Compose 的核心概念和安装流程,然后带你把一个真实的 MySQL 服务用 Compose 跑起来,包括配置数据持久化、健康检查、初始化脚本等完整生产级细节。我会穿插大量的实际踩坑记录,很多操作细节都是我在真实部署中试过、反复验证过的流程。这篇文章适合刚学会 Docker 基本操作、想在本地模拟真实部署环境,或者准备把应用做容器化交付的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与核心设计思路拆解
2.1 Docker Compose 到底在编排什么
在深入安装和实战之前,先把 Compose 的模型搞清楚。很多人上来就用,稀里糊涂把服务跑起来了,但出了问题就懵,因为不清楚 Compose 在背后到底干了什么。
Docker Compose 本质上做三件事:定义、创建、编排。
“定义”是指用 YAML 文件描述你整个应用的服务组成。YAML 里每个 service(服务)对应一个容器或一组相同配置的容器副本。你只需要告诉 Compose:这个服务用哪个镜像、开哪些端口、挂哪些目录、设哪些环境变量、依赖哪些其他服务。Compose 会把这些声明翻译成一个个独立容器,然后在同一个自定义网络中把它们串联起来。
“创建”是指 Compose 基于定义好的服务列表,自动完成镜像拉取、网络创建、数据卷创建、容器生成等动作。这里有个很关键的细节:所有通过 Compose 启动的服务,默认会落在同一个项目中,项目名默认取当前目录名。同一个项目下的容器会共享一个专用网络,于是容器之间可以直接用服务名互相访问,不需要再靠 IP 地址。
“编排”是 Compose 最有价值的部分:它管理整个服务生命周期的启动顺序、依赖关系,以及后续的扩缩容、更新、日志聚合。比如你的 Web 应用依赖数据库先启动,你可以用 depends_on 声明依赖。虽然 Compose 对“依赖”的处理不如 Kubernetes 那么精密(K8s 会等待 Pod 内的进程就绪,而 Compose 早期版本只保证启动顺序),但在大多数开发环境和中小型部署里已经足够用。
用一个生活化类比来解释:Docker 命令像单件快递,一次只能收发一个包裹,每个包裹怎么处理全靠你口头交代(命令行参数);Docker Compose 则像一张快递配送总单,你要寄什么、寄到哪、哪个先送、哪些必须同车,全部列在一张单子上,一个按钮全部搞定。
2.2 Compose 文件的核心构成
一个标准的 docker-compose.yml 文件通常包含四个顶级配置块:version、services、networks、volumes。
version 是 Compose 文件格式版本号。这里我必须多说一句:很多人还在写 version: "3.8" 之类的版本声明,但如果你用的是 Docker Compose V2(也就是 docker compose 命令,注意中间有空格),这个字段其实可以省略了。V2 版本已经对旧格式做了兼容,写不写都不影响启动。
services 是文件的核心。每个服务以服务名开头,下面挂配置项:
image:指定镜像名和标签,是镜像:标签的格式;container_name:手动指定容器名,不写则由 Compose 自动生成;ports:端口映射,格式是宿主机端口:容器端口;volumes:数据卷挂载,格式是宿主机路径:容器路径;environment或env_file:环境变量,前者直接写在 YAML 里,后者引用外部文件;depends_on:服务启动依赖;restart:重启策略,常见的值有no、always、on-failure、unless-stopped;networks:指定服务加入哪个自定义网络;healthcheck:健康检查,告诉 Compose 什么样的状态算“服务就绪”。
networks 和 volumes 是声明顶级对象,然后在具体服务里引用。如果不在顶层声明,Compose 会为每个服务创建一个匿名网络或匿名卷,虽然能跑,但多个服务之间想互相通信就得靠服务发现,管理起来会很混乱。所以正规做法是顶层显式声明。
2.3 为什么 Compose 比一堆 docker run 强
我知道肯定有人会说:“不就是把 docker run 参数翻译成 YAML 吗?我自己写个 shell 脚本也能实现。”这话对了一半。如果你只有两三个容器,而且只在固定的一台机器上部署,shell 脚本确实差不多够用。但 Compose 的边界在于它不是一个“固定流程”,而是基础设施即代码的一种具体落地形态。
第一个优势是可复现性。用 docker run 部署的环境,别人很难百分百还原——你当时用了什么参数、网络配置怎么连的、卷挂在哪个目录,全靠问。而 Compose 文件把这些全部固定下来,放到任何一台装有 Docker 的机器上,一条命令就能得到一模一样的服务环境。这对团队协作、交接、CI/CD 都有决定性意义。
第二个优势是统一生命周期管理。你不需要记住“先停 web 再停 db 还是反过来”,也不需要分别去查每个容器的 ID。up、down、logs、ps、restart,全部按项目维度操作,管理多容器就像管理单个应用一样顺手。
第三个优势是可编程、可组合。Compose 支持 extends 继承、支持 .env 环境变量插值、支持 docker-compose.override.yml 覆盖配置。意味着同一个基础 Compose 文件,开发环境、测试环境、生产环境通过不同 override 文件就能复用,不需要维护三套完全独立的脚本。
3. 安装与基础验证:从零跑通 Docker Compose
3.1 安装之前的准备工作
先确认两件事:你的机器上已经安装 Docker 引擎(不是 Docker Desktop 那种全家桶也行,只要有 docker 命令行能用),以及你的用户对 Docker 有操作权限。
Linux 环境下最直白的方法是在终端执行 docker info 或 docker version,看能不能正常输出。出现 permission denied 的话,说明当前用户不在 docker 用户组里,可以执行 sudo usermod -aG docker 你的用户名 然后重新登录。这一步别偷懒,因为后面所有 compose 命令你都会希望直接以普通用户执行,每次加 sudo 不仅麻烦,还容易导致文件权限问题——比如挂载的目录明明在,容器却写不进去。
Windows 和 Mac 用户建议直接安装 Docker Desktop,它的安装包里已经捆绑了 Compose V2 插件,装完即用。Linux 用户的选择比较多:可以用发行版包管理器安装、可以用官方脚本安装、也可以手动把二进制文件放到 ~/.docker/cli-plugins 目录下。我挨个拆开讲。
3.2 Linux 环境下的三种安装方式
方式一:官方二进制安装。先到 GitHub 的 docker/compose releases 页面,找到对应你系统架构的最新版本,用 wget 或 curl 下载,然后放到 /usr/local/lib/docker/cli-plugins/ 或你自己的用户目录 ~/.docker/cli-plugins/,给它加上执行权限。放用户目录的好处是不需要 root 权限,但只有当前用户能用;放系统目录则所有用户都能用。
bash复制# 以下命令以 Linux x86_64 为例
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
这里要特别说明一个历史遗留问题:老版本的 Compose 安装后,终端命令是 docker-compose(带横杠),而新版本作为 Docker CLI 插件安装后,命令是 docker compose(中间有空格)。两种命令格式都在大量教程里出现过,你很可能两个都用过,甚至混着用。我在实际操作中遇到的情况是:同一台机器上可能同时存在旧的 docker-compose 独立命令和新的 docker compose 插件,它们版本还不一样。建议统一使用新式命令 docker compose,这是当前官方主推的形态。
方式二:包管理器安装。Ubuntu 等基于 apt 的系统,可以直接 sudo apt install docker-compose-plugin,前提是你的 Docker 源已经配置好。CentOS 类似,用 sudo yum install docker-compose-plugin。这种方式最简单,但版本可能不是最新的。如果对版本没特殊要求,图省事可以用。
方式三:Pip 安装。pip install docker-compose 也能装,但我不推荐,因为它的更新常年滞后,且容易和系统 Python 环境产生冲突,你会在“为什么我装的是新版但版本号显示很旧”这个问题上浪费很多时间。
无论哪种方式,装完以后最重要的验证命令是:
bash复制docker compose version
期望的输出类似 Docker Compose version v2.x.x。如果提示 docker: 'compose' is not a docker command,说明插件没有安装到位或者路径不对,按我的经验八成是插件目录放错了位置。
3.3 Windows 和 macOS 环境的安装说明
Windows 和 macOS 用户装上 Docker Desktop 后,Compose 一般自带了。需要注意的一点是 Docker Desktop 的 Compose 版本会跟着软件本体一起升级,不像 Linux 需要手动管理。检查方法还是 docker compose version。
Windows 用户在终端里执行 Docker 命令时,如果发现 docker: command not found,先确认 Docker Desktop 有没有启动,再确认你的终端是 PowerShell 还是 CMD——PowerShell 对命令解析的规则和 Linux shell 有不少差异,建议装完 Docker Desktop 后直接用 PowerShell,在“设置-Resources-WSL 2 后端”里打开 WSL 2 支持。在 WSL 2 的 Linux 发行版里装 docker 和 compose,和纯 Linux 环境流程一样,但要注意 WSL 里的 Docker 和 Windows 端的 Docker Desktop 是两个不同的环境,同样拉取一个镜像,可能一份在 vhdx 虚拟磁盘里,一份在你的 Windows 磁盘里,别在两边绕来绕去把自己绕晕。
macOS 的 Apple Silicon 芯片(M1、M2 等)用户要留意镜像的架构问题。在 Compose 文件里如果没指定 platform,Docker 默认拉取的是适配当前平台架构的镜像;但有些镜像只发布了 amd64 版本,在 arm64 上跑会有性能损失或兼容问题。解决办法是在服务配置里加 platform: linux/x86_64,或者在拉取镜像时手动指定 --platform 参数。注意这会触发模拟执行,性能会打折,但至少能跑起来。
4. 核心实战:用 Docker Compose 部署一套 MySQL 服务
4.1 实战目标与目录规划
现在进入重头戏。我用一个最常见的场景——部署 MySQL 数据库——来完整演示 Compose 的实战流程。选择 MySQL 有一个原因:它在热词里出现了,很多人在配置 MySQL 容器时遇到的问题很有代表性,比如密码配置不生效、数据卷权限报错、初始化脚本不执行等,足以展开讲清楚关键概念。
我的习惯是先建一个项目目录,所有和这个服务栈有关的文件都放在里面,这样 Compose 默认的项目名就是目录名,后续管理起来非常清晰。实战中我会创建一个 mysql-lab 目录作为演示:
code复制mysql-lab/
├── docker-compose.yml # 核心编排文件
├── .env # 环境变量文件(可选)
├── init/
│ └── init.sql # 首次启动时自动执行的 SQL 脚本
└── data/ # 数据目录,用于挂载 MySQL 数据文件
为什么建议把挂载目录和 Compose 文件放在同一个项目目录下?因为如果你用的是相对路径挂载(比如 ./data:/var/lib/mysql),Compose 是相对 docker-compose.yml 所在目录解析的。把目录集中管理,后面做备份、迁移、清理都很顺手。
4.2 编写 docker-compose.yml:逐步解析
先给出完整的 docker-compose.yml,然后我会拆解每一部分的设计意图。这是我在实际项目中经过多次迭代后形成的模板:
yaml复制services:
mysql:
image: mysql:8.0
container_name: mysql-lab
restart: unless-stopped
ports:
- "3306:3306"
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-root123456}"
MYSQL_DATABASE: "${MYSQL_DATABASE:-app_db}"
MYSQL_USER: "${MYSQL_USER:-app_user}"
MYSQL_PASSWORD: "${MYSQL_PASSWORD:-app_pass_123}"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
volumes:
- ./data:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- app-network
networks:
app-network:
driver: bridge
环境变量部分是多数人容易踩坑的地方。MySQL 官方镜像里有一组以 MYSQL_ 开头的环境变量,镜像在第一次初始化时会读取它们来创建数据库和用户。注意关键词是“第一次初始化”——如果数据目录 /var/lib/mysql 已经有了旧数据,镜像不会重新执行初始化逻辑,此时修改密码或新增数据库都不生效。这就是很多人改了 MYSQL_ROOT_PASSWORD 后重启容器发现密码没变的原因。
我在这里用了 ${MYSQL_ROOT_PASSWORD:-root123456} 这种插值写法,意思是:优先读取宿主机环境变量或 .env 文件里的同名变量,如果没定义就用默认值。这样做的实际好处是:同一个 Compose 文件,开发环境可以不设任何变量直接起,密码走默认值;生产环境则通过 .env 文件注入高强度密码。密码不会硬编码到 docker-compose.yml 里,提交到 Git 仓库也不用担心泄露。
command 参数的作用是对默认配置做追加。MySQL 8.0 官方镜像默认字符集不是 utf8mb4,如果你建表时不显式指定,遇到 emoji 或生僻字就会报错或出现乱码。所以我在启动命令里强行指定字符集和排序规则。mysql_native_password 这一项是因为一些老客户端不支持 MySQL 8 的默认认证插件 caching_sha2_password,指定成老的认证方式可以兼容旧版客户端。如果你确定所有客户端都够新,可以去掉这一行。
数据卷挂载是这个文件里最重要的部分。./data:/var/lib/mysql 把宿主机当前目录下的 data 目录挂到容器里 MySQL 的数据目录。这样一来,容器删了、重建了,数据文件还在宿主机上。./init:/docker-entrypoint-initdb.d 挂载的是初始化脚本目录,镜像首次启动时会按文件名顺序执行这个目录下的 .sql 或 .sh 文件,非常适合在数据库初始化时建表、建索引、导入基础数据。我建议 init.sql 里只放幂等操作,因为它在“数据目录为空”的首次启动时才会执行,如果你后面重新挂载了一个空目录,脚本会再次执行,非幂等语句很容易造成冲突或重复数据。
我实际在 init.sql 里通常会写这样的东西:
sql复制CREATE DATABASE IF NOT EXISTS app_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE app_db;
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(120) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
不要小看这个 init 目录的用法。真实项目中,你可能会把一个几百兆的 SQL 备份放进去,让数据库首次启动时自动恢复。这个能力在搭建本地开发环境时极其好用——兄弟团队拉下仓库,一条 docker compose up -d,不仅数据库起来了,表结构和种子数据都给你备好了。
healthcheck 健康检查是很多新手容易忽略但生产级部署里必须有的配置。MySQL 容器本身有一个 mysqladmin ping 命令,可以用来检测数据库是否响应。我在 test 里加了这个命令,并且把周期设为 10 秒一次。注意:对于 MySQL 8.0 官方镜像,mysqladmin ping 即使密码错了也可能返回成功状态(因为 MySQL 8 中 ping 走的是 socket 握手,不校验账号密码),所以更可靠的做法是执行一个真实查询,比如:
yaml复制healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD && mysql -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD -e 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
注意 $$ 的写法——在 Compose 文件里写 $ 会被识别为插值变量,想传给容器内的 shell 就必须用 $$ 转义。这个细节如果不注意,你会在日志里看到变量被悄悄替换成了空字符串。
有了 healthcheck 后,后续如果你在 Compose 里再挂一个 Web 服务,可以用 depends_on 配合 condition: service_healthy 来确保 Web 服务只在数据库真正就绪后才启动,而不是仅仅等容器创建完成。这是开发环境模拟编排行为的重要技巧。
4.3 启动验证:up、ps、logs 全流程
配置文件都写好后,在项目目录下执行:
bash复制docker compose config
这条命令会读取 docker-compose.yml,把最终生效的配置渲染出来给你看。它会做格式校验,如果 YAML 写错了层级或缩进,这里就会直接报错。我每次改完 Compose 文件都会先跑一下这条,比直接 up 省心太多。
确认配置无误后,启动服务:
bash复制docker compose up -d
-d 是 detached 模式,意思是让服务在后台运行,终端不会被日志刷屏。首次执行时 Compose 会先拉取镜像,这个过程取决于网络状况,MySQL 8.0 镜像大约 600MB,等一会儿是正常的。看到 Started 或容器状态变成 Up 后,我用两条命令验证是否真的成功了:
bash复制docker compose ps
docker compose logs mysql
docker compose ps 输出里每行是一个服务,重点看状态列——正常的应该是 Up ... (healthy)。如果是有 healthcheck 的配置,需要等大概 30~60 秒让健康检查跑几轮才能看到 healthy 字样。logs 命令查看容器日志,MySQL 首次初始化时日志里会出现几段关键信息:初始化数据库、创建用户、绑定端口、ready for connections。看到 ready for connections 基本就说明数据库已经可以连了。
上面关键一步都没问题的前提下,我建议顺手验证一下端口。在宿主机执行:
bash复制mysql -h 127.0.0.1 -P 3306 -u root -p
如果你没有 MySQL 客户端,可以用 docker exec -it mysql-lab mysql -u root -p 直接进容器执行 SQL。这也是一种验证方式,而且平时排查问题经常要用。
4.4 服务停止与数据持久化验证
停止服务用:
bash复制docker compose down
这条命令会停止并删除容器,但默认不会删除 volumes(数据卷),所以你的数据还在。要连数据卷一起清掉得显式加 -v:
bash复制docker compose down -v
在开发环境清理重来可以用 -v,在生产环境打死都不要随手加这个参数。你要时刻记住:docker compose down -v 等于把你的数据库文件物理删除,没有任何回收站可以捞回来。
数据持久化验证的实操方法很简单:插入一条测试数据,然后 docker compose down,再 docker compose up -d,重新查询数据,数据应该还在。如果数据丢了,九成是挂载卷配置有问题——常见原因是宿主机目录没建好、权限不对、或者你用的是匿名卷而不是绑定挂载。
5. 实操手记:up -d 报错的排查实录
5.1 典型报错:cannot start docker compose application
热词里有个很典型的报错信息:cannot start docker compose application. reason: compose [start] exit status...。我第一次看到这个报错是在一次环境升级之后,当时有点懵,因为它不是那种一行就能说清错误的输出。遇到这种错误,优先去看完整日志,尤其是 docker compose ps 显示服务没起来时,一定要 docker compose logs 把容器输出调出来看。
这类报错常见原因有三种。第一种是端口冲突。比如宿主机的 3306 端口已经被别的 MySQL 占用,Compose 在绑定端口时会直接失败。排查方法很简单:sudo lsof -i :3306 或 netstat -tlnp | grep 3306,把占用端口的进程找出来,停掉它,或者把 Compose 文件里宿主机这边的端口改成 3307、3308 等空闲端口。端口映射格式是 宿主机端口:容器端口,改左边不改右边。
第二种是镜像拉取失败。可能是网络抖动、私有仓库登录失效、或者磁盘满了。日志里一般会有 pull access denied、no space left on device 之类的关键字。磁盘满的排查用 df -h,镜像仓库问题可以把日志贴出来看具体报什么。
第三种是权限问题。数据卷挂载的宿主机目录对容器内的 MySQL 用户没有写权限,MySQL 初始化时创建数据文件失败,容器就会反复重启或直接退出。日志里大多能看到 Permission denied 字样。
5.2 反复 CrashLoopBackOff 的排查思路
容器启动后一直重启,状态在 Up 和 Restarting 之间反复横跳——这几乎是每个用 Compose 部署有状态服务的人都会遇到的经历。摆正心态,这是正常的排错过程,不是你的配置“烂”。我的排查套路是固定的:
先看日志:
bash复制docker compose logs --tail 200 mysql
MySQL 的启动日志通常已经足够说明问题。如果日志显示 [ERROR] --initialize specified but the data directory has existing files,说明你挂载的 data 目录里已经有残留数据,和当前配置的初始化逻辑冲突。最常见的情景是:你先用一个旧版本镜像跑出了数据,然后改了镜像版本或参数,再次启动时数据库文件格式不匹配。这时决定是保留数据还是推倒重来,取决于数据对你是否重要。开发环境一般直接清掉 data 目录里所有内容重新初始化。
如果日志显示 Can't open the mysql.plugin table,多半是数据目录权限或文件损坏,可以尝试用 -v 清理卷之后再来一次。如果日志显示 Table 'mysql.user' doesn't exist,则大概率是数据目录根本没初始化成功,原因通常是权限或挂载路径不对。
另一个容易忽略的点是 .env 文件不生效。你可能把环境变量写到了 .env 文件里,但忘记在 Compose 文件里用 ${VAR} 引用,或者变量名拼写不一致。Compose 读取 .env 文件只做变量插值,不会自动注入到容器环境变量里。所以 MYSQL_ROOT_PASSWORD 必须写进 environment 配置块,而不能只在 .env 文件里定义一个同名变量就完事。
5.3 日志乱码与字符集问题排查
第二个高频问题是字符集。如果你用 Compose 部署 MySQL 后往表里插入中文或 emoji,查询出来是 ??? 或者报 Incorrect string value 错误,基本就是字符集配置没到位。我在前面的 Compose 文件里已经写了 --character-set-server=utf8mb4 和 --collation-server=utf8mb4_unicode_ci,这两项不是可选的,是必须的。
但光有服务端字符集还不够。很多人在排查时会漏掉客户端连接时的字符集。MySQL 客户端连接成功后会执行一次 SET NAMES utf8mb4,如果你的客户端工具(比如老版本的 Navicat、Python 的 pymysql 等)默认用的字符集不是 utf8mb4,照样会出现乱码。所以在代码层面也要指定连接字符集和排序规则。以 pymysql 为例,连接参数里加 charset="utf8mb4"。
如果你已经启动了一个没有指定字符集的 MySQL 容器,并且数据目录里已经写了数据,改 docker-compose.yml 再加 command 参数是不会自动生效的。因为 MySQL 只在数据目录为空时执行初始化,你改了配置但数据目录已有内容,字符集不会变。这种时候有两个选择:一个是在运行中的容器里执行 ALTER DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;(只对新建的表有效),另一个是把数据目录清了重来(对已有数据不友好)。所以这个参数一定在第一次启动前就要写对,后面悔改成本很高。
5.4 常见问题速查表
把我在各类环境里踩到的问题整理成一张表,按频率从高到低排列:
| 报错/症状 | 排查方向 | 解决方案 |
|---|---|---|
port is already allocated |
宿主机端口被占用 | lsof -i :端口 查占用进程,改宿主机端口映射 |
Permission denied |
挂载目录权限不足 | 给目录正确的属主/权限,或使用 Docker 命名卷 |
Container exits immediately |
命令或入口点执行失败 | 查看 docker compose logs 判断具体错误类型 |
MYSQL_ROOT_PASSWORD 改了不生效 |
数据目录已有旧数据 | 删除数据目录内容重新初始化(仅限开发环境) |
| 中文乱码 | 字符集配置不一致 | 服务端加 utf8mb4,客户端连接也指定 charset |
version is obsolete |
Compose 文件版本旧 | 去掉 version 字段或升级为最新格式 |
pull access denied |
镜像名写错或私有仓库未登录 | 检查镜像名/标签,登录仓库如 docker login |
depends_on 没效果 |
依赖检查不正确 | 配合 condition: service_healthy 使用健康检查 |
日志一直刷 [Warning] root@localhost is created |
初始化正常但连接方式不对 | 用 -h 127.0.0.1 而非本地 socket 连接 |
这个表里我额外想强调 Data Directory 和初始化的问题,因为几乎每个 MySQL Compose 部署都会碰到。判断“容器是不是第一次初始化”其实很简单:挂载的数据目录里有没有一个叫 auto.cnf 的文件,如果有,说明已经初始化过了,改任何 MYSQL_* 环境变量都不会再触发初始化逻辑。
6. 生产环境经验补充:卷、网络与资源治理
6.1 数据卷的三种使用方式辨析
Compose 里挂载数据有两种主流方式,很多人不清楚它们的区别,导致数据管理混乱。
第一种是绑定挂载(bind mount),我们在前面用的是这种方式:./data:/var/lib/mysql。它把宿主机一个具体目录直接映射到容器里。优点是路径明确,你在宿主机哪里写的文件就能从容器里看到,排查问题很直白;缺点是不同机器上的路径可能不同,而且宿主机文件系统的权限会影响容器内进程。
第二种是 Docker 命名卷(named volume),写法是:
yaml复制volumes:
- mysql-data:/var/lib/mysql
volumes:
mysql-data:
without 宿主机路径,只写一个卷名。Docker 会把它放在自己的存储目录 /var/lib/docker/volumes/ 下面统一管理。优点是跨机器迁移方便、性能通常更好、权限问题少,缺点是你在宿主机上不容易直接看到数据文件,想用编辑器修改或查看文件要绕一圈。
绑定挂载和命名卷没有绝对好坏,具体场景选具体方案。如果你需要在宿主机直接操作数据文件(比如导入导出 SQL 备份),绑定挂载更顺手;如果你只想让数据随容器“无声无息”持久化,不关心它在宿主机哪个目录,命名卷更好。生产环境推荐命名卷,尤其是数据库这种对文件性能敏感的场景。Docker 官方对命名卷在物理存储上的优化处理比普通目录挂载更可靠。
6.2 .env 文件与配置分层管理
服务上到生产后,一个核心原则是“代码和配置分离”。镜像和 Compose 文件可以进 Git 仓库,但环境相关的配置(密码、端口、密钥)应该通过环境变量或 .env 文件注入,而不是硬编码在 YAML 里。我在之前已经演示了 ${VAR:-default} 的写法,实际上 .env 文件的用法要更讲究一些。
.env 文件要和 docker-compose.yml 放在同一目录下,Compose 启动时会自动读取它来做插值。格式就是简单的 KEY=VALUE,注意:值里如果含特殊字符,可能需要对 $、# 等做转义,否则会被误解析。需要明确的是:.env 文件不会自动注入容器环境变量,它只服务于 Compose 文件的变量替换;容器内真正能读到的环境变量,必须在 environment 或 env_file 里单独声明。
我习惯的配置分层方案是:
docker-compose.yml:放服务拓扑和通用参数,提交到 Git,任何人拉下去都能跑通基础逻辑;docker-compose.override.yml:放本地开发专用配置,比如开放 debug 端口、挂载源码目录、使用本地镜像,默认不提交或单独管理;.env:放所有可变配置,加入.gitignore,只在部署机器上维护;每次部署前确认.env里的值符合当前环境。
这种做法在多人协作和 CI/CD 场景里帮了我大忙。别的同事拿到仓库后,不用改一行 YAML,只要按模板复制 .env.example 为 .env、填好本地的值,就能把整套服务拉起来。
6.3 资源限制与重启策略
还有个容易被忽略的生产要点:对容器做资源限制。默认情况下,容器可以使用宿主机的全部 CPU 和内存,多个服务之间互相争抢资源,一旦某个容器内存泄漏,可能导致整个宿主机卡死。因此在 Compose 文件里,对每个有状态服务建议都加上 deploy.resources.limits(Compose V2 兼容模式和 docker run 的 --memory、--cpus 对应):
yaml复制services:
mysql:
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
注意:只有 Docker Swarm 部署模式下 deploy 配置才完全生效,但现代 Docker Compose V2 可以单独使用这些限制参数。在单机部署里也能发挥实际作用。MySQL 这类对内存有要求的服务,建议根据你的数据量和查询模式,预留合理的 buffer pool 大小。
重启策略 restart: unless-stopped 是我推荐的默认项。它意味着:如果容器因为错误退出,Docker 会自动尝试重启它,但如果你手动执行了 docker compose stop,服务会保持停止状态,不会反复拉起。这个策略对生产环境很友好——进程崩了自动恢复,主动下线则不打扰。
7. 踩坑实录与排查技巧复盘
说完整个流程,我再系统整理一次实战过程中最常见的坑,每一条都是我亲测过的,写出来让你少走弯路。
7.1 Compose 命令新老格式混淆
我在文章前面反复提到 docker compose 和 docker-compose 的区别。这里再强调一次:从 Compose V2 开始,官方推荐并默认使用 docker compose(中间有空格)。如果你的机器里同时安装了旧版独立二进制 docker-compose 和新的插件版 docker compose,请统一用新命令。原因如下:V2 在性能、功能、输出信息方面都有明显改进,而且它和 Docker CLI 深度集成,很多扩展功能(比如 docker compose watch)只在 V2 里有。
你是怎么知道自己在用 V1 还是 V2 的?很简单,docker-compose version 会输出类似于 docker-compose version 1.29.2,而 docker compose version 输出的是 Docker Compose version v2.29.7 这类。看到版本号开头带 v2 就对了。
7.2 挂载目录权限引发的 MySQL 初始化失败
这是 Linux 上部署 MySQL 最常踩的坑。现象很典型:容器启动后一直处于 Restarting 状态,日志显示类似:
code复制[ERROR] failed to initialize database, aborting
[ERROR] chown /var/lib/mysql: operation not permitted
原因是宿主机上你创建的 data 目录属于当前用户,但容器内的 mysqld 进程以 mysql 用户身份运行,要对 /var/lib/mysql 写入就变成了写宿主机上别人(或者说是另一个 UID)的目录,没有权限。
解决办法有几种。最简单粗暴的是把目录属主改成 999(MySQL 官方镜像里 mysql 用户的 UID):
bash复制sudo chown -R 999:999 ./data
另一种更优雅的做法是干脆不用绑定挂载,改用命名卷。Docker 创建命名卷时会给卷设置合适的属主和权限,容器内的进程直接写入没有问题。我后来在团队内部推行了一个约定:本地快速验证用绑定挂载 + chown,正式部署一律命名卷。
7.3 初始化脚本未执行的排查
有时候你把 init.sql 放在 init 目录并挂载正确了,但启动后数据库里没有你预期的表。排查顺序是这样的:
第一,确认数据目录是否为空。只要 /var/lib/mysql 里已经有数据,镜像的初始化入口脚本就不会执行,你的 init.sql 会被完全跳过。这是最容易被误解的行为。想验证很简单:把 data 目录暂时改名,再启动一个新的容器,脚本就会执行。
第二,确认挂载路径是否正确。在容器里执行 ls -la /docker-entrypoint-initdb.d,看文件是否真的出现在容器里。
第三,确认文件后缀名支持。官方入口脚本会执行 .sh、.sql、.sql.gz,但如果你想执行的是 .txt 或没有扩展名的文件,它是不会碰的。
第四,如果 init.sql 里有语法错误,MySQL 在初始化过程中会把错误打进日志,但容器不会因此退出,你只是发现表没建出来。所以初始化完成后一定要立刻检查日志,别等到连数据库时才反应过来。
8. 扩展玩法与实际应用展望
做到上面的程度,你已经可以用 Compose 搭建一套相当完整的本地开发环境了。但 Compose 的能力远不止于此,我最后再分享几个我觉得很实用的扩展场景。
一个是 多环境切换。你可以维护多个 Compose 文件,比如 docker-compose.base.yml 定义基础服务,docker-compose.dev.yml 覆盖开发环境配置(比如开启调试端口、挂载源码),docker-compose.prod.yml 覆盖生产配置(比如限制资源、不暴露调试端口)。启动时用 -f 指定文件组合:
bash复制docker compose -f docker-compose.base.yml -f docker-compose.prod.yml config
docker compose -f docker-compose.base.yml -f docker-compose.prod.yml up -d
文件顺序很重要,后面的文件会覆盖前面的同名配置项。这种模式比维护三个完全独立的 Compose 文件要省心得多。
另一个是 应用与服务一起编排。你现在部署 MySQL 是单个服务,但结合开头说的多容器场景,可以加一个 Redis 做缓存、加一个后端 API 服务,甚至加一个前端 Nginx。整个技术栈写成一份 Compose 文件,一条命令就能启动完整开发环境。这对新成员入职配环境的效率提升是肉眼可见的:以前给他发十几条命令让他自己跑,现在发一个仓库地址让他 clone 后执行一条命令,二十分钟变成两分钟。
还有 docker compose v2 中新增的 docker compose watch 命令。它会监控指定文件的变化,自动触发服务重建或重启。开发时改了后端代码,保存文件不到半分钟,容器里的服务就自动加载了新逻辑,不用手动重启容器。这个体验对开发效率的提升非常明显。具体配置是在服务里加一段 develop.watch 字段,定义哪些路径变更时触发 rebuild。想尝鲜的话,改完 Compose 文件后跑一下 docker compose watch 试试看。
关于 Compose 在整个容器化路线图里的位置,我觉得可以这样理解:如果 Docker 是“容器化底座”,Compose 是“单机编排层”,那么 Kubernetes 就是“集群编排层”。对于大多数中小型项目和开发环境,Compose 已经够用了,并不需要一上来就上 K8s——那是另一种复杂度。先把 Compose 玩明白,理解服务、网络、存储、健康检查这些概念,将来你会觉得 K8s 的很多设计思路也是似曾相识的。
最后的最后,分享我在实际使用中养成的几个小习惯:每改一次 Compose 文件必先 docker compose config 校验语法;up 之前先 down 干净再启动,避免旧容器残留干扰判断;所有敏感变量永远不写死在 YAML 里;启动完成后必看 logs 确认关键服务输出正常。按照这个流程走,你踩的坑会少掉一半。希望你也能拿着这套方法,顺利把想跑的服务都编排起来。
