# 常用中间件安装和配置


# 常用中间件部署

## Redis/MySQL/Nacos

### 初始化脚本

```bash
#!/usr/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd "$SCRIPT_DIR"

mkdir -p ./redis/data

cat > ./redis/redis.conf <<'EOF'
port 6379
bind 0.0.0.0
protected-mode no
requirepass redis
appendonly yes
appendfsync everysec
dir /data
logfile ""
EOF

echo "Redis 单机挂载目录与配置已生成：${SCRIPT_DIR}"

mkdir -p ./mysql/{conf,data}

cat > ./mysql/conf/my.cnf <<'EOF'
[mysqld]
bind-address=0.0.0.0
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
default-time-zone=+08:00
[client]
default-character-set=utf8mb4
EOF

echo "MySQL 单机挂载目录与配置已生成：${SCRIPT_DIR}"

mkdir -p ./nacos/{data,logs}
```

### docker-compose.yaml

```yaml
networks:
  middle-net:
    driver: bridge

services:
  redis:
    image: redis:7.4.8
    container_name: redis
    restart: always
    networks: [middle-net]
    ports: ["6379:6379"]
    volumes:
      - ./redis/redis.conf:/etc/redis/redis.conf
      - ./redis/data:/data
    healthcheck:
      interval: 10s
      timeout: 5s
      retries: 12
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 6379 ping | grep -qx PONG"]
    command: ["redis-server", "/etc/redis/redis.conf"]

  mysql:
    image: mysql:8.4.10
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: mysql
      MYSQL_ROOT_HOST: "%"
      MYSQL_DATABASE: test
      TZ: Asia/Shanghai
    ports: ["3306:3306"]
    volumes:
      - ./mysql/conf:/etc/mysql/conf.d:ro
      - ./mysql/data:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "MYSQL_PWD=mysql mysql -h 127.0.0.1 -uroot -Nse 'SELECT 1' | grep -qx 1"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 30s
    networks: [middle-net]

  nacos:
    image: nacos/nacos-server:v3.2.4
    restart: always
    environment:
      MODE: standalone
      SPRING_DATASOURCE_PLATFORM: ""
      NACOS_AUTH_ENABLE: "false"
      NACOS_AUTH_ADMIN_ENABLE: "true"
      NACOS_AUTH_CONSOLE_ENABLE: "true"
      NACOS_AUTH_TOKEN: ZR8SunEu4VA8BwRx92SyRF1u5GM6lmQGByQCk+OPcvJfDBDnO4LZDOYwmilUFiqG
      NACOS_AUTH_IDENTITY_KEY: nacos
      NACOS_AUTH_IDENTITY_VALUE: nacos
      JVM_XMS: 512m
      JVM_XMX: 512m
      JVM_XMN: 256m
      TZ: Asia/Shanghai
    ports:
      - "8080:8080"
      - "8848:8848"
      - "9848:9848"
    volumes:
      - ./nacos/data:/home/nacos/data
      - ./nacos/logs:/home/nacos/logs
    networks: [middle-net]
```



## Redis 7.4.8

Redis 在容器化部署里最大的坑是 **集群模式（Cluster）和哨兵模式（Sentinel）会把地址"回传"给客户端**：

- Cluster 模式下，客户端第一次连接任意节点后，节点会返回 `MOVED`/`ASK` 重定向指令，其中包含的 IP 是节点通过 `cluster-announce-ip` 宣告的地址；如果不配置，节点默认宣告的是容器内部的桥接网络 IP（如 `172.18.0.x`），宿主机外的客户端根本连不上，导致 `-c` 集群模式客户端连接后卡死或报错。
- Sentinel 模式下，Sentinel 通过 `INFO replication` 探测到的 master/replica 地址同样是容器内部 IP；客户端问 Sentinel "谁是 master" 时，Sentinel 会把这个内部 IP 返回给客户端，导致宿主机上的客户端拿到一个不可达的地址。

**解决方式**：使用 bridge 模式 + 显式端口映射（宿主机端口 = 容器端口，不同节点用不同宿主机端口区分），并在每个节点的配置里显式设置：

- Cluster：`cluster-announce-ip <宿主机IP>`、`cluster-announce-port <映射的宿主机端口>`、`cluster-announce-bus-port <映射的宿主机总线端口>`。
- Sentinel：主从节点设置 `replica-announce-ip <宿主机IP>` + `replica-announce-port <映射的宿主机端口>`；Sentinel 自身监控 master 时也使用 `<宿主机IP>:<宿主机映射端口>`，而不是容器名/容器内部 IP。

统一密码：`requirepass redis`（主从复制场景同时设置 `masterauth redis`）。

### 单机模式

#### 准备目录和配置

`standalone/deploy.sh`

```bash
#!/usr/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd "$SCRIPT_DIR"
mkdir -p conf data

cat > conf/redis.conf <<'EOF'
port 6379
bind 0.0.0.0
protected-mode no
requirepass redis
appendonly yes
appendfsync everysec
dir /data
logfile ""
EOF

echo "Redis 单机挂载目录与配置已生成：${SCRIPT_DIR}"
```

#### docker-compose.yaml

`standalone/docker-compose.yaml`

```yaml
networks:
  redis-net:
    driver: bridge

services:
  redis:
    image: redis:7.4.8
    container_name: redis
    restart: always
    networks: [redis-net]
    ports: ["6379:6379"]
    volumes:
      - ./conf/redis.conf:/etc/redis/redis.conf
      - ./data:/data
    command: ["redis-server", "/etc/redis/redis.conf"]
```

#### 部署

```bash
cd standalone
chmod +x deploy.sh
bash deploy.sh
docker-compose up -d
```

#### 验证

```bash
redis-cli -h <宿主机IP> -p 6379 -a redis ping
# 期望输出：PONG
redis-cli -h <宿主机IP> -p 6379 -a redis set foo bar
redis-cli -h <宿主机IP> -p 6379 -a redis get foo
```

### 集群模式（6 节点，3 主 3 从）

#### 地址与端口设计

每个节点监听独立端口，并保持 **宿主机端口 = 容器监听端口**。业务端口为 7001–7006，总线端口为 17001–17006，所有节点通告 `HOST_IP`。

Cluster 客户端会根据 `MOVED` / `ASK` 返回的地址连接其他节点；若返回容器内部地址，宿主机外客户端可能无法访问。Sentinel 同样需要将可达的主从地址返回给客户端。

Redis 7.4.8 提供 `cluster-announce-ip`、`cluster-announce-port`、`cluster-announce-bus-port` 用于显式通告地址。本文按 bridge 要求采用固定通告地址方案，但它依赖容器可以经宿主机发布端口回流到自己及其他节点，不能视为任意 NAT 环境都适用。配置依据：[Redis 7.4.8 配置文件](https://raw.githubusercontent.com/redis/redis/7.4.8/redis.conf)。

客户端需要访问全部 6 个业务端口，节点间需要访问全部业务和总线端口。只开放种子节点端口会导致 `MOVED` 重定向后连接失败。总线端口仅向节点所需网络开放。

#### 配置

```bash
#!/usr/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd "$SCRIPT_DIR"

# 自动通过 `ip a` 获取本机物理网卡 IP（排除 lo/docker/br-/veth/virbr/tun/tap 等虚拟网卡）；
# 如自动识别的不是期望的网卡，可显式传参覆盖：./init.sh <宿主机IP>
detect_host_ip() {
  ip -4 -o addr show scope global 2>/dev/null \
    | awk '{print $2, $4}' \
    | grep -Ev '^(lo|docker[0-9]*|br-[0-9a-f]+|veth.*|virbr[0-9]*|tun[0-9]*|tap[0-9]*)\s' \
    | head -n1 \
    | awk '{print $2}' \
    | cut -d/ -f1 \
    || true
}

HOST_IP="${1:-$(detect_host_ip)}"
if [ -z "${HOST_IP}" ]; then
  echo "错误：未能通过 ip a 自动识别宿主机物理网卡 IP，请手动指定：$0 <宿主机IP>" >&2
  exit 1
fi
echo "使用宿主机IP：${HOST_IP}（如需手动指定，用法：$0 <宿主机IP>）"

for port in 7001 7002 7003 7004 7005 7006; do
  BUS_PORT=$((port + 10000))
  mkdir -p "node-${port}/conf" "node-${port}/data"

cat > "node-${port}/conf/redis.conf" <<EOF
bind 0.0.0.0
port ${port}
protected-mode yes
requirepass redis
masterauth redis
dir /data
appendonly yes
appendfsync everysec
cluster-enabled yes
cluster-config-file /data/nodes.conf
cluster-node-timeout 5000
cluster-announce-ip ${HOST_IP}
cluster-announce-port ${port}
cluster-announce-bus-port ${BUS_PORT}
replica-announce-ip ${HOST_IP}
replica-announce-port ${port}
daemonize no
logfile ""
EOF

  echo "已生成 node-${port}/conf/redis.conf (announce ${HOST_IP}:${port})"
done

echo "全部 6 个节点挂载目录与配置已生成：${SCRIPT_DIR}"
```

#### docker-compose

```yaml
networks:
  redis-net:
    driver: bridge

name: redis-cluster
x-redis: &redis-common
  image: redis:7.4.8
  restart: always
  networks: [redis-net]
x-healthcheck: &redis-healthcheck
  interval: 10s
  timeout: 5s
  retries: 12

services:
  redis-7001:
    <<: *redis-common
    command: ["redis-server", "/data/conf/redis.conf"]
    ports:
      - "7001:7001"
      - "17001:17001"
    volumes: ["./node-7001:/data"]
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 7001 ping | grep -qx PONG"]
  redis-7002:
    <<: *redis-common
    command: ["redis-server", "/data/conf/redis.conf"]
    ports:
      - "7002:7002"
      - "17002:17002"
    volumes: ["./node-7002:/data"]
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 7002 ping | grep -qx PONG"]
  redis-7003:
    <<: *redis-common
    command: ["redis-server", "/data/conf/redis.conf"]
    ports:
      - "7003:7003"
      - "17003:17003"
    volumes: ["./node-7003:/data"]
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 7003 ping | grep -qx PONG"]
  redis-7004:
    <<: *redis-common
    command: ["redis-server", "/data/conf/redis.conf"]
    ports:
      - "7004:7004"
      - "17004:17004"
    volumes: ["./node-7004:/data"]
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 7004 ping | grep -qx PONG"]
  redis-7005:
    <<: *redis-common
    command: ["redis-server", "/data/conf/redis.conf"]
    ports:
      - "7005:7005"
      - "17005:17005"
    volumes: ["./node-7005:/data"]
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 7005 ping | grep -qx PONG"]
  redis-7006:
    <<: *redis-common
    command: ["redis-server", "/data/conf/redis.conf"]
    ports:
      - "7006:7006"
      - "17006:17006"
    volumes: ["./node-7006:/data"]
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 7006 ping | grep -qx PONG"]

```

`nodes.conf` 是 Redis 自动维护的拓扑文件，不是手写配置，必须持久化，每节点独立保存。

#### 创建集群

先验证 **每个容器 → 每个宿主机业务/总线端口**，全部通过才能创建集群： `node-check.sh`

```bash
#!/bin/bash
# node-check.sh

detect_host_ip() {
  ip -4 -o addr show scope global 2>/dev/null \
    | awk '{print $2, $4}' \
    | grep -Ev '^(lo|docker[0-9]*|br-[0-9a-f]+|veth.*|virbr[0-9]*|tun[0-9]*|tap[0-9]*)\s' \
    | head -n1 \
    | awk '{print $2}' \
    | cut -d/ -f1 \
    || true
}

HOST_IP="${1:-$(detect_host_ip)}"
if [ -z "${HOST_IP}" ]; then
  echo "错误：未能通过 ip a 自动识别宿主机物理网卡 IP，请手动指定：$0 <宿主机IP>" >&2
  exit 1
fi
echo "使用宿主机IP：${HOST_IP}（如需手动指定，用法：$0 <宿主机IP>）"

for src in 7001 7002 7003 7004 7005 7006; do
  for dst in 7001 7002 7003 7004 7005 7006; do
    docker-compose exec -T -e REDISCLI_AUTH=redis "redis-${src}" \
      redis-cli -h "$HOST_IP" -p "$dst" PING
    docker-compose exec -T "redis-${src}" \
      timeout 3 bash -c "exec 3<>/dev/tcp/${HOST_IP}/$((dst + 10000))" || break 2
  done
done
```

业务端口应全部 `PONG`，总线 TCP 探测退出码应为 0（无需向总线发送 Redis 命令）。如有超时，先检查 Linux IP 转发、Docker NAT/防火墙、宿主机 IP 选择和端口绑定。仅容器内 `localhost` 能连通不算通过。

首次创建集群（只执行一次，要求节点无数据、未加入其他集群）：`create-cluster.sh`

```bash
#!/bin/bash

detect_host_ip() {
  ip -4 -o addr show scope global 2>/dev/null \
    | awk '{print $2, $4}' \
    | grep -Ev '^(lo|docker[0-9]*|br-[0-9a-f]+|veth.*|virbr[0-9]*|tun[0-9]*|tap[0-9]*)\s' \
    | head -n1 \
    | awk '{print $2}' \
    | cut -d/ -f1 \
    || true
}

HOST_IP="${1:-$(detect_host_ip)}"
if [ -z "${HOST_IP}" ]; then
  echo "错误：未能通过 ip a 自动识别宿主机物理网卡 IP，请手动指定：$0 <宿主机IP>" >&2
  exit 1
fi
echo "使用宿主机IP：${HOST_IP}（如需手动指定，用法：$0 <宿主机IP>）"

docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 redis-cli \
  --cluster create \
  "$HOST_IP:7001" "$HOST_IP:7002" "$HOST_IP:7003" \
  "$HOST_IP:7004" "$HOST_IP:7005" "$HOST_IP:7006" \
  --cluster-replicas 1 --cluster-yes
```

`--cluster-replicas 1` 表示每个主节点配 1 个从节点，最终自动分配为 3 主 3 从

同宿主机节点没有主从物理隔离，创建时可能提示同 IP 的反亲和限制。预期仍应形成 3 主 3 从，每主对应一个副本；角色以实际拓扑为准。

####  验收：槽位、节点、宿主机 IP 和读写

查看集群状态命令

```bash
# 查看集群状态（state:ok 表示成功，cluster_known_nodes 应为 6，cluster_size 应为 3）
redis-cli -h <宿主机IP> -p 6371 -a redis cluster info

# 查看集群节点信息（角色 master/slave、槽位分配、connected 状态）
redis-cli -h <宿主机IP> -p 6371 -a redis cluster nodes

# 用集群客户端模式验证读写与自动重定向
redis-cli -h <宿主机IP> -p 6371 -a redis -c set foo bar
redis-cli -h <宿主机IP> -p 6372 -a redis -c get foo
```

`cluster-check.sh`

```bash
#!/bin/bash

detect_host_ip() {
  ip -4 -o addr show scope global 2>/dev/null \
    | awk '{print $2, $4}' \
    | grep -Ev '^(lo|docker[0-9]*|br-[0-9a-f]+|veth.*|virbr[0-9]*|tun[0-9]*|tap[0-9]*)\s' \
    | head -n1 \
    | awk '{print $2}' \
    | cut -d/ -f1 \
    || true
}

HOST_IP="${1:-$(detect_host_ip)}"
if [ -z "${HOST_IP}" ]; then
  echo "错误：未能通过 ip a 自动识别宿主机物理网卡 IP，请手动指定：$0 <宿主机IP>" >&2
  exit 1
fi
echo "使用宿主机IP：${HOST_IP}（如需手动指定，用法：$0 <宿主机IP>）"

docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 \
  redis-cli --cluster check "$HOST_IP:7001"
docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 \
  redis-cli -h "$HOST_IP" -p 7001 CLUSTER INFO
docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 \
  redis-cli -h "$HOST_IP" -p 7001 CLUSTER NODES
docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 \
  redis-cli -h "$HOST_IP" -p 7001 CLUSTER SHARDS
docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 \
  redis-cli -c -h "$HOST_IP" -p 7001 SET deployment:cluster ok
docker-compose exec -T -e REDISCLI_AUTH=redis redis-7001 \
  redis-cli -c -h "$HOST_IP" -p 7002 GET deployment:cluster
```

成功标准：

- `cluster_state:ok`、`cluster_known_nodes:6`、`cluster_size:3`。
- `cluster_slots_assigned:16384`、`cluster_slots_ok:16384`，`cluster_slots_fail` 和 `cluster_slots_pfail` 为 0。
- `CLUSTER NODES` 中 3 个 `master`、3 个 `slave`（协议仍用此词），全部 `connected`；没有 `fail`/`fail?` 标记。
- 地址显示 `宿主机IP:700x@1700x`，不能是 `172.x` 等容器地址；每个副本的主节点 ID 有效。
- `--cluster check` 报告 16384 槽位覆盖完整；跨节点读写返回 `OK`、`ok`。

在客户端机器上使用 `REDISCLI_AUTH=redis redis-cli -c -h <宿主机IP> -p 7001 GET deployment:cluster` 再次验收。业务 SDK 必须启用 Cluster 模式，并配置多个种子节点。

故障演练：先用 `CLUSTER NODES` 找出一个主节点及其副本，执行 `docker-compose stop redis-<主节点端口>`。等待数个 `cluster-node-timeout` 后，从存活节点重复检查，确认副本晋升、槽位恢复且能读写。最后 `docker-compose start redis-<原主节点端口>`，确认节点重新加入。停止服务名应根据实际角色选择，不能默认 7001 永远是主节点。

### 哨兵：1主2从3哨兵

#### 部署说明

初始主节点为 `HOST_IP:16379`，副本为 26379、36379；哨兵端口 46379、46479、46579。监控名称为 `mymaster`，quorum 为 2。

所有 Redis 节点设置 `requirepass`、`masterauth`、`replica-announce-ip/port`；所有哨兵设置 `sentinel announce-ip/port`，监控目标也使用宿主机 IP。这样哨兵返回给客户端的主节点地址、发现的副本及哨兵地址均可通过宿主机访问。参考：[Sentinel 容器网络说明](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/)、[副本地址通告](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/)。

本示例 Redis 数据节点密码和 Sentinel 自身连接密码均为 `redis`。客户端需分别配置 Redis 密码、Sentinel 密码和主名称 `mymaster`，只填写 Redis 密码不足以查询受保护的哨兵。

#### 配置

`deploy.sh`

```bash
#!/usr/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd "$SCRIPT_DIR"

# 自动通过 `ip a` 获取本机物理网卡 IP（排除 lo/docker/br-/veth/virbr/tun/tap 等虚拟网卡）；
# 如自动识别的不是期望的网卡，可显式传参覆盖：./init.sh <宿主机IP>
detect_host_ip() {
  ip -4 -o addr show scope global 2>/dev/null \
    | awk '{print $2, $4}' \
    | grep -Ev '^(lo|docker[0-9]*|br-[0-9a-f]+|veth.*|virbr[0-9]*|tun[0-9]*|tap[0-9]*)\s' \
    | head -n1 \
    | awk '{print $2}' \
    | cut -d/ -f1 \
    || true
}

HOST_IP="${1:-$(detect_host_ip)}"
if [ -z "${HOST_IP}" ]; then
  echo "错误：未能通过 ip a 自动识别宿主机物理网卡 IP，请手动指定：$0 <宿主机IP>" >&2
  exit 1
fi
echo "使用宿主机IP：${HOST_IP}（如需手动指定，用法：$0 <宿主机IP>）"

mkdir -p master/conf master/data
cat > master/conf/redis.conf <<EOF
port 6379
bind 0.0.0.0
protected-mode no
requirepass redis
masterauth redis
appendonly yes
dir /data
replica-announce-ip ${HOST_IP}
replica-announce-port 16379
EOF

gen_replica() {
  local name=$1 port=$2
mkdir -p ${name}/conf ${name}/data
cat > "${name}/conf/redis.conf" <<EOF
port 6379
bind 0.0.0.0
protected-mode no
requirepass redis
masterauth redis
appendonly yes
dir /data
replicaof ${HOST_IP} 16379
replica-announce-ip ${HOST_IP}
replica-announce-port ${port}
EOF
}
gen_replica slave1 26379
gen_replica slave2 36379

gen_sentinel() {
  local name=$1 port=$2
mkdir -p ${name}/conf ${name}/data
cat > "${name}/conf/sentinel.conf" <<EOF
port 26379
bind 0.0.0.0
sentinel monitor mymaster ${HOST_IP} 16379 2
sentinel auth-pass mymaster redis
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1
sentinel announce-ip ${HOST_IP}
sentinel announce-port ${port}
EOF
}
gen_sentinel sentinel1 46379
gen_sentinel sentinel2 46479
gen_sentinel sentinel3 46579

echo "哨兵模式挂载目录与配置已生成：${SCRIPT_DIR}，宿主机IP=${HOST_IP}"
```

说明：`sentinel monitor mymaster <宿主机IP> 6390 2` 直接用宿主机 IP + 宿主机映射端口监控 master——因为 bridge 网络下容器访问宿主机映射端口通常是可达的（Linux 下宿主机 IP 本身就在同一网段/网桥可达；Docker Desktop for Windows/Mac 也可达宿主机的监听端口）。这样 Sentinel 后续 failover 后上报的新 master 地址也会是宿主机地址，客户端才能正常追随主从切换。

#### docker-compose.yaml

```yaml
networks:
  redis-net:
    driver: bridge

x-redis-server-common: &redis-server-common
  image: redis:7.4.8
  restart: always
  networks: [redis-net]
  command: ["redis-server", "/etc/redis/redis.conf"]

x-redis-sentinel-common: &redis-sentinel-common
  image: redis:7.4.8
  restart: always
  networks: [redis-net]
  command: ["redis-sentinel", "/etc/redis/sentinel.conf"]
  
x-healthcheck: &redis-healthcheck
  interval: 10s
  timeout: 5s
  retries: 12

services:
  redis-master:
    <<: *redis-server-common
    container_name: redis-master
    ports:
      - "16379:6379"
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 6379 ping | grep -qx PONG"]      
    volumes:
      - ./master/conf/redis.conf:/etc/redis/redis.conf
      - ./master/data:/data

  redis-slave1:
    <<: *redis-server-common
    container_name: redis-slave1
    ports:
      - "26379:6379"
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 6379 ping | grep -qx PONG"]  
    volumes:
      - ./slave1/conf/redis.conf:/etc/redis/redis.conf
      - ./slave1/data:/data
    depends_on: [redis-master]

  redis-slave2:
    <<: *redis-server-common
    container_name: redis-slave2
    ports:
      - "36379:6379"
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 6379 ping | grep -qx PONG"]  
    volumes:
      - ./slave2/conf/redis.conf:/etc/redis/redis.conf
      - ./slave2/data:/data
    depends_on: [redis-master]

  redis-sentinel1:
    <<: *redis-sentinel-common
    container_name: redis-sentinel1
    ports:
      - "46379:26379"
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 26379 ping | grep -qx PONG"]  
    volumes:
      - ./sentinel1/conf/sentinel.conf:/etc/redis/sentinel.conf
    depends_on: [redis-master, redis-slave1, redis-slave2]

  redis-sentinel2:
    <<: *redis-sentinel-common
    container_name: redis-sentinel2
    ports:
      - "46479:26379"
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 26379 ping | grep -qx PONG"] 
    volumes:
      - ./sentinel2/conf/sentinel.conf:/etc/redis/sentinel.conf
    depends_on: [redis-master, redis-slave1, redis-slave2]

  redis-sentinel3:
    <<: *redis-sentinel-common
    container_name: redis-sentinel3
    ports:
      - "46579:26379"
    healthcheck:
      <<: *redis-healthcheck
      test: ["CMD-SHELL", "REDISCLI_AUTH=redis redis-cli -p 26379 ping | grep -qx PONG"] 
    volumes:
      - ./sentinel3/conf/sentinel.conf:/etc/redis/sentinel.conf
    depends_on: [redis-master, redis-slave1, redis-slave2]
```

这里将整个节点目录挂载到 `/data`，配置放在其 `conf` 子目录且可写。Sentinel 会重写自身配置，Redis 数据节点也需要持久化故障切换后的角色配置；不能使用只读配置、只挂载单个配置文件，或每次启动重生成初始 `replicaof`。官方 Redis 镜像入口在 `/data` 下准备属主；若权限报错，检查实际 UID/GID 和宿主机挂载权限。

#### 部署

```bash
cd redis-sentinel
chmod +x deploy.sh
bash deploy.sh                # 默认通过 ip a 自动识别宿主机 IP；如识别有误，改用 ./init.sh <宿主机IP>
docker-compose up -d
```

#### 验证是否部署成功

```bash
# 查看主从复制状态
docker exec -it redis-master redis-cli -a redis info replication

# 查看哨兵视角
docker exec -it redis-sentinel1 redis-cli -p 26379 sentinel master mymaster
docker exec -it redis-sentinel1 redis-cli -p 26379 sentinel replicas mymaster
docker exec -it redis-sentinel1 redis-cli -p 26379 sentinel sentinels mymaster

# 模拟主库故障，观察自动切换
docker stop redis-master
docker exec -it redis-sentinel2 redis-cli -p 26379 sentinel master mymaster   # 几秒后应显示新 master
```

宿主机验证

```bash
# 从slave1容器内部，测试能不能通过"宿主机IP"连回同一台机器的master端口
$ docker exec -it redis-slave1 redis-cli -h <宿主机IP> -p 6379 -a redis ping
docker exec -it redis-slave1 redis-cli -h 10.4.100.125 -p 6379 -a redis ping
# 应该返回 PONG

# 从sentinel1容器内部同样测试
$ docker exec -it sentinel1 redis-cli -h <宿主机IP> -p 6379 -a redis ping
docker exec -it sentinel1 redis-cli -h 10.4.100.125 -p 6379 -a redis ping

# 1. 主从复制是否正常
docker exec -it redis-master redis-cli -a redis info replication
# 应看到 connected_slaves:2，并且两个slave的ip字段显示的是宿主机IP，不是容器内部IP

# 2. sentinel看到的master地址
docker exec -it sentinel1 redis-cli -h 10.4.100.125 -p 46379 sentinel get-master-addr-by-name mymaster
# 应返回 <宿主机IP> 6379，而不是172.x.x.x这种内部地址

# 3. sentinel看到的从库地址
docker exec -it sentinel1 redis-cli -h 10.4.100.125 -p 46379 sentinel replicas mymaster
# 检查每个slave的ip/port字段，应该也是宿主机IP + 6380/6381
```

## MySQL 8.4.10

### 单节点

默认库 `test`，账号 `root/mysql`。挂载整个 `/etc/mysql/conf.d` 配置目录及 `/var/lib/mysql` 数据目录。初始化变量只对空数据目录有效；修改环境变量不会修改已有 root 密码。

```bash
#!/bin/bash

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd "$SCRIPT_DIR"

mkdir -p ./{conf,data}

cat > conf/my.cnf <<'EOF'
[mysqld]
bind-address=0.0.0.0
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
default-time-zone=+08:00
[client]
default-character-set=utf8mb4
EOF

chmod 644 conf/my.cnf
```

`docker-compose.yaml`

```yaml
name: mysql-standalone
services:
  mysql:
    image: mysql:8.4.10
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: mysql
      MYSQL_ROOT_HOST: "%"
      MYSQL_DATABASE: test
      TZ: Asia/Shanghai
    ports: ["3306:3306"]
    volumes:
      - ./conf:/etc/mysql/conf.d:ro
      - ./data:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "MYSQL_PWD=mysql mysql -h 127.0.0.1 -uroot -Nse 'SELECT 1' | grep -qx 1"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 30s
    networks: [mysql-net]
networks:
  mysql-net:
    driver: bridge
```

```bash
docker-compose up -d
```

### 主从模式：1 主 1 从，GTID 异步复制

#### 准备配置

主库对外 3306，从库对外 3307。容器间通过 `mysql-primary:3306` 通信；这里无需向客户端通告拓扑，不存在 Redis 式重定向地址映射问题。

以下流程仅适用于 **两端均为全新空数据目录**。已有主库需先做一致性备份、恢复及 GTID 衔接，不能将空从库直接当作已同步。主从复制本身不提供自动主库故障切换。

```bash
#!/bin/bash

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd $SCRIPT_DIR

mkdir -p ./{primary,replica}/{conf,data}

cat > primary/conf/my.cnf <<'EOF'
[mysqld]
bind-address=0.0.0.0
server-id=1
log-bin=mysql-bin
binlog-format=ROW
gtid-mode=ON
enforce-gtid-consistency=ON
log-replica-updates=ON
sync-binlog=1
innodb-flush-log-at-trx-commit=1
binlog-expire-logs-seconds=604800
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
default-time-zone=+08:00
EOF

cat > replica/conf/my.cnf <<'EOF'
[mysqld]
bind-address=0.0.0.0
server-id=2
log-bin=mysql-bin
relay-log=relay-bin
relay-log-recovery=ON
binlog-format=ROW
gtid-mode=ON
enforce-gtid-consistency=ON
log-replica-updates=ON
read-only=ON
binlog-expire-logs-seconds=604800
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
default-time-zone=+08:00
EOF

chmod 644 primary/conf/my.cnf replica/conf/my.cnf
```

`docker-compose.yaml`

```yaml
name: mysql-replication

x-mysql: &mysql-common
  image: mysql:8.4.10
  restart: unless-stopped
  environment:
    MYSQL_ROOT_PASSWORD: mysql
    MYSQL_ROOT_HOST: "%"
    MYSQL_DATABASE: test
    TZ: Asia/Shanghai
  healthcheck:
    test: ["CMD-SHELL", "MYSQL_PWD=mysql mysql -h 127.0.0.1 -uroot -Nse 'SELECT 1' | grep -qx 1"]
    interval: 10s
    timeout: 5s
    retries: 30
    start_period: 30s
  networks: [mysql-net]

services:
  mysql-primary:
    <<: *mysql-common
    ports: ["3306:3306"]
    volumes:
      - ./primary/conf:/etc/mysql/conf.d:ro
      - ./primary/data:/var/lib/mysql
  mysql-replica:
    <<: *mysql-common
    ports: ["3307:3306"]
    volumes:
      - ./replica/conf:/etc/mysql/conf.d:ro
      - ./replica/data:/var/lib/mysql

networks:
  mysql-net:
    driver: bridge
```

#### 启动并建立复制

```bash
docker-compose config --quiet
docker-compose pull
docker-compose up -d --wait --wait-timeout 360

# 在主库新建专用复制账号。此处复制账号为 repl / mysql-repl。
docker-compose exec -T -e MYSQL_PWD=mysql mysql-primary mysql -uroot <<'SQL'
CREATE USER 'repl'@'%' IDENTIFIED BY 'mysql-repl';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
SELECT @@server_id, @@server_uuid, @@gtid_mode;
SQL

docker-compose exec -T -e MYSQL_PWD=mysql mysql-replica mysql -uroot <<'SQL'
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='mysql-primary',
  SOURCE_PORT=3306,
  SOURCE_USER='repl',
  SOURCE_PASSWORD='mysql-repl',
  SOURCE_AUTO_POSITION=1,
  GET_SOURCE_PUBLIC_KEY=1;
START REPLICA;
SET PERSIST read_only=ON;
SET PERSIST super_read_only=ON;
SHOW REPLICA STATUS\G
SQL
```

`super_read_only` 在镜像首次初始化完成后启用，并持久化到数据目录的 `mysqld-auto.cnf`，避免妨碍初始化创建用户及数据库。不能复制主库的整个运行中数据目录给从库，否则可能复用 `server_uuid`。

MySQL 8.4 使用默认 `caching_sha2_password`，无需启用旧 `mysql_native_password`。在此非 TLS 的内网示例中，`GET_SOURCE_PUBLIC_KEY=1` 支持复制账号认证；它不等于加密整个复制链路。SQL 使用 8.4 的 `SOURCE`/`REPLICA` 语法。参考：[CHANGE REPLICATION SOURCE TO](https://dev.mysql.com/doc/refman/8.4/en/change-replication-source-to.html)。

#### 验证复制线程与实际数据

```bash
docker-compose exec -T -e MYSQL_PWD=mysql mysql-primary mysql -uroot \
  -e 'SHOW BINARY LOG STATUS; SELECT @@server_id, @@server_uuid, @@gtid_mode;'
docker-compose exec -T -e MYSQL_PWD=mysql mysql-replica mysql -uroot \
  -e 'SHOW REPLICA STATUS\G'
docker-compose exec -T -e MYSQL_PWD=mysql mysql-replica mysql -uroot \
  -e 'SELECT @@server_id, @@server_uuid, @@read_only, @@super_read_only;'

docker-compose exec -T -e MYSQL_PWD=mysql mysql-primary mysql -uroot test <<'SQL'
CREATE TABLE IF NOT EXISTS replication_check (id INT PRIMARY KEY, note VARCHAR(64));
INSERT INTO replication_check VALUES (1, 'primary-to-replica-ok')
  ON DUPLICATE KEY UPDATE note='primary-to-replica-ok';
SQL

# 精确等待主库已提交的 GTID 集合，避免仅靠固定 sleep 判断复制完成。
GTID=$(docker-compose exec -T -e MYSQL_PWD=mysql mysql-primary \
  mysql -uroot -Nse 'SELECT @@GLOBAL.gtid_executed' | tr -d '\r')
docker-compose exec -T -e MYSQL_PWD=mysql mysql-replica mysql -uroot \
  -e "SELECT WAIT_FOR_EXECUTED_GTID_SET('${GTID}', 30) AS wait_result;"
docker-compose exec -T -e MYSQL_PWD=mysql mysql-replica mysql -uroot test \
  -e 'SELECT * FROM replication_check;'
```

成功标准：

- `Replica_IO_Running: Yes` 和 `Replica_SQL_Running: Yes`。
- `Source_Host: mysql-primary`、`Source_Port:3306`、`Auto_Position:1`。
- `Last_IO_Error`、`Last_SQL_Error` 为空；稳定后 `Seconds_Behind_Source:0`，不能只凭此一项判断成功。
- 主从 `server_id` 和 `server_uuid` 不同，从库 `read_only=1`、`super_read_only=1`。
- `wait_result=0`（1 为超时），从库查到 `1 / primary-to-replica-ok`。

从外部客户端分别连接 `宿主机IP:3306` 和 `宿主机IP:3307` 查询 `test.replication_check`。若出现连接线程失败检查 DNS、账号和认证；SQL 线程失败检查冲突事务，不能跳过错误后直接认定成功。

## Nginx 1.30.4

### 配置目录与 Compose

挂载整个 `/etc/nginx/conf.d` 目录，主配置继续使用官方镜像提供的 `/etc/nginx/nginx.conf`，避免空目录覆盖掉主配置及 `mime.types`。参考：[Nginx 官方镜像](https://hub.docker.com/_/nginx)。

```bash
mkdir -p "$DEPLOY_ROOT/nginx"/{conf.d,html}
cd "$DEPLOY_ROOT/nginx"
cat > conf.d/default.conf <<'EOF'
server {
    listen 80;
    server_name _;
    charset utf-8;

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }

    location = /health {
        default_type text/plain;
        return 200 "nginx-ok\n";
    }
}
EOF
printf '<!doctype html><html><body>Nginx deployment OK</body></html>\n' > html/index.html
```

创建 `docker-compose.yaml`：

```yaml
name: nginx
services:
  nginx:
    image: nginx:1.30.4
    restart: unless-stopped
    ports: ["80:80"]
    volumes:
      - ./conf.d:/etc/nginx/conf.d:ro
      - ./html:/usr/share/nginx/html:ro
    networks: [nginx-net]
networks:
  nginx-net:
    driver: bridge
```

### 启动、验证和配置重载

```bash
docker-compose config --quiet
docker-compose pull
docker-compose run --rm --no-deps nginx nginx -t
docker-compose up -d
docker-compose exec -T nginx nginx -v
docker-compose exec -T nginx nginx -t
curl --fail-with-body "http://${HOST_IP}/health"
curl --fail-with-body "http://${HOST_IP}/"
docker-compose logs --tail=100 nginx

# 修改 conf.d 中配置后，检查成功才执行重载。
docker-compose exec -T nginx nginx -t && \
  docker-compose exec -T nginx nginx -s reload
```

成功标准：版本 `nginx/1.30.4`，配置检测 `syntax is ok`、`test is successful`，`/health` 返回 HTTP 200 和 `nginx-ok`，首页显示 `Nginx deployment OK`。访问日志及错误日志通过 `docker-compose logs` 查看。

## RabbitMQ 3.13.6/4.3.6

### 版本选择与目录隔离

两个版本均提供单机及 3 节点仲裁队列部署。Compose 通过 `.env` 的 `RABBITMQ_TAG` 选择精确镜像，以下流程对两个版本分别执行即可。

| 版本   | `RABBITMQ_TAG`      | 单机目录                                | 集群目录                            |
| ------ | ------------------- | --------------------------------------- | ----------------------------------- |
| 3.13.6 | `3.13.6-management` | `rabbitmq/3.13.6-management/standalone` | `rabbitmq/3.13.6-management/quorum` |
| 4.3.6  | `4.3.6-management`  | `rabbitmq/4.3.6-management/standalone`  | `rabbitmq/4.3.6-management/quorum`  |

选择一个版本：

```bash
export RABBITMQ_TAG=3.13.6-management
# 部署另一个版本时，改为下面这一行，并使用新的独立目录：
# export RABBITMQ_TAG=4.3.6-management
```

同一集群三个节点必须使用相同版本。此处是两套独立新部署方案，不是将 3.13 数据目录直接切换至 4.3 的升级方案。不要复用数据或混用镜像标签。

### 单节点

```bash
mkdir -p "$DEPLOY_ROOT/rabbitmq/$RABBITMQ_TAG/standalone"/{conf,data}
cd "$DEPLOY_ROOT/rabbitmq/$RABBITMQ_TAG/standalone"
printf 'RABBITMQ_TAG=%s\n' "$RABBITMQ_TAG" > .env
cat > conf/rabbitmq.conf <<'EOF'
listeners.tcp.default = 5672
management.tcp.port = 15672
EOF
```

创建 `docker-compose.yaml`：

```yaml
name: rabbitmq-standalone
services:
  rabbitmq:
    image: rabbitmq:${RABBITMQ_TAG:?请设置精确版本}
    hostname: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_NODENAME: rabbit@rabbitmq
      RABBITMQ_DEFAULT_USER: rabbitmq
      RABBITMQ_DEFAULT_PASS: rabbitmq
    ports:
      - "5672:5672"
      - "15672:15672"
    volumes:
      - ./conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
      - ./data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
      interval: 10s
      timeout: 10s
      retries: 30
      start_period: 30s
    networks: [rabbitmq-net]
networks:
  rabbitmq-net:
    driver: bridge
```

```bash
docker-compose config --quiet
docker-compose pull
docker-compose up -d --wait --wait-timeout 360
docker-compose exec -T rabbitmq rabbitmq-diagnostics server_version
docker-compose exec -T rabbitmq rabbitmq-diagnostics check_port_connectivity
docker-compose exec -T rabbitmq rabbitmqctl list_users
curl --fail-with-body -u rabbitmq:rabbitmq \
  "http://${HOST_IP}:15672/api/overview" | jq '{rabbitmq_version,cluster_name}'
```

管理控制台为 `http://宿主机IP:15672`，登录 `rabbitmq/rabbitmq`；AMQP 为 `宿主机IP:5672`，虚拟主机 `/`。默认用户环境变量仅首次空数据目录初始化生效。

### 高可用仲裁队列：3 节点

#### 集群配置

RabbitMQ 集群负责节点协作，**普通 classic 队列不会因建成集群自动拥有 3 副本**。必须显式声明 `x-queue-type=quorum`，并验证成员数为 3。每条仲裁队列有自己的 leader 和 follower，整个 RabbitMQ 集群没有固定的全局主节点。3 副本多数派为 2，可容忍一个队列成员故障。参考：[仲裁队列](https://www.rabbitmq.com/docs/quorum-queues)。

节点间通过 `rabbitmq-net` 的 DNS 名访问 4369（EPMD）、25672（Erlang 分布式通信），这两个端口在本单宿主机方案无需发布给外部客户端。

```bash
mkdir -p "$DEPLOY_ROOT/rabbitmq/$RABBITMQ_TAG/quorum"/conf
cd "$DEPLOY_ROOT/rabbitmq/$RABBITMQ_TAG/quorum"
mkdir -p data/rmq1 data/rmq2 data/rmq3
printf 'RABBITMQ_TAG=%s\n' "$RABBITMQ_TAG" > .env
cat >> .env <<'EOF'
RABBITMQ_ERLANG_COOKIE=middleware-rabbitmq-shared-cookie-2026
EOF
chmod 600 .env
cat > conf/rabbitmq.conf <<'EOF'
listeners.tcp.default = 5672
management.tcp.port = 15672
cluster_formation.peer_discovery_backend = classic_config
cluster_formation.classic_config.nodes.1 = rabbit@rmq1
cluster_formation.classic_config.nodes.2 = rabbit@rmq2
cluster_formation.classic_config.nodes.3 = rabbit@rmq3
EOF
```

固定 hostname、节点名和持久化目录，三个节点必须使用相同 Erlang cookie。此处采用内置静态节点发现，不依赖额外发现插件。参考：[RabbitMQ 3.13 节点发现](https://www.rabbitmq.com/docs/3.13/cluster-formation)。

创建 `docker-compose.yaml`：

```yaml
name: rabbitmq-quorum
x-rabbitmq: &rabbitmq-common
  image: rabbitmq:${RABBITMQ_TAG:?请设置精确版本}
  restart: unless-stopped
  healthcheck:
    test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
    interval: 10s
    timeout: 10s
    retries: 30
    start_period: 30s
  networks: [rabbitmq-net]
x-environment: &rabbitmq-environment
  RABBITMQ_DEFAULT_USER: rabbitmq
  RABBITMQ_DEFAULT_PASS: rabbitmq
  RABBITMQ_ERLANG_COOKIE: ${RABBITMQ_ERLANG_COOKIE:?请设置共享 cookie}
services:
  rmq1:
    <<: *rabbitmq-common
    hostname: rmq1
    environment:
      <<: *rabbitmq-environment
      RABBITMQ_NODENAME: rabbit@rmq1
    ports: ["5672:5672", "15672:15672"]
    volumes:
      - ./conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
      - ./data/rmq1:/var/lib/rabbitmq
  rmq2:
    <<: *rabbitmq-common
    hostname: rmq2
    environment:
      <<: *rabbitmq-environment
      RABBITMQ_NODENAME: rabbit@rmq2
    ports: ["5673:5672", "15673:15672"]
    volumes:
      - ./conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
      - ./data/rmq2:/var/lib/rabbitmq
  rmq3:
    <<: *rabbitmq-common
    hostname: rmq3
    environment:
      <<: *rabbitmq-environment
      RABBITMQ_NODENAME: rabbit@rmq3
    ports: ["5674:5672", "15674:15672"]
    volumes:
      - ./conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
      - ./data/rmq3:/var/lib/rabbitmq
networks:
  rabbitmq-net:
    driver: bridge
```

首次空目录启动按顺序进行，避免节点同时初始化为独立集群：

```bash
docker-compose config --quiet
docker-compose pull
docker-compose up -d --wait --wait-timeout 360 rmq1
docker-compose up -d --wait --wait-timeout 360 rmq2
docker-compose up -d --wait --wait-timeout 360 rmq3
for node in rmq1 rmq2 rmq3; do
  docker-compose exec -T "$node" rabbitmqctl cluster_status
done
curl --fail-with-body -u rabbitmq:rabbitmq \
  "http://${HOST_IP}:15672/api/nodes" | jq '.[] | {name,running,partitions}'
```

必须在三个节点上看到同一个集群、3 个运行节点 `rabbit@rmq1`、`rabbit@rmq2`、`rabbit@rmq3`，无网络分区后，再声明队列。现有集群的重启使用 `docker-compose up -d` 同时恢复节点，不必强制沿用首次逐节点顺序；不应通过清空目录或 `reset` 来修复普通重启问题。

#### 声明仲裁队列，查询哪个节点是 leader

以下使用管理 HTTP API，`%2F` 为默认虚拟主机 `/` 的编码。队列名 `deployment.quorum` 应为新的测试队列；已有同名 classic 队列不能原地转换类型。API 用法参考：[RabbitMQ HTTP API](https://www.rabbitmq.com/docs/http-api-reference)。

```bash
curl --fail-with-body -u rabbitmq:rabbitmq \
  -H 'content-type: application/json' -X PUT \
  "http://${HOST_IP}:15672/api/queues/%2F/deployment.quorum" \
  -d '{"durable":true,"auto_delete":false,"arguments":{"x-queue-type":"quorum","x-quorum-initial-group-size":3}}'

# 查询 leader、成员及在线副本；以 quorum_status 的实时状态共同确认。
curl --fail-with-body -u rabbitmq:rabbitmq \
  "http://${HOST_IP}:15672/api/queues/%2F/deployment.quorum" | \
  jq '{name,type,durable,leader,node,members,online}'
docker-compose exec -T rmq1 rabbitmq-queues quorum_status --vhost / deployment.quorum
```

成功标准：队列类型 `quorum`、`durable=true`，3 个成员均在线；`quorum_status` 显示 1 个 `leader` 和 2 个 `follower`，稳定后日志提交/应用位置追上。**显示 leader 的那一行节点名，就是这条队列当前的“主节点”**，不能根据 rmq1 的启动顺序作判断。部分 HTTP 字段随版本展示不同，以 `quorum_status` 为准。

发布和消费一条持久消息：

```bash
curl --fail-with-body -u rabbitmq:rabbitmq \
  -H 'content-type: application/json' -X POST \
  "http://${HOST_IP}:15672/api/exchanges/%2F/amq.default/publish" \
  -d '{"properties":{"delivery_mode":2},"routing_key":"deployment.quorum","payload":"quorum-ok","payload_encoding":"string"}'

curl --fail-with-body -u rabbitmq:rabbitmq \
  -H 'content-type: application/json' -X POST \
  "http://${HOST_IP}:15673/api/queues/%2F/deployment.quorum/get" \
  -d '{"count":1,"ackmode":"ack_requeue_false","encoding":"auto","truncate":50000}'
```

预期发布返回 `routed:true`，从另一节点消费得到 `payload: quorum-ok`。该消费命令会确认并移除测试消息。生产客户端应启用发布确认、消费手动确认和断线重连，并配置多个 AMQP 地址；管理 API 验收不能代替应用客户端确认机制。

#### 停止实际 leader，验证重新选举与消息可用

先发布一条尚未消费的测试消息，确保 `routed:true`：

```bash
curl --fail-with-body -u rabbitmq:rabbitmq \
  -H 'content-type: application/json' -X POST \
  "http://${HOST_IP}:15672/api/exchanges/%2F/amq.default/publish" \
  -d '{"properties":{"delivery_mode":2},"routing_key":"deployment.quorum","payload":"survive-leader-stop","payload_encoding":"string"}'
docker-compose exec -T rmq1 rabbitmq-queues quorum_status --vhost / deployment.quorum

# 提取实际 leader；如未获取到有效节点名，则停止自动演练并按上方 CLI 输出核对。
LEADER=$(curl --fail-with-body -sS -u rabbitmq:rabbitmq \
  "http://${HOST_IP}:15672/api/queues/%2F/deployment.quorum" | jq -r '.leader // .node // empty')
case "$LEADER" in
  rabbit@rmq1) TARGET=rmq1; SURVIVOR=rmq2; MGMT_PORT=15673 ;;
  rabbit@rmq2) TARGET=rmq2; SURVIVOR=rmq1; MGMT_PORT=15672 ;;
  rabbit@rmq3) TARGET=rmq3; SURVIVOR=rmq1; MGMT_PORT=15672 ;;
  *) echo "未获取有效 leader，请人工核对 quorum_status"; TARGET= ;;
esac
if [ -n "$TARGET" ]; then
  docker-compose stop "$TARGET"
  docker-compose exec -T "$SURVIVOR" rabbitmq-queues quorum_status --vhost / deployment.quorum
fi
```

等待重新选举完成，再执行以下命令。若 `TARGET` 为空，不继续：

```bash
: "${TARGET:?请先确认并停止实际 leader}"
curl --fail-with-body -u rabbitmq:rabbitmq \
  "http://${HOST_IP}:${MGMT_PORT}/api/queues/%2F/deployment.quorum" | \
  jq '{name,type,leader,node,members,online}'
curl --fail-with-body -u rabbitmq:rabbitmq \
  -H 'content-type: application/json' -X POST \
  "http://${HOST_IP}:${MGMT_PORT}/api/queues/%2F/deployment.quorum/get" \
  -d '{"count":1,"ackmode":"ack_requeue_false","encoding":"auto","truncate":50000}'

# 原 leader 停止期间，继续验证可写。
curl --fail-with-body -u rabbitmq:rabbitmq \
  -H 'content-type: application/json' -X POST \
  "http://${HOST_IP}:${MGMT_PORT}/api/exchanges/%2F/amq.default/publish" \
  -d '{"properties":{"delivery_mode":2},"routing_key":"deployment.quorum","payload":"after-failover","payload_encoding":"string"}'

docker-compose start "$TARGET"
docker-compose exec -T "$SURVIVOR" rabbitmq-queues quorum_status --vhost / deployment.quorum
```

验收：leader 转移到存活节点，两个成员在线时仍能消费 `survive-leader-stop` 并发布 `after-failover`（`routed:true`）；恢复节点后重新有 3 个在线成员。不要同时停止两个节点，否则 3 副本队列失去多数派，停止读写是预期行为。单宿主机宕机仍会使三个节点同时不可用。

## Nacos v3.2.4 / v2.5.4（无外部数据库）

### 存储与登录约定

采用 `MODE=standalone` 的单节点内嵌 Derby 存储，不部署或依赖 MySQL。“不用数据库”在此指 **不用外部数据库**，Nacos 仍使用内嵌存储保存配置、用户等数据。挂载 `/home/nacos/data` 和 `/home/nacos/logs`，无需创建外部数据库表。

两个版本分别使用独立数据目录，不得混用 Derby 文件。镜像名与指定版本见 [Nacos 官方下载页](https://nacos.io/en/download/nacos-server/)。

Nacos 2.4 起不再提供开箱即用的管理员默认密码。本文通过首次启动后的管理员初始化接口，将账号设置为 **nacos/nacos**，不是依赖不存在的默认密码环境变量。Token 密钥与登录密码是两回事；Token 密钥使用随机 48 字节的 Base64 编码。参考：[Nacos 认证说明](https://nacos.io/en/docs/latest/manual/admin/auth/)。

| 版本   | 控制台地址                    | SDK 服务地址    | 对客户端开放端口 |
| ------ | ----------------------------- | --------------- | ---------------- |
| v2.5.4 | `http://宿主机IP:8848/nacos/` | `宿主机IP:8848` | 8848、9848       |
| v3.2.4 | `http://宿主机IP:8080/`       | `宿主机IP:8848` | 8080、8848、9848 |

9848 是客户端 gRPC 端口；默认由 SDK 按服务端口 +1000 推导。本方案保持 8848/9848 原端口映射。3.x 的 8080 是控制台端口，不能作为 SDK 的 serverAddr。单节点不需要对外发布集群用 9849、7848。参考：[Nacos Docker 快速开始](https://nacos.io/docs/latest/quickstart/quick-start-docker/)。

### v2.5.4 部署

```bash
mkdir -p "$DEPLOY_ROOT/nacos/v2.5.4"/{data,logs}
cd "$DEPLOY_ROOT/nacos/v2.5.4"
umask 077
printf 'NACOS_AUTH_TOKEN=%s\n' "$(openssl rand -base64 48 | tr -d '\n')" > .env
printf 'NACOS_AUTH_IDENTITY_VALUE=%s\n' "$(openssl rand -hex 24)" >> .env
umask 022
```

创建 `docker-compose.yaml`：

```yaml
name: nacos-v2
services:
  nacos:
    image: nacos/nacos-server:v2.5.4
    restart: unless-stopped
    environment:
      MODE: standalone
      SPRING_DATASOURCE_PLATFORM: ""
      NACOS_AUTH_ENABLE: "true"
      NACOS_AUTH_TOKEN: ${NACOS_AUTH_TOKEN:?请先创建 .env}
      NACOS_AUTH_IDENTITY_KEY: nacos-server-identity
      NACOS_AUTH_IDENTITY_VALUE: ${NACOS_AUTH_IDENTITY_VALUE:?请先创建 .env}
      JVM_XMS: 512m
      JVM_XMX: 512m
      JVM_XMN: 256m
      TZ: Asia/Shanghai
    ports:
      - "8848:8848"
      - "9848:9848"
    volumes:
      - ./data:/home/nacos/data
      - ./logs:/home/nacos/logs
    networks: [nacos-net]
networks:
  nacos-net:
    driver: bridge
```

启动及首次密码初始化：

```bash
docker-compose config --quiet
docker-compose pull
docker-compose up -d
docker-compose logs --tail=200 nacos
curl --fail-with-body "http://${HOST_IP}:8848/nacos/v1/console/health/readiness"

# 等待服务启动成功后，仅对尚未创建管理员的全新实例执行一次。
curl --fail-with-body -X POST \
  "http://${HOST_IP}:8848/nacos/v1/auth/users/admin" \
  --data-urlencode 'password=nacos'

curl --fail-with-body -X POST \
  "http://${HOST_IP}:8848/nacos/v1/auth/login" \
  --data-urlencode 'username=nacos' --data-urlencode 'password=nacos'
```

初始化路由来自 [Nacos 2.5.4 认证控制器](https://raw.githubusercontent.com/alibaba/nacos/2.5.4/plugin-default-impl/nacos-default-auth-plugin/src/main/java/com/alibaba/nacos/plugin/auth/impl/controller/UserController.java)。预期初始化成功、登录返回非空 `accessToken`，控制台可用 `nacos/nacos` 登录。已经初始化的实例会拒绝再次初始化，不应为重设密码删除数据。

功能验证：登录获取 Token，发布并读取配置。

```bash
TOKEN=$(curl --fail-with-body -sS -X POST \
  "http://${HOST_IP}:8848/nacos/v1/auth/login" \
  --data-urlencode 'username=nacos' --data-urlencode 'password=nacos' | \
  jq -er '.accessToken | select(length > 0)')
: "${TOKEN:?登录未获取 Token}"

curl --fail-with-body -X POST "http://${HOST_IP}:8848/nacos/v1/cs/configs" \
  --data-urlencode "accessToken=${TOKEN}" \
  --data-urlencode 'dataId=deployment-check' \
  --data-urlencode 'group=DEFAULT_GROUP' \
  --data-urlencode 'content=nacos-v2-ok' --data-urlencode 'type=text'
curl --fail-with-body -G "http://${HOST_IP}:8848/nacos/v1/cs/configs" \
  --data-urlencode "accessToken=${TOKEN}" \
  --data-urlencode 'dataId=deployment-check' --data-urlencode 'group=DEFAULT_GROUP'
```

预期发布 `true`，读取 `nacos-v2-ok`。重启后重新登录并读取同一配置，验证 data 挂载持久化。

### v3.2.4 部署

先停掉占用 8848/9848 的 v2 实例，再部署 v3。以下目录和数据独立，不是版本升级。

```bash
mkdir -p "$DEPLOY_ROOT/nacos/v3.2.4"/{data,logs}
cd "$DEPLOY_ROOT/nacos/v3.2.4"
umask 077
printf 'NACOS_AUTH_TOKEN=%s\n' "$(openssl rand -base64 48 | tr -d '\n')" > .env
printf 'NACOS_AUTH_IDENTITY_VALUE=%s\n' "$(openssl rand -hex 24)" >> .env
umask 022
```

创建 `docker-compose.yaml`：

```yaml
name: nacos-v3
services:
  nacos:
    image: nacos/nacos-server:v3.2.4
    restart: unless-stopped
    environment:
      MODE: standalone
      SPRING_DATASOURCE_PLATFORM: ""
      NACOS_AUTH_ENABLE: "true"
      NACOS_AUTH_ADMIN_ENABLE: "true"
      NACOS_AUTH_CONSOLE_ENABLE: "true"
      NACOS_AUTH_TOKEN: ${NACOS_AUTH_TOKEN:?请先创建 .env}
      NACOS_AUTH_IDENTITY_KEY: nacos-server-identity
      NACOS_AUTH_IDENTITY_VALUE: ${NACOS_AUTH_IDENTITY_VALUE:?请先创建 .env}
      JVM_XMS: 512m
      JVM_XMX: 512m
      JVM_XMN: 256m
      TZ: Asia/Shanghai
    ports:
      - "8080:8080"
      - "8848:8848"
      - "9848:9848"
    volumes:
      - ./data:/home/nacos/data
      - ./logs:/home/nacos/logs
    networks: [nacos-net]
networks:
  nacos-net:
    driver: bridge
```

启动及首次密码初始化：

```bash
docker-compose config --quiet
docker-compose pull
docker-compose up -d
docker-compose logs --tail=200 nacos
curl --fail-with-body "http://${HOST_IP}:8080/"

# 等待启动完成后，仅对全新、未初始化管理员的实例执行。
curl --fail-with-body -X POST \
  "http://${HOST_IP}:8848/nacos/v3/auth/user/admin" \
  --data-urlencode 'password=nacos'
curl --fail-with-body -X POST \
  "http://${HOST_IP}:8848/nacos/v3/auth/user/login" \
  --data-urlencode 'username=nacos' --data-urlencode 'password=nacos'
```

预期日志显示启动成功并使用内嵌存储；初始化成功，登录返回 `accessToken`；控制台 `http://宿主机IP:8080/` 可登录。控制台首页返回 200 只验证页面可访问，还需完成以下配置读写验证。

```bash
TOKEN=$(curl --fail-with-body -sS -X POST \
  "http://${HOST_IP}:8848/nacos/v3/auth/user/login" \
  --data-urlencode 'username=nacos' --data-urlencode 'password=nacos' | \
  jq -er '.accessToken | select(length > 0)')
: "${TOKEN:?登录未获取 Token}"

curl --fail-with-body -X POST "http://${HOST_IP}:8848/nacos/v3/admin/cs/config" \
  -H "accessToken: ${TOKEN}" \
  --data-urlencode 'dataId=deployment-check' \
  --data-urlencode 'groupName=DEFAULT_GROUP' \
  --data-urlencode 'content=nacos-v3-ok' --data-urlencode 'type=text'
curl --fail-with-body -G "http://${HOST_IP}:8848/nacos/v3/client/cs/config" \
  -H "accessToken: ${TOKEN}" \
  --data-urlencode 'dataId=deployment-check' --data-urlencode 'groupName=DEFAULT_GROUP'
```

预期发布接口业务响应成功，读取响应包含 `nacos-v3-ok`，不能只判断 HTTP 200；有 `code` 字段时应为成功码 0。重启后重新登录再读取，确认配置仍存在。

### 两个版本通用排查

```bash
docker-compose ps
docker-compose logs --tail=200 nacos
docker-compose exec -T nacos sh -c 'id; ls -ld /home/nacos/data /home/nacos/logs'
# 在客户端所在 Linux 机器执行，确认 SDK gRPC 端口可达：
timeout 3 bash -c "exec 3<>/dev/tcp/${HOST_IP}/9848"
```

若控制台可用但 SDK 失败，先检查 9848、防火墙、SDK 的 `serverAddr` 和用户名密码。若挂载目录不可写，通过指定镜像的 `id` 确认运行 UID/GID，再调整当前实例目录属主。若出现外部数据库连接错误，确认未设置 `SPRING_DATASOURCE_PLATFORM=mysql` 或遗留 `MYSQL_*` 参数。每次重启不得重新生成 `.env` 中的密钥。

## PostgreSQL 17.10

### 配置与 Compose

账号 `postgres`，密码 `postgresql`，默认数据库 `postgres`。PostgreSQL 17 的数据目录挂载点为 `/var/lib/postgresql/data`；不要套用其他大版本不同的数据目录约定。参考：[PostgreSQL 官方镜像](https://hub.docker.com/_/postgres)、[17.10 发布说明](https://www.postgresql.org/docs/release/17.10/)。

```bash
mkdir -p "$DEPLOY_ROOT/postgresql/17.10"/{conf,data}
cd "$DEPLOY_ROOT/postgresql/17.10"
cat > conf/postgresql.conf <<'EOF'
listen_addresses = '*'
port = 5432
max_connections = 100
shared_buffers = 128MB
password_encryption = 'scram-sha-256'
timezone = 'Asia/Shanghai'
log_timezone = 'Asia/Shanghai'
EOF
```

创建 `docker-compose.yaml`：

```yaml
name: postgresql
services:
  postgresql:
    image: postgres:17.10
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgresql
      POSTGRES_DB: postgres
      POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
      POSTGRES_HOST_AUTH_METHOD: scram-sha-256
      TZ: Asia/Shanghai
    command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
    ports: ["5432:5432"]
    volumes:
      - ./conf:/etc/postgresql:ro
      - ./data:/var/lib/postgresql/data
    shm_size: 256mb
    healthcheck:
      test: ["CMD-SHELL", "PGPASSWORD=postgresql psql -h 127.0.0.1 -U postgres -d postgres -tAc 'SELECT 1' | grep -qx 1"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 20s
    networks: [postgresql-net]
networks:
  postgresql-net:
    driver: bridge
```

官方入口根据上述参数在空数据目录生成 `pg_hba.conf`，主机 TCP 连接使用 SCRAM 密码认证。环境变量只参与首次初始化；已有数据目录更改密码须通过 SQL，不能只改 `POSTGRES_PASSWORD`。

### 启动与验证

```bash
docker-compose config --quiet
docker-compose pull
docker-compose up -d --wait --wait-timeout 360
docker-compose exec -T -e PGPASSWORD=postgresql postgresql \
  psql -h "$HOST_IP" -p 5432 -U postgres -d postgres -v ON_ERROR_STOP=1 \
  -c 'SELECT version(), current_database(), current_user;'
docker-compose exec -T -e PGPASSWORD=postgresql postgresql \
  psql -h "$HOST_IP" -U postgres -d postgres -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE IF NOT EXISTS deployment_check (id INTEGER PRIMARY KEY, note TEXT);
INSERT INTO deployment_check VALUES (1, 'postgresql-ok')
  ON CONFLICT (id) DO UPDATE SET note=EXCLUDED.note;
SELECT * FROM deployment_check;
SHOW config_file;
SHOW data_directory;
SHOW hba_file;
SQL
```

成功标准：版本 `PostgreSQL 17.10`，数据库和用户均为 `postgres`，查询得到 `1 / postgresql-ok`；`config_file` 为 `/etc/postgresql/postgresql.conf`，数据和 HBA 文件位于持久化数据目录。

从外部客户端验证：

```bash
PGPASSWORD=postgresql psql -h "$HOST_IP" -p 5432 -U postgres -d postgres \
  -v ON_ERROR_STOP=1 -c 'SELECT * FROM deployment_check;'
```

重启容器后重复查询确认数据仍在。所有章节的常规停机均在对应模式目录执行 `docker-compose down`；本文的 bind mount 数据不会因此删除，重新 `up -d` 应继续使用原数据及配置。清空数据目录属于重新初始化，不是普通重启步骤。




