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 --scale 与 deploy.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. EXPOSE、expose 和 ports 的区别
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。
理想情况下,系统吞吐上限可以近似写成:
当数据库只能处理 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
结果可能是用户随机掉线。
常见解决方案有两类:
- 将 session 放入共享存储,例如数据库或 Redis;
- 使用带签名的无状态令牌,让每个副本都能验证请求。
“粘性会话”可以让同一客户端尽量回到同一副本,但它不是共享状态:
- 副本故障后,状态仍会丢失;
- 客户端切换网络或代理后,粘性依据可能改变;
- 负载可能集中到少数副本;
- 扩缩容和滚动更新会使映射变化。
因此,粘性会话是特定场景下的流量策略,不是共享状态机制。
八、扩容后的并发正确性:请求、重试和幂等
多个副本同时处理请求后,系统通常会出现重试。重试可能来自:
- 客户端超时后再次发送;
- 代理连接失败后重试;
- 应用访问数据库失败后重试;
- 消息队列重新投递;
- 容器重启导致请求结果未知。
如果请求已经在副本中执行成功,但响应在返回途中丢失,调用方无法判断操作是否成功:
客户端 ── 创建订单 ──> app-1
app-1 ── 已写入数据库
网络故障,响应丢失
客户端 ── 重试创建订单 ──> app-2
如果创建订单没有幂等键,就可能生成两个订单。
因此,对可能重试的写操作,需要定义幂等语义。例如客户端发送:
POST /orders
Idempotency-Key: 7f2b...
服务端在共享数据库中以该键建立唯一约束:
CREATE UNIQUE INDEX orders_idempotency_key_uq
ON orders (idempotency_key);
多个副本收到同一个幂等键时,数据库唯一约束保证只能产生一个业务结果。应用仍须正确处理唯一约束冲突,并返回已有结果,而不是把冲突当成普通服务器错误。
幂等性可以形式化为:
其中:
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,等待一段时间后再强制终止。应用应捕获终止信号:
- 停止接受新请求;
- 等待正在处理的请求结束;
- 关闭数据库和消息队列连接;
- 退出进程。
如果应用立即退出,正在处理的请求可能中断。若外部客户端自动重试,就又回到了幂等性问题。
十一、常见误解与可验证的诊断方法
误解一:副本数增加,宿主机端口也能重复发布
错误配置:
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[验证副本故障、重建和重连]
具体而言,需要确认:
- 端口:多个容器是否只监听内部容器端口,宿主机是否只有稳定入口?
- 发现:客户端连接的是服务名还是某个实例名?
- 分发:分发单位是 TCP 连接、HTTP 请求还是后台任务?
- 健康:失败副本是否真的会被入口摘除?
- 状态:会话、计数、任务、文件和缓存是否依赖单个副本?
- 并发:读改写、事务和锁是否在多进程下仍然正确?
- 重试:请求可能执行成功但响应丢失时,重复执行是否安全?
- 重建:容器 IP 变化后,客户端是否能够重新解析和连接?
- 退出:副本缩容或更新时,应用是否优雅停止?
- 平台:需求是否已经超出单机 Compose,需要集群调度和发布控制?
Compose 的副本扩展只有在这些问题同时成立时才具有实际意义:
其中任一条件不成立,系统都可能表现为“容器数量增加了,但服务能力没有增加”,甚至出现端口冲突、登录态丢失、重复扣款、任务重复执行或故障后整体不可用。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Compose 多环境治理:开发、测试、生产差异、Secret 和不可变制品
- 下一篇:Compose 反向代理架构:Nginx、TLS、服务发现、路由和零停机
- 延伸:Compose 服务发现与依赖:DNS、Healthcheck、启动顺序和重连
- 延伸:Docker Swarm 基础与边界:Service、Task、Overlay、Secret 和滚动更新
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论