土法炼钢兴趣小组的算法知识备份

【网络工程】健康检查与故障转移:主动探测、被动检测与优雅处理

文章导航

分类入口
network
标签入口
#health-check#load-balancing#failover#envoy#keepalived

目录

健康检查是负载均衡的”安全网”——它决定了一台出问题的服务器多快被摘除、多快被恢复。设计不当的健康检查比没有健康检查更危险:假阳性把健康的服务器误摘除,假阴性把故障的服务器继续留在池中。

本文从工程视角讲解健康检查的设计决策:频率、超时、阈值、检查方式的选择,以及故障转移过程中的连接排空和优雅处理。

一、主动健康检查

1.1 检查类型

主动健康检查由负载均衡器主动发起探测请求到后端服务器。三种常见的检查类型:

TCP 检查:最基础——只检查端口是否可连接。

LB → [SYN] → Backend:8080
LB ← [SYN-ACK] ← Backend:8080    ✓ 健康
LB → [RST] → Backend:8080        (关闭检查连接)

LB → [SYN] → Backend:8080
LB ← [RST] ← Backend:8080        ✗ 不健康(端口未监听)

LB → [SYN] → Backend:8080
(超时无响应)                      ✗ 不健康(网络不通/进程挂死)

HTTP 检查:检查应用层是否正常——发送 HTTP 请求,验证响应状态码和可选的响应体。

LB → GET /health HTTP/1.1
LB ← HTTP/1.1 200 OK               ✓ 健康
     {"status": "ok", "db": "ok"}

LB → GET /health HTTP/1.1
LB ← HTTP/1.1 503 Service Unavailable  ✗ 不健康
     {"status": "degraded", "db": "down"}

gRPC 检查:使用 gRPC Health Checking Protocol(grpc.health.v1)。

// gRPC 标准健康检查协议
service Health {
    rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
    rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

message HealthCheckResponse {
    enum ServingStatus {
        UNKNOWN = 0;
        SERVING = 1;
        NOT_SERVING = 2;
        SERVICE_UNKNOWN = 3;
    }
    ServingStatus status = 1;
}

1.2 三种检查类型的比较

维度 TCP 检查 HTTP 检查 gRPC 检查
检测精度 低(只检查端口) 高(检查应用状态) 高(检查服务状态)
性能开销 最低
能检测到的故障 进程崩溃、端口未监听 + 应用异常、依赖故障 + 特定服务异常
检测不到的故障 应用死锁、依赖失败 响应体不含依赖检查时 同 HTTP
适用场景 纯 TCP 服务 HTTP/REST 服务 gRPC 微服务

1.3 健康检查端点设计

一个好的健康检查端点应该区分存活性(Liveness)就绪性(Readiness)

// Go 示例:分层健康检查
package main

import (
    "encoding/json"
    "net/http"
    "sync/atomic"
    "time"
)

var ready atomic.Bool

type HealthResponse struct {
    Status   string            `json:"status"`
    Checks   map[string]string `json:"checks,omitempty"`
    Uptime   string            `json:"uptime,omitempty"`
}

func livenessHandler(w http.ResponseWriter, r *http.Request) {
    // Liveness: 进程是否存活?
    // 只要进程能响应就返回 200
    // 不检查依赖——依赖故障不应导致进程被杀
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(HealthResponse{Status: "alive"})
}

func readinessHandler(w http.ResponseWriter, r *http.Request) {
    // Readiness: 能否处理请求?
    // 检查所有关键依赖
    checks := make(map[string]string)
    allOK := true

    // 检查数据库
    if err := checkDB(); err != nil {
        checks["database"] = err.Error()
        allOK = false
    } else {
        checks["database"] = "ok"
    }

    // 检查 Redis
    if err := checkRedis(); err != nil {
        checks["redis"] = err.Error()
        allOK = false
    } else {
        checks["redis"] = "ok"
    }

    status := "ready"
    code := http.StatusOK
    if !allOK || !ready.Load() {
        status = "not_ready"
        code = http.StatusServiceUnavailable
    }

    w.WriteHeader(code)
    json.NewEncoder(w).Encode(HealthResponse{
        Status: status,
        Checks: checks,
    })
}

// 启动时标记为未就绪,初始化完成后标记就绪
func main() {
    ready.Store(false)

    // 初始化(加载缓存、建立连接池等)
    go func() {
        initialize()
        ready.Store(true)
    }()

    http.HandleFunc("/healthz", livenessHandler)
    http.HandleFunc("/readyz", readinessHandler)
    http.ListenAndServe(":8080", nil)
}

关键设计原则

1.4 参数调优

健康检查的三个关键参数:间隔(interval)超时(timeout)阈值(threshold)

时间线:
  |--间隔--|--间隔--|--间隔--|--间隔--|--间隔--|
  检查1    检查2    检查3    检查4    检查5
  ✓        ✗       ✗       ✗       → 连续失败 3 次,标记不健康
                                     (fall=3)

  ...      ✗       ✓       ✓       → 连续成功 2 次,标记健康
                                     (rise=2)

检测到故障的最短时间 = interval × fall
恢复服务的最短时间 = interval × rise

参数选择指南:

参数 保守值 激进值 推荐值 说明
interval 10s 1s 3-5s 检查频率
timeout 5s 1s 2-3s 单次检查超时
fall 5 2 3 判定不健康的连续失败次数
rise 3 1 2 判定恢复的连续成功次数
故障检测时间 50s 2s 9-15s interval × fall
恢复时间 30s 1s 6-10s interval × rise
# Nginx(商业版 Plus 支持主动健康检查)
upstream backend {
    zone backend 64k;
    server 10.0.2.10:8080;
    server 10.0.2.11:8080;
}

server {
    location / {
        proxy_pass http://backend;
        health_check interval=5s fails=3 passes=2
                     uri=/readyz match=healthy;
    }
}

match healthy {
    status 200;
    body ~ "ready";
}
# HAProxy 健康检查配置
backend app_back
    option httpchk GET /readyz
    http-check expect status 200

    # inter: 检查间隔, fall: 失败阈值, rise: 恢复阈值
    server app1 10.0.2.10:8080 check inter 3s fall 3 rise 2
    server app2 10.0.2.11:8080 check inter 3s fall 3 rise 2

    # 慢启动:恢复后 30 秒内逐渐增加权重
    server app3 10.0.2.12:8080 check inter 3s fall 3 rise 2 slowstart 30s

1.5 高级健康检查模式

依赖级联检查。有时需要检查间接依赖的健康状态:

# Python 示例:分级健康检查
from flask import Flask, jsonify
import redis
import psycopg2
import time

app = Flask(__name__)

# 缓存依赖检查结果(避免每次检查都查数据库)
_cache = {"result": None, "timestamp": 0, "ttl": 5}

def cached_check(func, ttl=5):
    """缓存检查结果,避免频繁探测依赖"""
    key = func.__name__
    now = time.time()
    if _cache.get(key) and now - _cache[key]["ts"] < ttl:
        return _cache[key]["result"]
    result = func()
    _cache[key] = {"result": result, "ts": now}
    return result

def check_database():
    try:
        conn = psycopg2.connect(
            host="db.internal", dbname="app",
            connect_timeout=2
        )
        cur = conn.cursor()
        cur.execute("SELECT 1")
        cur.close()
        conn.close()
        return {"status": "ok"}
    except Exception as e:
        return {"status": "error", "message": str(e)}

def check_redis():
    try:
        r = redis.Redis(host="redis.internal", socket_timeout=2)
        r.ping()
        return {"status": "ok"}
    except Exception as e:
        return {"status": "error", "message": str(e)}

@app.route("/readyz")
def readiness():
    db = cached_check(check_database)
    cache = cached_check(check_redis)

    all_ok = db["status"] == "ok" and cache["status"] == "ok"
    code = 200 if all_ok else 503

    return jsonify({
        "status": "ready" if all_ok else "not_ready",
        "checks": {
            "database": db,
            "redis": cache,
        }
    }), code

@app.route("/healthz")
def liveness():
    # 只检查进程是否存活
    return jsonify({"status": "alive"}), 200

部分降级模式。当非关键依赖故障时,服务仍然可以提供部分功能:

@app.route("/readyz")
def readiness_with_degradation():
    db = cached_check(check_database)
    cache = cached_check(check_redis)
    search = cached_check(check_elasticsearch)

    # 数据库是硬依赖——挂了就不接流量
    if db["status"] != "ok":
        return jsonify({"status": "not_ready", "reason": "database"}), 503

    # Redis 和 ES 是软依赖——挂了仍可接流量(降级模式)
    status = "ready"
    if cache["status"] != "ok" or search["status"] != "ok":
        status = "degraded"  # 降级但仍可服务

    return jsonify({
        "status": status,
        "checks": {
            "database": db,
            "redis": cache,
            "elasticsearch": search,
        }
    }), 200  # 200 表示仍然可以接收流量

二、被动健康检查

2.1 基于真实流量的检测

被动健康检查不主动发送探测请求,而是观察真实流量的响应来判断后端健康状态:

主动检查: LB 定期发送 GET /health 探测
  优点: 无流量时也能发现故障
  缺点: 探测请求 ≠ 真实请求,可能存在差异

被动检查: LB 观察真实请求的成功/失败
  优点: 基于真实流量,无额外开销
  缺点: 无流量时无法检测故障
# Nginx 被动健康检查
upstream backend {
    server 10.0.2.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.2.11:8080 max_fails=3 fail_timeout=30s;
    # max_fails=3: 30 秒内失败 3 次标记不健康
    # fail_timeout=30s: 标记不健康后 30 秒不发送请求
    # 30 秒后自动重试,如果成功则恢复
}

2.2 Envoy 的 Outlier Detection

Envoy 的异常点检测(Outlier Detection)是被动健康检查的高级形式,结合了多种检测策略:

# Envoy Outlier Detection 配置
clusters:
- name: app_cluster
  outlier_detection:
    # 连续失败触发驱逐
    consecutive_5xx: 5                    # 连续 5 个 5xx 响应
    interval: 10s                         # 检测间隔
    base_ejection_time: 30s              # 基础驱逐时间
    max_ejection_percent: 50             # 最多驱逐 50% 的节点
    enforcing_consecutive_5xx: 100       # 100% 执行(不是采样)

    # 成功率触发驱逐
    success_rate_minimum_hosts: 3        # 至少 3 台才启用
    success_rate_request_volume: 100     # 至少 100 个请求
    success_rate_stdev_factor: 1900      # 低于平均值 1.9 个标准差

    # 延迟触发驱逐
    consecutive_local_origin_failure: 5  # 本地错误(连接失败等)

驱逐时间递增机制。每次驱逐的时间按指数增长,避免频繁的驱逐-恢复震荡:

第 1 次驱逐: base_ejection_time × 1 = 30s
第 2 次驱逐: base_ejection_time × 2 = 60s
第 3 次驱逐: base_ejection_time × 3 = 90s
...
上限: max_ejection_time (默认 300s)

2.3 主动 + 被动结合

最佳实践是同时使用主动和被动健康检查:

维度 仅主动 仅被动 主动 + 被动
无流量时检测
真实请求异常检测
额外网络开销
故障检测速度 取决于 interval 第一个失败请求 最快
误判风险 探测与真实差异 偶发错误触发 相互校验
# HAProxy: 主动 + 被动结合
backend app_back
    # 主动检查
    option httpchk GET /readyz
    http-check expect status 200

    # 被动检查 (observe 指令)
    option httpclose
    default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions

    server app1 10.0.2.10:8080 check observe layer7
    server app2 10.0.2.11:8080 check observe layer7
    # observe layer7: 观察 HTTP 响应来被动判断健康
    # observe layer4: 观察 TCP 连接来被动判断健康

三、假阳性与假阴性

3.1 两种错误的代价

错误类型 定义 后果 严重程度
假阳性 健康的服务器被标记为不健康 可用容量减少,剩余服务器负载增加 中等
假阴性 故障的服务器被认为健康 部分请求失败,用户受影响 严重

假阳性更常见。网络抖动、瞬间 CPU 毛刺、GC 暂停都可能导致健康检查超时。如果阈值设得太低(fall=1),一次偶发超时就会摘除一台健康的服务器。

假阴性更危险。应用层死锁(进程在但无法处理请求)、内存泄漏(GC 压力大导致请求变慢但仍能响应健康检查)——这些故障 TCP 检查无法发现。

3.2 减少误判的策略

减少假阳性(避免误摘除):
├── 增加 fall 阈值(3-5 次)
├── 增加 timeout(给慢响应留余地)
├── 分离健康检查端口(避免业务端口被打满影响检查)
└── 限制最大驱逐比例(至少保留 50% 的节点)

减少假阴性(避免漏检测):
├── 使用 HTTP 检查替代 TCP 检查
├── 健康检查端点检查依赖(数据库、Redis)
├── 结合被动检查(真实流量异常检测)
└── 监控健康检查响应时间(变慢可能是故障前兆)

3.3 雪崩保护

最危险的情况是健康检查导致的级联故障——当多台服务器同时被判定不健康时,剩余服务器负载暴增,也开始超时,也被摘除,最终所有服务器都被摘除:

正常状态: 4 台 RS,每台 250 QPS(总 1000 QPS)
  ↓ 网络抖动,2 台健康检查超时
中间状态: 2 台 RS,每台 500 QPS
  ↓ 负载翻倍,响应变慢,健康检查也超时
灾难状态: 0 台 RS,100% 流量失败

防护措施

# Envoy: 限制最大驱逐比例
outlier_detection:
    max_ejection_percent: 30   # 最多驱逐 30%,保证至少 70% 可用

# HAProxy: 至少保留一台
backend app_back
    option allbackups          # 所有 backup 都可以同时使用
    server app1 10.0.2.10:8080 check
    server app2 10.0.2.11:8080 check
    server backup1 10.0.2.20:8080 check backup  # 只在主服务器都不可用时启用

四、故障转移与优雅下线

4.1 连接排空(Connection Draining)

当一台服务器需要下线时(升级、维护),不能直接停止——必须等待已有请求处理完毕:

#!/bin/bash
# graceful_shutdown.sh — 优雅下线脚本

SERVER="10.0.2.10:8080"

# 1. 标记为不健康(健康检查端点返回 503)
curl -X POST http://$SERVER/admin/drain

# 2. 通知 LB 停止分配新请求
# HAProxy: 通过 Runtime API
echo "set server app_back/app1 state drain" | socat stdio /var/run/haproxy.sock

# 3. 等待已有连接结束
echo "等待连接排空..."
while true; do
    ACTIVE=$(ss -tn state established | grep "$SERVER" | wc -l)
    echo "剩余活跃连接: $ACTIVE"
    if [ "$ACTIVE" -eq 0 ]; then
        break
    fi
    sleep 5
done

# 4. 停止服务
echo "所有连接已排空,停止服务"
ssh $SERVER "systemctl stop app"

4.2 应用层优雅关闭

// Go 示例:优雅关闭 HTTP 服务器
package main

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "sync/atomic"
    "syscall"
    "time"
)

var draining atomic.Bool

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
        if draining.Load() {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })
    mux.HandleFunc("/api/", apiHandler)

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // 监听关闭信号
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)

    go func() {
        <-quit
        // 1. 标记为 draining,健康检查返回 503
        draining.Store(true)

        // 2. 等待 LB 检测到并停止发送新请求
        // 这个时间应该 >= LB 的健康检查间隔 × fall 阈值
        time.Sleep(15 * time.Second)

        // 3. 优雅关闭(等待已有请求完成,最多 30 秒)
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        server.Shutdown(ctx)
    }()

    server.ListenAndServe()
}

4.3 滚动更新中的健康检查

在 Kubernetes 滚动更新中,健康检查参数直接影响更新速度和可用性:

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1     # 最多 1 个 Pod 不可用
      maxSurge: 1           # 最多多出 1 个 Pod
  template:
    spec:
      terminationGracePeriodSeconds: 60  # 优雅关闭时间
      containers:
      - name: app
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 10  # 启动后等 10 秒再检查
          periodSeconds: 5
          failureThreshold: 3
          timeoutSeconds: 2

        readinessProbe:
          httpGet:
            path: /readyz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 3         # Readiness 检查更频繁
          failureThreshold: 2      # 更快摘除
          successThreshold: 2      # 需要连续成功 2 次才标记就绪
          timeoutSeconds: 2

        # 启动探针(1.20+)
        startupProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 0
          periodSeconds: 2
          failureThreshold: 30     # 最多等 60 秒启动

PreStop Hook——确保在 Pod 被摘除前有足够时间排空:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]
      # 等待 10 秒让 Endpoints 更新传播到所有节点
      # 然后 SIGTERM 触发应用的优雅关闭

4.4 故障转移的测试方法

健康检查和故障转移必须定期测试,而不是等到真正故障时才发现不工作:

#!/bin/bash
# test_failover.sh — 故障转移测试脚本

VIP="10.0.1.100"
BACKEND="10.0.2.10"

echo "=== 故障转移测试 ==="

# 1. 基准:确认所有后端正常
echo "Step 1: 确认基准状态"
curl -s http://$VIP/health | jq .
echo "当前后端分布:"
for i in $(seq 1 100); do
    curl -s http://$VIP/api/whoami
done | sort | uniq -c

# 2. 模拟故障:停止一台后端
echo "Step 2: 停止 $BACKEND"
ssh $BACKEND "systemctl stop app"

# 3. 观察故障检测时间
echo "Step 3: 等待 LB 检测到故障..."
START=$(date +%s)
while true; do
    RESP=$(curl -s -o /dev/null -w "%{http_code}" http://$VIP/api/test)
    if [ "$RESP" = "200" ]; then
        NOW=$(date +%s)
        ELAPSED=$((NOW - START))
        echo "  ${ELAPSED}s: 仍在返回 200"
    else
        NOW=$(date +%s)
        ELAPSED=$((NOW - START))
        echo "  ${ELAPSED}s: 检测到 $RESP,故障检测完成"
        break
    fi
    sleep 1
done

# 4. 验证流量已切走
echo "Step 4: 验证流量分布"
for i in $(seq 1 50); do
    curl -s http://$VIP/api/whoami
done | sort | uniq -c
# 应该不再出现故障后端

# 5. 恢复
echo "Step 5: 恢复 $BACKEND"
ssh $BACKEND "systemctl start app"

# 6. 等待恢复检测
echo "Step 6: 等待 LB 检测到恢复..."
sleep 15
for i in $(seq 1 50); do
    curl -s http://$VIP/api/whoami
done | sort | uniq -c

echo "=== 测试完成 ==="

五、监控与告警

5.1 健康检查指标

# HAProxy 统计接口
echo "show stat" | socat stdio /var/run/haproxy.sock | \
    awk -F, '{print $2, $18, $19, $20}'
# server_name  chkfail  chkdown  lastchg
# app1         12       3        1234

# 关键指标:
# chkfail: 健康检查失败总次数
# chkdown: 被标记为 down 的总次数
# lastchg: 最后一次状态变化的时间(秒)
# check_duration: 健康检查响应时间

# 告警规则
# 1. 健康检查失败率 > 10%/分钟 → Warning
# 2. 任何 RS 被标记 down → Alert
# 3. 可用 RS 数量 < 总数的 50% → Critical
# 4. 健康检查响应时间 > 正常值 3 倍 → Warning(故障前兆)

5.2 健康检查仪表盘

# Prometheus 告警规则
groups:
- name: health_check_alerts
  rules:
  - alert: BackendDown
    expr: haproxy_server_up == 0
    for: 1m
    labels:
      severity: critical
    annotations:
      summary: "后端 {{ $labels.server }} 不健康超过 1 分钟"

  - alert: TooFewHealthyBackends
    expr: |
      count(haproxy_server_up == 1) by (backend)
      / count(haproxy_server_up) by (backend) < 0.5
    for: 30s
    labels:
      severity: critical
    annotations:
      summary: "后端组 {{ $labels.backend }} 健康节点不足 50%"

  - alert: HealthCheckSlow
    expr: haproxy_server_check_duration_seconds > 2
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "{{ $labels.server }} 健康检查响应变慢"

六、总结

  1. HTTP 检查是默认选择。TCP 检查太粗糙(检测不到应用层故障),gRPC 检查只适用于 gRPC 服务。HTTP 检查能覆盖大多数场景,且可以通过响应体传递详细的健康信息。

  2. 分离 Liveness 和 Readiness。Liveness 只检查进程存活,Readiness 检查所有依赖。LB 用 Readiness 端点做健康检查——依赖故障应该让流量转移,而不是杀掉进程。

  3. 主动 + 被动结合是最佳实践。主动检查保证无流量时也能发现故障,被动检查能在第一个失败请求时立即响应。两者互补。

  4. 设置最大驱逐比例。永远不要允许健康检查摘除所有服务器。设置 max_ejection_percent=30-50%,宁可让部分请求失败,也不要让所有流量无处可去。

  5. 优雅下线是三步操作:标记 draining → 等待 LB 检测 → 排空已有连接 → 停止进程。跳过任何一步都会导致请求失败。K8s 的 preStop hook + terminationGracePeriodSeconds 是这个流程的标准化实现。

  6. 定期测试故障转移。健康检查和故障转移是否正常工作,只有通过测试才能确认。混沌工程(随机停止后端实例)应该成为常规实践,而不是等到真正故障时才发现检查配置有问题。

  7. 健康检查本身不应成为负担。当后端有 1000 个 Pod 时,每 3 秒一次 HTTP 健康检查意味着每秒 333 个额外请求。如果检查端点执行了数据库查询,这个开销不可忽视。使用缓存、异步更新状态、分离健康检查端口都是有效的优化手段。


参考文献


上一篇:负载均衡算法深度解析:从轮询到 P2C

下一篇:全局负载均衡:GSLB、DNS 调度与 Anycast

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-08-18 · network

【网络工程】L4 负载均衡:IPVS、LVS 与连接级调度

系统讲解 L4 负载均衡的内核实现:IPVS 的工作原理与三种转发模式(NAT/DR/TUN)、调度算法选择、LVS 高可用方案(Keepalived + VIP)、云环境中的 L4 LB(NLB/MetalLB),建立传输层负载均衡的工程能力。

2025-08-31 · network

【网络工程】HAProxy 工程:高级配置、ACL 与运维

系统讲解 HAProxy 的工程实践:Frontend/Backend/Listen 配置模型、ACL 规则引擎的高级用法、Runtime API 的动态管理能力、多线程模型与性能调优、SSL 终止与健康检查策略,建立 HAProxy 从配置到生产运维的完整体系。

2025-09-03 · network

【网络工程】Envoy 架构剖析:xDS、Filter Chain 与热重启

系统剖析 Envoy 代理的架构设计:per-Worker 线程模型与事件循环、Filter Chain 的分层扩展机制、xDS 协议族的动态配置发现、热重启的实现原理与零停机更新、Envoy 在 Service Mesh 中的数据面角色,建立 Envoy 从架构到运维的完整理解。


By .