← 中文资源

简体中文版本

构建支持优雅关闭的 HTTP 服务

收到终止信号后停止接入新连接、等待在途请求、执行清理钩子并在限期内退出。

阅读英文原文

项目目标

本项目显式建模终止信号后的那几秒:先停止接入、再排空、随后运行清理钩子,最后退出或强制结束。服务跟踪在途请求,一经开始排空就把就绪标记翻为不健康,并在限期前等待活跃计数归零。

核心特性

  • 排空开始时立刻停止接入新连接
  • 在可配置宽限期内等待在途请求完成
  • 按注册顺序执行清理钩子
  • 超过限期强制退出,避免卡死

架构与分层

请求跟踪器统计活跃数;生命周期模块持有从运行到排空再到关闭的状态机;关闭注册表存放清理钩子;服务把这些与进程信号接起来。状态机可直接在测试中调用其转移,无需真实信号。

实施阶段

六个阶段为:请求跟踪、生命周期状态机、清理钩子、服务接线、信号处理与退出、排空测试。先翻就绪再关监听是避免丢请求的顺序。

测试与验收

用 vitest 运行。生命周期测试直接调用 beginDrain 与 close 断言状态序列与钩子顺序;排空测试启动服务、发起慢请求、触发挥干,断言在途请求完成而之后发起的请求被拒。

延伸方向

用于立即关闭的第二信号、逐路由的排空优先级与更详细健康载荷都是同一状态机外围的增量。钩子是注册而非硬编码,新资源可加入清理而无需改动生命周期。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function createLifecycle({ deadlineMs = 10000 } = {}) {
  let state = 'running';
  const hooks = [];
  return {
    get state() { return state; },
    beginDrain() { if (state === 'running') state = 'draining'; },
    registerHook(fn) { hooks.push(fn); },
    async close(active) {
      state = 'closing';
      await waitFor(() => active() === 0, deadlineMs);
      for (const hook of hooks) await hook();
      state = 'closed';
      return { drained: active() === 0 };
    },
  };
}
mkdir graceful-server && cd graceful-server
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "graceful-server",
  "type": "module",
  "scripts": {
    "start": "node src/cli.js",
    "test": "vitest run"
  }
}
export function createTracker() {
  let active = 0;
  return {
    active: () => active,
    track(handler) {
      return async (...args) => {
        active += 1;
        try { return await handler(...args); }
        finally { active -= 1; }
      };
    },
  };
}
export function waitFor(predicate, timeoutMs, step = 25) {
  return new Promise((resolve) => {
    const started = Date.now();
    const tick = () => {
      if (predicate()) return resolve(true);
      if (Date.now() - started >= timeoutMs) return resolve(false);
      setTimeout(tick, step);
    };
    tick();
  });
}

export function createLifecycle({ deadlineMs = 10000 } = {}) {
  let state = 'running';
  return {
    get state() { return state; },
    beginDrain() { if (state === 'running') state = 'draining'; },
    async close(active) {
      state = 'closing';
      const drained = await waitFor(() => active() === 0, deadlineMs);
      state = 'closed';
      return { drained };
    },
  };
}
export function createHookRegistry() {
  const hooks = [];
  return {
    register(name, fn) { hooks.push({ name, fn }); },
    async runAll() {
      const ran = [];
      for (const hook of hooks) {
        await hook.fn();
        ran.push(hook.name);
      }
      return ran;
    },
  };
}
import { createServer } from 'node:http';
import { createTracker } from './tracker.js';
import { createLifecycle } from './lifecycle.js';
import { createHookRegistry } from './hooks.js';

export function createApp({ deadlineMs = 10000, delayMs = 0 } = {}) {
  const tracker = createTracker();
  const lifecycle = createLifecycle({ deadlineMs });
  const hooks = createHookRegistry();

  const handle = tracker.track(async (req, res) => {
    if (req.url === '/readyz') {
      return send(res, lifecycle.state === 'running' ? 200 : 503, { state: lifecycle.state });
    }
    if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
    send(res, 200, { ok: true });
  });

  const server = createServer((req, res) => {
    if (lifecycle.state !== 'running') return send(res, 503, { error: 'draining' });
    return handle(req, res);
  });

  return { server, lifecycle, hooks, tracker };
}

function send(res, status, body) {
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(JSON.stringify(body));
}
import { createApp } from './server.js';

const app = createApp({
  deadlineMs: Number(process.env.GRACE_MS ?? 10000),
  delayMs: Number(process.env.DELAY_MS ?? 0),
});
app.hooks.register('close-db', async () => { /* release handles here */ });

app.server.listen(process.env.PORT ?? 3000, () => console.log('listening'));

async function shutdown(signal) {
  console.log('received ' + signal + ', draining');
  app.lifecycle.beginDrain();
  app.server.close();
  const result = await app.lifecycle.close(app.tracker.active);
  await app.hooks.runAll();
  console.log(result.drained ? 'drained cleanly' : 'deadline reached, forcing exit');
  process.exit(result.drained ? 0 : 1);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
import { createApp } from '../src/server.js';

test('in-flight request completes during drain and new ones are refused', async () => {
  const app = createApp({ deadlineMs: 2000, delayMs: 100 });
  await new Promise((r) => app.server.listen(0, r));
  const port = app.server.address().port;

  const inflight = fetch('http://localhost:' + port + '/work').then((r) => r.status);
  app.lifecycle.beginDrain();
  app.server.close();
  const late = await fetch('http://localhost:' + port + '/work').catch(() => 'refused');
  expect(await inflight).toBe(200);
  expect(late === 'refused' || late === 503).toBe(true);
});