系统学习 演练学习路径:35 个 lab 的难度排序、4 周基础计划与复盘方法

版本信息最后核对:2026-10-25

Lab 状态:部分通过2026-10-25 · Docker Compose / kind

DNS、入口切换、数据库切换/回滚、证书轮换与清理通过;kind 上游与 200 业务响应未验证

在本地 Docker Compose 与小型 kind 集群上组合演练区域入口故障:预降 DNS TTL、切换入口、数据库主从切换、证书轮换与应用重连

演练目标

本 Lab 用修改本地 DNS 记录、停止入口容器代替 BGP 收敛和区域网络隔离。真实区域故障还涉及 Anycast、运营商缓存、跨区链路和云厂商控制面,不能直接外推结论。

安全边界:只允许在一次性虚拟机、kind 和本地 Docker Compose 中进行。不要修改公司 DNS、生产 hosts、真实负载均衡或线上证书,也不要把本页命令直接套到生产入口。所有命令都必须带 timeout,按阶段执行;任一阶段失败先停止并回滚当前阶段,不要带着未知状态进入下一阶段。实验结束删除全部容器、集群、卷和临时证书。

环境准备

OpenSSL 兼容性:s_client -verify_hostname 和 x509 -ext 需要 OpenSSL 1.1.1+;macOS 自带 LibreSSL 不支持这些选项,演练时使用 Linux 虚拟机或 Homebrew OpenSSL 3,并把 openssl 指向对应二进制。

准备 Docker/Compose v2、kind、kubectl、dig、curl 和 OpenSSL。所有文件放在 /tmp/lab-region。Compose 使用 172.28.0.0/24:resolver 为 .10,edge-a 为 .11,edge-b 为 .12,HTTP 探针为 .13,数据库探针为 .14,主库为 .21,从库为 .22。kind 中运行两副本 web,通过 NodePort 30080 暴露给入口。本页命令按 Linux/bash 与 GNU coreutils 编写;macOS 默认没有 timeout,可安装 coreutils 后将命令中的 timeout 替换为 gtimeout,或在 Linux 虚拟机中执行。

resolver 在宿主机发布为 127.0.0.1:15353,不要用 5353:macOS 的 mDNSResponder(以及 Linux 上的 avahi)会占用该 UDP 端口,导致 Compose 启动失败。若宿主机设置了 HTTP(S)_PROXY,curl --resolve 仍可能走代理并报 SSL 错误,本页 HTTPS 检查命令已加 --noproxy '*'。

先创建旧 CA、入口叶子证书和目录,SAN 必须是 app.lab.internal:

timeout 20 mkdir -p /tmp/lab-region/{certs/old,certs/new,certs/trust,certs/active-a,certs/active-b,dns,backup}
timeout 30 openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/lab-region/certs/old/ca-old.key
timeout 30 openssl req -x509 -new -key /tmp/lab-region/certs/old/ca-old.key -sha256 -days 30 -subj "/CN=Lab Region Old Root CA" -out /tmp/lab-region/certs/old/ca-old.crt -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
timeout 30 openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/lab-region/certs/old/leaf-old.key
timeout 30 openssl req -new -key /tmp/lab-region/certs/old/leaf-old.key -out /tmp/lab-region/certs/old/leaf-old.csr -subj "/CN=app.lab.internal"
timeout 10 tee /tmp/lab-region/certs/old/leaf.ext >/dev/null <<'EOF'
subjectAltName=DNS:app.lab.internal
extendedKeyUsage=serverAuth
keyUsage=critical,digitalSignature,keyEncipherment
EOF
timeout 30 openssl x509 -req -in /tmp/lab-region/certs/old/leaf-old.csr -CA /tmp/lab-region/certs/old/ca-old.crt -CAkey /tmp/lab-region/certs/old/ca-old.key -CAcreateserial -CAserial /tmp/lab-region/certs/old/ca-old.srl -days 7 -sha256 -extfile /tmp/lab-region/certs/old/leaf.ext -out /tmp/lab-region/certs/old/leaf-old.crt
timeout 30 openssl verify -CAfile /tmp/lab-region/certs/old/ca-old.crt /tmp/lab-region/certs/old/leaf-old.crt
timeout 10 install -m 0644 /tmp/lab-region/certs/old/leaf-old.crt /tmp/lab-region/certs/active-a/tls.crt
timeout 10 install -m 0600 /tmp/lab-region/certs/old/leaf-old.key /tmp/lab-region/certs/active-a/tls.key
timeout 10 install -m 0644 /tmp/lab-region/certs/old/leaf-old.crt /tmp/lab-region/certs/active-b/tls.crt
timeout 10 install -m 0600 /tmp/lab-region/certs/old/leaf-old.key /tmp/lab-region/certs/active-b/tls.key
timeout 10 openssl x509 -in /tmp/lab-region/certs/old/leaf-old.crt -noout -fingerprint -sha256 | tee /tmp/lab-region/backup/fingerprint-old.txt

写 CoreDNS 配置和两条内部记录。初始 TTL 为 30 秒,便于观察预降 TTL;改完 hosts 后重启 resolver,不依赖 reload 插件:

timeout 10 tee /tmp/lab-region/dns/Corefile >/dev/null <<'EOF'
.:53 {
    hosts /etc/coredns/hosts {
        ttl 30
        fallthrough
    }
    forward . 127.0.0.11
    log
    errors
}
EOF
timeout 10 tee /tmp/lab-region/dns/hosts >/dev/null <<'EOF'
172.28.0.11 app.lab.internal
172.28.0.21 db.lab.internal
EOF
timeout 10 tee /tmp/lab-region/edge.conf >/dev/null <<'EOF'
server {
    listen 80;
    server_name app.lab.internal;
    location / {
        proxy_pass http://host.docker.internal:30080;
        proxy_set_header Host $host;
        proxy_connect_timeout 2s;
        proxy_read_timeout 5s;
    }
}
server {
    listen 443 ssl;
    server_name app.lab.internal;
    ssl_certificate /etc/nginx/tls/tls.crt;
    ssl_certificate_key /etc/nginx/tls/tls.key;
    location / {
        proxy_pass http://host.docker.internal:30080;
        proxy_set_header Host $host;
        proxy_connect_timeout 2s;
        proxy_read_timeout 5s;
    }
}
EOF

创建 PostgreSQL 认证与复制账号。trust 只用于隔离的 Docker 网络,生产必须使用强密码、TLS 和最小权限:

timeout 10 tee /tmp/lab-region/pg_hba.conf >/dev/null <<'EOF'
local   all             all                                     trust
host    all             all             172.28.0.0/24           scram-sha-256
host    replication     replicator      172.28.0.0/24           trust
EOF
timeout 10 tee /tmp/lab-region/init-replication.sql >/dev/null <<'EOF'
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'labpass';
EOF

Compose 中 resolver 提供内部域名,两台 nginx 是入口,两个探针模拟持续访问的应用,并直接使用 resolver:

timeout 10 tee /tmp/lab-region/compose.yaml >/dev/null <<'EOF'
name: lab-region
services:
  resolver:
    image: coredns/coredns:1.11.3
    container_name: lab-dns
    command: -conf /etc/coredns/Corefile
    ports:
      - "127.0.0.1:15353:53/udp"
      - "127.0.0.1:15353:53/tcp"
    volumes:
      - ./dns:/etc/coredns:ro
    networks:
      lab:
        ipv4_address: 172.28.0.10
  edge-a:
    image: nginx:stable
    container_name: lab-edge-a
    ports:
      - "127.0.0.1:8080:80"
      - "127.0.0.1:8443:443"
    volumes:
      - ./edge.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs/active-a:/etc/nginx/tls:ro
    extra_hosts:
      - "host.docker.internal:host-gateway"
    networks:
      lab:
        ipv4_address: 172.28.0.11
  edge-b:
    image: nginx:stable
    container_name: lab-edge-b
    ports:
      - "127.0.0.1:8081:80"
      - "127.0.0.1:9443:443"
    volumes:
      - ./edge.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs/active-b:/etc/nginx/tls:ro
    extra_hosts:
      - "host.docker.internal:host-gateway"
    networks:
      lab:
        ipv4_address: 172.28.0.12
  probe:
    image: curlimages/curl:8.10.1
    container_name: lab-probe
    dns:
      - 172.28.0.10
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        while :; do
          timeout 5 curl -sS --max-time 4 -o /dev/null -w "http_code=%{http_code} remote_ip=%{remote_ip}\n" http://app.lab.internal/ || echo http_probe_failed
          timeout 5 sleep 2
        done
    depends_on: [resolver, edge-a, edge-b]
    networks:
      lab:
        ipv4_address: 172.28.0.13
  db-probe:
    image: postgres:16
    container_name: lab-db-probe
    environment:
      PGPASSWORD: labpass
    dns:
      - 172.28.0.10
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        until timeout 5 pg_isready -h db.lab.internal -U lab; do timeout 5 sleep 1; done
        while :; do
          timeout 5 psql -h db.lab.internal -U lab -d app -Atc "INSERT INTO probe_events DEFAULT VALUES RETURNING id;" || echo db_probe_failed
          timeout 5 sleep 3
        done
    depends_on: [resolver, pg-primary, pg-replica]
    networks:
      lab:
        ipv4_address: 172.28.0.14
  pg-primary:
    image: postgres:16
    container_name: lab-pg-primary
    environment:
      POSTGRES_USER: lab
      POSTGRES_PASSWORD: labpass
      POSTGRES_DB: app
    command: postgres -c wal_level=replica -c max_wal_senders=5 -c hot_standby=on -c hba_file=/etc/postgresql/pg_hba.conf
    volumes:
      - pgprimary:/var/lib/postgresql/data
      - ./pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
      - ./init-replication.sql:/docker-entrypoint-initdb.d/10-replication.sql:ro
    networks:
      lab:
        ipv4_address: 172.28.0.21
  pg-replica:
    image: postgres:16
    container_name: lab-pg-replica
    user: postgres  # 覆盖 entrypoint 时避免以 root 运行 postgres
    environment:
      PGDATA: /var/lib/postgresql/replica
    depends_on: [pg-primary]
    command: >
      bash -c "until timeout 5 pg_isready -h pg-primary -U lab; do timeout 5 sleep 1; done;
      timeout 180 pg_basebackup -h pg-primary -U replicator -D /var/lib/postgresql/replica -Fp -Xs -R -P;
      exec postgres -c hba_file=/etc/postgresql/pg_hba.conf -c hot_standby=on"
    volumes:
      - ./pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
    networks:
      lab:
        ipv4_address: 172.28.0.22
networks:
  lab:
    name: lab-region-net
    ipam:
      config:
        - subnet: 172.28.0.0/24
volumes:
  pgprimary:
EOF

先建 kind 和 web,再启动 Compose。入口通过 host.docker.internal:30080 访问 NodePort;Linux 上由 host-gateway 映射,不通时先核对地址:

timeout 10 tee /tmp/lab-region/kind.yaml >/dev/null <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 30080
        hostPort: 30080
        protocol: TCP
  - role: worker
EOF
timeout 300 kind create cluster --name lab-region --config /tmp/lab-region/kind.yaml
timeout 120 kubectl wait --for=condition=Ready nodes --all --timeout=90s
timeout 10 tee /tmp/lab-region/web.yaml >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:stable
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: NodePort
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080
EOF
timeout 120 kubectl apply -f /tmp/lab-region/web.yaml
timeout 150 kubectl rollout status deployment/web --timeout=120s
timeout 20 curl -sS -o /dev/null -w 'nodeport=%{http_code}\n' --max-time 5 http://127.0.0.1:30080/
timeout 300 docker compose -f /tmp/lab-region/compose.yaml up -d
timeout 180 bash -c 'until docker exec lab-pg-replica pg_isready -U lab -d app; do sleep 2; done'

创建业务表,等待复制追平并记录基线。probe 应持续返回 200,db-probe 应插入递增 ID:

timeout 30 docker exec lab-pg-primary psql -U lab -d app -c "CREATE TABLE probe_events (id bigserial PRIMARY KEY, note text NOT NULL DEFAULT 'probe', created_at timestamptz NOT NULL DEFAULT now());"
timeout 30 sleep 5
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc "SELECT count(*) FROM probe_events;"
timeout 30 docker exec lab-pg-primary psql -U lab -d app -c "SELECT application_name, state, sync_state, replay_lag FROM pg_stat_replication;"
timeout 30 docker exec lab-pg-primary psql -U lab -d app -Atc "SELECT count(*), coalesce(max(id),0) FROM probe_events;" | tee /tmp/lab-region/backup/db-baseline.txt
timeout 20 dig @127.0.0.1 -p 15353 +noall +answer app.lab.internal db.lab.internal | tee /tmp/lab-region/backup/dns-baseline.txt
timeout 30 docker compose -f /tmp/lab-region/compose.yaml logs --tail=10 probe db-probe
timeout 20 curl --noproxy '*' --cacert /tmp/lab-region/certs/old/ca-old.crt --resolve app.lab.internal:8443:127.0.0.1 -sS -o /dev/null -w 'https_baseline=%{http_code}\n' --max-time 5 https://app.lab.internal:8443/

基线不通过就不要注入故障。成功基线应同时满足:DNS 返回 .11 和 .21;probe 最近 5 行都是 200;db-probe 有递增 ID;从库追平;旧证书验证成功。失败时先查日志和端口映射,回滚用 down -v 重建。

注入故障

下面分五个阶段。每个阶段结束都把实际时间写进时间线,后面的 RTO 用这些时间戳计算,而不是凭印象估算。

阶段 1:预降 DNS TTL

把 resolver 的 TTL 从 30 秒降到 5 秒,等待 31 秒让旧缓存过期。真实环境要覆盖所有递归 DNS、客户端缓存和连接池,等待时间取旧 TTL 的上限;只改权威记录而不等缓存,切入口时仍会打到旧地址。

timeout 10 tee /tmp/lab-region/dns/Corefile >/dev/null <<'EOF'
.:53 {
    hosts /etc/coredns/hosts {
        ttl 5
        fallthrough
    }
    forward . 127.0.0.11
    log
    errors
}
EOF
timeout 90 docker compose -f /tmp/lab-region/compose.yaml restart resolver
timeout 90 sleep 31
timeout 20 dig @127.0.0.1 -p 15353 +noall +answer app.lab.internal
timeout 5 sh -c 'date -u +"%FT%TZ stage=ttl_ok" >> /tmp/lab-region/timeline.log'

成功判据:Answer 中的 TTL 小于等于 5,probe 没有持续失败。失败判据:TTL 仍为 30、DNS 超过 10 秒不可用、probe 连续失败。回滚:恢复 ttl 30 的 Corefile 并重启 resolver,确认解析恢复后再继续;此时还没有切流量,回滚成本最低。

阶段 2:切换入口并停掉旧入口

把 app.lab.internal 从 edge-a 改到 edge-b,等 resolver 生效后观察 probe 的 remote_ip。确认新入口稳定,再停掉 edge-a,模拟区域入口失联。不要先停 edge-a 再改 DNS,否则在 TTL 窗口内会出现一段本可避免的中断。

timeout 10 tee /tmp/lab-region/dns/hosts >/dev/null <<'EOF'
172.28.0.12 app.lab.internal
172.28.0.21 db.lab.internal
EOF
timeout 90 docker compose -f /tmp/lab-region/compose.yaml restart resolver
timeout 30 sleep 8
timeout 20 dig @127.0.0.1 -p 15353 +noall +answer app.lab.internal
timeout 20 curl -sS -o /dev/null -w 'edge_b=%{http_code}\n' --max-time 5 http://127.0.0.1:8081/
timeout 30 docker stop lab-edge-a
timeout 20 curl -sS -o /dev/null -w 'edge_a_after_stop=%{http_code}\n' --max-time 5 http://127.0.0.1:8080/ || echo edge_a_down
timeout 30 docker compose -f /tmp/lab-region/compose.yaml logs --tail=20 probe
timeout 5 sh -c 'date -u +"%FT%TZ stage=edge_switch_ok" >> /tmp/lab-region/timeline.log'

成功判据:dig 返回 172.28.0.12 且 TTL 小于等于 5;probe 的 remote_ip 切到 .12 并持续 200;edge-a 停止后 probe 没有持续失败。失败判据:DNS 仍返回 .11、edge-b 返回 5xx、probe 连续失败超过 30 秒、停掉 edge-a 后流量没有切走。回滚:把 hosts 改回 .11,启动 edge-a,确认 probe 恢复 200;edge-b 保留为备用入口。

阶段 3:数据库主从切换

按“记录基线、停旧主、确认从库只读、提升、切换 db.lab.internal、验证新写入”的顺序执行。db-probe 每次插入都会新建连接,因此 DNS 切换后它会自然重连;如果应用使用长连接或连接池,必须额外刷新连接池或滚动重启。

timeout 30 docker exec lab-pg-primary psql -U lab -d app -Atc "SELECT count(*), coalesce(max(id),0) FROM probe_events;" | tee /tmp/lab-region/backup/db-pre-failover.txt
timeout 30 docker exec lab-pg-primary psql -U lab -d app -Atc "SELECT pg_current_wal_lsn();" | tee /tmp/lab-region/backup/lsn-pre-failover.txt
timeout 60 docker stop lab-pg-primary
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c "INSERT INTO probe_events DEFAULT VALUES;"; echo "replica_write_exit=$?"
timeout 60 docker exec -u postgres lab-pg-replica pg_ctl promote -D /var/lib/postgresql/replica
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c "SELECT pg_is_in_recovery(), pg_current_wal_lsn();"
timeout 10 tee /tmp/lab-region/dns/hosts >/dev/null <<'EOF'
172.28.0.12 app.lab.internal
172.28.0.22 db.lab.internal
EOF
timeout 90 docker compose -f /tmp/lab-region/compose.yaml restart resolver
timeout 30 sleep 8
timeout 20 dig @127.0.0.1 -p 15353 +noall +answer db.lab.internal
timeout 30 docker compose -f /tmp/lab-region/compose.yaml logs --tail=20 db-probe
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc "SELECT count(*), coalesce(max(id),0) FROM probe_events;"
timeout 5 sh -c 'date -u +"%FT%TZ stage=db_failover_ok" >> /tmp/lab-region/timeline.log'

成功判据:新主 pg_is_in_recovery() 为 false 且能提交写入;db.lab.internal 解析到 .22;db-probe 恢复插入;旧主保持停止;行数和最大 ID 能由“切换前基线 + 切换后写入 - 丢失行”解释。失败判据:提升失败、两个节点同时可写、db-probe 持续失败、行数或最大 ID 无法解释。回滚:若新主尚未接受写入,可重启旧主并把 DNS 改回 .21;若新主已经接受写入,只能保留新主,把旧主重建为从库,绝不能把旧主直接启动为可写主库。

阶段 4:入口证书轮换

只替换当前入口 edge-b 的叶子证书,但先把新旧根放进合并信任包。顺序必须是:生成新链、分发合并信任包、替换叶子、验证,观察期结束后再移除旧 CA。不要先删旧 CA,也不要把合并包当作签发证书使用。

timeout 30 openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/lab-region/certs/new/ca-new.key
timeout 30 openssl req -x509 -new -key /tmp/lab-region/certs/new/ca-new.key -sha256 -days 30 -subj "/CN=Lab Region New Root CA" -out /tmp/lab-region/certs/new/ca-new.crt -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
timeout 30 openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/lab-region/certs/new/leaf-new.key
timeout 30 openssl req -new -key /tmp/lab-region/certs/new/leaf-new.key -out /tmp/lab-region/certs/new/leaf-new.csr -subj "/CN=app.lab.internal"
timeout 10 tee /tmp/lab-region/certs/new/leaf.ext >/dev/null <<'EOF'
subjectAltName=DNS:app.lab.internal
extendedKeyUsage=serverAuth
keyUsage=critical,digitalSignature,keyEncipherment
EOF
timeout 30 openssl x509 -req -in /tmp/lab-region/certs/new/leaf-new.csr -CA /tmp/lab-region/certs/new/ca-new.crt -CAkey /tmp/lab-region/certs/new/ca-new.key -CAcreateserial -CAserial /tmp/lab-region/certs/new/ca-new.srl -days 7 -sha256 -extfile /tmp/lab-region/certs/new/leaf.ext -out /tmp/lab-region/certs/new/leaf-new.crt
timeout 30 openssl verify -CAfile /tmp/lab-region/certs/new/ca-new.crt /tmp/lab-region/certs/new/leaf-new.crt
timeout 10 sh -c 'cat /tmp/lab-region/certs/old/ca-old.crt /tmp/lab-region/certs/new/ca-new.crt > /tmp/lab-region/certs/trust/ca-bundle.crt'
timeout 30 openssl verify -CAfile /tmp/lab-region/certs/trust/ca-bundle.crt /tmp/lab-region/certs/old/leaf-old.crt
timeout 30 openssl verify -CAfile /tmp/lab-region/certs/trust/ca-bundle.crt /tmp/lab-region/certs/new/leaf-new.crt
timeout 10 install -m 0644 /tmp/lab-region/certs/new/leaf-new.crt /tmp/lab-region/certs/active-b/tls.crt
timeout 10 install -m 0600 /tmp/lab-region/certs/new/leaf-new.key /tmp/lab-region/certs/active-b/tls.key
timeout 30 docker compose -f /tmp/lab-region/compose.yaml exec -T edge-b nginx -t
timeout 30 docker compose -f /tmp/lab-region/compose.yaml exec -T edge-b nginx -s reload
timeout 10 sleep 3
timeout 20 openssl s_client -connect 127.0.0.1:9443 -servername app.lab.internal -verify_hostname app.lab.internal -verify_return_error -CAfile /tmp/lab-region/certs/trust/ca-bundle.crt </dev/null | grep -E 'Verify return code|issuer='
timeout 20 curl --noproxy '*' --cacert /tmp/lab-region/certs/trust/ca-bundle.crt --resolve app.lab.internal:9443:127.0.0.1 -sS -o /dev/null -w 'https_after_rotation=%{http_code}\n' --max-time 5 https://app.lab.internal:9443/
timeout 5 sh -c 'date -u +"%FT%TZ stage=cert_rotation_ok" >> /tmp/lab-region/timeline.log'

成功判据:openssl verify 对新旧叶子都成功;s_client 返回 Verify return code: 0 (ok),issuer 为新 CA,SAN 为 app.lab.internal;HTTPS 返回 200;probe 和 db-probe 没有持续失败。nginx -s reload 是优雅切换,旧 worker 可能仍在处理新连接;验证前等待 2–3 秒,否则可能读到上一版证书。失败判据:unknown authority、hostname mismatch、证书过期、入口 5xx、旧叶子在合并包下验证失败。回滚:把 active-b 的 tls.crt 和 tls.key 换回旧证书,nginx -t 后 reload,再用旧 CA 和合并包分别验证;不要删除新 CA,它可能已被其他入口或客户端使用。

阶段 5:应用重连与收口

最后重启两个探针,强制丢弃旧连接和旧解析结果,观察它们是否重新解析到 edge-b 和 pg-replica。重启不是生产首选,但在这个一次性实验里能清楚区分“DNS 已切换”和“应用仍握着旧连接”。

timeout 120 docker compose -f /tmp/lab-region/compose.yaml restart probe db-probe
timeout 30 sleep 10
timeout 30 docker compose -f /tmp/lab-region/compose.yaml logs --tail=20 probe db-probe
timeout 20 curl --noproxy '*' --cacert /tmp/lab-region/certs/trust/ca-bundle.crt --resolve app.lab.internal:9443:127.0.0.1 -sS -o /dev/null -w 'app_https=%{http_code}\n' --max-time 5 https://app.lab.internal:9443/
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc "SELECT count(*), coalesce(max(id),0) FROM probe_events;"
timeout 5 sh -c 'date -u +"%FT%TZ stage=app_reconnect_ok" >> /tmp/lab-region/timeline.log'

成功判据:probe 的 remote_ip 为 .12 且持续 200;db-probe 插入成功;HTTPS 证书验证通过;新主行数持续增长。失败判据:仍解析到旧地址、连接池复用旧连接、DB 写入失败、证书不受信任。回滚:按证书、数据库、入口、DNS 的逆序逐层恢复;数据库只要有了新写入,就只做向前修复。

观察现象

阶段 1 只看到 TTL 从 30 降到 5,probe 基本不受影响。阶段 2 的 dig 答案从 .11 变为 .12,probe 日志中的 remote_ip 在一个 TTL 周期内切换;停掉 edge-a 后,直连 8080 会 connection refused,但经 DNS 的 probe 仍返回 200。阶段 3 停主库后,从库写入报 cannot execute INSERT in a read-only transaction;提升后 pg_is_in_recovery() 变为 false,db-probe 从失败恢复为持续插入。阶段 4 的 s_client 签发者从旧 CA 变为新 CA,合并包下旧叶子仍然通过。阶段 5 重启探针后,DNS、入口、数据库和证书四条链路同时恢复。

建议把实际时间线整理成下表,表里的“目标耗时”只是演练预算,不是生产 SLO:

阶段 动作 关键观察 目标耗时
0 建立基线 probe 200、复制 lag、旧证书通过 —
1 TTL 30→5 dig 的 TTL ≤ 5 ≤ 1 分钟
2 app→edge-b,停 edge-a probe 的 remote_ip 变为 .12 ≤ 2 分钟
3 停主库、提升、db→.22 新主可写、db-probe 恢复 ≤ 10 分钟
4 替换 edge-b 叶子 s_client 返回 0、issuer 为新 CA ≤ 5 分钟
5 重启两个探针 HTTP/HTTPS 和 DB 写入同时恢复 ≤ 3 分钟

RPO 定义为旧主最后已确认的写入与新主可见数据之间的差距,用 max(id) 和缺失行数衡量;RTO 定义为从停旧主到 db-probe 恢复成功写入的时长。本实验不预设 RPO 为 0:db-probe 在停旧主前仍在写入,异步复制下最后几行可能未到达从库,必须按实际差值记录。若要把目标设为 0,应先暂停 db-probe、等待从库回放追平并记录 LSN,再停旧主。出现缺失必须按实际差值记录,不能用“复制正常”掩盖。入口切换的 RTO 单独计算:从停 edge-a 到 probe 恢复 200,目标不超过 2 分钟。

数据一致性至少检查:切换前后 count(*)、max(id)、重复 ID 和缺失 ID 区间;在新主写入一条唯一标记,等待重建从库读到;从库 pg_is_in_recovery() 与新主写入同时成立;DNS 答案、证书指纹、入口进程和探针日志互相印证。序列可能因失败插入产生空洞,空洞要结合时间线和日志解释,不能直接判定为丢数据。

判断与定位

  1. DNS 层先看 dig 的 Answer、TTL 和 authority,再用 getent hosts 或应用日志确认实际解析。TTL 未过期时旧地址仍可能被缓存;所有名字都失败查 resolver 容器和 53 端口,只有某个名字失败则查 hosts 文件、Corefile 和重启是否成功。
  2. 入口层分别 curl 8080 和 8081,区分“入口挂了”和“DNS 没切”。connection refused 说明进程或端口不在;502 说明入口活着但上游不可达;超时可能是网络、上游阻塞或连接池耗尽。结合 nginx error log 和 probe 的 remote_ip 判断。
  3. 数据库层用 pg_is_in_recovery()、pg_stat_replication、pg_last_wal_replay_lsn() 和 pg_stat_wal_receiver 区分主从、复制中断和回放延迟。提升后仍只读,先看提升命令是否成功、数据目录是否正确、是否还残留 recovery 配置。
  4. 证书层用 openssl verify 看链,用 s_client -showcerts 看服务端实际发送的链,用 x509 -dates -ext subjectAltName 看有效期和主机名。curl -k 成功不代表证书受信任,HTTP 200 也不代表客户端校验通过。
  5. 应用层要区分新连接和旧连接。DNS 已切但应用仍连旧地址,常见原因是长连接、连接池、运行时 DNS 缓存或本地 hosts;探针重启不能当作生产修复手段。
  6. 容易误判:probe 200 只证明 HTTP 链路通,不证明数据库写入成功;数据库提升成功不证明应用已重连;证书替换成功不证明所有客户端都信任新链;停掉旧入口后 DNS 仍返回旧 IP,不一定是 DNS 故障,可能只是 TTL 未到。

恢复与回滚

回滚顺序与变更顺序相反:证书、数据库、入口、DNS。每一层先确认上一层的成功判据,再动下一层。任何回滚都要保留日志、时间线、证书指纹和数据库基线。

证书回滚只影响入口:把 active-b 的两个文件换回旧证书,执行 nginx -t 和 reload,然后用旧 CA 与合并包各验证一次。旧 CA 和旧叶子在观察期内不得删除。数据库回滚要区分是否已有新写入:没有新写入时,启动旧主、把 db.lab.internal 指回 .21、确认从库重新指向旧主;已有新写入时,旧主只能通过基础备份重建为从库:

timeout 30 docker rm -f lab-pg-old-rebuilt 2>/dev/null || true
timeout 240 docker run -d --name lab-pg-old-rebuilt --network lab-region-net --user postgres -e PGDATA=/var/lib/postgresql/replica -v /tmp/lab-region/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro postgres:16 bash -c "until timeout 5 pg_isready -h lab-pg-replica -U lab; do timeout 5 sleep 1; done; timeout 180 pg_basebackup -h lab-pg-replica -U replicator -D /var/lib/postgresql/replica -Fp -Xs -R -P; exec postgres -c hba_file=/etc/postgresql/pg_hba.conf -c hot_standby=on"
timeout 180 bash -c 'until docker exec lab-pg-old-rebuilt pg_isready -U lab -d app; do sleep 2; done'
timeout 30 docker exec lab-pg-old-rebuilt psql -U lab -d app -Atc "SELECT pg_is_in_recovery();"
timeout 30 docker exec lab-pg-old-rebuilt psql -U lab -d app -Atc "SELECT status, sender_host, sender_port FROM pg_stat_wal_receiver;"

入口回滚是把 hosts 改回 .11 并启动 edge-a;如果 edge-a 仍不可用,保留 edge-b,不要为了“回滚”停掉唯一可用入口。DNS 回滚是恢复 TTL 30 和原记录,确认所有探针稳定后再进入观察期。全局回滚触发条件:RPO 超限、出现双写、证书连续 5 分钟不受信任、probe 连续失败超过 2 分钟、数据库提升失败。触发后停止后续阶段,由负责人决定继续向前修复还是回滚。

实验结束只清理本实验的资源,不要对任何其他 Compose 项目执行 down -v:

timeout 60 docker rm -f lab-pg-old-rebuilt 2>/dev/null || true
timeout 120 docker compose -f /tmp/lab-region/compose.yaml down -v --remove-orphans
timeout 180 kind delete cluster --name lab-region
timeout 30 rm -rf /tmp/lab-region

验证

最终验证要覆盖 DNS、入口、数据库、证书和应用五个层面,所有命令都带超时:

timeout 20 dig @127.0.0.1 -p 15353 +noall +answer app.lab.internal db.lab.internal
timeout 30 docker ps --format '{{.Names}} {{.Status}}' | grep '^lab-'
timeout 20 curl --noproxy '*' --cacert /tmp/lab-region/certs/trust/ca-bundle.crt --resolve app.lab.internal:9443:127.0.0.1 -sS -o /dev/null -w 'https_verify=%{http_code}\n' --max-time 5 https://app.lab.internal:9443/
timeout 20 openssl s_client -connect 127.0.0.1:9443 -servername app.lab.internal -verify_hostname app.lab.internal -verify_return_error -CAfile /tmp/lab-region/certs/trust/ca-bundle.crt </dev/null | grep -E 'Verify return code|issuer='
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc "SELECT pg_is_in_recovery(), count(*), coalesce(max(id),0), count(DISTINCT id) FROM probe_events;"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc "SELECT count(*) FROM generate_series(1, (SELECT max(id) FROM probe_events)) AS g(id) LEFT JOIN probe_events p ON p.id = g.id WHERE p.id IS NULL;"
timeout 30 docker compose -f /tmp/lab-region/compose.yaml logs --tail=20 probe db-probe
timeout 5 sh -c 'date -u +"%FT%TZ stage=final_verify_ok" >> /tmp/lab-region/timeline.log'

复盘

相关命令

参考链接

读完自测

  1. 为什么先把 TTL 降到 5 并等待旧 TTL 过期,再切换入口?如果不等待会怎样?
  2. 数据库提升成功后,为什么不能直接把旧主库重新启动并接回入口?什么情况下可以回滚,什么情况下只能向前修复?
  3. 证书轮换时,为什么合并信任包要先于新叶子证书生效?验证时只看 curl 返回 200 够不够?
答案
  1. 下游递归 DNS、客户端和连接池可能仍缓存旧记录,等待旧 TTL 上限过去,才能让新记录逐步覆盖所有使用方。若不等待,切换窗口内仍会有请求打到旧入口;如果此时已经停掉旧入口,就会造成本可避免的中断。真实环境还要确认客户端不忽略 TTL。
  2. 旧主库一旦落后于新主,直接启动并接回读写入口会形成双写和脑裂,数据分叉很难合并。若新主尚未接受任何写入,可以回滚到旧主;若新主已经接受写入,只能保留新主,把旧主通过基础备份或 pg_rewind 重建为从库,属于向前修复。
  3. 过渡期旧叶子证书还在使用,客户端必须同时信任新旧 CA;先分发合并包,旧链才不会立刻变成 unknown authority。只看 curl 200 不够,它可能用了 -k、缓存连接或没有校验主机名;还要看 openssl verify、s_client 的验证码、issuer、SAN 和有效期。
微信小程序码:扫码打开命令速查小程序

小程序版

微信扫码,随时查命令