一个基于 Docker Compose 编排的四服务多容器应用:
Nginx 作为统一入口,以轮询策略将请求分发到两个 Node.js 实例。
两个 Node.js 实例(web1 / web2)运行相同的 Express 应用,实现水平扩展。
Redis 作为共享存储,保证跨实例的访问计数全局一致。
准备工作:
安装 Docker DeskTop。
登录 Docker DeskTop。
用户只需访问 http://localhost:80,即可观察到请求被交替分配到不同主机名的实例,同时计数器持续递增。正如 Docker 官方文档所指出的,容器的最佳实践是每个容器只做一件事,并把它做好。本项目的四个服务正是对这一原则的实践。
一、架构全景
┌──────────────────┐
浏览器 ──► :80 ───►│ Nginx │ 反向代理 + 负载均衡
│ (Round-Robin) │
└───────────┬──────┘
│ 轮询分发
┌─────────────┴───────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ web1 │ │ web2 │
│ Node.js │ │ Node.js │
│ Express │ │ Express │
│ :5000 │ │ :5000 │
└──────┬───────┘ └──────┬───────┘
│ │
└─────────┬───────────┘
▼
┌──────────────┐
│ Redis │ 共享计数器
│ :6379 │
└──────────────┘服务 | 技术栈 | 主机端口 → 容器端口 | 职责 |
|---|---|---|---|
nginx | Nginx | 80 → 80 | 反向代理 + 负载均衡入口 |
web1 | Node.js + Express | 81 → 5000 | Web 应用实例 #1 |
web2 | Node.js + Express | 82 → 5000 | Web 应用实例 #2 |
redis | Redis | 63790 → 6379 | 共享访问计数存储 |
端口说明:81 和 82 端口仅用于单独调试各实例,正常使用只需访问 :80。Redis 映射到 63790 而非标准 6379,是为了避免与主机上可能存在的其他 Redis 实例冲突。
二、工程结构
.
├── compose.yml # Docker Compose 编排文件(核心)
├── nginx/
│ ├── Dockerfile # Nginx 镜像构建
│ └── nginx.conf # Nginx 配置(负载均衡 + 反向代理)
├── web/
│ ├── Dockerfile # Node.js 镜像构建
│ ├── package.json # Node.js 依赖声明
│ ├── package-lock.json
│ └── server.js # Express 应用入口
├── LICENSE
└── README.md三、从 docker run 到 Compose:多容器编排的必然选择
3.1 手动编排的困境
在引入 Compose 之前,让我们先理解为什么需要它。如果不使用 Compose,你需要为每个服务执行一条 docker run 命令,并手动创建网络、指定容器名、配置端口映射。Docker 官方文档对此有精准的概括:
Imagine running several
docker runcommands (frontend, backend, and database) with different configurations for development, testing, and production environments. It's error-prone and time-consuming.
具体来说,手动方式面临以下挑战:
命令冗长:每个容器都需要记住完整的 docker run 参数。
网络配置繁琐:需要手动创建 bridge 网络并将每个容器加入。
启动顺序难以保证:Nginx 必须在 web1/web2 之后启动,否则 DNS 解析会失败。
扩展困难:增加第三个实例意味着重复大量配置。
环境变量管理分散:每个容器的环境变量在各自的命令中设置,难以统一维护。
3.2 手动方式(理解原理)
为了深入理解多容器协作的底层机制,我们先看看手动 docker run 方式的完整命令:
# 1. 创建专用网络
docker network create counter-net
# 2. 启动 Redis
docker container run -d --name redis --network counter-net redis:6-alpine
# 3. 启动 web1 (需要先构建镜像 my-web,cd web/目录下,运行:docker image build -t my-web .)
docker container run -d --name web1 --hostname web1 --network counter-net -p 81:5000 my-web
# 4. 启动 web2
docker container run -d --name web2 --hostname web2 --network counter-net -p 82:5000 my-web
# 5. 启动 Nginx(需先构建镜像,cd nginx/目录下,运行:docker image build -t my-nginx .)
docker container run -d --name nginx --network counter-net -p 80:80 my-nginx这五条命令才能完成与一份 compose.yml 等效的部署。Docker 官方文档也推荐先通过手动方式理解原理,再用 Compose 简化整个部署过程。
3.3 Compose 方案(推荐)
使用 Docker Compose,上述所有步骤被浓缩为一份声明式配置文件和一个命令。Compose 将整个多容器应用定义在一个名为 compose.yml 的 YAML 文件中,指定所有容器的配置、依赖关系、环境变量,甚至数据卷和网络。核心优势包括:
配置集中化:一个文件替代多条命令。
启动顺序可控:通过 depends_on 管理依赖关系。
独立伸缩:可以单独扩展某个服务,而非整个应用。
持久化卷:轻松实现数据持久化。
环境变量统一管理:一次定义,全局使用。
四、核心配置逐文件详解
4.1 compose.yml — 编排中枢
services:
redis:
image: redis:6-alpine
ports:
- '63790:6379'
web1:
restart: on-failure
build: ./web
hostname: web1
ports:
- '81:5000'
web2:
restart: on-failure
build: ./web
hostname: web2
ports:
- '82:5000'
nginx:
build: ./nginx
ports:
- '80:80'
depends_on:
- web1
- web2关键决策解析:
配置项 | 值 | 说明 |
|---|---|---|
image: redis:6-alpine | 官方镜像 | Redis 无需定制,直接拉取即可 |
build: ./web | 本地构建 | web1 和 web2 共用同一份源码和 Dockerfile |
hostname: web1/web2 | 显式主机名 | 三重作用 |
restart: on-failure | 异常重启 | 仅在进程非正常退出时重启(如崩溃) |
depends_on: [web1, web2] | 启动顺序 | 保证 nginx 在 web1/web2 之后启动,但不保证服务就绪 |
ports: '63790:6379' | 非标准端口 | 避免与主机已有 Redis 冲突;容器间通信仍用 redis:6379,不受影响 |
三重作用:
Docker DNS 可解析。
nginx.conf 中 server web1:5000 能找到目标。
server.js 中 os.hostname() 返回正确标识。
Docker 内部网络机制:
docker compose up 时,Docker 自动创建名为 项目名_default 的 bridge 网络,所有服务加入其中。Docker 的嵌入式 DNS 会自动将这些服务名解析为容器 IP。容器间通过 服务名 DNS 解析互相访问:
nginx 容器内 → ping web1 解析到 web1 容器 IP
web1 容器内 → ping redis 解析到 redis 容器 IP这就是为什么 nginx.conf 写 server web1:5000 而非硬编码 IP——容器重启后 IP 会变,但 DNS 名不变。
ping: not found 解决:nginx 容器内 或 web1/2 容器内运行:
apt update
apt install -y iputils-ping
4.2 nginx/nginx.conf — 负载均衡核心
# 定义了一个名为 loadbalancer 的上游服务器组,这是 Nginx 实现负载均衡的核心机制
# Nginx upstream 默认使用 轮询(Round-Robin) 策略:请求依次分配给 web1、web2、web1、web2…… 确保两个 Node.js 实例分担流量。
upstream loadbalancer {
# 引用 Docker Compose 中的服务名 web1,端口 5000(Node.js 应用监听端口)
server web1:5000;
# 引用 Docker Compose 中的服务名 web2,端口 5000(Node.js 应用监听端口)
server web2:5000;
}
# HTTP 虚拟主机,监听端口 80,将所有请求代理到 loadbalancer 上游服务器组
server {
# 监听容器内的 80 端口(对应 compose.yml 中映射的 80:80)
listen 80;
# 匹配请求的 Host 头,这里用 localhost 表示本机访问
server_name localhost;
# 匹配所有以 / 开头的请求(即所有请求路径)
location / {
# 核心指令 —— 将请求反向代理到上面定义的 upstream 组,由 Nginx 负责在 web1/web2 之间分发请求
proxy_pass http://loadbalancer;
}
}4.3 nginx/Dockerfile — nginx镜像构建
FROM nginx:1.29-alpine
# 删除默认配置,避免与自定义配置冲突或产生意外行为
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf4.4 web/server.js — Express 应用
const os = require('os');
const express = require('express');
const app = express();
const redis = require('redis');
const redisClient = redis.createClient({
host: 'redis',
port: 6379
});
app.get('/', function(req, res) {
redisClient.incr('numVisits', (err, count) => {
res.send(os.hostname() + ': Number of visits is: ' + count);
});
});
app.listen(5000, function() {
console.log('Web application is listening on port 5000');
});4.5 web/Dockerfile — Node.js 镜像构建
FROM node:21
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci
COPY ./server.js ./
CMD ["npm","start"]RUN npm ci :安装依赖(ci 比 install 更严格,按 lock 文件安装)。
4.6 web/package.json — 依赖声明
{
"name": "web",
"version": "1.0.0",
"description": "Running Node.js and Express.js on Docker",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.17.2",
"redis": "3.1.2"
},
"author": "",
"license": "MIT"
}五、部署与验证
5.1 启动应用
# 先cd 到 compoose.yaml所在目录
docker compose up -d预期输出:
[+] up 8/8
✔ Image 202609-nginx-node-redis-multi-container-app-web1 Built 11.2s
✔ Image 202609-nginx-node-redis-multi-container-app-web2 Built 11.2s
✔ Image 202609-nginx-node-redis-multi-container-app-nginx Built 11.3s
✔ Network 202609-nginx-node-redis-multi-container-app_default Created 0.0s
✔ Container 202609-nginx-node-redis-multi-container-app-web1-1 Started 0.9s
✔ Container 202609-nginx-node-redis-multi-container-app-web2-1 Started 0.5s
✔ Container 202609-nginx-node-redis-multi-container-app-redis-1 Started 0.7s
✔ Container 202609-nginx-node-redis-multi-container-app-nginx-1 Started 5.2 查看容器状态
# 先cd 到 compoose.yaml所在目录
docker compose ps预期看到 4 个容器均为 running 状态,端口映射如下:
NAME SERVICE CREATED STATUS PORTS
multi-container-app-nginx-1 nginx 2 minutes ago Up 2 minutes 0.0.0.0:80->80/tcp, [::]:80->80/tcp
multi-container-app-redis-1 redis 2 minutes ago Up 2 minutes 0.0.0.0:63790->6379/tcp, [::]:63790->6379/tcp
multi-container-app-web1-1 web1 2 minutes ago Up 2 minutes 0.0.0.0:81->5000/tcp, [::]:81->5000/tcp
multi-container-app-web2-1 web2 2 minutes ago Up 2 minutes 0.0.0.0:82->5000/tcp, [::]:82->5000/tcp5.3 测试负载均衡
连续请求,观察主机名交替变化、计数持续递增:
$ curl localhost:80
web1: Number of visits is: 1
$ curl localhost:80
web2: Number of visits is: 2
$ curl localhost:80
web1: Number of visits is: 3
$ curl localhost:80
web2: Number of visits is: 4可直接打开浏览器访问:http:localhost
web1 / web2 交替出现 → 负载均衡生效。
计数连续递增 → Redis 共享状态生效。
5.4 单独调试实例
curl localhost:81 # 直接访问 web1
curl localhost:82 # 直接访问 web25.5 外部连接 Redis
redis-cli -p 63790
> GET numVisits
"4"
> INCR numVisits
(integer) 5需要在宿主机上安装:redis-cli
5.6 停止并清理
docker compose down # 删除容器,保留数据卷
docker compose down -v # 删除容器,同时删除数据卷(Redis 数据会丢失)七、总结
本文通过一个访问计数器应用,完整拆解了 Docker Compose 多容器编排的核心模式:
核心概念 | 本文体现 |
|---|---|
服务发现 | nginx.conf 用 Docker DNS 名(web1、redis)代替 IP |
负载均衡 | Nginx upstream 轮询分发到多个 Node.js 实例 |
共享状态 | 两个无状态实例通过 Redis 共享计数器 |
水平扩展 | 新增实例只需加 compose 服务 + upstream server |
分层缓存 | Dockerfile 先 COPY 依赖再 COPY 源码,加速重建 |
端口隔离 | 对外仅暴露 :80,内部服务通过 Docker 网络互通 |
Docker Compose 的本质不是“多容器运行”,而是应用级的协同编排。DNS 发现、启动依赖、网络隔离,让分布式容器拥有单机般的协作体验。从 docker run 到 Compose,是从“操作容器”到“描述应用”的思维跃迁。掌握它,即掌握了微服务架构的基石。