Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 9월 17일 09:09중국어 → 한국어

LLM 앱의 Graceful Shutdown: 진행 중인 AI 요청 안전하게 멈추기

K8s 롤링 업데이트나 컨테이너 재시작 때 진행 중인 LLM 스트리밍 요청 대응을 위해 drain window, in-flight 추적, shutdown token, 과금 상태 저장 등 4가지 함정을 코드와 함께…

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

새벽 2시의 경보

우리 LLM 서비스는 K8s 롤링 업데이트 한 번 후, 사용자 측에서 대량의 "생성 중단" 불만이 발생했습니다. 로그를 조사해 보니: 새 버전 Pod가 준비되면, 구 버전 Pod가 SIGTERM을 받고 곧바로 종료되었습니다——streaming 중이던 모든 요청이 순간적으로 끊기고 503을 반환했습니다.

이것은 우발적인 문제가 아닙니다. 당신의 LLM 서비스가 다음과 같다면:

- K8s에 배포되어 있고 롤링 업데이트를 사용하며

- streaming 엔드포인트(Server-Sent Events / WebSocket)가 하나라도 있고

- 요청 시간이 30초를 초과한다면(대형 모델이 2000 token을 생성하는 데 40-60초 소요)

반드시 이 문제를 겪게 됩니다.

이 글은 우리가 밟았던 함정과 최종적으로 안착시킨 방안을 전부 적어냅니다.

문제 분해: LLM Shutdown이 일반 HTTP 서비스보다 어려운 이유

일반 HTTP 서비스의 graceful shutdown에는 이미 성숙한 방안이 있습니다: in-flight 요청이 완료될 때까지 기다리고, 타임아웃 후 강제 종료하는 것입니다. 하지만 LLM streaming 요청에는 세 가지 특수성이 있습니다:

1. 요청 시간을 예측할 수 없음

100 token 생성에는 3초가 걸리고, 4000 token 생성에는 2분이 걸립니다. 한 요청이 언제 끝날지 알 수 없습니다. drain window를 얼마로 설정하는 것이 적절할까요? 30초면 절반의 요청을 중단시키고, 300초면 K8s가 타임아웃될 때까지 기다리게 합니다.

2. 부분 응답은 가치도 있고 위험도 있음

일반 API는 성공하거나 실패하거나 둘 중 하나이며, 중간 상태가 없습니다. LLM streaming의 중간 상태는 이미 생성된 token 시퀀스입니다——그것은 가치가 있지만(사용자가 이미 1000자를 보았음), 잘린 텍스트는 의미가 불완전할 수 있으므로 클라이언트는 "이것이 정상 종료가 아니라 예기치 않은 중단"임을 알아야 합니다.

3. 과금 상태를 영속화해야 함

LLM 호출은 보통 token 기준으로 과금됩니다. 요청이 output token 3000개를 생성한 후 중단되면, 이 3000개 token은 이미 provider 측에서 과금되었지만, 당신의 과금 시스템에는 기록되지 않았을 수 있습니다. 재시도하면 한 번 더 과금됩니다.

아래에서 4개 계층으로 나누어 해법을 설명합니다.

제1계층: SIGTERM 처리 + Drain Window

가장 기초적인 계층입니다. Node.js / Python 애플리케이션은 기본적으로 SIGTERM을 처리하지 않고, 프로세스가 곧바로 종료됩니다.

Node.js 구현

// shutdown.ts import { Server } from 'http' ; const DRAIN_TIMEOUT_MS = process. env . DRAIN_TIMEOUT_MS ? parseInt (process. env . DRAIN_TIMEOUT_MS ) : 120_000 ; // 2분 export function setupGracefulShutdown ( server: Server ) { let isShuttingDown = false ; // K8s가 SIGTERM을 보내면, drain할 충분한 시간을 줌 process. on ( 'SIGTERM' , async () => { if (isShuttingDown) return ; isShuttingDown = true ; console . log ( `[shutdown] SIGTERM received. Drain window: ${DRAIN_TIMEOUT_MS} ms` ); // 1. 새 연결 수신 중지 server. close (); // 2. in-flight 요청이 완료될 때까지 대기, 또는 타임아웃 const drainStart = Date . now (); await waitForInflightRequests (drainStart); console . log ( '[shutdown] Drain complete. Exiting.' ); process. exit ( 0 ); }); // Liveness probe: shutdown이 시작된 후 503을 반환하여, K8s가 더 이상 트래픽을 라우팅하지 않도록 함 server. on ( 'request' , ( req, res ) => { if (isShuttingDown && req. url === '/healthz' ) { res. writeHead ( 503 ); res. end ( 'shutting down' ); } }); }

K8s 설정

deployment.yaml spec: template: spec: terminationGracePeriodSeconds: 180 # 반드시 > DRAIN_TIMEOUT_MS containers: - name: llm-server lifecycle: preStop: exec: # kube-proxy가 iptables를 갱신할 시간을 주어, SIGTERM과 트래픽이 동시에 도착하는 것을 방지 command: [ "/bin/sleep" , "5" ]

terminationGracePeriodSeconds는 K8s가 Pod에 주는 총 유예 시간이며, 반드시 당신의 drain timeout보다 커야 합니다. 그렇지 않으면 K8s가 drain이 끝나기 전에 SIGKILL을 보냅니다.

제2계층: In-Flight 요청 추적

drain window만으로는 충분하지 않습니다——"지금 얼마나 많은 LLM 요청이 아직 실행 중인지"를 알아야 언제 종료할 수 있는지 결정할 수 있습니다.

// inflight-tracker.ts export class InflightTracker { private requests = new Map < string , { startedAt : number ; model : string ; estimatedTokens?: number ; abortController : AbortController ; }>(); register ( requestId: string , model: string , abortController: AbortController ) { this . requests . set (requestId, { startedAt : Date . now (), model, abortController, }); } complete ( requestId: string ) { this . requests . delete (requestId); } get count () { return this . requests . size ; } // maxAgeMs를 초과한 요청은 멈춘 것으로 간주하여 강제 중단 abortStale ( maxAgeMs: number ) { const now = Date . now (); for ( const [id, req] of this . requests ) { if (now - req. startedAt > maxAgeMs) { console . warn ( `[tracker] Aborting stale request ${id} (age: ${now - req.startedAt} ms)` ); req. abortController . abort ( 'shutdown-stale' ); this . requests . delete (id); } } } abortAll ( reason: string ) { for ( const [id, req] of this . requests ) { console . log ( `[tracker] Aborting in-flight request ${id} (reason: ${reason} )` ); req. abortController . abort (reason); } this . requests . clear (); } } export const inflightTracker = new InflightTracker ();

drain window 안에서 폴링:

async function waitForInflightRequests ( drainStart: number ) { while (inflightTracker. count > 0 ) { const elapsed = Date . now () - drainStart; if (elapsed >= DRAIN_TIMEOUT_MS - 10_000 ) { // 마지막 10초: 남은 모든 요청을 중단하고 shutdown token을 전송 console . warn ( `[shutdown] Drain timeout approaching. Aborting ${inflightTracker.count} requests.` ); inflightTracker. abortAll ( 'shutdown' ); break ; } console . log ( `[shutdown] Waiting for ${inflightTracker.count} in-flight requests (elapsed: ${elapsed} ms)` ); await new Promise ( r => setTimeout (r, 2000 )); } }

제3계층: Shutdown Token + 클라이언트 이어받기

가장 핵심적인 계층이자, 가장 쉽게 간과되는 계층입니다.

drain window가 만료되면, SSE 연결을 그냥 닫아서는 안 됩니다——클라이언트는 네트워크 장애로 오인하여 재시도해야 할지 판단하지 못합니다. SSE 스트림의 마지막에 특수한 shutdown token을 보내, 클라이언트에게 "내가 곧 멈출 테니, 너는 중단 지점부터 이어받을 수 있다"고 알려야 합니다.

서버 측: shutdown token 전송

// sse-handler.ts import { inflightTracker } from './inflight-tracker' ;

export async function handleStreamRequest ( req: Request, res: Response ) { const requestId = req. headers [ 'x-request-id' ] || crypto. randomUUID (); const abortController = new AbortController ();

// tracker에 등록 inflightTracker. register (requestId, req. body . model , abortController);

// SSE headers 설정 res. setHeader ( 'Content-Type' , 'text/event-stream' ); res. setHeader ( 'Cache-Control' , 'no-cache' ); res. setHeader ( 'X-Request-Id' , requestId);

// abort 신호 수신( drain window 타임아웃에서 발생 ) abortController. signal . addEventListener ( 'abort' , () => { const reason = abortController. signal . reason ;

// shutdown token을 전송하며, 중단점 정보를 함께 전달 const checkpointData = { type : 'shutdown' , reason, requestId, // 이미 생성된 token 수, 클라이언트 판단에 사용 generatedTokens : tokenCounter. get (requestId) || 0 , // 클라이언트는 이 ID로 이어받기 요청을 시작할 수 있음 resumeToken : generateResumeToken (requestId), timestamp : Date . now (), };

res. write ( `data: ${ JSON .stringify(checkpointData)} \n\n` ); res. end (); console . log ( `[sse] Sent shutdown token for ${requestId} ` ); });

try { // 정상 스트리밍 생성 for await ( const chunk of llmClient. streamGenerate (req. body , { signal : abortController. signal , })) { if (res. destroyed ) break ; res. write ( `data: ${ JSON .stringify(chunk)} \n\n` ); tokenCounter. increment (requestId); }

// 정상 종료 res. write ( 'data: [DONE]\n\n' ); res. end (); } catch (e) { if (e. name === 'AbortError' ) { // abort는 이미 signal 이벤트로 처리되었으므로 여기서 중복 전송하지 않음 } else { res. write ( `data: ${ JSON .stringify({ type : 'error' , message : e.message })} \n\n` ); res. end (); } } finally { inflightTracker. complete (requestId); } }

클라이언트: shutdown token 인식 및 이어받기

// client.ts async function * streamGenerate ( prompt: string , options: { resumeToken?: string ; previousContent?: string ; } = {} ) { const response = await fetch ( '/api/generate' , { method : 'POST' , body : JSON . stringify ({ prompt, resumeToken : options. resumeToken , // 이어받기임을 서버에 알림 }), });

const reader = response. body !. getReader (); const decoder = new TextDecoder (); let buffer = '' ;

while ( true ) { const { done, value } = await reader. read (); if (done) break ;

buffer += decoder. decode (value, { stream : true }); const lines = buffer. split ( '\n' ); buffer = lines. pop () || '' ;

for ( const line of lines) { if (!line. startsWith ( 'data: ' )) continue ; const data = line. slice ( 6 ); if (data === '[DONE]' ) return ;

const event = JSON . parse (data);

// 핵심: shutdown token 인식 if (event. type === 'shutdown' ) { console . log ( `[client] Server shutting down. Resume token: ${event.resumeToken} ` );

// 자동 재시도, 이어받기 컨텍스트를 함께 전달 yield * streamGenerate (prompt, { resumeToken : event. resumeToken , previousContent : options. previousContent , }); return ; }

yield event; } } }

네 번째 계층: 과금 상태 영속화

이곳이 데이터가 가장 쉽게 유실되는 지점이다. LLM 호출은 token 단위로 과금되는데, shutdown 시점에 이미 소비된 token을 제때 기록하지 않으면 두 가지 문제가 발생한다:

- 과소 과금 : 사용자가 3000 token을 사용했는데 시스템이 기록하지 않아 수익 손실

- 중복 과금 : 이어받기 시 같은 prompt를 다시 보내 provider가 두 번 차감했는데, 당신은 한 번만 기록

해법: token 소비의 멱등적 쓰기

// token-billing.ts import { createClient } from 'redis' ;

const redis = createClient ({ url : process. env . REDIS_URL });

export async function recordTokenUsage ( params: { requestId: string ; userId: string ; model: string ; promptTokens: number ; completionTokens: number ; isPartial: boolean ; // shutdown으로 중단된 경우 true resumeToken?: string ; } ) { // 멱등 키: 같은 requestId는 한 번만 기록 const idempotencyKey = `billing: ${params.requestId} ` ; const exists = await redis. exists (idempotencyKey);

if (exists && !params. isPartial ) { // 이미 완전한 기록이 있으므로 덮어쓰지 않음 return ; }

// 원자적 쓰기, MULTI/EXEC로 부분 쓰기 방지 await redis. multi () . hSet ( `billing:record: ${params.requestId} ` , { userId : params. userId , model : params. model , promptTokens : params. promptTokens , completionTokens : params. completionTokens , isPartial : params. isPartial ? '1' : '0' , resumeToken : params. resumeToken || '' , recordedAt : Date . now (), }) . expire ( `billing:record: ${params.requestId} ` , 86400 * 7 ) // 7일 TTL . set (idempotencyKey, '1' , { EX : 86400 * 7 }) . exec ();

// 이어받기 시, resumeToken으로 원래 requestId를 찾아 중복된 prompt token을 차감해야 함 if (params. resumeToken ) { const originalRequestId = await getOriginalRequestId (params. resumeToken ); if (originalRequestId) { await deductDuplicatePromptTokens (originalRequestId, params. promptTokens ); } } }

실측 데이터: graceful shutdown 유무 비교

부하 테스트 환경에서 100 동시성으로 K8s 롤링 업데이트를 시뮬레이션하여 두 가지 구성을 비교했다:

지표 | Graceful Shutdown 없음 | Graceful Shutdown 있음 업데이트당 중단 요청 수 | 약 40개 | 0개 (drain window 내) 클라이언트 503 비율 | 12.3% | 0.1% (극히 느린 요청에 한함) Token 과금 유실률 | 8.7% | 0.02% 롤링 업데이트 소요 시간 | 45s | 165s (drain window 포함) 사용자 체감 중단 | 빈번 | 극히 낮음

소요 시간은 늘었지만, 사용자 경험과 과금 정확도는 모두 크게 향상되었다. LLM 서비스라면 이 비용은 지불할 가치가 있다.

몇 가지 빠지기 쉬운 함정

함정 1: preStop hook이 충분히 길지 않음

K8s의 preStop 단계와 terminationGracePeriodSeconds는 직렬이 아니라 병렬로 시간이 계산된다. 많은 사람이 preStop sleep 5 + terminationGracePeriodSeconds 180 = 185초라고 생각하지만, 실제로 Pod는 180초만 가지며 preStop sleep 5가 그중 5초를 차지한다.

함정 2: Nginx / Envoy가 Pod 앞단에서 먼저 종료

Nginx sidecar로 SSL 종료를 처리하는 경우, Nginx가 SIGTERM을 받아 LLM server보다 먼저 종료되면서 이미 성립된 연결까지 끊길 수 있다. Nginx에 worker_shutdown_timeout을 drain window보다 크게 설정해야 한다.

worker_shutdown_timeout 130s; # drain window보다 약간 크게

함정 3: SSE 연결이 ALB/CLB에 의해 강제 타임아웃됨

AWS ALB의 기본 idle timeout은 60초인데, LLM streaming은 이를 초과할 수 있다. 필요한 조치:

- ALB idle timeout을 늘린다(최대 4000초)

- 또는 SSE 스트림에서 주기적으로 keep-alive 하트비트(빈 주석 줄)를 보낸다

// 30초마다 keep-alive 전송 const keepAlive = setInterval(() => { if (!res.destroyed) res.write(': keepalive\n\n'); }, 30_000); // 완료 시 해제 onComplete(() => clearInterval(keepAlive));

함정 4: drain window가 너무 길어 K8s가 반복적으로 축출함

만약 drain window를 300초로 설정하면, K8s는 리소스가 부족할 때 이 Pod를 "응답이 너무 느림"으로 표시하고 강제 축출할 수 있다. PodDisruptionBudget과 함께 사용해 동시에 내려가는 Pod 수를 제한할 것을 권장한다:

apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: llm-server-pdb spec: minAvailable: "50%" selector: matchLabels: app: llm-server

방안 요약

┌─────────────────────────────────────────────────────────────┐ │ Graceful Shutdown 4계층 │ ├─────────────────────────────────────────────────────────────┤ │ 제1계층 SIGTERM 처리 + Drain Window │ │ terminationGracePeriodSeconds > drainTimeout │ │ preStop sleep 5로 kube-proxy에 시간 제공 │ ├─────────────────────────────────────────────────────────────┤ │ 제2계층 In-Flight 추적 │ │ Map<requestId, AbortController> │ │ drain 종료 전 남은 요청 중단 │ ├─────────────────────────────────────────────────────────────┤ │ 제3계층 Shutdown Token + 클라이언트 이어받기 │ │ SSE 마지막에 { type: 'shutdown', resumeToken } 전송 │ │ 클라이언트가 자동으로 이어받아 사용자는 인지하지 못함 │ ├─────────────────────────────────────────────────────────────┤ │ 제4계층 과금 상태 영속화 │ │ 멱등 쓰기, 이어받기 시 중복 prompt token 차감 │ └─────────────────────────────────────────────────────────────┘

정리

LLM 애플리케이션의 graceful shutdown에서 어려운 점은 "프로세스 중지"가 아니라 다음에 있다:

- 클라이언트에 알림: shutdown token으로 "서비스 중지"와 "네트워크 장애"를 구분한다

- 중단 지점 보존: resumeToken으로 클라이언트가 처음부터 재시도하는 대신 이어받을 수 있게 한다

- 과금 영속화: shutdown 전에 이미 소모한 token을 원자적으로 기록해 누락과 중복 계산을 방지한다

- 인프라스트럭처 협조: drain window는 반드시 K8s terminationGracePeriodSeconds, ALB timeout, Nginx 설정과 정렬되어야 한다

프로덕션에서 우리가 설정한 값: DRAIN_TIMEOUT_MS=120000, terminationGracePeriodSeconds=180, ALB idle timeout=600. 이 설정 조합은 99%의 시나리오에서 진행 중인 요청이 정상적으로 끝나게 하며, 극소수의 2분 이상 초장시간 생성만이 shutdown token 이어받기를 트리거한다.

코드는 이미 바로 실행할 수 있으며, 문제가 있으면 댓글에서 논의하기 바란다.

AINative 소프트웨어 엔지니어링

AI Infrastructure Engineer

143

32k

읽음

28

팔로워