Docker 基础体系 · 第 57/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Compose 反向代理架构:Nginx、TLS、服务发现、路由和零停机

在一个典型的 Compose 部署中,客户端不直接访问应用容器,而是先连接 Nginx。Nginx 负责 TLS 终止、请求路由和反向代理;应用服务只在 Compose 网络中监听,不必直接暴露到宿主机。

一个最小的数据流如下:

flowchart LR
    C[客户端] -->|HTTPS :443| E[Nginx edge]
    E -->|Compose 网络<br/>HTTP :80| A[backend 容器]
    A --> D[(数据库或其他依赖)]
    E -.->|Docker Embedded DNS| DNS[127.0.0.11]
    DNS -.->|backend -> 容器 IP| A

这里有四个必须区分的地址:

  • 客户端访问的是宿主机地址,例如 https://app.example.test
  • Nginx 在宿主机上发布 80443
  • Nginx 到应用使用 Compose 网络中的服务名和容器端口,例如 http://backend:80
  • backend 不是宿主机 DNS 名称,而是 Docker 用户定义网络中的服务发现名称。

如果把这些层次混淆,最常见的结果是:容器内访问 localhost 访问到了错误的容器、把容器端口错误地写成宿主机端口,或者应用容器重建后 Nginx 仍然连接旧 IP。


一、反向代理架构中的各个角色

1. 正向代理与反向代理

正向代理代表客户端访问外部服务。客户端知道代理的存在,并把请求交给代理。

反向代理代表服务端接收客户端请求。客户端通常只知道公开域名,不知道后端应用的实际地址:

客户端 -> Nginx -> 应用服务

Nginx 接收客户端的 TCP/TLS 连接,再以另一个连接访问后端。这意味着一次请求至少涉及两个连接:

  1. 客户端到 Nginx;
  2. Nginx 到后端容器。

客户端连接使用 HTTPS,并不自动意味着 Nginx 到后端也是 HTTPS。后端协议由 Nginx 的 proxy_pass 决定:

proxy_pass http://backend:80;

表示边缘到后端是 HTTP;而:

proxy_pass https://backend:8443;

才表示 Nginx 到后端也使用 TLS。生产环境是否需要后端 TLS,取决于网络边界和安全要求,不能从“前端使用 HTTPS”推导出来。

2. TLS 终止

TLS 终止是指 Nginx 完成 TLS 握手、证书校验和加密解密,然后把请求以明文 HTTP 转发到后端。

这种架构的优点是:

  • 证书只在边缘层管理;
  • 应用不必实现证书轮换和 TLS 配置;
  • 多个应用可以共享同一个 443 入口;
  • 可以在进入应用前统一执行重定向、限流、请求头处理和路由。

它也产生一个安全边界:从 Nginx 到后端的流量在 Docker 网络中是明文的。若 Docker 网络跨越不可信主机、存在严格的合规要求,或者后端网络并非可信边界,就应使用后端 TLS,形成 TLS re-encryption:

客户端 --HTTPS--> Nginx --HTTPS--> backend

3. 服务发现

服务发现是“根据逻辑名称找到当前服务实例地址”的过程。在 Compose 创建的用户定义网络中,Docker 提供内置 DNS。服务名通常可以直接解析:

backend -> 172.20.0.5

容器重建后,IP 可能变为 172.20.0.8,但服务名仍然是 backend。因此应用之间不应硬编码容器 IP。

Compose 的服务名是网络范围内的逻辑名称,而不是:

  • 宿主机的 /etc/hosts 条目;
  • 公共 DNS 记录;
  • 可以从互联网访问的域名;
  • 宿主机上可直接解析的名称。

4. 路由

路由决定请求应该进入哪个后端。常见维度有:

  • 主机名:api.example.comweb.example.com
  • 路径前缀:/api//static/
  • 请求方法、请求头或 Cookie;
  • 蓝绿版本标记。

Nginx 的 server_name 主要解决主机名路由,location 主要解决路径路由。路由规则是有顺序和匹配语义的,不能简单理解为“从上到下逐字匹配”。

5. 零停机

零停机不是“容器从来不停止”,而是对于满足条件的客户端请求,发布过程中仍有可用且正确的后端实例。

至少要区分三件事:

  • 进程存活:容器中的进程还存在;
  • 服务就绪:服务已经能正确处理业务请求;
  • 连接排空:旧实例不再接收新请求,但已有请求允许完成。

Docker healthcheck 主要表达健康状态,不能自动完成 Nginx 的连接排空,也不能让 Compose 获得完整的滚动发布能力。


二、Compose 网络、端口和 DNS

下面这个 Compose 文件展示一个边缘 Nginx 和一个后端 Nginx:

services:
  edge:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - edge_net

  backend:
    image: nginx:1.27-alpine
    expose:
      - "80"
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
      interval: 5s
      timeout: 2s
      retries: 12
      start_period: 5s
    networks:
      - edge_net

networks:
  edge_net:
    driver: bridge

这里有几个重要区别。

portsexpose

ports:
  - "443:443"

表示把容器端口发布到宿主机。外部客户端可以通过宿主机的 443 连接到 Nginx。

expose:
  - "80"

主要是声明服务使用的内部端口,不创建宿主机端口映射。edge 可以通过 Compose 网络访问 backend:80,但宿主机不会因为这项配置而开放一个新的端口。

因此,Nginx 应该写:

proxy_pass http://backend:80;

而不是:

proxy_pass http://localhost:80;

edge 容器内,localhost 指的是 edge 自己。也不应默认写宿主机映射端口,因为容器间通信走的是容器端口,不经过宿主机发布端口。

服务名和容器名

Compose 默认把服务加入项目网络,并为服务名提供 DNS 记录。应用应依赖服务名:

backend

不建议把 container_name 作为服务发现机制。固定容器名会限制同一服务的水平扩展,因为多个实例不能共享同一个固定名称。

DNS 解析不是永久 IP 租约

Docker DNS 返回的是当前网络状态下的解析结果。容器重建时,服务名不变,IP 可能变化:

第一次:
backend -> 172.20.0.5

重建后:
backend -> 172.20.0.9

客户端库通常会在连接失败后重新解析或建立新连接,但 Nginx 的上游解析行为需要单独配置,不能仅凭“配置里写了服务名”就断定它会始终跟踪新 IP。


三、depends_on、Healthcheck 与真正的就绪状态

1. 启动顺序不是可用性保证

最简单的配置:

depends_on:
  - backend

表达的是 Compose 创建 backend 后再创建 edge。它不等价于:

  • 后端已经监听端口;
  • 后端已经完成数据库迁移;
  • 后端已经可以处理业务请求;
  • 后端之后永远健康。

如果 Nginx 启动时后端进程还没有监听端口,首次请求可能得到 502 Bad Gateway

2. 使用健康条件控制初始启动

可以把后端定义为带健康检查的服务:

services:
  edge:
    image: nginx:1.27-alpine
    depends_on:
      backend:
        condition: service_healthy

  backend:
    image: nginx:1.27-alpine
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
      interval: 5s
      timeout: 2s
      retries: 12
      start_period: 5s

状态变化大致为:

created -> starting -> healthy
                    \-> unhealthy

Compose 在初始创建 edge 时等待 backend 进入 healthy,但这只是启动阶段的条件。之后 backend 变成 unhealthy 时,Compose 不会因此自动重启 edge,也不会自动修改 Nginx 上游列表。

3. 健康检查必须检查“就绪”,而非仅检查进程

下面的检查价值很低:

test: ["CMD", "pidof", "nginx"]

它只能说明进程存在,不能说明 HTTP 服务可用。

更有意义的检查至少应验证:

  • 监听端口可接受连接;
  • HTTP 返回预期状态码;
  • 必要时检查应用依赖是否已准备好。

例如应用有 /readyz

healthcheck:
  test:
    [
      "CMD-SHELL",
      "wget -q -O - http://127.0.0.1/readyz | grep -q '^ready$'"
    ]
  interval: 5s
  timeout: 2s
  retries: 12
  start_period: 10s

健康检查命令必须存在于镜像中。精简镜像不一定包含 curl,上述示例使用 Alpine 常见的 BusyBox wget,但实际项目仍应通过构建镜像后验证,而不是假设所有基础镜像都相同。


四、Nginx 的服务发现与容器重建

1. 静态上游配置的边界

常见写法是:

upstream backend_pool {
    server backend:80;
}

server {
    listen 80;

    location / {
        proxy_pass http://backend_pool;
    }
}

Nginx 通常在配置加载时解析 backend。如果后端容器后来被重建并获得新 IP,已加载的 Nginx worker 可能仍使用旧地址,直到 Nginx reload 或重新启动。

这不是 Docker DNS 失效,而是“DNS 解析发生在何时”和“代理是否重新解析”两个问题。

2. 使用 Docker 内置 DNS 的动态解析

可以让 Nginx 使用 Docker DNS,并通过变量形式的 proxy_pass 触发运行时解析:

resolver 127.0.0.11 valid=10s ipv6=off;

server {
    listen 80;

    location / {
        set $backend http://backend:80;
        proxy_pass $backend;
    }
}

127.0.0.11 是 Docker 用户定义网络中常见的内置 DNS 地址。valid=10s 表示 Nginx 对解析结果使用有限缓存时间;它不是 Docker DNS 的 TTL 修改,也不保证 10 秒内一定完成切换。

这种写法的关键机制是:

  1. 请求进入 location
  2. Nginx 需要构造变量形式的上游 URL;
  3. Nginx 通过 resolver 查询 backend
  4. 使用解析到的地址建立连接;
  5. 缓存结果一段时间,随后重新解析。

动态解析的代价是每次解析结果变化时,连接可能转移到新实例,且配置中的 URI 处理语义要更加谨慎。为了减少歧义,代理路径重写应显式写清楚。

例如:

location /api/ {
    set $backend http://backend:80;
    proxy_pass $backend;
}

这种写法会把原始请求 URI 传给后端,例如 /api/users 仍然是 /api/users。如果希望去掉 /api/ 前缀,应明确使用 URI:

location /api/ {
    proxy_pass http://backend/;
}

这里的 / 会改变转发 URI。不要只因为看到两种写法都能启动,就认为它们路由结果相同。

3. 另一种方案:发布时 reload Nginx

如果不希望使用变量形式的 proxy_pass,也可以使用静态上游配置,并在后端容器更换后 reload Nginx:

docker compose exec edge nginx -t
docker compose exec edge nginx -s reload

nginx -t 先验证配置语法和引用文件;只有验证成功后才执行 reload。reload 通常让旧 worker 处理已有连接,新 worker 使用新配置接收新请求。

这种方案要求发布流程知道“后端地址或容器状态发生了变化”。它的优点是配置语义直观;缺点是需要把 reload 纳入交付流程。

4. Nginx 不会自动读取 Docker health 状态

即使 Compose 显示:

backend  ... (unhealthy)

Nginx 也不会自动知道这个状态。开源版 Nginx 的常规 upstream 机制主要依赖被动失败判断,例如连接失败或响应失败;它不是 Docker healthcheck 的订阅者。

因此:

Docker healthcheck = 容器编排层的健康信号
Nginx upstream      = 代理层的连接和响应行为

两者需要通过发布流程、服务发现机制或额外的控制组件连接起来,不能混为一谈。


五、TLS 终止的完整配置

1. 生成本地测试证书

生产证书应来自受信任的 CA。下面的命令只适合本地测试:

mkdir -p certs

openssl req -x509 -nodes -newkey rsa:2048 \
  -keyout certs/server.key \
  -out certs/server.crt \
  -days 30 \
  -subj "/CN=app.example.test" \
  -addext "subjectAltName=DNS:app.example.test"

chmod 600 certs/server.key

subjectAltName 很重要。现代客户端通常依据 SAN 校验证书主机名,仅设置传统 CN 不能替代 SAN。

2. Nginx 配置

nginx/nginx.conf

events {}

http {
    resolver 127.0.0.11 valid=10s ipv6=off;

    log_format main
        '$remote_addr "$request" $status '
        'host="$host" upstream="$upstream_addr" '
        'request_time=$request_time upstream_time=$upstream_response_time';

    access_log /var/log/nginx/access.log main;

    server {
        listen 80;
        server_name app.example.test;

        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name app.example.test;

        ssl_certificate     /etc/nginx/certs/server.crt;
        ssl_certificate_key /etc/nginx/certs/server.key;

        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;

        location / {
            set $backend http://backend:80;

            proxy_http_version 1.1;
            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 https;

            proxy_connect_timeout 3s;
            proxy_read_timeout 60s;
            proxy_send_timeout 60s;

            proxy_pass $backend;
        }
    }
}

这段配置完成了几个因果链:

  1. 80 收到请求后返回 301
  2. $host 保留客户端请求的主机名;
  3. $request_uri 保留原始路径和查询字符串;
  4. 443 完成 TLS 握手;
  5. proxy_pass 把请求发送到 Compose 服务 backend
  6. X-Forwarded-Proto https 告诉后端,客户端与边缘之间使用的是 HTTPS。

如果应用根据 X-Forwarded-Proto 判断是否安全,必须只信任来自可信 Nginx 的该请求头。不能让互联网客户端任意伪造后端信任的代理头。Nginx 的 proxy_set_header X-Forwarded-Proto https 会覆盖传入值,这比直接转发客户端提供的同名头更安全。

3. WebSocket 需要升级头

普通 HTTP 代理不自动完成 WebSocket 升级。若应用使用 WebSocket,应增加:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    server_name app.example.test;

    # TLS 配置省略重复部分

    location /socket/ {
        set $backend http://backend:80;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;

        proxy_read_timeout 1h;
        proxy_pass $backend;
    }
}

proxy_read_timeout 是空闲读取超时,不是 WebSocket 生命周期的硬上限。设置过大可能导致异常连接长期占用资源,实际值应结合心跳和连接数容量确定。


六、基于主机名和路径的路由

1. 主机名路由

可以在同一个 443 监听器上配置多个虚拟主机:

server {
    listen 443 ssl;
    server_name api.example.test;

    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;

    location / {
        set $api_backend http://api:80;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_pass $api_backend;
    }
}

server {
    listen 443 ssl;
    server_name web.example.test;

    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;

    location / {
        set $web_backend http://web:80;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_pass $web_backend;
    }
}

客户端发送的 TLS ClientHello 还可能包含 SNI,Nginx 可以据此选择证书;TLS 建立后,HTTP Host 又参与虚拟主机选择。证书必须覆盖客户端实际访问的域名,否则客户端会在到达 HTTP 路由前就报告证书错误。

2. 路径路由与前缀处理

server {
    listen 443 ssl;
    server_name app.example.test;

    location /api/ {
        set $api_backend http://api:80;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Prefix /api;
        proxy_pass $api_backend;
    }

    location / {
        set $web_backend http://web:80;
        proxy_set_header Host $host;
        proxy_pass $web_backend;
    }
}

此时 /api/users 会带原路径转发到 api。若后端只接受 /users,则应写:

location /api/ {
    proxy_pass http://api/;
}

Nginx 在 proxy_pass 中是否包含 URI,会影响匹配到的 location 前缀是否被替换。路径重写错误通常表现为:

  • 后端返回 404
  • 静态资源 URL 失效;
  • 应用重定向到错误路径;
  • API 文档中的基础路径不正确。

诊断时要同时查看 Nginx access log 中的 $request 和后端实际收到的 URI,不能只看客户端 URL。

3. location 匹配不是简单的最长字符串替换

例如:

location / {
    ...
}

location /api/ {
    ...
}

/api/users 会优先匹配更具体的 /api/。但正则 location、精确匹配 =, 以及 ^~ 会改变选择过程。复杂配置应通过实际请求验证,而不是依靠直觉排列配置块。


七、从 Compose 启动到请求失败的状态路径

一次初始启动可以抽象为:

sequenceDiagram
    participant C as docker compose
    participant D as Docker Engine
    participant B as backend
    participant E as edge
    participant U as 客户端

    C->>D: 创建网络
    C->>D: 创建 backend
    D->>B: 启动容器
    B-->>D: healthcheck starting
    B-->>D: healthcheck healthy
    C->>D: 创建并启动 edge
    E->>D: 解析 backend
    U->>E: HTTPS 请求
    E->>B: HTTP 代理请求
    B-->>E: HTTP 响应
    E-->>U: HTTPS 响应

后端异常时,路径可能变成:

后端进程退出
  -> Docker healthcheck 失败或容器停止
  -> Nginx 仍保留原有上游信息
  -> 新请求连接失败或超时
  -> Nginx 返回 502/504

其中:

  • 502 Bad Gateway 常见于无法建立后端连接、后端返回无效响应;
  • 504 Gateway Timeout 常见于后端连接建立后,在配置的读取或发送超时时间内没有完成。

如果 Nginx 有多个上游实例,proxy_next_upstream 可以在某些失败类型下尝试下一个实例:

upstream api_pool {
    server api:80;
}

location /api/ {
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_pass http://api_pool;
}

但这不是事务回滚。请求可能已经被后端部分处理,随后 Nginx 才发现连接失败;对有副作用的 POST 请求自动重试可能造成重复写入。重试策略必须结合幂等键、请求方法和业务语义。


八、Compose 中的扩展与负载分发

可以尝试扩展后端:

docker compose up -d --scale backend=2
docker compose ps

预期会看到两个 backend 实例,但前提是没有配置固定的 container_name

服务名解析在多实例场景下可能返回多个地址,客户端或代理是否使用多个地址,取决于其 DNS 和连接池行为。不要仅凭 DNS 返回多个 IP,就推导出 Nginx 一定会以理想方式进行负载均衡。

更明确的 Nginx upstream 可以写成:

upstream api_pool {
    zone api_pool 64k;

    server api_1:80;
    server api_2:80;
}

但这要求实例名称稳定,通常不适合直接交给 Compose 自动生成的副本名称。对 Compose 来说,更现实的做法通常是:

  • 让 Nginx 通过 Docker DNS 发现服务;
  • 或在发布脚本中生成上游配置并 reload;
  • 或使用专门的服务代理/编排系统处理动态实例。

Compose 规范描述了服务、网络、卷、健康检查和依赖等应用模型,但 Compose 本身不是 Kubernetes、Nomad 或 Swarm,不应假设它提供完整的调度、滚动升级、自动摘除和跨节点故障转移能力。


九、零停机发布的必要条件

1. 单实例重建为什么不能保证零停机

假设只有一个 backend

backend-old 正在服务
停止 backend-old
启动 backend-new
等待健康

停止和新实例就绪之间存在空窗。即使 Nginx 容器没有停止,仍可能产生:

502:没有可连接的后端

因此,“docker compose up -d 没有报错”不等价于“用户无感知”。

2. 至少两个实例与就绪门槛

要让旧实例更新期间仍有服务,至少需要:

backend-1 healthy  <- 接收流量
backend-2 healthy  <- 新版本先启动并验证
backend-1         <- 再排空并移除

但 Compose 的普通 up 命令不定义通用的滚动更新算法。即使使用多个副本,也必须明确:

  • 新实例何时算就绪;
  • 旧实例何时停止接收新请求;
  • 长连接如何排空;
  • 数据库迁移是否向前兼容;
  • 失败时如何恢复到旧版本。

3. 一个可执行的蓝绿发布思路

蓝绿发布保留两组服务:

blue  -> 当前生产版本
green -> 新版本

先启动并验证 green:

docker compose -f compose.green.yml up -d
docker compose -f compose.green.yml ps
docker compose -f compose.green.yml exec app wget -q -O - http://127.0.0.1/readyz

然后修改 Nginx 上游,例如从:

set $app_backend http://app_blue:80;

切换到:

set $app_backend http://app_green:80;

验证配置并 reload:

docker compose exec edge nginx -t
docker compose exec edge nginx -s reload

最后观察错误率、延迟和业务指标,再删除 blue。

这个过程之所以可以减少停机,是因为切换前 green 已经独立运行。它仍然不是绝对零风险:

  • green 可能只在简单健康检查上正常;
  • 切换后可能暴露真实流量问题;
  • 有状态会话可能仍绑定旧实例;
  • 数据库 schema 变更可能使旧版本无法继续运行;
  • reload 只改变 Nginx 配置,不会修复应用自身的兼容性问题。

4. 连接排空

对于短 HTTP 请求,旧实例通常可以快速停止;对于 WebSocket、SSE 或长轮询,停止容器可能立即断开大量连接。

零停机需要应用支持优雅关闭:

  1. 标记实例不再就绪;
  2. 代理不再向它发送新请求;
  3. 等待已有请求完成;
  4. 超过最大排空时间后强制终止。

Compose 的 stop_grace_period 可以延长停止等待时间:

services:
  backend:
    stop_grace_period: 30s

它只控制 Docker 停止容器时等待的时间。它不会自动通知 Nginx 摘除实例,也不会为应用实现排空逻辑。应用需要处理终止信号并停止接受新请求。


十、证书轮换与 Nginx reload

证书不应直接打包进应用镜像。可以将证书以只读方式挂载:

services:
  edge:
    volumes:
      - ./certs:/etc/nginx/certs:ro

轮换流程可以是:

cp new/fullchain.pem certs/server.crt
cp new/privkey.pem certs/server.key

docker compose exec edge nginx -t
docker compose exec edge nginx -s reload

顺序不能反过来:

  • 若先 reload 再替换文件,Nginx 可能仍加载旧证书;
  • 若新证书或私钥格式错误,nginx -t 应该失败;
  • nginx -t 失败时,不应执行 reload。

对于挂载文件的替换,最好先在临时路径验证证书和私钥匹配,再以原子方式替换。验证命令示例:

openssl x509 -in certs/server.crt -noout -subject -issuer -dates
openssl x509 -in certs/server.crt -pubkey -noout | openssl sha256
openssl pkey -in certs/server.key -pubout | openssl sha256

两条 SHA-256 摘要应相同,才能说明证书公钥和私钥匹配。


十一、端到端运行与验证

目录结构:

.
├── compose.yml
├── nginx
│   └── nginx.conf
└── certs
    ├── server.crt
    └── server.key

启动:

docker compose config
docker compose up -d
docker compose ps

docker compose config 会展开并校验 Compose 配置。docker compose ps 应看到:

  • edge 处于运行状态;
  • backend 处于 healthy,而不是仅仅 Up

测试 HTTP 到 HTTPS 的重定向:

curl -i --resolve app.example.test:80:127.0.0.1 \
  http://app.example.test/

预期包含:

HTTP/1.1 301 Moved Permanently
Location: https://app.example.test/

测试 HTTPS:

curl -k -i --resolve app.example.test:443:127.0.0.1 \
  https://app.example.test/

--resolve 只在本次请求中把域名映射到 127.0.0.1,同时保留正确的 Host 和 TLS SNI。-k 忽略本地自签名证书错误;生产验证不应使用 -k,而应安装正确的信任链。

查看证书和 TLS 协议:

openssl s_client \
  -connect 127.0.0.1:443 \
  -servername app.example.test \
  -showcerts </dev/null

这里的 -servername 很重要。没有 SNI 时,多虚拟主机配置可能返回默认证书,导致验证结果与真实客户端不同。

检查容器内 DNS:

docker compose exec edge getent hosts backend

如果镜像没有 getent,可以使用:

docker compose exec edge nginx -T

并结合请求日志判断解析和连接情况。也可以从后端反向检查网络:

docker compose exec edge wget -q -O - http://backend/

查看代理错误:

docker compose logs edge
docker compose exec edge tail -f /var/log/nginx/error.log

诊断时按层次排查:

宿主机端口是否监听
  -> TLS 是否成功
  -> Nginx server_name 是否匹配
  -> Docker DNS 是否能解析服务名
  -> 后端容器是否监听容器端口
  -> 健康检查是否真正通过
  -> 后端是否返回有效 HTTP 响应

不要在看到 502 后直接修改 TLS 配置;502 发生在 TLS 成功之后,通常应优先检查 Nginx 到后端的连接。


十二、常见失败配置及其原因

失败一:在 Nginx 中使用 localhost

proxy_pass http://localhost:8080;

如果 Nginx 和应用在不同容器中,这会访问 Nginx 容器自身的 8080。除非 Nginx 和应用确实运行在同一个容器,否则这是错误的服务发现方式。

正确方向是:

proxy_pass http://backend:80;

失败二:把宿主机端口当成容器端口

backend:
  ports:
    - "8080:80"

然后 Nginx 写:

proxy_pass http://backend:8080;

这里的 8080 是宿主机端口映射,后端容器在 Compose 网络中仍监听 80。Nginx 应写:

proxy_pass http://backend:80;

失败三:只写 depends_on 就认为发布安全

depends_on:
  - backend

它只影响初始创建顺序,不能解决:

  • 后端启动后仍未就绪;
  • 后端运行中变为不健康;
  • 后端重建后 IP 变化;
  • 发布时旧实例连接排空。

失败四:认为健康检查会自动摘除后端

健康检查失败不会自动改变 Nginx 的配置。若代理必须依据主动健康状态摘除节点,需要额外的控制机制,或者使用支持该能力的代理/编排系统。

失败五:使用自签名证书测试生产行为

curl -k 可以确认请求链路大致可用,但它跳过证书信任校验,不能证明:

  • 证书链完整;
  • 域名匹配;
  • 客户端信任正确;
  • 中间证书配置正确。

本地测试和生产 TLS 验证必须分开。

失败六:把 Nginx reload 当成应用滚动发布

Nginx reload 只重新加载 Nginx 配置和证书。它不会:

  • 启动新的应用容器;
  • 执行数据库迁移;
  • 等待应用 readiness;
  • 迁移连接;
  • 回滚业务状态。

它只是切换代理进程使用的配置,不能替代完整交付流程。


十三、Compose 适用的边界

Compose 很适合描述单机或单个 Linux 主机上的多容器应用:

网络创建
服务创建
卷挂载
环境变量
健康检查
依赖关系
端口发布

但“单机 Compose 反向代理”有明确边界:

  • Docker Engine 主机故障时,整套服务可能不可用;
  • Compose 不自动跨主机调度;
  • 普通 up 不提供通用滚动升级语义;
  • 健康检查不会自动建立全链路流量摘除;
  • Nginx 不知道应用层业务是否真的安全;
  • 容器删除后,容器内临时状态会丢失;
  • 数据库、会话、文件和队列需要独立设计高可用与备份。

因此,在单机上可以通过“多后端实例 + readiness + Nginx 动态解析或 reload + 优雅停止”降低发布中断概率;但若要求跨节点故障转移、自动滚动升级、自动调度和声明式回滚,就需要更强的编排或发布系统。


十四、一个可验证的发布条件

可以把一次后端切换抽象成以下条件。设:

  • AoldA_{\text{old}} 是旧实例集合;
  • AnewA_{\text{new}} 是新实例集合;
  • RR 是被路由到当前流量的实例集合;
  • H(x)H(x) 表示实例 xx 已通过就绪检查;
  • C(x)C(x) 表示实例 xx 仍能处理已有连接。

要让切换前后都存在可用实例,至少需要:

t[tswitch,tdrain]:xR(t), H(x)=true\forall t \in [t_{\text{switch}}, t_{\text{drain}}]: \exists x \in R(t),\ H(x)=\text{true}

也就是在切换和旧实例排空期间,路由集合中始终至少有一个已就绪实例。

对于多实例服务,还需要容量条件。设:

  • L(t)L(t) 是时刻 tt 的请求负载;
  • KiK_i 是实例 ii 在目标延迟下的可处理容量;
  • S(t)S(t) 是当前接收流量的健康实例集合。

则至少应满足:

L(t)iS(t)KiL(t) \leq \sum_{i \in S(t)} K_i

如果发布时先摘除一个实例,使:

L(t)>iS(t)KiL(t) > \sum_{i \in S(t)} K_i

即使没有任何容器崩溃,也会因为排队、超时和连接耗尽产生服务中断。由此可见,零停机不仅是“还有一个容器在运行”,还要求剩余容量足以承载流量。

完整发布顺序应接近:

启动新版本
  -> 等待 readiness
  -> 确认至少一个健康实例接收流量
  -> 修改 Nginx 路由或服务发现结果
  -> reload 并验证
  -> 旧版本停止接收新请求
  -> 等待连接排空
  -> 停止旧版本

如果跳过“等待 readiness”,切换可能把流量发送到刚启动但尚未完成迁移的实例;如果跳过“连接排空”,长连接会被直接中断;如果跳过 reload 配置验证,错误配置可能让边缘代理整体无法重新加载。


十五、实践中的取舍

对于简单单机服务,可以采用:

客户端 HTTPS
  -> Nginx TLS 终止
  -> Compose 服务名发现
  -> 动态 DNS 或发布后 reload
  -> 多实例后端
  -> healthcheck + readiness
  -> 优雅停止

对于只需要一次性部署、后端重启不频繁的系统,静态 upstream 加发布后 reload 通常更容易理解和排查。

对于容器 IP 经常变化、需要自动发现的系统,Docker DNS 加 Nginx resolver 可以减少人工 reload,但必须验证当前 Nginx 版本和配置语义,并观察 DNS 缓存、失败重试和连接复用行为。

对于严格的零停机要求,不能只依赖 Compose 文件中的 depends_onhealthcheck。应把以下步骤作为交付流程的一部分:

构建镜像
  -> 启动新版本
  -> 健康与就绪验证
  -> 切换路由
  -> 观测错误率和延迟
  -> 连接排空
  -> 保留可回滚版本

最终,Nginx、TLS、Docker 服务发现和 Compose 依赖解决的是不同层的问题:

  • TLS 解决客户端到入口的身份与加密;
  • Nginx 解决入口代理和请求路由;
  • Docker DNS 解决服务名到当前容器地址的发现;
  • Healthcheck 表达容器健康状态;
  • Compose 管理服务生命周期;
  • 零停机发布还需要容量、就绪、排空、兼容性和回滚共同成立。

只有把这些边界连接起来,Compose 反向代理架构才不会停留在“容器能启动、页面能打开”的演示状态,而能成为可验证、可诊断、可交付的 Linux 容器部署方案。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。