Graceful Shutdown이 중요한 이유
프로덕션에서 Pod이 종료될 때 진행 중인 요청이 중단되면 502 에러, 데이터 불일치, 메시지 유실이 발생합니다. NestJS의 enableShutdownHooks()만으로는 부족합니다. HTTP 서버의 연결 드레이닝, WebSocket 세션 정리, 큐 워커 중단, DB 커넥션 풀 해제까지 종료 순서를 정확히 제어해야 실제 무중단 배포가 가능합니다.
1. 종료 시그널과 NestJS 라이프사이클
| 시그널 | 발생 시점 | NestJS 동작 |
|---|---|---|
| SIGTERM | K8s Pod 종료, docker stop | onModuleDestroy → onApplicationShutdown |
| SIGINT | Ctrl+C (로컬 개발) | 동일 |
| SIGKILL | terminationGracePeriod 초과 | 즉시 종료 (핸들링 불가) |
// main.ts — 기본 설정
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 필수: Shutdown Hooks 활성화
app.enableShutdownHooks();
// 타임아웃이 있는 서버 설정
const server = app.getHttpServer();
server.keepAliveTimeout = 65000; // ALB 기본 60s보다 길게
server.headersTimeout = 66000; // keepAliveTimeout + 1s
await app.listen(3000);
}
bootstrap();
2. 종료 순서 설계
올바른 Graceful Shutdown의 핵심은 종료 순서입니다.
// K8s Pod 종료 시 실행 순서:
// 1. preStop hook 실행 (설정한 경우)
// 2. SIGTERM 수신
// 3. readinessProbe 실패 → Service에서 제거 (트래픽 차단)
// 4. 진행 중인 요청 완료 대기 (드레이닝)
// 5. WebSocket/SSE 연결 종료
// 6. 큐 워커 중단 (현재 작업 완료 후)
// 7. DB 커넥션 풀 해제
// 8. 종료 완료
// 문제: 3번과 2번이 동시에 발생 → 라우팅 지연으로 새 요청 유입 가능
// 해결: preStop에서 sleep으로 EndpointSlice 전파 대기
3. HTTP 서버 드레이닝 구현
// shutdown/graceful-shutdown.service.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Server } from 'http';
@Injectable()
export class GracefulShutdownService implements OnApplicationShutdown {
private server: Server;
private isShuttingDown = false;
private activeConnections = new Set<any>();
constructor(private httpAdapterHost: HttpAdapterHost) {}
onModuleInit() {
this.server = this.httpAdapterHost.httpAdapter.getHttpServer();
// 모든 연결 추적
this.server.on('connection', (socket) => {
this.activeConnections.add(socket);
socket.on('close', () => {
this.activeConnections.delete(socket);
});
});
}
get shuttingDown(): boolean {
return this.isShuttingDown;
}
async onApplicationShutdown(signal?: string) {
console.log(`Shutdown signal received: ${signal}`);
this.isShuttingDown = true;
// 1단계: 새 연결 거부
await new Promise<void>((resolve) => {
this.server.close(() => {
console.log('Server closed to new connections');
resolve();
});
});
// 2단계: 기존 연결에 keep-alive 비활성화
for (const socket of this.activeConnections) {
if (!socket.destroyed) {
socket.end();
}
}
// 3단계: 잔여 연결 강제 종료 대기 (최대 25초)
const deadline = Date.now() + 25000;
while (this.activeConnections.size > 0 && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 500));
}
// 타임아웃 후 강제 종료
for (const socket of this.activeConnections) {
socket.destroy();
}
console.log('All connections drained');
}
}
4. 헬스체크 연동: Readiness 분리
// health/health.controller.ts
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private redis: RedisHealthIndicator,
private shutdownService: GracefulShutdownService,
) {}
// Liveness: 프로세스 살아있는지만 확인
@Get('liveness')
liveness() {
return { status: 'ok' };
}
// Readiness: 트래픽 수신 가능 여부
@Get('readiness')
async readiness() {
// 종료 중이면 즉시 503 → K8s가 Service에서 제거
if (this.shutdownService.shuttingDown) {
throw new ServiceUnavailableException('Shutting down');
}
return this.health.check([
() => this.db.pingCheck('database'),
() => this.redis.pingCheck('redis'),
]);
}
}
5. 리소스별 정리: onModuleDestroy 패턴
// BullMQ 워커: 현재 작업 완료 후 중단
@Injectable()
export class OrderWorker implements OnModuleDestroy {
private worker: Worker;
onModuleInit() {
this.worker = new Worker('orders', async (job) => {
await this.processOrder(job.data);
}, {
connection: this.redis,
concurrency: 5,
});
}
async onModuleDestroy() {
// 새 작업 수신 중단, 진행 중인 작업은 완료 대기
await this.worker.close();
console.log('Order worker stopped gracefully');
}
}
// WebSocket: 클라이언트에 종료 알림
@Injectable()
export class WsShutdownService implements OnApplicationShutdown {
constructor(private wsGateway: ChatGateway) {}
async onApplicationShutdown() {
const server = this.wsGateway.server;
// 모든 클라이언트에 종료 메시지 전송
server.emit('server:shutdown', {
message: 'Server is restarting, please reconnect',
reconnectAfter: 5000,
});
// 연결 종료 대기
await new Promise(r => setTimeout(r, 2000));
server.disconnectSockets(true);
}
}
// DB 커넥션 풀 해제
@Injectable()
export class DatabaseCleanup implements OnModuleDestroy {
constructor(private dataSource: DataSource) {}
async onModuleDestroy() {
if (this.dataSource.isInitialized) {
await this.dataSource.destroy();
console.log('Database connections released');
}
}
}
// Redis 연결 해제
@Injectable()
export class RedisCleanup implements OnModuleDestroy {
constructor(@InjectRedis() private redis: Redis) {}
async onModuleDestroy() {
await this.redis.quit();
console.log('Redis connection closed');
}
}
6. K8s Deployment 설정
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
replicas: 3
strategy:
rollingUpdate:
maxUnavailable: 0 # 무중단: 항상 전체 replicas 유지
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 60 # SIGKILL까지 60초 유예
containers:
- name: api
image: api-server:latest
ports:
- containerPort: 3000
lifecycle:
preStop:
exec:
# EndpointSlice 전파 대기 (핵심!)
# SIGTERM 전에 5초 대기 → 새 트래픽이 이 Pod에 도달 안 함
command: ["sh", "-c", "sleep 5"]
readinessProbe:
httpGet:
path: /health/readiness
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 1 # 1번 실패로 즉시 제거
livenessProbe:
httpGet:
path: /health/liveness
port: 3000
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 250m
memory: 256Mi
| 타이밍 | 값 | 이유 |
|---|---|---|
| preStop sleep | 5초 | EndpointSlice 전파 + iptables 갱신 시간 |
| 서버 드레이닝 | 25초 | 진행 중인 요청 완료 대기 |
| 워커/DB 정리 | ~20초 | 큐 작업 완료 + 커넥션 해제 |
| terminationGracePeriod | 60초 | preStop + 드레이닝 + 정리 합계보다 여유있게 |
7. 종료 미들웨어: 새 요청 거부
// shutdown/shutdown.middleware.ts
@Injectable()
export class ShutdownMiddleware implements NestMiddleware {
constructor(private shutdownService: GracefulShutdownService) {}
use(req: Request, res: Response, next: NextFunction) {
if (this.shutdownService.shuttingDown) {
// Connection: close 헤더로 클라이언트에 재연결 유도
res.set('Connection', 'close');
res.status(503).json({
statusCode: 503,
message: 'Service is shutting down',
retryAfter: 5,
});
return;
}
next();
}
}
// app.module.ts
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ShutdownMiddleware)
.forRoutes('*');
}
}
8. 테스트: 종료 시나리오 검증
describe('Graceful Shutdown', () => {
let app: INestApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.enableShutdownHooks();
await app.init();
});
it('종료 중 readiness가 503 반환', async () => {
// 종료 시작
const shutdownService = app.get(GracefulShutdownService);
(shutdownService as any).isShuttingDown = true;
await request(app.getHttpServer())
.get('/health/readiness')
.expect(503);
});
it('종료 중 새 요청이 503 반환', async () => {
const shutdownService = app.get(GracefulShutdownService);
(shutdownService as any).isShuttingDown = true;
await request(app.getHttpServer())
.get('/api/users')
.expect(503)
.expect(res => {
expect(res.body.message).toBe('Service is shutting down');
expect(res.headers.connection).toBe('close');
});
});
it('진행 중인 요청은 완료됨', async () => {
// 느린 엔드포인트 호출 시작
const slowRequest = request(app.getHttpServer())
.get('/api/slow-endpoint'); // 3초 걸리는 엔드포인트
// 1초 후 종료 시작
setTimeout(() => app.close(), 1000);
// 요청이 정상 완료되어야 함
const res = await slowRequest;
expect(res.status).toBe(200);
});
});
마무리
NestJS Graceful Shutdown의 핵심은 preStop sleep으로 EndpointSlice 전파를 대기하고, server.close()로 새 연결을 거부한 뒤, 진행 중인 요청과 큐 작업이 완료될 때까지 기다리는 것입니다. terminationGracePeriodSeconds는 모든 단계의 합계보다 여유 있게 설정해야 합니다. NestJS Terminus 헬스체크로 readiness를 정확히 제어하고, K8s Pod Probe 전략과 함께 무중단 배포 파이프라인을 완성하는 것을 권장합니다.