상황

사내 서비스를 기존 Elastic Beanstalk(이하 EB)에서 Ncloud Kubernetes Service(이하 NKS)로 옮기는 작업을 하던 중, 상용에 배포한 Node.js API Pod 중 하나가 계속 CrashLoopBackOff 상태로 재시작되고 있었다.

파드를 확인해보니 종료 사유는 OOMKilled, exit code는 137이었다. 컨테이너 리소스는 cpu: 200m, memory: 512Mi로 충분히 잡혀 있었고, 애플리케이션 로그에는 Master server started 한 줄 뒤에 Cluster server started가 네 번 찍혀 있었다.

결론부터 말하자면, 서버 환경변수에 Node.js 클러스터링 모드를 끄는 옵션이 있었는데 이걸 껐더니 Pod가 정상으로 떴다.

하지만 클러스터링 모드를 끄는 게 왜 해결이 된 걸까? 모드가 켜져 있을 때 왜 OOM이 발생했을까?

지금부터 이 질문들에 대한 답을 찾아가는 여정을 떠나보자. 생각보다 많은 지식을 얻을 수 있을 것이다.


1. worker 프로세스가 4개인 이유

일단 로그에 있던 Cluster server started가 4번 보인 것이 걸렸다. 왜 하필 4개일까?

previous log는 다음과 같았다.

Master server started on 7
Cluster server started on 27
Cluster server started on 15
Cluster server started on 21
Cluster server started on 28

Master server started가 한 번, Cluster server started가 네 번. primary process 1개에 worker process 4개가 생성된 상태였다.

여기서 참고차 짚고 넘어갈 게 하나 있는데, Node.js cluster는 멀티스레드가 아니다. thread pool을 만드는 게 아니라 별개의 process를 fork하는 구조다. 공식 문서를 보면, cluster의 worker는 child_process.fork()로 생성된다고 나와 있다. 즉 worker 하나하나가 독립된 프로세스다.

이게 왜 중요할까? 만약 cluster가 스레드였다면 어땠을지 비교해보면 감이 온다.

구분스레드 (thread)프로세스 (process)
메모리 공간부모와 heap을 공유각자 독립된 메모리 공간
생성 비용가볍다무겁다
Node.js에서worker_threadscluster (child_process.fork)
앱 초기화공유 가능프로세스마다 통째로 다시

스레드는 같은 프로세스 안에서 메모리를 나눠 쓰기 때문에 가볍다. 반면 프로세스는 각자 완전히 독립된 메모리 공간을 갖는다. 그래서 cluster로 worker를 4개 띄운다는 건, 곧 애플리케이션을 4번 새로 부팅하는 것과 다를 바가 없다.

Primary process
├─ worker process 1
├─ worker process 2
├─ worker process 3
└─ worker process 4

worker가 늘어나면 다음 자원이 worker 수만큼 그대로 중복된다.

  • V8 heap
  • NestJS module graph
  • dependency injection container
  • DB connection pool
  • Redis connection
  • secret/config 로딩 결과
  • logger buffer
  • native module memory
  • OpenTelemetry 또는 SDK 초기화 비용

정리하면, worker 4개는 “하나의 앱을 4 thread로 나눠 실행하는 것”이 아니라, 운영 관점에서는 “같은 컨테이너 안에 NestJS 앱 프로세스 4개를 각각 띄운 것”에 가깝다. 메모리 사용량이 4배로 뛸 수 있다는 뜻이다.

그럼 왜 하필 4개였을까? 2개도 3개도 아니고 왜 4개일까.

로그가 찍혔다는 건 서버 코드에 해당 로그를 남기는 코드가 있다는 것이다. 그럼 코드를 직접 확인해보자.


1-1. os.cpus().length의 함정

코드를 보니 cluster 로직은 다음과 같았다.

const clusterize = (callback: Function): void => {
  const numCPUs = os.cpus().length;
  if (config.clustering === true && cluster.isPrimary) {
    console.log(`Master server started on ${process.pid}`);
    for (let i = 0; i < numCPUs; i++) {
      cluster.fork();
    }
    cluster.on('exit', (worker, code, signal) => {
      console.log(`Worker ${worker.process.pid} died. Restarting`);
      cluster.fork();
    });
  } else {
    console.log(`Cluster server started on ${process.pid}`);
    callback();
  }
};
 
clusterize(bootstrap);

한 줄로 요약하면, config.clustering === true일 때 os.cpus().length만큼 worker를 fork한다.

로그에 Cluster server started...가 4번 찍혔으니 for (let i = 0; i < numCPUs; i++)를 4번 돌았다는 뜻이고, 그러려면 numCPUs는 4여야 한다. 즉 os.cpus().length가 4를 반환했다는 것이다.

그런데 좀 이상하다. 내가 Deployment에서 정의한 CPU limit이 200m인데, os.cpus().length도 그에 맞게 나와야 하지 않을까?

이 지점이 이번 문제를 푸는 실마리였다.

os는 Node.js의 기본 내장 모듈이고, os.cpus()는 운영체제가 노출하는 CPU 정보 배열을 반환한다. os.cpus().length는 컨테이너 limit을 읽는 게 아니라, OS 레벨에서 보이는 logical CPU 개수를 센다.

이번 Pod가 올라간 Node VM은 4 vCPU였다.

Node: app-prod-node-1
Capacity:
  cpu:    4
  memory: 16377148Ki

Node VM이 4 vCPU라면 CPU topology는 이렇게 보인다.

logical CPU 0
logical CPU 1
logical CPU 2
logical CPU 3

컨테이너 CPU limit이 200m이어도, 컨테이너 안 Node.js 프로세스는 OS 레벨에서 CPU 4개를 “볼 수 있는” 상태다. 그래서 os.cpus().length는 4를 반환하고, worker도 4개가 만들어진다.

그럼 CPU limit과 실제 노드의 CPU 개수는 관련이 없다는 소리인데.. 왜 이런 일이 생길까?

이 이유를 알려면, 컨테이너 기술에 대해 짚고 넘어가야 한다.


1-2. 컨테이너는 작은 VM이 아니다

컨테이너를 흔히 작은 VM처럼 생각하곤 한다. CPU limit을 200m으로 걸었으니 컨테이너 안에서도 CPU가 0.2개처럼 보여야 하는 것 아닌가 싶은 것도 이런 오해에서 온다.

하지만 컨테이너는 VM처럼 별도의 커널을 띄우는 방식이 아니다. 호스트의 Linux kernel을 그대로 공유하고, 그 위에서 namespace와 Control Groups(이하 cgroup)으로 프로세스를 격리한다.

Host VM
├─ Linux kernel
├─ container namespace
│  ├─ process id 격리
│  ├─ network 격리
│  └─ filesystem view 격리
└─ container cgroup
   ├─ CPU 사용 시간 제한
   └─ memory 사용량 제한

그림으로 보면 이런 구조다.

flowchart TB
    subgraph Host["Host VM / Kubernetes Node"]
        CPU["CPU topology<br/>logical CPU 0,1,2,3"]
        Kernel["Linux kernel"]

        subgraph Container["Container"]
            App["Node.js process"]
            NS["namespace<br/>무엇이 보이는가 격리"]
            CG["cgroup<br/>얼마나 쓸 수 있는가 제한"]
        end
    end

    App -->|"os.cpus() 조회"| CPU
    Kernel --> NS
    Kernel --> CG
    NS -->|"PID / network / filesystem view 격리"| App
    CG -->|"cpu quota = 200m"| Throttle["CPU 사용 시간 제한"]
    CG -->|"memory limit = 512Mi"| OOM["초과 시 OOMKilled"]

    CPU -.->|"CPU 목록은 4개처럼 보일 수 있음"| App
    Throttle -.->|"CPU 개수를 줄이는 것이 아님"| App

컨테이너를 이해하는 핵심은 namespace와 cgroup 두 가지다.

namespace는 “무엇이 보이는가”를 격리한다. 컨테이너 안에서는 자기 프로세스만 보이고, 자기 파일시스템처럼 보이고, 자기 네트워크 인터페이스처럼 보인다. 자신이 볼 수 있는 시야를 제한해두는 것이다.

반면 cgroup은 “얼마나 쓸 수 있는가”를 제한한다. CPU를 얼마나 오래 쓸 수 있는지, memory를 얼마나 쓸 수 있는지 같은 자원 사용량을 제한한다.

여기서 중요한 지점이 나온다. k8s에서 설정하는 CPU limit은 cgroup을 통해 “CPU를 사용할 수 있는 시간”을 제한하는 설정이다. 하지만 logical CPU 개수는 cpuset 같은 걸 따로 설정하지 않는 이상 namespace 단위로 격리되지 않는다. 그래서 컨테이너 안에서 /proc/cpuinfo나 Node.js의 os.cpus()가 호스트의 logical CPU 목록을 그대로 볼 수 있는 것이다.

Kubernetes의 resources.limits.cpu: 200m도 같은 맥락이다. 일반적인 fractional CPU limit은 CPU topology를 숨기는 게 아니라, cgroup quota로 CPU 사용 시간을 제한한다.

그래서 이번 상황은 이렇게 정리된다.

Node CPU topology: 4
os.cpus().length: 4
cluster worker: 4개
Container CPU limit: 200m
Container memory limit: 512Mi

컨테이너 안에서 CPU 4개가 보였기 때문에 worker 4개가 생성됐다. 하지만 실제로 쓸 수 있는 CPU 시간은 200m으로 제한되어 있었고, memory는 worker 전체가 512Mi를 같이 쓰는 구조였다.

여기서 두 가지 의문이 생긴다.

첫째, 앞서 얘기한 CPU limit이 시간을 제한한다는 게 정확히 무슨 뜻일까? 둘째, 그럼 OOMKilled는 왜 발생했을까?

이걸 이해하려면 CFS Quota가 무엇인지, CPU와 memory가 각각 어떻게 다르게 제한되는지를 알아야 한다.


2. CPU limit은 CPU 사용 시간을 제한한다

Kubernetes에서 CPU limit을 이렇게 설정했다고 하자.

resources:
  limits:
    cpu: 200m

200m은 CPU 1개의 0.2배 분량이다.

CPU 1개 전체 = 1000m
200m = CPU 0.2개 분량

그런데 사실 CPU는 한 번에 하나의 작업밖에 하지 못한다. CPU가 여러 작업을 동시에 하는 것처럼 보이지만, 실제로는 수많은 작업을 우선순위에 따라 빠르게 스위칭하면서 처리하는 것이다. 이 전환을 context switch라고 부른다.

즉 CPU 1개는 나눌 수 없는 공통 자원이고, 이 공통 자원을 “시간”으로 분배한다. 그래서 “CPU 0.2개를 준다”는 말은 CPU의 20% 영역만 내가 쓰고 나머지 80%를 다른 프로세스가 동시에 쓴다는 뜻이 아니다. 100ms 중 최대 20ms 동안만 내가 CPU 1개를 온전히 쓴다. 나머지 80ms는 다른 프로세스에게 넘긴다.

이것이 Linux cgroup의 CFS Quota로 반영된다. CFS는 Completely Fair Scheduler, Linux의 기본 CPU 스케줄러다. CFS Quota는 특정 프로세스 그룹이 일정 시간 동안 CPU를 얼마나 쓸 수 있는지 제한하는 기능이다.

cgroup v2에서는 이런 식으로 표현된다.

cpu.max = 20000 100000

즉,

100ms 중 20ms만 CPU 사용 가능

핵심은, CFS Quota는 CPU 개수를 줄이는 게 아니다. CPU가 4개처럼 보여도, 실제로 쓸 수 있는 CPU 시간이 200m으로 제한될 뿐이다.

100ms 주기
├─ 20ms: 실행 가능
└─ 80ms: quota를 다 쓰면 대기 또는 throttling

정리하면 이렇다.

CPU Topology
= CPU가 몇 개처럼 보이는가
 
CFS Quota
= 그 CPU들을 시간 기준으로 얼마나 쓸 수 있는가

CPU limit을 초과하면 느려진다. 흔히 아는 CPU Throttling이다. 쉽게 말하면 할 일은 산더미인데 그 일을 할 시간이 충분히 주어지지 않는 것이다. 그러면 해당 프로세스가 느려지긴 하지만, 그렇다고 죽지는 않는다.

그러면 이번 Pod는 왜 죽은 걸까. 답은 memory에 있었다.


3. memory는 프로세스마다 나눠주지 않는다

컨테이너는 내부에서 프로세스를 여러 개 만들어도, 그 프로세스들이 모두 같은 container cgroup 아래에 들어간다. Linux의 cgroup은 말 그대로 group이다. worker process가 몇 개든 간에 컨테이너를 하나의 group으로 보고, 그 group 안의 모든 프로세스가 합쳐서 limit만큼만 쓸 수 있게 적용한다.

Container limit: cpu 200m, memory 512Mi
├─ primary process
├─ worker process 1
├─ worker process 2
├─ worker process 3
└─ worker process 4
 
=> 다 합쳐서 cpu 200m / memory 512Mi 공유

worker 4개가 각자 512Mi를 받는 게 아니다. primary 포함 프로세스 5개가 512Mi를 같이 쓰게 된다.

그런데 앞에서 봤듯이 각 프로세스는 NestJS 앱을 통째로 다시 부팅한다. heap, connection pool, SDK 초기화까지 5번 중복된다. 이 총합이 512Mi를 넘어서는 순간 문제가 터진다.

여기서 CPU와 memory의 결정적인 차이가 드러난다. 초과했을 때 결과가 다르기 때문이다.

항목초과 시 결과의미
CPU limitthrottling실행 시간이 제한되어 느려질 뿐, 죽지는 않음
Memory limitOOMKilledhard limit 초과 시 커널이 프로세스를 강제 종료

반면 memory는 hard limit이라, 넘는 순간 커널의 OOM killer가 프로세스를 종료한다. Kubernetes는 이를 OOMKilled로 기록한다.

이번 장애의 인과관계를 정리하면 이렇다.

flowchart TB
    A["컨테이너 resource limit<br/>cpu 200m / memory 512Mi"]
    B["Pod가 올라간 Node<br/>4 vCPU"]
    C["os.cpus().length = 4"]
    D["cluster worker 4개 생성"]
    E["primary + worker 4개가<br/>하나의 container cgroup 공유"]
    F["NestJS 앱과 connection pool 등<br/>worker 수만큼 중복 초기화"]
    G["memory 512Mi 초과"]
    H["OOMKilled / exitCode 137"]
    I["CrashLoopBackOff 반복"]

    B --> C
    C --> D
    A --> E
    D --> E
    E --> F
    F --> G
    G --> H
    H --> I

이제 clustering: false로 두는 것이 왜 OOMKilled를 해결했는지도 명확하게 설명할 수 있다. Pod 안에서 primary + worker 4개 구조를 만들지 않으니, 하나의 NestJS 프로세스만 512Mi를 쓰게 되기 때문이다.


4. EB에서는 왜 됐을까?

여기서 한 가지 의문이 더 생겼다. 기존 EB 환경에서도 같은 코드에 같은 클러스터링 설정이 켜져 있었는데, 왜 거기서는 문제가 없었을까?

EC2 한 대에서 Node.js 앱을 직접 실행하는 EB 환경을 생각해보자.

EC2 instance: 4 vCPU / 8GiB
├─ primary
├─ worker 1
├─ worker 2
├─ worker 3
└─ worker 4

이 구조에서는 worker들이 EC2 인스턴스 전체 자원을 쓴다. 8GiB를 5개 프로세스가 나눠 써도 각각 1.6GiB씩은 되고, CPU도 4개를 온전히 쓸 수 있다. 그리고 EC2 인스턴스는 K8s처럼 cgroup으로 리소스를 잘게 쪼개 배분하지 않는다. 서버 전체가 앱 것이다. 그래서 os.cpus().length가 4를 반환하고 worker를 4개 띄우는 게 당연하다.

반면 Kubernetes는 하나의 노드 안에 여러 파드를 띄우는 것을 지원한다. 이때 여러 파드가 노드의 자원을 잘 나눠서 써야 하기 때문에, 노드가 가진 자원을 Pod 단위로 잘라 배분하는 모델을 갖고 있다.

Deployment replicas: 4
 
Pod 1: Node process 1개, memory 512Mi
Pod 2: Node process 1개, memory 512Mi
Pod 3: Node process 1개, memory 512Mi
Pod 4: Node process 1개, memory 512Mi

이렇게 하면 Pod마다 memory limit이 독립적으로 적용된다.

반대로 한 Pod 안에 worker 4개를 넣으면 Kubernetes 입장에서는 여전히 Pod 1개다.

Pod 1: memory 512Mi
├─ primary
├─ worker 1
├─ worker 2
├─ worker 3
└─ worker 4

worker 4개가 내부에서 어떻게 자원을 나눠 쓰는지, K8s는 알지 못한다. 장애 격리도 흐려진다. worker 하나에서 메모리 누수가 생기면, 같은 컨테이너 안의 다른 worker까지 같이 죽게 된다.

항목큰 Pod 1개 + cluster작은 Pod N개
CPU 활용가능가능
memory 격리약함. 한 container limit 공유좋음. Pod별 limit 분리
장애 격리worker 문제로 Pod 전체 영향 가능Pod 하나만 영향
rolling update큰 단위로 교체점진적 교체 쉬움
HPAPod 단위라 내부 worker를 모름replica 단위 확장 쉬움
DB connection 수worker 수만큼 증가replica 수만큼 증가
운영 단순성낮음높음

Kubernetes에서는 아래 구조가 더 예측 가능하다.

Pod 1개 = Node.js process 1개
scale-out = Pod replica 증가

정리하면, EB에서 문제가 없던 이유는 clustering이 그 환경의 자원 모델과 우연히 맞아떨어졌기 때문이다. 같은 코드가 K8s로 오면서 그 전제가 깨진 것이다.


5. 애초에 클러스터링은 왜 하는 걸까?

그럼 EB에서 클러스터링을 쓸 수 있다는 건 이해를 했는데, 애초에 왜 이 클러스터링이라는 걸 쓰는 걸까?

이유는 Node.js가 싱글스레드이기 때문이다.

정확히는, Node.js에서 우리가 작성하는 JavaScript 코드는 하나의 스레드(이벤트 루프) 위에서 실행된다. 그래서 서버에 CPU 코어가 4개 있어도, Node.js 프로세스 하나는 그중 한 코어만 온전히 쓸 수 있다. 나머지 3개는 놀게 된다.

4 vCPU 서버 + Node.js 프로세스 1개
 
CPU 0: ████ 이벤트 루프 실행 (바쁨)
CPU 1: ░░░░ 놀고 있음
CPU 2: ░░░░ 놀고 있음
CPU 3: ░░░░ 놀고 있음

이 낭비를 없애기 위해 나온 게 cluster다. CPU 코어 수만큼 프로세스를 띄우면, 각 프로세스가 코어 하나씩을 맡아 병렬로 요청을 처리할 수 있다.

4 vCPU 서버 + cluster worker 4개
 
CPU 0: ████ worker 1
CPU 1: ████ worker 2
CPU 2: ████ worker 3
CPU 3: ████ worker 4

os.cpus().length만큼 worker를 fork하던 코드도 바로 이 발상에서 나온 것이다. “코어 수만큼 프로세스를 띄워 코어를 놀리지 말자”는 의도다.

문제는 이 발상이 “서버 한 대의 CPU 개수 = 내가 쓸 수 있는 CPU 개수”라는 전제 위에 서 있다는 점이다. EB의 EC2 환경에서는 이 전제가 성립했지만, 컨테이너와 K8s 환경에서는 성립하지 않는다. 앞에서 본 것처럼 컨테이너가 보는 CPU 개수와 실제로 쓸 수 있는 CPU 시간이 다르기 때문이다.


6. 그럼 진짜 문제는 뭘까?

그렇다면 K8s에서 Node.js의 cluster mode는 무조건 문제를 일으킬까?

단순하게 그렇게 볼 수도 있지만, 조금 더 근본 원인을 파고들면 꼭 그렇지는 않다. 다만 K8s에서 cluster mode를 권장하지 않는다는 것 정도는 이제 이해할 수 있다.

정확히는 cluster mode 자체가 잘못된 게 아니라, os.cpus().length로 정한 worker 수와 Pod의 실제 resource limit이 안 맞는 게 문제였다.

이번 사례의 조합을 보면 이렇다.

Node CPU topology: 4
os.cpus().length: 4
cluster worker: 4개
Container CPU limit: 200m
Container memory limit: 512Mi

만약 Node가 4 vCPU이고 Pod CPU limit도 4에 가깝다면 CPU 관점의 불일치는 줄어든다.

Node CPU topology: 4
os.cpus().length: 4
Container CPU limit: 4
cluster worker: 4개

하지만 CPU가 맞아도 memory는 별도 문제다. cluster worker는 thread가 아니라 별도 process이므로, 각 worker가 NestJS 앱과 connection pool을 중복으로 갖는다. memory limit도 primary + worker N개의 실제 RSS/heap을 감당해야 한다.

cluster mode를 K8s에서 쓰려면 최소한 이 조건을 맞춰야 한다.

1. worker 수를 명시적으로 제한한다
2. CPU limit을 worker 수에 맞춘다
3. memory limit이 primary + worker N개의 실제 사용량을 감당해야 한다
4. DB/Redis connection pool 총합이 backend 한도를 넘지 않아야 한다
5. startupProbe timeout이 모든 worker 부팅 시간을 감당해야 한다
6. HPA/Deployment replica 전략과 중복 확장되지 않도록 설계한다

특히 os.cpus().length에 worker 수를 맡기는 건 컨테이너 환경에서 위험하다. 더 안전한 방식은 worker 수를 환경 변수로 직접 지정하는 것이다.

const workerCount = Number(process.env.WEB_CONCURRENCY || 1);
 
for (let i = 0; i < workerCount; i++) {
  cluster.fork();
}

Kubernetes manifest에서는 worker 수와 resource를 함께 맞춰준다.

env:
  - name: WEB_CONCURRENCY
    value: "2"
resources:
  requests:
    cpu: "2"
    memory: 2Gi
  limits:
    cpu: "2"
    memory: 2Gi

request와 limit을 각 노드의 크기에 맞게 크게 잡을 수도 있다.

resources:
  requests:
    cpu: "4"
    memory: 4Gi
  limits:
    cpu: "4"
    memory: 4Gi

이 경우 Pod는 거의 4 CPU짜리 큰 Pod가 된다. Node가 4 vCPU라면 사실상 Node 하나를 이 Pod가 거의 독차지한다. 그리고, 실제 Node의 allocatablecapacity보다 작기 때문에(Allocatable CPU: 3920m), requests.cpu: "4"는 스케줄 자체가 안 된다.

그래서 대부분의 API 서버에서는 cluster mode로 worker를 늘리는 것보다 Pod replica를 늘리는 게 더 단순하다.


마치며

정리해보면, 이번 문제의 본질은 Node.js cluster mode 자체가 아니었다.

os.cpus().length는 컨테이너 CPU limit을 반영하지 않는다. Node VM의 CPU topology를 기준으로 worker 수를 정한다. 그리고 컨테이너 안에서 만들어진 모든 프로세스는 하나의 container cgroup limit을 공유한다. 이 두 가지가 겹치면서, 4 vCPU Node 위에서 cpu: 200m, memory: 512Mi짜리 컨테이너가 worker 4개를 만들었고, 5개의 프로세스가 512Mi를 나눠 쓰다가 OOM으로 종료된 것이다.

clustering: false는 임시방편이 아니라, K8s의 자원 모델에 맞는 올바른 선택이었다.

이 문제를 파고들면서 몇 가지를 확실히 알게 됐다.

  • 컨테이너는 작은 VM이 아니다. namespace는 시야를, cgroup은 사용량을 격리한다. 이 둘은 다르게 동작하고, 그래서 컨테이너 안에서 host의 CPU 개수가 그대로 보일 수 있다.
  • K8s의 CPU limit은 CPU 개수를 줄이는 게 아니라 CPU 사용 시간을 제한한다. CPU는 시간으로 분배되는 공통 자원이기 때문이다.
  • CPU와 memory는 초과했을 때 결과가 다르다. CPU는 throttling으로 느려지고, memory는 OOMKilled로 죽는다.
  • K8s에서 scale-out은 프로세스가 아니라 Pod를 늘리는 것이다. Pod가 이미 배포·격리·확장의 단위이기 때문이다.

Kubernetes에서 Pod는 이미 배포·격리·확장 단위이므로, 일반적인 NestJS API 서버는 Pod 안에서 다시 worker를 늘리기보다 Pod replica로 확장하는 편이 더 예측 가능하다.