Docker 多容器部署后故障率反而上升:127.0.0.1、UID 权限、depends_on 三个必踩的坑

从一台机器到三台机器,故障率反而上升了

很多个人站长都会走到这一步:单机跑得好好的,随着流量和功能增长,开始拆分成 web + db + 缓存三台机器,或者干脆上 Docker Compose 多容器部署。架构看起来专业了,运维反而变得更痛苦——最常见的新问题是:以前重启一个服务就好,现在重启完要么连不上数据库,要么文件权限报错,要么配置读的是旧的。

这篇文章不讲抽象原则,只讲多机/多容器部署时最常踩的具体坑,以及每个坑的验证命令。这些问题的共同特征是本地单机测试永远不会发现,只有在真实的多节点环境里才会暴露。

核心原则先记住:在分布式环境中,"看起来启动了"不等于"服务可用"。容器的 status 是 running,只说明主进程还活着;服务能不能真的对外提供功能,需要单独验证。

陷阱一:127.0.0.1 是每个容器自己的 127.0.0.1

这是最经典、也是最高频的错误。你在单机上写 mysql_host = 127.0.0.1 完全正常,拆到容器里立刻报 Connection refused。

原因很直白:每个容器有自己独立的网络命名空间,容器内的 127.0.0.1 指的是容器自己,不是宿主机,也不是数据库容器。要跨容器通信,必须用 Compose 的服务名(Compose 自带 DNS 解析)或宿主机 IP。

# 错误:指向容器自己
DB_HOST=127.0.0.1

# 正确(同 Compose 网络内,用服务名):
DB_HOST=db
# 正确(跨主机,用内网 IP):
DB_HOST=10.0.0.5

验证是否联通,不要靠猜,直接在容器里测:

# 进入 web 容器,测试能否解析和连接数据库
docker compose exec web sh -c '
  echo "--- DNS 解析 ---"
  getent hosts db || echo "DNS 解析失败:不在同一网络?"
  echo "--- 端口连通 ---"
  nc -zv db 3306 || echo "端口不通:数据库未启动或防火墙"
  echo "--- 应用配置实际读到的值 ---"
  env | grep -i db_host
'

最后一行特别重要。绝大多数"配置改了没生效"是因为环境变量没传到容器里,而不是配置写错了。先确认容器内实际读到的值,再怀疑配置语法。

另外注意:容器里通常没装 nc。可以先用 docker compose exec web sh -c 'cat < /dev/tcp/db/3306' 这个纯 shell 技巧测试,或者装 netcat-openbsd。更通用的是从宿主机侧测:

# 找出 db 容器的 IP,从宿主机直接测端口
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker compose ps -q db)

陷阱二:MySQL 用户权限里的 host 决定一切

即使网络通了,你还是可能遇到 Access denied for user 'blog'@'172.18.0.3'。注意报错里的那个 IP——它是调用方容器的内网 IP,每次容器重建都可能变。

MySQL 的用户是由 (user, host) 二元组唯一确定的。单机时代你建的用户是 'blog'@'localhost',它只允许来自本机 socket 或 127.0.0.1 的连接。多容器场景下调用的源 IP 是 172.x 网段,自然被拒绝。

-- 查看现有账号及其允许来源
SELECT user, host FROM mysql.user WHERE user = 'blog';

-- 正确做法:授权给具体的容器网段,而不是 %
CREATE USER 'blog'@'172.18.%' IDENTIFIED BY 'strong-password';
GRANT ALL PRIVILEGES ON blogdb.* TO 'blog'@'172.18.%';
FLUSH PRIVILEGES;

千万不要图省事用 'blog'@'%',除非 3306 端口只监听内网。用 % 意味着任何能连到 3306 的地址都可能尝试登录。检查监听地址:

# 数据库必须只监听内网,不能是 0.0.0.0 暴露到公网
ss -tlnp | grep 3306
# 期望看到 10.0.0.5:3306 或 172.18.0.2:3306
# 如果看到 0.0.0.0:3306,请立刻在 my.cnf 设置 bind-address = 内网IP

如果必须跨主机访问数据库,请不要直接暴露 3306,用 WireGuard 之类的加密隧道把两台机连成一个内网,让数据库只听隧道地址。这比在防火墙里开端口安全得多。

陷阱三:挂载卷的权限在换机器后全部失效

这是第二个高频事故。你在 A 机器上 chown -R www-data:www-data /data/www 一切正常,把数据目录同步到 B 机器后,Nginx 报 403,PHP 报 Permission denied: failed to open stream。

根因是数字 UID/GID 在机器之间不保证一致。宿主机上 www-data 是 UID 33,容器里可能是 82,B 机器上又可能是 1000。文件权限记录的是数字,不是名字。

# 在 A 机器和 B 机器分别执行,比对数字
getent passwd www-data
# A: www-data:x:33:33:...
# B: www-data:x:1000:1000:...   <-- 数字不同,权限必然错乱

# 容器内也要比对
docker compose exec web id www-data

解决办法是统一为数字 UID,用 user: "33:33" 在 Compose 里显式声明,让容器固定以这个 UID 运行:

services:
  web:
    image: php:8.2-fpm
    user: "33:33"          # 显式固定,不依赖镜像内的用户名
    volumes:
      - /data/www:/var/www/html
      - ./php-fpm.conf:/usr/local/etc/php-fpm.d/zz.conf:ro

另一个更隐蔽的相关问题:SELinux/AppArmor 会阻止容器访问挂载目录,报错信息同样是权限拒绝,但 ls -l 看起来完全正常,因为问题出在安全模块而不是 Unix 权限位。

# 检查 SELinux 是否拦截
getenforce                 # Enforcing 表示开启
ausearch -m avc -ts recent | tail -20   # 找 AVC denied 记录

# 临时验证是否为 SELinux 所致(仅用于定位,不要长期关闭)
setenforce 0
# 若问题消失即可确认,随后用 :z 或 :Z 重标挂载卷,而不是永久关闭 SELinux
docker run -v /data/www:/var/www/html:z ...

Compose 里的写法是 - /data/www:/var/www/html:z。加 z 后 Docker 会自动给目录打上正确的 SELinux 标签。

陷阱四:配置文件挂了但应用没重新加载

这个坑的迷惑性在于"操作确实做了,但没生效"。你改了 Nginx 配置,执行了 docker compose restart nginx,访问结果还是旧的。原因是宿主机上改的文件没有真正进到容器里,或者 Nginx 读的是别的路径。

排查顺序,逐条验证,不要跳步:

# 1. 宿主机上文件内容确实是新的
grep "关键配置" ./nginx/site.conf

# 2. 容器内看到的内容也是新的(这一步最容易被跳过)
docker compose exec nginx cat /etc/nginx/conf.d/default.conf | grep "关键配置"

# 3. 容器内实际的 Nginx 配置树,确认 include 路径覆盖了你的文件
docker compose exec nginx nginx -T | grep -n "server_name"

# 4. 语法检查通过
docker compose exec nginx nginx -t

# 5. 真正重载(reload 优于 restart,不中断连接)
docker compose exec nginx nginx -s reload

注意第 3 步用 nginx -T(大写 T)会打印合并后的完整配置,这是唯一能确认"我的文件真的被加载了"的方式。-t 只做语法检查,即使你的文件被 include 顺序覆盖了它也不会报错。

一个非常常见的具体案例:官方 Nginx 镜像默认 include /etc/nginx/conf.d/*.conf;,如果你把宿主机文件挂载到 /etc/nginx/sites-enabled/,那是 Debian 发行版系的位置,官方镜像里根本没有 include 这个目录,你的配置就是一段死文件。表现为"配置改了完全没反应,服务也不报错"。

陷阱五:启动顺序不等于就绪顺序

Compose 的 depends_on 只保证启动顺序,不保证被依赖的服务已经准备好接受连接。MySQL 容器从 running 到真正能接受查询,中间有十几秒甚至更长的初始化时间(尤其是首次启动要建库)。

结果就是 web 容器启动时连数据库失败,直接退出,或者带病运行。这个问题在重启时特别常见:docker compose up -d 所有容器同时起,web 抢在 db 前面。

Compose 的解法是用 healthcheck + condition,让依赖方等待健康状态:

services:
  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PW}
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${DB_ROOT_PW}"]
      interval: 5s
      timeout: 5s
      retries: 12
      start_period: 30s      # 首次初始化很慢,给足缓冲
    volumes:
      - dbdata:/var/lib/mysql

  web:
    build: .
    depends_on:
      db:
        condition: service_healthy   # 关键:等健康,不是等启动
    restart: unless-stopped

volumes:
  dbdata:

start_period 这个参数经常被漏掉。它表示"这段时间内的失败不计入 retries",正是为数据库首次初始化这种慢启动场景设计的。不设这个值,MySQL 容器会被 healthcheck 反复判定为 unhealthy。

验证健康状态是否真的生效:

docker compose ps
# STATUS 列应显示 (healthy),而不是仅 Up
docker inspect -f '{{.State.Health.Status}}' $(docker compose ps -q db)

如果你的应用本身不支持等待(比如老 PHP 应用没有重试逻辑),最稳妥的办法是在入口脚本里加轮询等待:

#!/bin/sh
# docker-entrypoint-wrapper.sh
set -e
echo "等待数据库就绪..."
i=0
until nc -z db 3306; do
  i=$((i+1))
  [ $i -gt 60 ] && { echo "数据库 60 次重试后仍不可用,退出"; exit 1; }
  sleep 2
done
echo "数据库已就绪,启动应用"
exec "$@"

把 ENTRYPOINT ["/entrypoint-wrapper.sh"] 指向它。set -e 配合明确的失败退出,能避免"应用带着坏连接一直半死不活"的状态——那比直接崩溃更难排查。

陷阱六:日志分散在各容器,出问题无从下手

多容器之后,最消耗时间的其实不是故障本身,而是定位故障在哪一层。请求经过 CDN → Nginx → PHP → MySQL,日志分布在四个地方。

最低成本的改善是给所有容器统一日志配置,并保证宿主机能按时间线同时查看:

# docker-compose.yml 顶层
x-logging: &default-logging
  driver: json-file
  options:
    max-size: "20m"      # 单文件上限,防止磁盘被日志吃满
    max-file: "5"        # 保留份数
    tag: "{{.Name}}"

services:
  web:
    logging: *default-logging
  db:
    logging: *default-logging
  nginx:
    logging: *default-logging

max-size 和 max-file 不是可选项。没有它们,一个死循环报错的容器能在几小时内写满磁盘,而磁盘满了之后会引发一连串更难诊断的故障。

按时间线联合查看(排查跨服务问题时最有用):

docker compose logs -f --tail=50 -t nginx web db
# -t 加时间戳,才能把三个服务的日志按真实发生顺序对齐

上线前的检查清单

把上面的坑整理成一个可以在每次部署后跑一遍的清单:

#!/bin/bash
# verify-deploy.sh —— 多容器部署后的一致性检查
set -uo pipefail

echo "=== 1. 容器健康状态 ==="
docker compose ps

echo "=== 2. 容器内实际读到的关键配置 ==="
docker compose exec -T web env | grep -Ei 'db_host|redis_host|app_env' || echo "!! 配置未注入"

echo "=== 3. 跨容器连通性 ==="
docker compose exec -T web sh -c 'cat < /dev/tcp/db/3306' && echo "DB 端口可达" || echo "!! DB 不可达"

echo "=== 4. 挂载是否生效(文件内容而非路径存在) ==="
docker compose exec -T nginx nginx -T 2>/dev/null | grep -q "你的标志性配置" \
  && echo "Nginx 配置已加载" || echo "!! Nginx 未加载新配置"

echo "=== 5. 数据库监听地址 ==="
ss -tlnp | grep -E ':3306|:6379' | grep -v 127.0.0.1 \
  && echo "!! 数据库/缓存暴露在非回环地址,确认已封防火墙"

echo "=== 6. 卷的剩余空间 ==="
df -h | awk 'NR==1 || /\/data|\/var\/lib\/docker/'

echo "=== 7. 日志驱动是否配置上限 ==="
for c in $(docker compose ps -q); do
  docker inspect -f '{{.Name}} {{.HostConfig.LogConfig.Config}}' "$c"
done

这个脚本的价值在于:它检查的是结果(配置真的被读到了吗、端口真的通吗),而不是动作(我执行了重启命令)。在分布式环境里,"我执行了操作"和"操作生效了"之间经常有一条你想象不到的鸿沟。

小结

多节点部署带来的问题,绝大多数不是复杂的技术难题,而是单机时代形成的直觉在分布式环境里失效:

  • 127.0.0.1 是容器自己的,跨容器通信要用服务名或内网 IP;
  • MySQL 用户由 (user, host) 共同定义,容器来源 IP 会变,要按网段授权且绝不用 %;
  • 文件权限认数字 UID,跨机器要统一编号;别忘了 SELinux 也会拦截;
  • 配置改完要用 nginx -T 确认真的被加载,而不是只看文件存在;
  • depends_on 只管启动顺序,要就绪必须加 healthcheck + condition;
  • 日志必须设上限,否则磁盘被写满会引发更难查的连带故障。

最后一句经验:每次遇到这类问题,把它沉淀成上面那个检查脚本里的一条断言。多节点环境的运维水平,本质上就是这些断言覆盖了多少你曾经踩过的坑。

Last modification:September 26th, 2026 at 08:27 pm

Leave a Comment