Devin.KR

배포와 프로세스 관리 - 정상 종료부터 무중단 교체까지

개발자KR 조회 14

이 장에서 배우는 것

앞 장에서 입력 한도와 권한, 비밀값을 다뤘다. 서버가 요청을 안전하게 받아들이더라도 배포할 때 처리 중인 요청을 끊거나, 준비가 덜 된 프로세스에 트래픽을 보내면 기록이 빠질 수 있다. 이번에는 로그 수집 서버를 실행하는 것에서 한 걸음 더 나아가, 실행 중인 버전을 다른 버전으로 교체하는 절차를 만든다.

정상 종료 자체는 기본서에서 익혔다. 여기서는 정상 종료를 프로세스 관리자의 제한 시간, 로그 파일 관리, 트래픽 전환과 연결한다. 애플리케이션은 자신이 언제 요청을 받을 수 있는지 알려 주고, 운영 도구는 그 상태를 확인한 뒤 시작과 종료 순서를 결정해야 한다.

  • SIGTERM을 받으면 신규 요청을 차단하고, 진행 중인 요청과 파일 쓰기를 마무리한다.
  • systemd와 PM2의 역할을 비교하고 종료 제한 시간을 맞춘다.
  • 로그 파일을 회전한 뒤 열린 파일 핸들을 새 파일로 바꾼다.
  • 준비 상태 확인과 트래픽 전환을 분리하여 새 버전을 투입한다.
  • 이전 버전과 데이터 호환성을 보존하고 롤백 순서를 정한다.

문제 상황

로그 수집 서버가 한 대에서 실행 중이다. 배포 스크립트는 기존 프로세스를 종료한 다음 새 파일을 복사하고 서버를 시작한다. 평소에는 문제가 잘 보이지 않지만, 요청이 몰리는 시간에는 파일 쓰기가 끝나기 전에 프로세스가 내려간다. 기존 프로세스가 사라지고 새 프로세스가 준비되는 사이에는 연결 자체가 실패한다.

디스크에서도 다른 문제가 생긴다. 관리자가 커진 access.log를 access.log.1로 바꿨는데 새 access.log가 생기지 않는다. 서버가 파일 이름을 매번 찾아 쓰는 것이 아니라, 시작할 때 열었던 파일 핸들을 계속 사용하기 때문이다. 이름을 바꾸는 작업만으로 쓰기 대상이 새 파일로 전환되지는 않는다.

급하게 이전 코드로 되돌리는 것도 간단하지 않다. 기존 배포 디렉터리에 파일을 덮어썼다면 무엇이 이전 버전이었는지부터 찾아야 한다. 새 버전이 기록한 데이터를 이전 버전이 읽지 못한다면 코드만 되돌려도 서비스가 복구되지 않는다. 배포는 파일 복사 명령 하나가 아니라, 준비 상태와 연결, 데이터와 복구 경로를 함께 다루는 절차다.

종료를 요청받은 프로세스가 해야 할 일

SIGTERM은 프로세스에 종료를 요청하는 신호다. 처리기를 등록하면 애플리케이션이 정리 순서를 실행할 수 있다. 처리기를 등록한 뒤에는 기본 종료 동작에 기대지 말고, 서버와 파일 같은 자원을 직접 닫아야 한다. 반면 SIGKILL은 애플리케이션이 잡아서 정리할 수 없다. 따라서 관리자가 강제 종료하기 전까지 사용할 시간을 미리 정한다.

종료 순서는 준비 상태를 내리는 것에서 시작한다. 이어서 HTTP 서버의 수신을 닫고 진행 중인 작업을 기다린다. 마지막으로 쓰기 대기열이 끝났는지 확인하고 파일을 닫는다. 이때 연결이 끝났다는 사실과 애플리케이션 작업이 끝났다는 사실은 같지 않다. 클라이언트가 먼저 연결을 끊어도 이미 시작한 파일 쓰기는 남아 있을 수 있다.

정상 종료는 준비 상태를 내린 뒤 연결과 작업을 정리하고 파일을 닫는 순서로 진행한다

Node.js 22의 server.close()는 신규 연결 수신을 멈추고, 유휴 연결을 정리하면서 기존 요청의 종료를 기다린다. 처리 중인 요청까지 즉시 끊는 함수는 아니다. 따라서 애플리케이션에서도 종료 중임을 나타내는 상태를 검사해야 한다. 종료 전에 들어온 작업은 완료시키고, 종료 상태로 바뀐 뒤 처리기에 도착한 요청은 503으로 돌려보낸다.

정리에는 상한이 필요하다. 클라이언트가 응답을 읽지 않거나 파일 시스템이 응답하지 않으면 기다림이 길어질 수 있다. 예제는 8초 동안 정리하고, 그 안에 끝나지 않으면 일반 HTTP 연결을 강제로 닫고 종료 코드 1로 끝낸다. 이 경로에서는 미완료 기록이 남을 수 있다. 제한 시간이 있다는 사실을 정상 완료와 구별하여 운영 기록에 남겨야 한다.

server.closeAllConnections()는 이 예제처럼 일반 HTTP 연결을 다루는 서버에 사용할 수 있다. 연결을 다른 프로토콜로 전환했다면 별도 추적과 종료가 필요하다. 또한 이 함수는 진행 중인 파일 작업까지 취소하지 않는다. 예제의 제한 시간 초과 경로에서 process.exit()를 사용하는 이유는 모든 정리를 완료했다는 뜻이 아니라, 정해진 시간 안에 더 기다릴 수 없다는 뜻이다.

프로세스 관리자와 로그 파일의 수명

프로세스 관리자는 실행 명령을 기억하고, 종료 결과를 관찰하고, 재시작 정책을 적용한다. 애플리케이션이 정상 종료 순서를 구현하는 일과 관리자가 다시 실행하는 일은 서로 다른 책임이다. 관리자가 재시작해 준다는 이유로 애플리케이션의 정리 과정이 생략되지는 않는다.

systemd와 PM2를 고를 때 비교할 운영 책임
항목systemdPM2
사용 환경systemd를 사용하는 Linux별도로 설치하는 Node.js 운영 도구
실행 단위운영체제 서비스PM2가 관리하는 애플리케이션
종료 설정KillSignal, TimeoutStopSec기본 SIGINT, kill_timeout
로그 관리표준 출력을 journal로 수집 가능관리 로그 파일의 별도 회전 정책 필요

systemd는 운영체제 서비스와 함께 계정, 작업 디렉터리, 재시작 정책을 선언하기에 적합하다. macOS에서 동일한 서비스 파일을 실행할 수는 없다. PM2는 애플리케이션 목록과 재시작을 공통 명령으로 다루기 편하지만 별도 설치와 운영이 필요하다. 이번 프로그램에는 외부 패키지가 필요 없으며, PM2는 선택할 수 있는 배포 도구로만 비교한다.

관리자의 종료 제한 시간은 애플리케이션의 정리 제한 시간보다 길게 둔다. 애플리케이션이 8초를 기다리는데 관리자가 5초 만에 강제 종료한다면 정리 코드의 의도가 지켜지지 않는다. 예를 들어 관리자는 12초를 허용하여 신호 전달과 마지막 출력에 여유를 둘 수 있다. 같은 프로세스를 두 관리자가 각각 재시작하도록 중복 등록하는 방식도 피한다.

로그 회전은 커진 파일을 보관 파일로 옮기고 새 파일에 기록을 이어 가는 작업이다. 예제의 access.log는 수집 데이터이며, ready와 stopped 같은 운영 메시지는 표준 출력으로 보낸다. 두 종류의 기록은 보관 목적이 다르다. journal이나 PM2가 표준 출력을 수집하더라도 애플리케이션이 직접 연 access.log까지 자동으로 관리하는 것은 아니다.

회전은 파일 이름 변경, 재개방 요청, 완료 확인의 세 단계로 다룬다. 예제는 SIGUSR2를 받으면 파일 쓰기와 같은 대기열에 재개방 작업을 넣는다. 앞선 쓰기가 끝난 뒤 새 파일을 열고 기존 핸들을 닫으므로, 쓰기 도중 핸들을 닫는 경쟁을 피할 수 있다. 이름 변경 이후 재개방 이전의 기록은 보관 파일에 들어갈 수 있으며, 이 경계는 정상적인 결과다.

파일을 복사한 다음 원본 크기를 줄이는 방식은 재개방이 어려울 때 사용되기도 하지만, 복사와 크기 변경 사이에 들어온 기록을 놓칠 수 있다. 여기서는 이름 변경과 재개방을 사용한다. 회전 도구가 새 파일을 미리 만든다면 실행 계정의 쓰기 권한도 맞춰야 한다. 이전 파일의 압축과 삭제는 재개방 완료를 확인한 뒤 보관 정책에 따라 실행한다.

준비 상태와 교체 순서

준비 상태 확인(readiness)은 해당 인스턴스가 지금 실제 요청을 받을 수 있는지 판단하는 절차다. 프로세스가 살아 있거나 포트를 열었다는 사실만으로는 충분하지 않다. 예제에서는 저장 디렉터리와 로그 파일을 준비한 다음 포트를 열고, 그 뒤에 준비 상태를 올린다. 종료가 시작되면 준비 상태를 먼저 내린다.

준비 상태 검사는 요청마다 새 로그를 쓰지 않는다. 검사가 서비스에 지속적인 부하를 추가해서는 안 되기 때문이다. 대신 초기 자원 확보 성공 여부와 종료 상태를 응답한다. 실행 중 디스크가 가득 차는 상황까지 미리 보장하지는 못한다. 실제 쓰기가 실패하면 준비 상태를 내리고 종료를 시작하여, 계속 성공 응답을 보내는 상황을 막는다.

무중단 교체에는 이전 인스턴스를 유지하면서 새 인스턴스를 준비할 공간이 필요하다. 이번 예제에서는 서로 다른 포트를 사용하는 두 인스턴스 앞에 역방향 프록시(reverse proxy)가 있다고 가정한다. 새 인스턴스의 준비 상태를 직접 확인한 뒤 프록시가 새 요청을 보내는 대상을 바꾼다. 변경을 적용하고 기존 대상으로 새 요청이 들어가지 않는 것을 확인한 다음 이전 인스턴스에 SIGTERM을 보낸다.

새 버전의 준비 상태를 확인하고 트래픽을 전환한 뒤 이전 버전을 종료해야 요청 수신 공백을 줄일 수 있다

프록시의 대상 변경과 프로세스 종료는 동시에 일어나지 않는다. 상태 검사 주기, 설정 전파, 기존 연결 재사용 때문에 잠시 이전 대상으로 요청이 향할 수 있다. 따라서 준비 상태를 내린 것만으로 즉시 안전한 종료가 보장된다고 생각해서는 안 된다. 프록시의 대상 제외 동작과 기존 연결 처리 정책을 확인하고, 필요하면 전파를 기다리는 단계를 별도로 둔다.

롤백은 이전 실행 파일을 다시 선택하는 절차까지 준비되어 있어야 한다. 버전별 배포 디렉터리를 보존하고, 실행 파일과 환경 설정을 한 묶음으로 식별한다. 저장 데이터는 배포 디렉터리 밖에 둔다. 예제의 RELEASE 값은 응답과 기록에 버전을 남겨, 전환 이후 어떤 프로세스가 처리했는지 확인하는 데 사용한다.

복구할 때는 이전 버전의 준비 상태를 먼저 확인하고 프록시 대상을 되돌린다. 이전 프로세스가 이미 종료되었다면 보존한 디렉터리와 설정으로 다시 시작한다. 요청이 이전 버전으로 돌아간 것을 확인한 다음 문제가 있는 버전을 정리한다. 코드 롤백이 새 버전에서 이미 기록한 데이터를 지우거나 되돌리지는 않는다. 저장 형식 변경은 구버전의 읽기 가능 여부와 함께 검토해야 한다.

완성 코드

server.mjs는 표준 모듈만 사용한다. POST /logs?path=/home으로 경로 하나를 수집하며 요청 본문은 사용하지 않는다. 실제 접속 로그 전송 규격을 단순화하여, 이번에는 배포 상태와 자원 수명을 관찰한다. 준비 상태는 GET /ready에서 확인한다. PORT, RELEASE, LOG_DIR로 인스턴스를 구분할 수 있다.

server.mjs

import http from 'node:http';
import { mkdir, open } from 'node:fs/promises';
import { join } from 'node:path';

const port = Number(process.env.PORT ?? 3100);
const release = process.env.RELEASE ?? 'blue';
const logDir = process.env.LOG_DIR ?? './logs';
const graceMs = 8_000;

if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error('invalid PORT');
}
if (!/^[a-zA-Z0-9._-]{1,40}$/.test(release)) {
  throw new Error('invalid RELEASE');
}

let file;
let ready = false;
let stopping = false;
let tail = Promise.resolve();
const jobs = new Set();

function enqueue(operation) {
  const result = tail.then(operation);
  tail = result.catch(() => {});
  return result;
}

function reply(res, status, value) {
  if (res.destroyed || res.writableEnded) return;
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
  });
  res.end(JSON.stringify(value) + '\n');
}

function storageFailure() {
  console.error('storage-error');
  void stop(1);
}

async function handle(req, res) {
  req.resume();

  if (!ready) {
    res.setHeader('connection', 'close');
    reply(res, 503, { ready: false, release });
    return;
  }

  const url = new URL(req.url, 'http://localhost');

  if (req.method === 'GET' && url.pathname === '/ready') {
    reply(res, 200, { ready: true, release });
    return;
  }

  if (req.method !== 'POST' || url.pathname !== '/logs') {
    reply(res, 404, { error: 'not-found' });
    return;
  }

  const path = url.searchParams.get('path');
  if (!path || path.length > 200) {
    reply(res, 400, { error: 'invalid-path' });
    return;
  }

  const line = JSON.stringify({ path, release }) + '\n';

  try {
    await enqueue(() => file.appendFile(line, 'utf8'));
  } catch {
    storageFailure();
    reply(res, 500, { error: 'storage-error' });
    return;
  }

  reply(res, 201, { stored: true, release });
}

const server = http.createServer((req, res) => {
  res.on('error', () => {});
  const job = handle(req, res).catch(() => {
    reply(res, 500, { error: 'request-error' });
  });
  jobs.add(job);
  void job.then(() => jobs.delete(job));
});

server.requestTimeout = 15_000;
server.headersTimeout = 10_000;

async function stop(code = 0) {
  if (code !== 0) process.exitCode = code;
  if (stopping) return;

  stopping = true;
  ready = false;
  console.log('draining');

  const deadline = setTimeout(() => {
    console.error('shutdown-timeout');
    server.closeAllConnections();
    process.exit(1);
  }, graceMs);

  try {
    await new Promise((resolve, reject) => {
      server.close((error) => {
        if (error) reject(error);
        else resolve();
      });
    });

    await Promise.all([...jobs]);
    await tail;
    await file.close();

    clearTimeout(deadline);
    console.log('stopped');
  } catch {
    console.error('shutdown-error');
    server.closeAllConnections();
    process.exit(1);
  }
}

async function rotate() {
  if (stopping) return;

  try {
    await enqueue(async () => {
      const next = await open(join(logDir, 'access.log'), 'a');
      const previous = file;
      file = next;
      await previous.close();
    });
    console.log('log-reopened');
  } catch {
    storageFailure();
  }
}

async function main() {
  await mkdir(logDir, { recursive: true });
  file = await open(join(logDir, 'access.log'), 'a');

  await new Promise((resolve, reject) => {
    const onError = (error) => reject(error);
    server.once('error', onError);
    server.listen(port, '127.0.0.1', () => {
      server.off('error', onError);
      resolve();
    });
  });

  server.on('error', () => {
    console.error('server-error');
    void stop(1);
  });

  process.on('SIGTERM', () => { void stop(); });
  process.on('SIGINT', () => { void stop(); });
  process.on('SIGUSR2', () => { void rotate(); });

  ready = true;
  console.log(`ready release=${release} port=${port}`);
}

try {
  await main();
} catch {
  console.error('startup-error');
  if (file) await file.close().catch(() => {});
  process.exitCode = 1;
}

줄별 해설

첫 세 줄은 HTTP 서버, 비동기 파일 작업, 경로 결합에 필요한 모듈을 가져온다. 설정 부분에서는 포트와 버전 식별자를 검증한다. 버전 식별자는 응답과 로그에 그대로 들어가므로 짧은 문자 집합으로 제한한다. LOG_DIR는 배포 파일과 분리할 수 있도록 외부에서 지정한다.

file은 현재 기록 대상이고 ready는 요청 수락 여부다. stopping은 종료 절차의 중복 실행을 막는다. SIGTERM 이후 SIGINT가 도착하더라도 파일을 두 번 닫지 않는다. 종료 도중 저장 오류가 발생하면 stop(1)의 첫 줄에서 실패 종료 코드를 남기므로, 먼저 시작된 정상 종료 요청이 실패를 가리지 않는다.

enqueue()는 앞선 작업이 끝난 다음 새 작업을 실행한다. 호출자에게는 실패할 수 있는 result를 돌려주고, 내부 연결용 tail에는 거부를 처리한 약속을 저장한다. 앞선 작업의 실패 때문에 이후 정리 작업까지 실행되지 않는 상황을 피하는 구조다. 오류를 조용히 무시하는 설계는 아니다. 파일 쓰기와 회전의 호출자가 result의 실패를 받아 준비 상태를 내린다.

reply()는 연결이 이미 사라졌거나 응답이 끝났다면 추가 출력을 하지 않는다. handle()의 첫 부분은 사용하지 않는 본문을 소비하고, 준비 상태가 내려갔는지 확인한다. 이 검사와 파일 쓰기 등록 사이에는 비동기 대기가 없다. 따라서 수락한 기록은 종료를 기다리는 대기열에 들어간다.

수집 요청은 파일 추가 쓰기가 성공한 뒤에만 201을 받는다. 이 성공은 운영체제에 쓰기 작업을 전달했다는 의미이며 전원 손실에도 보존된다는 보장은 아니다. 그런 수준의 내구성이 필요하면 별도의 동기화 및 저장 정책을 정해야 한다. 또한 쓰기가 성공한 직후 연결이 끊기면 클라이언트는 결과를 모를 수 있다. 정상 종료만으로 재전송에 따른 중복까지 해결되지는 않는다.

jobs는 응답 연결과 별도로 요청 처리 작업을 추적한다. 각 작업은 거부 처리까지 포함한 약속으로 저장되며, 끝나면 집합에서 제거된다. stop()은 HTTP 연결 정리가 끝난 다음 남은 작업을 기다린다. 그 뒤 tail을 기다려 재개방 작업까지 마무리한 뒤 파일을 닫는다. 이 순서가 연결 수명과 파일 작업 수명의 차이를 연결한다.

deadline은 종료를 시작할 때 한 번 만든다. 정상 경로에서는 파일을 닫은 후 타이머를 해제하고 이벤트 루프가 자연스럽게 끝나도록 둔다. 정리가 실패하거나 제한 시간을 넘긴 경우에만 즉시 종료한다. 신호 처리기에 process.exit(0) 한 줄을 넣는 것과 달리 성공과 중단을 구별할 수 있다.

rotate()의 재개방도 enqueue()를 통과한다. 새 파일을 여는 데 성공한 다음 현재 핸들을 교체하므로, 새 파일 생성 실패를 감지할 수 있다. 종료를 시작한 뒤에 받은 회전 신호는 무시한다. 이미 등록된 재개방은 종료 코드가 tail을 기다리므로 정리 대상에 포함된다.

main()은 저장 자원을 먼저 준비하고 포트를 연다. listen 성공 이후에 신호 처리기를 연결하고 ready를 올린다. 그 이전의 시작 실패는 startup-error와 실패 종료 코드로 드러난다. 이 프로그램은 신호 처리기가 설치된 실행 상태의 정리를 다루며, 초기화 중에 종료 신호를 받으면 운영체제의 기본 종료 동작이 적용된다.

실행 결과

macOS 또는 Linux의 셸에서 빈 실습 디렉터리에 server.mjs를 저장한다. 다음 명령은 표준 출력과 오류를 run.log로 모아 셸의 백그라운드 작업 알림과 프로그램 출력을 구분한다. Node.js는 .mjs를 직접 실행하므로 별도 컴파일 단계가 없으며, 먼저 구문 검사를 실행한다.

node --check server.mjs
PORT=3100 RELEASE=blue LOG_DIR=./logs node server.mjs >run.log 2>&1 &
pid=$!

until curl -fsS http://127.0.0.1:3100/ready >/dev/null 2>&1; do
  if ! kill -0 "$pid" 2>/dev/null; then
    cat run.log
    break
  fi
  sleep 0.1
done

curl -sS http://127.0.0.1:3100/ready
curl -sS -X POST 'http://127.0.0.1:3100/logs?path=/home'

구문 검사가 통과하면 출력이 없다. 포트가 비어 있고 저장 디렉터리에 쓰기 권한이 있다면 두 요청의 출력은 다음과 같다. 서버가 시작에 실패했다면 이후 실습을 진행하기 전에 run.log를 확인한다.

{"ready":true,"release":"blue"}
{"stored":true,"release":"blue"}

같은 셸에서 파일을 회전한다. 아래 대기는 실습에서 재개방 완료를 확인하기 위한 것으로, 성공적으로 실행 중인 프로세스를 전제로 한다. 운영 자동화에서는 대기 횟수와 실패 처리를 추가한다.

mv logs/access.log logs/access.log.1
kill -USR2 "$pid"

until grep -q '^log-reopened$' run.log; do
  sleep 0.1
done

curl -sS -X POST 'http://127.0.0.1:3100/logs?path=/checkout'
kill -TERM "$pid"
wait "$pid"
cat run.log
cat logs/access.log.1
cat logs/access.log

위 명령이 순서대로 성공하면 프로그램과 파일의 출력은 다음과 같다. 첫 번째 기록은 보관 파일에, 재개방을 확인한 뒤 보낸 기록은 새 파일에 들어간다.

{"stored":true,"release":"blue"}
ready release=blue port=3100
log-reopened
draining
stopped
{"path":"/home","release":"blue"}
{"path":"/checkout","release":"blue"}

종료가 끝난 후 /ready에 접속하면 503 응답이 아니라 연결 실패가 발생한다. 503은 종료 상태로 바뀐 뒤에도 요청 처리기에 도달한 요청에 대한 응답이다. 준비 상태를 확인하는 도구는 비정상 상태 코드와 연결 실패 모두를 대상 제외 조건으로 다뤄야 한다.

Linux에서 systemd를 사용한다면 다음과 같은 서비스 설정을 출발점으로 삼을 수 있다. logcollector 계정과 /var/lib/log-collector 디렉터리를 먼저 만들고 쓰기 권한을 부여한다. /usr/bin/node와 배포 디렉터리는 실제 설치 경로에 맞춰야 한다.

[Unit]
Description=Log collector
After=network.target

[Service]
Type=exec
User=logcollector
WorkingDirectory=/opt/log-collector/releases/blue
Environment=PORT=3100
Environment=RELEASE=blue
Environment=LOG_DIR=/var/lib/log-collector/blue
ExecStart=/usr/bin/node server.mjs
KillSignal=SIGTERM
TimeoutStopSec=12s
Restart=on-failure
RestartSec=2s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Type=exec는 실행 파일을 시작하는 데 성공했다는 것을 확인하지만, /ready가 200인지 대신 검사하지는 않는다. 배포 스크립트나 프록시에서 준비 상태를 따로 확인해야 한다. PM2를 선택한다면 kill_timeout을 12000으로 맞추고, 기본 종료 신호인 SIGINT에도 같은 정리 코드가 실행되는지 확인한다. PM2의 준비 알림 기능을 사용하려면 process.send('ready') 같은 해당 도구의 규약을 별도로 구현해야 한다. 이 예제는 HTTP 준비 상태만 제공한다.

두 버전을 동시에 실행할 때는 green을 3101 포트와 별도 저장 디렉터리로 시작한다. 하나의 파일에 두 인스턴스가 쓰면 회전 신호 전달과 핸들 전환까지 함께 조정해야 하므로, 예제에서는 기록 경로를 분리한다. 각 인스턴스의 기록은 수집 대상에 모두 포함한다.

PORT=3101 RELEASE=green LOG_DIR=./logs-green node server.mjs

이 명령의 시작 출력은 다음과 같다.

ready release=green port=3101

새 인스턴스의 /ready를 확인한 것만으로 사용자의 요청 경로가 바뀌지는 않는다. 실제 무중단 교체를 검증하려면 사용하는 프록시에서 대상 변경을 수행하고, 외부 경로로 연속 요청을 보내 상태 코드와 RELEASE를 관찰해야 한다. 위의 단일 프로세스 실습은 파일 회전과 종료 순서를 확인하는 범위다.

실무에서 자주 틀리는 것

신호를 받자마자 성공 코드로 종료한다

다음 코드는 아직 응답 중인 연결과 대기 중인 기록을 남겨 둔 채 성공 종료를 보고한다.

process.on('SIGTERM', () => {
  process.exit(0);
});

완성 코드의 stop()처럼 상태 변경과 정리를 하나의 경로로 모으고, 정상 경로에서는 자원을 닫아 자연스럽게 종료한다.

process.on('SIGTERM', () => { void stop(); });
process.on('SIGINT', () => { void stop(); });

살아 있는 프로세스를 준비된 프로세스로 본다

항상 200을 반환하는 검사는 종료 중에도 요청을 받을 수 있다고 보고한다. 또한 시작 직후 포트 응답만 확인하면 필요한 저장 자원이 확보되었는지 놓칠 수 있다.

if (url.pathname === '/ready') {
  reply(res, 200, { ready: true });
}

준비 상태를 자원 초기화 완료와 종료 상태에 연결한다. 다음 조각의 ready는 초기화가 끝난 뒤에만 true가 되고 종료 시작과 함께 false가 된다.

if (url.pathname === '/ready') {
  reply(res, ready ? 200 : 503, { ready, release });
}

파일 이름을 바꾸면 쓰기 대상도 바뀐다고 생각한다

다음 명령만 수행하면 기존 핸들은 이름이 바뀐 파일을 계속 가리킨다. 새 파일이 생기지 않거나 보관 파일의 크기가 계속 커질 수 있다.

mv logs/access.log logs/access.log.1

완성 코드처럼 재개방 처리기가 있는 프로세스에는 재개방을 요청하고 완료를 확인한다. SIGUSR2는 이 애플리케이션이 정한 규약이며 모든 서버의 공통 규약은 아니다.

mv logs/access.log logs/access.log.1
kill -USR2 "$pid"

이전 버전을 덮어쓴 다음 복구를 생각한다

실행 디렉터리에 새 파일을 덮어쓰면 복구에 필요한 이전 파일과 설정의 조합을 잃기 쉽다. 다음 예시는 배포 산출물 보존 없이 같은 위치를 재사용한다.

cp server.mjs /opt/log-collector/current/server.mjs

버전별 디렉터리를 유지하고 실행 경로를 명시한다. 아래는 이미 보존한 blue 배포를 별도 포트로 다시 띄우는 예다. 준비 상태와 외부 요청 경로를 확인한 뒤에 문제가 있는 버전을 종료한다.

PORT=3102 RELEASE=blue \
LOG_DIR=/var/lib/log-collector/blue-recovery \
node /opt/log-collector/releases/blue/server.mjs

한눈에 보기

배포 단계별로 확인할 조건과 실패 시 행동
단계확인 조건다음 행동실패 시 행동
새 버전 시작저장 자원 확보, 준비 상태 200실제 수집 요청 확인기존 버전 유지
트래픽 전환외부 응답의 버전과 오류율기존 대상 제외 확인기존 대상으로 복귀
이전 버전 정리새 요청 유입 중단SIGTERM, 최대 8초 정리실패 종료와 미완료 작업 조사
로그 회전재개방 완료, 새 파일 생성이전 파일 보관권한과 디스크 상태 확인
롤백이전 산출물과 데이터 호환성준비 확인 후 경로 복원별도 데이터 복구 절차 적용

프로세스 관리자는 서버를 다시 시작할 수 있지만, 준비 상태와 데이터 호환성까지 대신 판단하지는 않는다. 종료 코드, 준비 상태, 버전 식별자, 재개방 완료 기록을 함께 남기면 배포 중 어느 단계에서 문제가 생겼는지 구분할 수 있다.

API의 정확한 동작과 설정 항목은 Node.js 22의 HTTP 서버 종료 문서, systemd 서비스 설정 문서, PM2의 종료 신호와 준비 알림 문서에서 확인할 수 있다.

연습 문제

  1. 애플리케이션의 종료 제한 시간이 8초인데 관리자는 5초 뒤 강제 종료한다. 어떤 문제가 생기는지 설명하고 관리자의 제한 시간을 제안하라.
  2. access.log의 이름을 바꾼 뒤 SIGUSR2를 보내기 전에 요청 하나가 들어왔다. 이 기록이 access.log.1에 들어갔다면 회전 실패인가. 완성 코드의 대기열 순서로 설명하라.
  3. green의 /ready는 200이지만 실제 수집 요청에서 500이 증가한다. blue가 아직 실행 중일 때 롤백 순서를 작성하고, green이 이미 기록한 데이터의 처리 원칙을 설명하라.
  4. 클라이언트 연결은 모두 닫혔지만 파일 쓰기 약속이 아직 끝나지 않았다. server.close()의 완료만 기다리는 구현과 완성 코드의 차이를 설명하라.

정답과 해설

  1. 관리자가 애플리케이션의 정리 제한 시간보다 먼저 강제 종료하므로 파일 쓰기와 연결 정리가 중간에 끊길 수 있다. 예제에서는 관리자에 12초를 주어 애플리케이션의 8초와 마지막 종료 작업에 여유를 둔다. 실제 값은 관찰한 정리 시간에 맞춰 조정하되 두 제한 시간의 순서를 유지한다.
  2. 실패가 아니다. 이름을 바꾸어도 기존 핸들은 같은 파일을 가리킨다. 재개방보다 앞에 등록된 쓰기는 그 핸들로 실행된다. 재개방 작업이 끝난 다음 등록되거나 실행 순서가 뒤인 쓰기부터 새 파일을 사용한다. 회전 경계 부근의 기록은 두 파일을 함께 확인한다.
  3. blue의 준비 상태와 실제 수집 동작을 확인하고 프록시 대상을 blue로 되돌린다. 외부 요청이 blue에서 처리되는 것을 확인한 다음 green을 대상에서 제외하고 종료한다. green이 남긴 기록은 보존하고 집계 대상에 포함한다. 구버전과 호환되지 않는 기록이 있다면 별도 변환이나 복구 절차를 적용하며, 코드 복귀를 데이터 복구로 간주하지 않는다.
  4. 연결이 사라진 뒤에도 애플리케이션의 파일 작업은 계속될 수 있다. server.close()만 기다리면 파일을 너무 일찍 닫을 수 있다. 완성 코드는 jobs로 요청 작업을 기다리고 tail로 재개방을 포함한 파일 작업의 순서를 확인한다. 두 대기가 끝난 뒤 파일을 닫으며, 전체 과정에는 8초 제한을 적용한다.

이 책에서 확장한 로그 수집 서버는 이제 시작과 요청 처리뿐 아니라 교체와 복구의 순서까지 갖추었다. 실제 배포 환경에서는 연속 요청을 보내면서 대상 전환, 정상 종료, 로그 회전, 이전 버전 복귀를 반복해 확인한다. 배포 절차는 문서에 적힌 순서와 실행 중 관찰한 결과가 일치할 때 운영에 사용할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.