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

Compose 扩容与并发:Replica、端口、负载均衡、共享状态和边界

在 Compose 中把一个服务从一个容器扩展到多个容器,表面上只是执行一次 --scale

docker compose up -d --scale app=3

但这条命令真正改变的是系统的并发模型:

  • 原来一个服务名通常对应一个容器;
  • 现在一个服务名对应多个独立的容器实例;
  • 每个实例有自己的进程、网络命名空间、可写层和内存;
  • 请求需要被分配到某个实例;
  • 如果实例之间需要共享登录态、任务状态或文件,就必须引入明确的共享状态机制;
  • 如果实例监听相同的宿主机端口,端口绑定会立即冲突。

因此,Compose 扩容不是“复制几个容器”这么简单,而是把单进程问题变成了一个小型分布式系统问题。

本文讨论的是现代 Docker Engine、BuildKit 和 Compose 规范下的 Linux 容器边界。Compose 可以管理一组容器及其网络、卷和生命周期,但它不是完整的集群调度器,也不自动提供 Swarm 或 Kubernetes 那样的服务调度、滚动发布和跨节点网络能力。


一、先区分三个数量:服务、容器和副本

1. Compose 服务不是一个进程

Compose 文件中的服务定义描述了一类容器:

services:
  app:
    image: example/app:1.0

这里的 app 是服务名,不是容器名,也不是进程名。执行普通启动时,Compose 通常会创建一个属于该服务的容器,例如:

project-app-1

执行扩容后,可能变成:

project-app-1
project-app-2
project-app-3

它们共享同一个服务定义,但不是同一个容器。每个容器都有自己的:

  • PID 命名空间;
  • 网络命名空间;
  • 文件系统可写层;
  • 环境变量实例;
  • 内存和 CPU 使用情况;
  • 进程生命周期。

因此,“服务有 3 个副本”更准确的含义是:

当前 Compose 项目中,有 3 个按照同一服务定义创建的容器实例。

2. Replica 表示期望实例数,不等于高可用

Replica,通常译为副本或实例数,是同一服务同时运行的容器数量。

命令:

docker compose up -d --scale app=3

表示希望 app 服务运行 3 个容器。可以使用以下命令核对实际结果:

docker compose ps app

示例输出可能是:

NAME          IMAGE        SERVICE   STATUS          PORTS
demo-app-1    demo-app     app       Up 20 seconds
demo-app-2    demo-app     app       Up 20 seconds
demo-app-3    demo-app     app       Up 20 seconds

这里的“期望”很重要。Compose 的职责主要是根据项目配置创建、启动、停止和删除容器。它不像集群调度器那样持续监视一个跨节点集群,并在节点故障后自动把副本调度到其他节点。

如果使用了 Docker 的重启策略,例如:

services:
  app:
    image: example/app:1.0
    restart: unless-stopped

Docker Engine 可以在容器进程退出后尝试重启该容器。但这仍然不是完整的副本调度:

  • 它通常是在原来的 Docker 主机上重启同一个容器;
  • 它不会自动把容器迁移到另一台主机;
  • 它不负责将流量从故障实例重新规划到健康实例;
  • 它不提供通用的滚动更新策略。

3. container_name 会破坏扩容模型

以下配置通常不能用于扩容:

services:
  app:
    image: example/app:1.0
    container_name: fixed-app

因为每个容器都必须拥有唯一名称,而 container_name 强制所有实例使用同一个名字。Compose 无法同时创建多个 fixed-app

扩容服务应让 Compose 自动生成实例名:

services:
  app:
    image: example/app:1.0

如果应用需要识别自身实例,可以读取容器的 hostname,或者在启动时生成实例标识,而不应把固定容器名当作服务发现机制。


二、docker compose up --scaledeploy.replicas 的边界

最直接、最明确的 Compose 扩容方式是:

docker compose up -d --scale app=3

缩容则是:

docker compose up -d --scale app=1

缩容时 Compose 会停止并删除多余的服务容器。正在处理的请求是否完成,取决于应用如何处理停止信号、Compose 的停止等待时间以及请求本身的生命周期。

Compose 文件还可以包含:

services:
  app:
    image: example/app:1.0
    deploy:
      replicas: 3

deploy 的语义取决于实际部署平台和实现。Compose Specification 允许声明部署相关属性,但不是所有属性在所有 Compose 实现或运行方式下都具有相同效果。尤其不能把:

deploy:
  replicas: 3

简单等同于 Swarm 中的:

docker service create --replicas 3 ...

在以 Docker Compose 管理本机容器的场景中,应使用实际命令验证行为:

docker compose config
docker compose up -d
docker compose ps

如果目标是使用 Swarm 的 Service、Task、跨节点调度、Overlay 网络或滚动更新,应使用 docker stack deploy 等 Swarm 机制,而不是把 Compose 的本机容器扩容误认为 Swarm 服务扩容。


三、容器端口与宿主机端口不是同一个概念

扩容中最常见的错误,是把“每个副本都监听容器端口”理解成“每个副本都可以发布同一个宿主机端口”。

1. 容器内端口可以重复

假设应用在容器内监听:

0.0.0.0:8000

扩展为三个容器后,实际存在三个不同的网络命名空间:

app-1:8000
app-2:8000
app-3:8000

它们可以同时监听 8000,因为端口只要求在各自的网络命名空间内唯一。

2. 宿主机发布端口必须满足绑定唯一性

以下配置把宿主机 8080 映射到容器 8000

services:
  app:
    image: example/app:1.0
    ports:
      - "8080:8000"

单实例时可以:

宿主机 0.0.0.0:8080
    └── app-1 容器:8000

但扩展为三个实例时,Compose 需要创建三个相同的绑定:

宿主机 0.0.0.0:8080 ── app-1:8000
宿主机 0.0.0.0:8080 ── app-2:8000
宿主机 0.0.0.0:8080 ── app-3:8000

同一个宿主机地址和端口不能被三个普通容器同时独占,因此通常会看到类似端口分配冲突的错误:

Bind for 0.0.0.0:8080 failed: port is already allocated

这不是应用端口冲突,而是 Docker 在宿主机层面无法重复创建同一个端口发布规则。

3. EXPOSEexposeports 的区别

Dockerfile 中:

EXPOSE 8000

主要是镜像元数据,表示应用预期使用 8000,不会自动让宿主机可以访问该端口。

Compose 中:

services:
  app:
    expose:
      - "8000"

表示向相关容器声明或开放容器端口,通常用于容器网络内部通信,但不创建宿主机端口发布。

Compose 中:

services:
  app:
    ports:
      - "8080:8000"

才是把宿主机端口 8080 映射到容器端口 8000

扩容时,后端服务通常不直接配置固定 ports,而是只在内部网络提供服务:

services:
  app:
    image: example/app:1.0
    expose:
      - "8000"

再由一个固定入口服务发布宿主机端口:

services:
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"

结构变为:

客户端
  │
  ▼
宿主机:8080
  │
  ▼
proxy 容器:80
  │
  ├── app-1:8000
  ├── app-2:8000
  └── app-3:8000

这样宿主机只需要绑定一个端口,副本之间的选择由内部入口或应用层负载均衡完成。

4. 动态宿主机端口能扩容,但不会自动形成统一入口

也可以写:

services:
  app:
    ports:
      - "8000"

这表示让 Docker 为每个容器随机分配宿主机端口,并映射到容器的 8000。扩容可能得到:

app-1: localhost:32771 -> 8000
app-2: localhost:32772 -> 8000
app-3: localhost:32773 -> 8000

可以通过:

docker compose ps

查看实际端口。

这种方式适合调试、测试或由外部系统读取端口列表,但它没有提供一个稳定的 localhost:8000 入口,也没有自动负载均衡。客户端必须知道多个动态端口,并自行选择或实现重试。


四、Compose 服务发现:服务名是入口,不是副本名

Compose 默认会为项目创建网络,并将同一网络中的服务接入其中。容器通常可以通过服务名访问另一个服务:

http://app:8000

应用不应该连接:

http://project-app-1:8000

因为实例名是动态的,缩放、重建和项目名称变化都可能改变它。

1. 一个服务名如何对应多个副本

app 有多个副本时,Docker 的嵌入式 DNS 可以为服务名返回多个实例地址。抽象地看:

查询 app
  ├── 172.20.0.11
  ├── 172.20.0.12
  └── 172.20.0.13

客户端获得地址后,再由客户端连接其中一个地址。这里存在一个关键区别:

Docker DNS 提供的是名称解析,不等于一个具备健康检查、连接跟踪和故障摘除能力的完整负载均衡器。

不同客户端库的行为可能不同:

  • 有的只使用第一个地址;
  • 有的按地址轮换;
  • 有的缓存 DNS 结果;
  • 有的在连接失败后才尝试下一个地址;
  • 长连接建立后,后续请求可能一直使用同一个副本。

因此,“DNS 返回多个地址”不等于“每个请求严格均匀地分发到每个副本”。

2. 健康检查不会自动变成应用层摘除

Compose 的 healthcheck 可以描述容器内应用是否健康:

services:
  app:
    image: example/app:1.0
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - |
          import urllib.request
          urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=2)
      interval: 5s
      timeout: 2s
      retries: 3

它会使容器进入类似以下状态:

starting
healthy
unhealthy

但 Compose 本身不会因此自动把所有已有客户端连接迁移走,也不保证所有服务发现客户端都会立刻停止使用该地址。真正的流量摘除要由入口代理、客户端重试逻辑或更高层的平台负责。

depends_on 也不等于运行时流量治理。例如:

services:
  proxy:
    image: nginx:1.27-alpine
    depends_on:
      app:
        condition: service_healthy

这可以使 proxy 在创建阶段等待 app 通过健康检查后再启动,但它不能解决:

  • app 启动后再次失效;
  • proxy 已经建立的连接;
  • DNS 缓存;
  • 上游实例重建后的地址变化;
  • 业务请求重试和幂等性。

依赖关系解决的是启动时序的一部分,不是服务运行期间的完整可用性问题。


五、一个可运行的 Compose 扩容示例

下面构造一个最小的 HTTP 应用。每个副本返回自己的 hostname,用于观察请求实际到达了哪个容器。

1. 应用代码

app.py

import json
import os
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            body = b'{"status":"ok"}'
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return

        body = json.dumps({
            "hostname": socket.gethostname(),
            "pid": os.getpid(),
            "path": self.path,
        }).encode()

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        print(fmt % args, flush=True)


server = ThreadingHTTPServer(("0.0.0.0", 8000), Handler)
server.serve_forever()

这里使用 ThreadingHTTPServer,使单个容器可以并发处理多个请求。它只解决单个 Python 进程内部的并发,不解决多个副本之间的状态一致性。

Dockerfile

FROM python:3.12-alpine

WORKDIR /app
COPY app.py .

CMD ["python", "app.py"]

2. Compose 配置

compose.yaml

services:
  app:
    build:
      context: .
    expose:
      - "8000"
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - |
          import urllib.request
          urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=2)
      interval: 3s
      timeout: 2s
      retries: 5

  proxy:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy

nginx.conf

server {
    listen 80;

    location / {
        proxy_pass http://app:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

启动并扩容:

docker compose up -d --build --scale app=3
docker compose ps

预期可以看到一个 proxy 和三个 app 容器。此时只有 proxy 发布宿主机端口:

proxy-1   0.0.0.0:8080->80/tcp
app-1
app-2
app-3

测试请求:

for i in $(seq 1 10); do
  curl -s http://localhost:8080/
  echo
done

响应中的 hostname 可能出现多个不同值:

{"hostname": "demo-app-1", "pid": 1, "path": "/"}
{"hostname": "demo-app-2", "pid": 1, "path": "/"}
{"hostname": "demo-app-3", "pid": 1, "path": "/"}

这里的分发由 Nginx 连接上游时对 app 服务解析出的地址进行选择。该示例适合说明基本数据流,但不能把它当作生产级动态服务发现的完整方案:上游实例重建后,代理的 DNS 解析和连接复用行为需要单独验证,生产代理通常需要明确配置动态解析、连接失败重试、超时、熔断或主动健康检查。

查看日志:

docker compose logs -f proxy app

停止一个副本:

docker compose stop app-2

然后再次请求,可以观察代理是否重试到其他实例。是否立即得到理想结果取决于代理的连接失败处理、DNS 状态和请求时机。Compose 不会因为停止一个副本就自动为代理建立一套新的负载均衡控制面。

清理:

docker compose down

该命令会删除项目创建的容器和网络,但默认不会删除命名卷。若需要删除卷,应显式使用:

docker compose down -v

这一步具有数据删除风险,尤其当卷中存放数据库文件时。


六、负载均衡究竟分发什么:连接、请求还是任务

负载均衡的“均匀”必须先说明分发单位。

1. 连接级分发

一个 TCP 连接被选中某个副本后,该连接上的数据通常都由同一个副本处理。对于 HTTP/1.1 Keep-Alive 或 HTTP/2,多次请求可能共享一个连接,因此:

请求 1 ─┐
请求 2 ─┼── 同一条连接 ── app-1
请求 3 ─┘

即使入口代理在新连接之间进行轮询,长连接也可能使请求长期集中到少数副本。

2. 请求级分发

如果代理能够在每个 HTTP 请求层面选择后端,则可以更接近请求级负载均衡。但这要求代理终止客户端连接,而不是简单转发 TCP。应用协议、连接复用方式和代理配置都会影响结果。

3. 任务级分发

后台任务通常不是“每个请求选一个副本”这么简单。例如三个副本同时从同一个队列取任务:

队列 ──┬── app-1
       ├── app-2
       └── app-3

需要由队列的消费确认、可见性超时、租约或分布式锁保证任务不会被永久丢失。即便队列支持重新投递,也通常需要接受“至少一次投递”,也就是同一个任务可能被执行多次。

4. 理想容量不是简单的副本数乘法

设:

  • N 为副本数;
  • C 为单个副本在给定延迟目标下的最大吞吐量;
  • B 为数据库、队列、网络或入口代理的瓶颈容量;
  • E 为分发和协调开销,且 0 < E ≤ 1

理想情况下,系统吞吐上限可以近似写成:

Tmin(N×C×E, B)T \leq \min(N \times C \times E,\ B)

当数据库只能处理 B 个请求时,继续增加应用副本不会突破数据库瓶颈:

app-1 ─┐
app-2 ─┼── 数据库:固定瓶颈
app-3 ─┤
app-4 ─┘

如果每个副本都创建连接池,扩容还可能使数据库连接数从 N × P 增加到超过数据库允许值,其中 P 是每个副本的连接池上限。此时扩容应用反而会使数据库拒绝连接。


七、无状态不是“没有变量”,而是状态不依赖某个副本

一个服务称为“无状态”,不是说代码中没有变量,而是说:

处理一次请求所需的持久状态,不只存在于某个特定副本的本地内存或本地文件中。

下面这个计数器在单实例时看似正确:

counter += 1

扩容后,每个副本都有自己的内存:

app-1: counter = 10
app-2: counter = 10
app-3: counter = 10

请求先后到达不同副本时,客户端看到的计数可能倒退或分裂。即使只有一个副本,多个线程同时执行读改写,也可能产生竞态:

初始 counter = 10

线程 A 读取 10
线程 B 读取 10
线程 A 写入 11
线程 B 写入 11

两个请求实际执行了两次增加,但最终结果只有 11。扩容只是把这个问题从进程内扩大到了多个进程。

正确的共享计数通常应交给具备原子操作的外部存储,例如数据库:

UPDATE counters
SET value = value + 1
WHERE name = 'requests';

数据库在行锁、事务隔离和日志机制下执行这个更新,应用副本不需要先读取旧值再写回新值。实际系统仍应检查:

  • name 是否有唯一约束;
  • 事务是否提交成功;
  • 连接失败时是否重试;
  • 重试是否会造成重复业务操作;
  • 返回值是否需要通过 RETURNING 或再次查询获得。

1. 本地文件系统不是天然共享存储

每个容器都有自己的可写层:

app-1:/data/file.txt
app-2:/data/file.txt
app-3:/data/file.txt

即使路径相同,默认也不是同一个文件。一个副本写入的文件,其他副本通常看不到。

命名卷也不能自动改变这个结论:

services:
  app:
    volumes:
      - app-data:/data

volumes:
  app-data:

在单台 Linux Docker 主机上,多个容器挂载同一个本地卷,通常会看到同一份主机文件。但这带来的是并发访问问题,而不是自动获得分布式文件系统:

  • 两个进程同时写同一个文件可能互相覆盖;
  • 文件锁必须真的被所有写入者遵守;
  • SQLite 依赖文件锁和底层文件系统语义,不能因为挂载了卷就适合多副本高并发写入;
  • 本地卷通常绑定某一台主机,不能随着容器自动跨主机移动;
  • NFS 等远程文件系统有自己的锁、缓存和一致性边界。

文件共享需要明确选择适合的存储系统。上传文件通常放到对象存储;结构化状态放到数据库;短期缓存或分布式锁可以使用 Redis 等专门系统,而不是让多个 Web 容器直接竞争一个本地文件。

2. 会话状态需要外置或使用可验证令牌

假设登录后把 session 放进内存:

登录请求 ── app-1,session=S 保存在 app-1 内存
后续请求 ── app-2,找不到 session=S

结果可能是用户随机掉线。

常见解决方案有两类:

  1. 将 session 放入共享存储,例如数据库或 Redis;
  2. 使用带签名的无状态令牌,让每个副本都能验证请求。

“粘性会话”可以让同一客户端尽量回到同一副本,但它不是共享状态:

  • 副本故障后,状态仍会丢失;
  • 客户端切换网络或代理后,粘性依据可能改变;
  • 负载可能集中到少数副本;
  • 扩缩容和滚动更新会使映射变化。

因此,粘性会话是特定场景下的流量策略,不是共享状态机制。


八、扩容后的并发正确性:请求、重试和幂等

多个副本同时处理请求后,系统通常会出现重试。重试可能来自:

  • 客户端超时后再次发送;
  • 代理连接失败后重试;
  • 应用访问数据库失败后重试;
  • 消息队列重新投递;
  • 容器重启导致请求结果未知。

如果请求已经在副本中执行成功,但响应在返回途中丢失,调用方无法判断操作是否成功:

客户端 ── 创建订单 ──> app-1
app-1 ── 已写入数据库
网络故障,响应丢失
客户端 ── 重试创建订单 ──> app-2

如果创建订单没有幂等键,就可能生成两个订单。

因此,对可能重试的写操作,需要定义幂等语义。例如客户端发送:

POST /orders
Idempotency-Key: 7f2b...

服务端在共享数据库中以该键建立唯一约束:

CREATE UNIQUE INDEX orders_idempotency_key_uq
ON orders (idempotency_key);

多个副本收到同一个幂等键时,数据库唯一约束保证只能产生一个业务结果。应用仍须正确处理唯一约束冲突,并返回已有结果,而不是把冲突当成普通服务器错误。

幂等性可以形式化为:

f(f(s,x),x)=f(s,x)f(f(s, x), x) = f(s, x)

其中:

  • s 是系统状态;
  • x 是同一个业务请求;
  • f 是执行请求后的状态变换。

对于设置某个资源为指定值,通常容易做到幂等;对于“余额加 10”这类操作,必须依赖事务、唯一业务流水号或原子更新,不能只靠应用代码的重试。


九、Compose 中的网络隔离与 Linux 边界

Compose 通常创建一个项目网络,例如:

demo_default

同一 Compose 项目中的服务可以通过服务名通信:

proxy -> app:8000

不同项目即使服务名相同,默认也不应直接互通,因为它们使用不同网络。可以显式声明共享外部网络:

networks:
  shared:
    external: true

services:
  proxy:
    networks:
      - shared

  app:
    networks:
      - shared

但外部网络必须预先创建:

docker network create shared

共享网络会扩大通信边界。加入该网络的容器都可能访问其中其他容器的端口,因此不能把“能互相解析”误认为“已经完成认证和授权”。

Linux 容器的网络通常建立在:

  • network namespace;
  • veth pair;
  • Linux bridge;
  • iptables/nftables NAT;
  • Docker 内嵌 DNS;

之上。宿主机端口发布本质上还涉及宿主机网络栈和 NAT 规则。network_mode: host 则绕过常见的容器网络隔离,使容器直接使用宿主机网络命名空间;在 Linux 上有明确语义,但会重新引入宿主机端口竞争和更弱的网络隔离。在不同操作系统的 Docker Desktop 环境中,其行为还可能经过虚拟机转发,不能直接套用原生 Linux 的网络观察结果。


十、故障路径:副本退出、重建和连接失效

扩容系统至少要考虑以下故障路径。

1. 副本进程退出

若没有重启策略,容器可能停在 Exited 状态,副本数下降:

docker compose ps
docker compose ps -a

若设置了 restart,Docker 可能重启该容器,但重启期间已有连接会失败。客户端或代理必须具备合理的超时和重试机制。

2. 容器被重建,IP 改变

容器重建后,新的容器可能获得新的 IP。正确的客户端应连接服务名:

app:8000

而不应永久缓存某个容器 IP。应用连接数据库、缓存或其他服务时,也应在连接断开后重新解析名称并建立连接。

3. 数据库暂时未就绪

以下配置只能表示启动依赖:

depends_on:
  db:
    condition: service_healthy

数据库健康检查通过后,应用可以开始启动;但数据库之后仍可能重启、拒绝连接或进入只读状态。因此应用需要:

  • 启动时连接重试;
  • 运行时连接断开后重连;
  • 对事务失败进行分类;
  • 避免把不可重试的写操作盲目重试。

4. 副本被停止时请求是否丢失

停止容器通常会先发送 SIGTERM,等待一段时间后再强制终止。应用应捕获终止信号:

  1. 停止接受新请求;
  2. 等待正在处理的请求结束;
  3. 关闭数据库和消息队列连接;
  4. 退出进程。

如果应用立即退出,正在处理的请求可能中断。若外部客户端自动重试,就又回到了幂等性问题。


十一、常见误解与可验证的诊断方法

误解一:副本数增加,宿主机端口也能重复发布

错误配置:

ports:
  - "8080:8000"

然后执行:

docker compose up -d --scale app=3

验证:

docker compose ps
docker compose events

如果出现端口已分配,解决方案不是修改应用监听地址,而是:

  • 删除后端服务的固定 ports
  • 让后端只加入内部网络;
  • 由一个代理或其他入口发布宿主机端口。

误解二:服务名会自动把请求均匀分配到所有副本

验证 DNS 结果:

docker compose run --rm --no-deps \
  --entrypoint getent \
  app hosts app

如果镜像中没有 getent,可以使用带工具的临时容器加入项目网络,例如先查看网络名:

docker network ls

再执行:

docker run --rm --network demo_default busybox nslookup app

实际项目名不一定是 demo,应以 docker network ls 的结果为准。

即使解析出多个地址,也要继续验证:

  • 客户端是否只取第一个地址;
  • 是否缓存 DNS;
  • 连接是否长时间复用;
  • 上游失败时是否尝试其他地址;
  • 健康状态变化后是否重新选择。

误解三:depends_on 能保证服务一直可用

可以检查状态:

docker compose ps
docker inspect demo-app-1

depends_on 只影响启动阶段的依赖关系。应用必须在运行时自己处理连接失败和重连。

误解四:共享命名卷就等于共享数据库

在单机上把同一个目录挂载给多个副本,可能让它们看见同一组文件,但不会自动提供:

  • 事务;
  • 主从复制;
  • 故障转移;
  • 跨主机挂载;
  • 应用级并发协调。

如果两个副本同时写 SQLite 数据库,出现锁等待、写入失败或性能下降并不意外。应根据并发模型选择数据库,而不是通过增加副本掩盖存储设计问题。

误解五:健康检查失败后,所有流量会立即停止

检查健康状态:

docker inspect \
  --format '{{json .State.Health}}' \
  demo-app-1

健康检查只产生容器健康状态。是否摘除流量,取决于入口代理、客户端或外部平台是否读取并使用这个状态。Compose 不会凭借 healthcheck 自动提供完整的服务网格式流量治理。


十二、Compose 扩容的适用边界

Compose 扩容适合以下场景:

  • 单台 Linux Docker 主机上的开发和测试;
  • 本地验证应用是否具备基本无状态并发能力;
  • 使用一个固定代理连接多个后端容器;
  • 小型单机部署,且能够接受手动扩缩容;
  • 验证服务发现、健康检查和共享存储设计。

它不天然解决以下问题:

  • 跨主机调度;
  • 节点故障后的跨主机重建;
  • 多节点 Overlay 网络;
  • 自动水平伸缩;
  • 完整的服务级负载均衡;
  • 滚动更新和自动回滚;
  • Secret 的集群级分发;
  • 副本级资源调度;
  • 发布期间的连接排空和流量迁移。

需要这些能力时,Docker Swarm 的 Service、Task、Overlay、Secret 和滚动更新机制,或者 Kubernetes 的 Deployment、Service、Ingress 等对象,才是相应的控制面。Compose 文件可以作为开发和本地编排格式,但不能因此假设所有平台语义完全相同。


十三、从单容器扩展到多副本必须重新回答的问题

一个服务能否安全扩容,可以用以下因果链检查,而不是只看副本是否成功创建:

flowchart LR
    A[创建多个容器] --> B{宿主机端口是否冲突}
    B -- 是 --> C[移除后端固定 ports]
    B -- 否 --> D[建立入口或客户端分发]
    C --> D
    D --> E{请求是否依赖本地状态}
    E -- 是 --> F[外置状态或设计幂等机制]
    E -- 否 --> G[验证服务发现和连接复用]
    F --> H[验证并发一致性与重试]
    G --> H
    H --> I[验证副本故障、重建和重连]

具体而言,需要确认:

  1. 端口:多个容器是否只监听内部容器端口,宿主机是否只有稳定入口?
  2. 发现:客户端连接的是服务名还是某个实例名?
  3. 分发:分发单位是 TCP 连接、HTTP 请求还是后台任务?
  4. 健康:失败副本是否真的会被入口摘除?
  5. 状态:会话、计数、任务、文件和缓存是否依赖单个副本?
  6. 并发:读改写、事务和锁是否在多进程下仍然正确?
  7. 重试:请求可能执行成功但响应丢失时,重复执行是否安全?
  8. 重建:容器 IP 变化后,客户端是否能够重新解析和连接?
  9. 退出:副本缩容或更新时,应用是否优雅停止?
  10. 平台:需求是否已经超出单机 Compose,需要集群调度和发布控制?

Compose 的副本扩展只有在这些问题同时成立时才具有实际意义:

可用扩容=可发现可分发状态可共享或可重建并发可证明故障可恢复\text{可用扩容} = \text{可发现} \land \text{可分发} \land \text{状态可共享或可重建} \land \text{并发可证明} \land \text{故障可恢复}

其中任一条件不成立,系统都可能表现为“容器数量增加了,但服务能力没有增加”,甚至出现端口冲突、登录态丢失、重复扣款、任务重复执行或故障后整体不可用。


系列导航与关联阅读

官方资料

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