微服务平滑上下线机制

大白话先说清楚

没有平滑下线:
  K8s要杀Pod → 直接kill进程
  → 正在处理的100个请求全部中断
  → 用户看到500错误

有了平滑下线:
  K8s要杀Pod → 先通知Swoole
  → 从Etcd摘掉节点(新请求不来了)
  → 等老请求全部处理完
  → 再销毁进程
  → 用户无感知

preStop钩子是什么:
  K8s杀Pod之前会先执行preStop命令
  给你一段时间做善后工作
  就像"你要被开除了,先把手头的活交接完"

整体时序:

K8s发升级指令
    │
    ├─ 1. 执行preStop钩子(发信号给Swoole)
    │
    ├─ 2. Swoole收到信号 → 从Etcd摘除节点
    │       └─ 新请求不会再路由过来
    │
    ├─ 3. 等待已有请求处理完(最多等30秒)
    │
    ├─ 4. 所有请求处理完 → 进程退出
    │
    └─ 5. K8s销毁Pod

K8s配置

# deployment.yaml
spec:
  template:
    spec:
      containers:
        - name: order-service
          lifecycle:
            preStop:
              exec:
                # Pod停止前执行:发SIGTERM给PHP进程
                command: ["/bin/sh", "-c", "kill -SIGTERM 1 && sleep 35"]
          # K8s等待终止的最长时间(要比业务等待时间长)
          terminationGracePeriodSeconds: 60

代码

<?php
// server.php

// 记录当前正在处理的请求数
$GLOBALS['requestCount'] = 0;
$GLOBALS['stopping']     = false;

// ─── Etcd操作
function etcdDelete(string $key): void
{
    go(function () use ($key) {
        $client = new Swoole\Coroutine\Http\Client('etcd服务IP', 2379);
        $client->post('/v3/kv/deleterange', json_encode([
            'key' => base64_encode($key),
        ]));
        $client->close();
        echo "已从Etcd摘除节点:{$key}\n";
    });
}

function etcdPut(string $key, string $value, string $leaseId): void
{
    go(function () use ($key, $value, $leaseId) {
        $client = new Swoole\Coroutine\Http\Client('etcd服务IP', 2379);
        $client->post('/v3/kv/put', json_encode([
            'key'   => base64_encode($key),
            'value' => base64_encode($value),
            'lease' => $leaseId,
        ]));
        $client->close();
    });
}

function etcdGrantLease(int $ttl): string
{
    $client = new Swoole\Coroutine\Http\Client('etcd服务IP', 2379);
    $client->post('/v3/lease/grant', json_encode(['TTL' => $ttl, 'ID' => 0]));
    $result = json_decode($client->body, true);
    $client->close();
    return $result['ID'] ?? '';
}

// ─── 服务启动
$server = new Swoole\Http\Server('0.0.0.0', 9514);
$server->set([
    'worker_num'            => 4,
    'max_wait_time'         => 30,  // worker退出最多等30秒
    'reload_async'          => true, // 异步重启,不强杀
]);

$server->on('workerStart', function ($server, $workerId) {
    $ip      = '192.168.1.10';
    $port    = 9514;
    $nodeKey = "/services/order-service/{$ip}:{$port}";
    $nodeVal = json_encode(['ip' => $ip, 'port' => $port, 'worker' => $workerId]);

    // 注册到Etcd
    $leaseId = etcdGrantLease(30);
    etcdPut($nodeKey, $nodeVal, $leaseId);
    echo "Worker{$workerId} 注册到Etcd\n";

    // 续约心跳
    $timerId = Swoole\Timer::tick(10000, function () use ($leaseId, $workerId) {
        // 正在停止就不续约了(让租约自然过期也行,但主动删更快)
        if ($GLOBALS['stopping']) return;
        go(function () use ($leaseId) {
            $client = new Swoole\Coroutine\Http\Client('etcd服务IP', 2379);
            $client->post('/v3/lease/keepalive', json_encode(['ID' => $leaseId]));
            $client->close();
        });
    });

    // ─── 收到SIGTERM(K8s preStop发来的)
    Swoole\Process::signal(SIGTERM, function () use (
        $server, $nodeKey, $leaseId, $timerId, $workerId
    ) {
        echo "Worker{$workerId} 收到SIGTERM,开始平滑下线\n";
        $GLOBALS['stopping'] = true;

        // Step1:停止续约,清除心跳定时器
        Swoole\Timer::clear($timerId);

        // Step2:从Etcd摘除节点(新请求不再路由过来)
        etcdDelete($nodeKey);

        // Step3:等待已有请求处理完
        go(function () use ($server, $workerId) {
            $waited = 0;
            while ($GLOBALS['requestCount'] > 0 && $waited < 30) {
                echo "Worker{$workerId} 还有 {$GLOBALS['requestCount']} 个请求处理中,等待...\n";
                Swoole\Coroutine::sleep(1);
                $waited++;
            }

            if ($GLOBALS['requestCount'] > 0) {
                echo "Worker{$workerId} 等待超时,强制退出\n";
            } else {
                echo "Worker{$workerId} 请求全部处理完,安全退出\n";
            }

            // Step4:退出进程
            $server->stop();
        });
    });
});

// ─── 请求进来,计数+1
$server->on('request', function ($req, $resp) {
    // 正在停止,拒绝新请求(理论上Etcd摘除后不会再来,双重保险)
    if ($GLOBALS['stopping']) {
        $resp->status(503);
        $resp->end(json_encode(['msg' => '服务升级中,请重试']));
        return;
    }

    $GLOBALS['requestCount']++;

    // 模拟业务处理(假设要花2秒)
    Swoole\Coroutine::sleep(2);
    $resp->end(json_encode(['code' => 0, 'msg' => '处理完成']));

    // 请求处理完,计数-1
    $GLOBALS['requestCount']--;
});

// ─── Worker退出前最后确认
$server->on('workerStop', function ($server, $workerId) {
    echo "Worker{$workerId} 已安全退出\n";
});

$server->start();

完整时序图

K8s触发滚动更新
    │
    ▼
执行preStop: kill -SIGTERM 1
    │
    ▼
Swoole收到SIGTERM
    ├─ stopping = true(拒绝新请求)
    ├─ 停止心跳续约
    ├─ 删除Etcd节点 ←── 负载均衡立刻摘掉这个节点
    │                    新请求路由到其他Pod
    │
    ▼
等待 requestCount == 0
    │
    ├─ 每秒检查一次
    ├─ 最多等30秒
    │
    ▼
requestCount == 0
    │
    ▼
server->stop() → workerStop → 进程退出
    │
    ▼
K8s销毁Pod(用户全程无感知)

新Pod上线流程(上线也要平滑)

// 新Pod启动时:先注册Etcd,再接受流量
$server->on('workerStart', function ($server, $workerId) {
    // 先做好准备:预热缓存、建好连接池
    warmUpCache();
    initConnectionPool();

    // 准备好了再注册到Etcd(注册了才有流量进来)
    $leaseId = etcdGrantLease(30);
    etcdPut($nodeKey, $nodeVal, $leaseId);
    echo "准备就绪,开始接收流量\n";
});

核心三句话

1. SIGTERM信号    → K8s preStop触发,Swoole收到后开始善后
2. 摘除Etcd节点   → 新请求不来了,只处理存量请求
3. requestCount   → 计数器归零才退出,一个请求都不丢

更多推荐