
在这个多容器应用成为标配的时代单跑一个容器根本解决不了问题。前端要 Nginx 扛流量业务数据要 MySQL 存热点会话要 Redis 加速这三个服务一旦拆成三个docker run命令去管环境变量、网络、数据卷、重启策略全得手工对齐稍不留神就漏配置。Docker Compose 的价值就在这儿把三个容器的启动参数、网络关系、依赖顺序写进一个 YAML一条命令拉起整条链路再一条命令拆掉全部环境。这篇文章就从我实际部署 NginxMySQLRedis 服务栈的过程出发把编排设计、配置踩坑、日常运维命令和故障排查一次讲透适合刚把 Docker 跑起来、准备上手多容器项目的同学参考。1. 先想清楚多容器应用到底需要编排什么1.1 真实痛点三个容器不是三条独立命令我第一次接触多容器项目时也尝试过用三个docker run把 Nginx、MySQL、Redis 各自拉起来。单看每个容器命令都不复杂MySQL 挂个数据卷、Redis 指定密码、Nginx 映射 80 端口。但问题很快暴露三个容器之间互相访问时IP 地址是动态分配的每次重启都可能变应用里的数据库连接串怎么写都不稳。MySQL 容器启动比业务容器慢Nginx 开始转发请求时数据库还没就绪探针没做全靠人工 sleep 碰运气。环境变量散落在 shell 脚本里换一台机器部署就得重新改一遍根本没有可复现性。想要把整套环境清掉重来得手动docker stop三个容器再逐个docker rm操作顺序错了还可能删不掉。这些痛点用一句话总结我需要的不是三个容器而是一套有拓扑关系的应用栈。Compose 把这个拓扑关系用声明式文件表达出来一次定义、处处运行。1.2 为什么 Compose 是当下最合适的选型在编排工具里Kubernetes 无疑更强大但引入它意味着要管理控制平面、工作节点、网络插件、Ingress Controller对单机开发环境和中小型项目来说学成本和运维成本都不划算。Compose 正好卡在单个 Docker 引擎之上做多容器编排这个生态位上它解决的是开发环境搭建、CI 集成测试、小规模生产部署这类场景文件格式简单到一天就能上手。从技术债角度看Compose 文件是纯文本的 YAML天然适合放进 Git 做版本管理。我现在的做法是每个项目根目录都放一份docker-compose.yml新同事 clone 下来直接docker compose up -d不用再读一页一页的环境搭建文档。这个习惯坚持下去省下的沟通成本是实打实的。提示Docker 官方在 2022 年起已将 Compose 定位为docker compose子命令V2不再推荐单独安装 Python 版的docker-compose。下文所有命令均基于 V2 写法。1.3 从零到能用安装 Docker Compose 的完整动作这里我之所以单独拎出来说是因为很多入门者卡在第一步——敲docker compose报unknown command。这是 Docker 引擎版本太老没带 compose 插件。检查当前 Docker 是否支持docker --version docker compose version如果第二条报错分两种情况处理。Ubuntu/Debian 系用 apt 装 docker 的直接补装插件sudo apt update sudo apt install docker-compose-pluginCentOS/RHEL 系的用 dnf 装插件sudo dnf install docker-compose-plugin另一种情况是用了很老的 Docker 版本建议直接升级 Docker 引擎本身。安装完成后用/usr/libexec/docker/cli-plugins/docker-compose或docker compose version验证能看到版本号就说明插件就位了。2. 服务栈架构设计三个服务各司其职2.1 职责切分与资源画像这套 NginxMySQLRedis 的经典组合在架构上天然分成三个层次接入层Nginx接收客户端请求做反向代理、静态资源托管、请求头清洗。站在容器角度看它是无状态的随时可以水平扩展端口映射只暴露 80/443。数据层MySQL持久化存储核心业务数据对磁盘 IO 敏感容器内不存放任何业务代码数据全部落到宿主机数据卷。缓存层Redis承接热点数据查询、会话保持、分布式锁走内存计算对网络延迟敏感必须和业务容器在同一个内网段内互联。三个服务对资源的诉求不同MySQL 吃磁盘性能Redis 吃内存容量Nginx 吃 CPU 和连接数。在设计 Compose 文件时建议先按宿主机配置给每个服务定好资源上限避免 Redis 的maxmemory默认值把内存吃满也避免 MySQL 的 buffer pool 配置不当引发容器 OOM。2.2 网络规划一张内部网解决互联互通Compose 默认会为当前项目创建一个 bridge 网络所有定义了networks字段的服务都挂在这张网里彼此通过服务名直接互访。这是编排的核心机制之一容器间通信用服务名不依赖 IP。我把网络规划分成两步。第一步自定义一个app_net网络并显式指定驱动为 bridgenetworks: app_net: driver: bridge第二步在 MySQL 容器里业务服务要连数据库连接串里的 host 直接写服务名mysql端口写3306。Nginx 反代后端 PHP 或 Java 服务时proxy_pass http://backend:8080里的backend同样是 Compose 服务名。这个设计彻底摆脱了 IP 漂移问题容器重建后只要服务名不变连接关系就永远成立。端口映射方面我的原则是能不放宿主机就不放。MySQL 和 Redis 这类内部服务默认不映射端口到宿主机避免被外部扫描。如果需要用 Navicat 或 Redis Desktop Manager 连上去调试再加映射并限定绑定地址ports: - 127.0.0.1:3306:3306只监听回环地址外部访问不到本地工具却能直连开发调试两不误。2.3 数据持久化容器可以删数据不能没容器是无状态的这是 Docker 的基本哲学。但 MySQL 的数据必须持久化。我用的方案是命名卷named volume)由 Docker 管理卷的存放路径宿主机上不用手动创建目录volumes: mysql_data: driver: local在服务里声明挂载services: mysql: volumes: - mysql_data:/var/lib/mysql命名卷的好处是迁移方便docker compose down不会删除它即使加了-v参数删除卷也能在宿主机/var/lib/docker/volumes/下找到原始数据做恢复。Redis 的持久化相对轻量可以只挂载 AOF 或 RDB 文件所在目录volumes: - redis_data:/dataNginx 容器本身不需要持久化业务数据但配置文件建议用 bind mount 挂载出来方便直接改配置后nginx -s reloadvolumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/html:/usr/share/nginx/html:ro2.4 依赖与健康检查不让业务容器抢跑直接配置depends_on只能保证容器启动顺序却不能保证服务已就绪。MySQL 容器起来了但初始化可能还没完成业务容器硬连照样报connection refused。更可靠的作法是给 MySQL 配置 healthcheck业务容器在健康状态变为 healthy 之后再启动services: mysql: image: mysql:8.0 healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -uroot, -p$$MYSQL_ROOT_PASSWORD] interval: 5s timeout: 3s retries: 12 backend: depends_on: mysql: condition: service_healthy这里有个细节$$是 Compose 对$的转义运行时会把$$MYSQL_ROOT_PASSWORD解析成容器内的环境变量而不是宿主机环境变量。刚接触 Compose 的人经常在这里踩坑直接用$MYSQL_ROOT_PASSWORD在宿主机找不到变量healthcheck 永远失败。这个机制搞懂了依赖编排才算过关。3. 核心配置实操从零到一部署服务栈3.1 完整 Compose 文件与分段拆解我把生产环境里实际在用的docker-compose.yml精简一版出来你可以直接复制改参数version: 3.8 services: nginx: image: nginx:1.25-alpine container_name: web_nginx ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/html:/usr/share/nginx/html:ro - ./nginx/logs:/var/log/nginx networks: - app_net restart: unless-stopped depends_on: - backend mysql: image: mysql:8.0 container_name: web_mysql command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --default-authentication-pluginmysql_native_password environment: MYSQL_ROOT_PASSWORD: ChangeMe123! MYSQL_DATABASE: app_db MYSQL_USER: app_user MYSQL_PASSWORD: AppPass456! volumes: - mysql_data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d:ro networks: - app_net healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -uroot, -p$$MYSQL_ROOT_PASSWORD] interval: 5s timeout: 3s retries: 12 restart: unless-stopped redis: image: redis:7-alpine container_name: web_redis command: [redis-server, /usr/local/etc/redis/redis.conf] environment: REDIS_PASSWORD: RedisPass789! volumes: - ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro - redis_data:/data networks: - app_net restart: unless-stopped backend: image: your-app-backend:latest container_name: web_backend expose: - 8080 environment: DB_HOST: mysql DB_PORT: 3306 DB_NAME: app_db DB_USER: app_user DB_PASSWORD: AppPass456! REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: RedisPass789! depends_on: mysql: condition: service_healthy redis: condition: service_started networks: - app_net restart: unless-stopped volumes: mysql_data: redis_data: networks: app_net: driver: bridge逐个说明几个关键点container_name显式指定容器名方便docker logs时不用查 ID。command覆写容器的默认启动命令。MySQL 的启动参数用--前缀加上去Redis 则指定加载自定义配置文件。expose只暴露给同网络的其他容器不映射宿主机端口。backend 对外只让 Nginx 访问不直接开给宿主机。restart: unless-stopped宿主机重启后自动拉起容器手动 stop 的容器除外。这是生产环境的基本配置。3.2 Nginx 配置要点反向代理与静态资源分离nginx/conf.d/default.conf我常用的模板如下server { listen 80; server_name your-domain.com; # 静态资源直接由 Nginx 托管不经过后端 location /static/ { alias /usr/share/nginx/html/static/; expires 7d; add_header Cache-Control public; } # 动态请求反向代理到 backend 服务 location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location / { root /usr/share/nginx/html; index index.html; } }有个容易踩的坑location /api/和proxy_pass http://backend:8080;末尾不带斜杠时Nginx 会把完整 URI 原样转发也就是/api/user会变成后端收到的/api/user。如果后端接口不含/api前缀proxy_pass末尾就得加斜杠变成http://backend:8080/Nginx 会去掉匹配部分再拼接。这个区分是面试常考的细节实际部署时错一次就明白了。3.3 MySQL 配置要点字符集与初始化脚本MySQL 8.0 默认字符集是 utf8mb4但为了稳妥我依然在command里显式声明。--default-authentication-pluginmysql_native_password是为了兼容老版本客户端如果你确定所有连接方都支持 caching_sha2_password可以删掉这一行。初始化脚本目录./mysql/init挂载到/docker-entrypoint-initdb.d后MySQL 容器首次启动时会按文件名字母顺序执行里面的.sql、.sh、.sql.gz文件。这个机制很适合做建表初始化-- init/01_create_tables.sql USE app_db; CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意初始化脚本只在数据目录为空的首次启动时执行。如果mysql_data卷里已经有数据再往 init 目录加脚本也不会生效。想重跑初始化只能docker compose down -v清掉卷重来这会连带删掉所有数据务必确认不需要再执行。3.4 Redis 配置要点密码、持久化与连接数Redis 官方镜像默认不加载配置文件所以必须用command指定配置路径。redis/redis.conf关键项bind 0.0.0.0 port 6379 requirepass RedisPass789! appendonly yes appendfsync everysec maxmemory 256mb maxmemory-policy allkeys-lrubind 0.0.0.0在容器里是必要的容器网络接口和宿主机不同只绑 127.0.0.1 会导致其他容器连不上。requirepass设置访问密码业务端连接时用AUTH命令或连接串带密码。appendonly yes开启 AOF 持久化everysec每秒刷盘崩溃时最多丢 1 秒数据性能损耗可接受。maxmemory 256mb根据宿主内存调整配合allkeys-lru策略避免缓存撑爆内存。改完配置后执行docker compose exec redis redis-cli -a RedisPass789! INFO memory能连上就说明配置加载成功。Redis 7 镜像里自带redis-cli不需要额外安装客户端工具。4. 部署启动与生命周期管理4.1 项目目录准备与首次启动我先列一个标准化目录结构这套结构可以直接作为新项目的模板myapp/ ├── docker-compose.yml ├── nginx/ │ ├── conf.d/ │ │ └── default.conf │ ├── html/ │ │ └── index.html │ └── logs/ ├── mysql/ │ └── init/ │ └── 01_create_tables.sql └── redis/ └── redis.conf启动前先做校验docker compose config这条命令会解析 YAML、展开变量、检查格式错误输出最终的配置内容。我每次改动 Compose 文件都会先跑一遍格式问题立即现形不用等 up 的时候才报错。确认无误后正式启动docker compose up -d-d是后台运行。首次启动会拉取镜像耗时取决于网络。看到输出里所有服务都标了Started或Healthy后用docker compose ps核对状态docker compose ps输出表格里 STATE 列显示 Up 且 HEALTHY就说明整套栈跑起来了。打开浏览器访问http://localhost能看到 Nginx 默认页面就说明接入层正常。4.2 日常运维命令速查Compose 子命令覆盖了日常所有操作场景我按使用频率整理成表命令用途备注docker compose ps查看当前项目服务状态加-a显示已停止的容器docker compose logs -f 服务名跟踪指定服务日志-f相当于 tail -fdocker compose exec 服务名 命令进入运行中容器执行命令不加-it时非交互docker compose restart 服务名重启单个服务不重建容器docker compose pull拉取最新镜像配合 up 使用docker compose down停止并删除所有容器和网络默认保留数据卷docker compose down -v停止并删除容器、网络、卷数据不可恢复docker compose up -d --build重新构建镜像后启动镜像依赖项目 Dockerfile 时用这里必须强调docker compose down和docker compose down -v之间有天壤之别。前者保留数据卷下次up数据还在后者把卷一起删了MySQL 和 Redis 里的数据全部清空。我在生产环境从来不用-v只有开发环境需要重置数据时才考虑。4.3 配置变更与版本升级日常改配置分两种场景场景一只改 Compose 文件本身比如加环境变量、改端口映射执行docker compose up -dCompose 会检测配置变化重建配置变更的容器不变的容器保持运行。这个过程叫recreate是增量式的很快。场景二改了 Nginx 或 Redis 的挂载配置文件不需要重建容器docker compose exec nginx nginx -s reload docker compose exec redis redis-cli -a RedisPass789! CONFIG REWRITE但如果改了 Nginx 配置文件后mount 是:ro只读挂载容器内直接改文件不行必须在宿主机改完再 reload。升级镜像版本的流程是改 Compose 文件里的 tag →docker compose pull→docker compose up -d。想要回滚到旧版本把 tag 改回去再执行同样的命令。镜像 tag 一定要写具体版本号mysql:8.0.32别用latest。latest 在不同时间拉到的镜像可能不一样出了问题不好定位。5. 运维异常与问题排查实录5.1 常见报错速查表我把这段时间排查过的典型问题整理成一张表对应现象、可能原因和解决动作新手可以直接按图索骥现象可能原因解决动作docker: unknown command: docker composeDocker 版本过旧未安装 compose 插件升级 Docker 或安装 docker-compose-pluginContainer ... is unhealthyhealthcheck 配置错误或 MySQL 初始化未完成查看日志检查 healthcheck 中的$$转义Cant connect to local MySQL server through socket /tmp/mysql.sock客户端直连 3306 被拒或用了错误的连接方式确认连接串使用mysql服务名检查容器日志DENIED Redis is running in protected mode未设置密码且绑定了非本机地址配置文件加requirepass或显式bind 0.0.0.0502 Bad GatewayNginx 反代的后端服务未启动或不健康docker compose ps查看状态docker compose logs backend查日志port is already allocated宿主机的 80 或 3306 端口被占用netstat -tlnp找占用进程或改 Compose 端口映射修改配置文件后不生效挂载的:ro只读挂载未同步或容器未重建确认修改的是宿主机路径必要的话docker compose up -d重建5.2 案例MySQL socket 连接失败后端日志报ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock这个错误我一开始也困惑因为后端和 MySQL 明明在同一张网络里。排查思路拆开看第一步确认 MySQL 容器状态docker compose ps如果状态是 healthy排除启动问题。第二步确认后端连接串配的 host。我当时发现配置里写的是localhost:3306问题就出在这localhost在容器内表示后端容器自身而不是 MySQL 容器。客户端尝试找本地 socket 文件/tmp/mysql.sock自然找不到。改成mysql:3306后问题立即消失。这个案例暴露了容器网络的一个核心概念容器内的 localhost 不等于其他容器的 host。在任何跨容器访问场景一律使用服务名或显式 IP。5.3 案例Redis 拒绝连接Redis 容器起来了但业务应用连不上日志报Connection refused或DENIED。当时排查步骤如下先docker compose exec redis redis-cli ping返回PONG说明容器内部正常。再试docker compose exec redis redis-cli -a 密码 ping如果这里报NOAUTH Authentication required说明需要密码才能访问。问题更常见的根源是 protected mode 的坑Redis 默认允许本机连接但外部容器连接需要密码或配置bind。解决方式就是我在 3.4 节里强调的配置文件里设requirepass同时bind 0.0.0.0。两个条件缺一不可否则 Redis 会拒绝非本机连接。我还遇到过一种情况Redis 容器已经运行旧配置改了redis.conf后没重建容器。因为配置文件是通过:ro挂载进去的容器内改了也没有持久化效果必须执行docker compose up -d redis让 Compose 基于新配置重建容器。5.4 案例Nginx 502 网关错误服务都拉起来了访问首页正常但只要请求打到/api/就返回 502。排查日志docker compose logs nginx -f日志里看到connect() failed (111: Connection refused) while connecting to upstream说明 Nginx 想连 backend 容器的 8080 端口但连不上。先看 backend 容器是否正常docker compose ps如果 backend 状态是 Exited大概率是启动崩了docker compose logs backend看具体原因。如果 backend 正常运行检查 Nginx 配置里的proxy_pass指向的服务名是否正确。我实际遇到的坑是 Nginx 配置里写了http://localhost:8080但在 Nginx 容器里 localhost 是 Nginx 自己根本没有 8080 服务。改成http://backend:8080后立即恢复。这个问题和 MySQL 的 socket 报错本质是同一类——跨容器访问不能用localhost必须用服务名。6. 几个花时间换来的运维经验第一点在 2.3 节提到过但值得再说一次init 脚本只在首次启动时执行。我有段时间反复调整 SQL 初始化脚本发现容器重建后表结构没变化白白排查了半天。后面习惯是先docker compose down确认数据卷确实清空了再up。开发环境为了提高重置效率我一般把 MySQL 数据卷挂载到临时目录重置时直接删目录不用动 Compose 文件。第二点是关于日志管理的。Compose 默认的 json-file 日志驱动会把容器所有 stdout 写到宿主机长期运行不清理日志文件能涨到几个 G。我在 Compose 文件里加了日志限制logging: driver: json-file options: max-size: 20m max-file: 5这样每个容器的日志文件最大 20MB保留 5 个轮转文件老日志自动删。虽然牺牲了一部分历史日志查询能力但换来的是磁盘不会被日志写满的安心。生产环境如果对日志有审计需求可以接入外部日志采集系统但在单机场景里这个配置够用且简单。第三点是关于镜像 tag 管理的。生产环境的 Compose 文件里所有镜像 tag 都要写死具体版本不要用latest。我用过一个 MySQL 8.0.31 镜像没问题但某天 CI 构建时莫名拉了一个带安全漏洞的镜像排查了很久才发现是latest漂移了。从那以后我在所有项目里规定镜像 tag 必须精确到小版本号升级时手动改 tag 并跑测试。第四点一定要给宿主机配好时间同步。Docker 容器默认继承宿主机的时钟如果宿主机时间不准MySQL 的NOW()函数返回的时间不对前端页面上会显示奇怪的日期。这个现象很隐蔽我第一次碰到时还以为程序里有 bug最后对照 NTP 服务才发现是系统时间差了几分钟。这套 NginxMySQLRedis 的编排方案我在多个项目里复用过从开发环境到单机生产环境基本可以无缝平移。Compose 的学习曲线有多平缓只要把网络、数据卷、健康检查这三个核心概念吃透剩下的都是在配置文件里堆细节而已。遇到问题时第一反应永远去查docker compose logs日志里已经把大部分答案写好了剩下的坑多踩几次自然就记住了。