본문으로 건너뛰기
← Development Log

Development Log

8편. OOMKilled 이후 - 비정상 프로세스 추적, 보안 패치, 런타임 메모리 관측

반복 재시작의 OOMKilled 기록에서 비정상 프로세스를 찾아내고 Next.js 패치와 새 이미지 배포로 대응한 과정입니다.

  • #Kubernetes
  • #Next.js
  • #Incident
  • #Security

문제 Pod가 반복적으로 재시작됐고 마지막 종료는 OOMKilled / 137이었지만 현재 메모리는 정상처럼 보였다. 처음 가설 Next.js 메모리 누수·순간 spike·보안 사고 가능성을 모두 열어두고 조사했다. 실제 확인 커널 OOM 로그에서 같은 Pod cgroup의 정체불명 프로세스가 약 300~380MiB씩 반복 사용하며 512MiB limit을 넘긴 기록을 확인했다. 해결 취약한 Next.js를 15.5.25로 패치하고 기존 Pod를 폐기한 뒤 새 image로 재배포했으며 memory limit은 유지했다. 미확정 정확한 악성코드 종류와 최초 침투 경로. 호스트·Kubernetes 권한 확장 흔적은 현재까지 발견하지 못했다.

이번 작업은 단순한 메모리 튜닝이 아니었다. 정상처럼 보이던 Pod에서 과거 47회 재시작 기록을 확인했고, 마지막 종료 원인은 OOMKilled / exitCode 137이었다. 처음에는 원인을 메모리 누수·순간 spike·보안 사고 가능성으로 열어두고 조사했지만, 이후 커널 OOM 로그에서 같은 Pod cgroup 안의 정체불명 프로세스가 반복적으로 약 300~380MiB의 메모리를 사용한 뒤 OOM killer에 의해 종료된 기록을 확인했다. 이 시점부터 OOMKilled의 직접 원인은 비정상 프로세스의 메모리 사용으로 판단할 수 있었고, 정확한 최초 침투 경로와 악성코드 종류는 별도 미확정 사항으로 남겼다. 대응은 서비스 격리 → 취약한 Next.js 패치 → 새 이미지 재빌드·검증 → 침해 범위 확인 → 런타임 메모리 관측 순서로 진행했다.

시작은 "현재 Pod는 초록색인데 왜 재시작 기록이 많지?"였다

Argo CD에서 web-app Pod는 현재 Running이고 Health도 정상으로 보였다. 하지만 Events에는 과거 probe 실패와 container restart 흔적이 남아 있었다. 현재 상태가 정상이라는 사실만으로 과거 장애 원인이 사라지는 것은 아니다. 그래서 먼저 현재 상태가 아니라 컨테이너의 종료 이력을 확인했다.

bash
sudo kubectl get pod web-app-86f4c7fbcd-2xsf2 \
  -o jsonpath='{.status.containerStatuses[0].restartCount}{"\n"}'

결과:

plain
47

Argo CD Events에 보였던 Started 48과도 대략 맞아떨어졌다. 최초 실행 1회와 재시작 47회로 해석할 수 있었다. 하지만 여기서 47번 모두 OOM이었다고 단정하지 않았다. restartCount는 재시작 횟수만 말해주고, 각 재시작의 원인을 모두 보존해주는 값은 아니기 때문이다.

마지막 종료 원인은 확실히 OOMKilled였다

다음으로 Kubernetes가 보존하고 있는 lastState를 확인했다.

bash
sudo kubectl get pod web-app-86f4c7fbcd-2xsf2 \
  -o jsonpath='reason={.status.containerStatuses[0].lastState.terminated.reason} exitCode={.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'

결과:

plain
reason=OOMKilled exitCode=137

이 결과로 가장 최근 재시작 직전 컨테이너가 메모리 부족으로 종료됐다는 사실은 확인할 수 있었다. Kubernetes의 memory limit은 Linux cgroup을 통해 적용되고, 제한을 초과한 메모리 사용은 OOM kill로 이어질 수 있다. Kubernetes 공식 문서의 예시에서도 lastState.terminated.reason=OOMKilled, exitCode=137 형태로 확인한다.

그런데 현재 메모리는 64MiB밖에 안 썼다

현재 컨테이너 사용량을 확인하면 오히려 평온했다.

bash
sudo kubectl top pod web-app-86f4c7fbcd-2xsf2 --containers
plain
CPU     3m
MEMORY  64Mi

Deployment의 memory limit은 512Mi였다.

yaml
resources:
  requests:
    cpu: 50m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

즉 다음 두 사실이 동시에 존재했다.

plain
현재 사용량        약 64MiB
마지막 종료 원인   OOMKilled

이건 모순이 아니다. OOM은 현재 평균 사용량이 아니라 특정 순간의 spike 또는 누적 증가 때문에 발생할 수 있다. 컨테이너가 재시작되면 메모리는 다시 초기 상태로 돌아가기 때문에 현재 kubectl top만 보면 과거 peak를 놓칠 수 있다.

이번 조사에서 가장 먼저 배운 점은 "지금 60MiB니까 메모리 문제는 아니다"라고 판단하면 안 된다는 것이었다. 현재값, peak, 종료 상태를 각각 따로 봐야 했다.

이전 컨테이너 로그에서 예상하지 못한 셸 명령을 발견했다

OOM으로 종료된 이전 컨테이너 로그를 확인했다.

bash
sudo kubectl logs web-app-86f4c7fbcd-2xsf2 --previous | tail -100

Next.js는 정상적으로 기동돼 있었다.

plain
▲ Next.js 15.5.6
✓ Starting...
✓ Ready in 1312ms

그런데 뒤이어 다음 오류가 반복됐다.

plain
cat: can't open '/tmp/.XIN-unix/config.json': No such file or directory
Error: Command failed: cat /tmp/.XIN-unix/config.json |grep user

next start 자체가 일반적으로 이런 파일을 읽기 위해 cat | grep 셸 명령을 실행할 이유는 없다. 그렇다고 이 로그 하나만 보고 곧바로 "서버가 해킹됐다"고 결론 내릴 수도 없었다. 그래서 가능성을 두 갈래로 나눴다.

핵심은 침해 여부를 확정하기 전에, 침해됐을 경우 피해가 커지는 경로부터 차단하는 것이었다.

커널 OOM 로그에서 직접 원인이 보였다

lastState=OOMKilled만으로는 왜 512MiB를 넘었는지 알 수 없었다. 그래서 K3s 노드의 커널 OOM 로그를 확인했다.

bash
sudo journalctl -k \
  --since "2026-09-07 15:00" \
  --until "2026-09-08 10:00" | \
  grep -Ei 'oom|out of memory|killed process|memory cgroup'

여기서 이전 Pod의 cgroup 안에 Next.js와 무관해 보이는 프로세스들이 반복적으로 등장했다.

plain
znlefox6ak03      anon-rss ≈ 359MiB
18874ubrdjc3      anon-rss ≈ 371MiB
wu6paambrhg4      anon-rss ≈ 365MiB
app_2101291602    anon-rss ≈ 325MiB

반면 같은 시점의 next-server는 대체로 수십 MiB의 anonymous RSS를 사용하고 있었다. 즉 구조는 다음에 가까웠다.

plain
정상 Next.js / npm / node 프로세스
        +
정체불명 프로세스 약 300~380MiB
        ↓
Pod memory limit 512MiB 초과
        ↓
Memory cgroup OOM
        ↓
컨테이너 프로세스 종료 / 재시작

특히 랜덤한 이름뿐 아니라 app_2101291602, .php-fpm161, mdmon827처럼 애플리케이션과 관계없는 이름이 보였고, 같은 OOM 시점에 sh, grep도 함께 등장했다. 이전 로그에서 발견했던 cat /tmp/.XIN-unix/config.json | grep user와도 일관된 흐름이었다.

여기서 OOMKilled의 직접 원인은 비정상 프로세스가 컨테이너 메모리를 대량 사용해 512MiB cgroup limit을 초과한 것으로 확인했다. 다만 이 프로세스가 정확히 어떤 악성코드였는지, 최초 진입점이 React2Shell이었는지까지는 남아 있는 증거만으로 확정하지 않았다.

그래서 해결은 memory limit 상향이 아니라 패치와 컨테이너 교체였다

원인을 보기 전이라면 512MiB → 1GiB처럼 limit을 올리고 싶은 유혹이 있다. 하지만 이번 경우에는 오히려 비정상 프로세스에 더 많은 메모리를 제공하는 결과가 될 수 있었다. 따라서 해결 방향은 다음과 같이 잡았다.

  1. 취약한 런타임 제거 — Next.js 15.5.6 → 15.5.25
  2. 기존 Pod 폐기 — 기존 컨테이너의 writable layer와 /tmp에 남아 있을 수 있는 파일까지 함께 제거
  3. 새 이미지로 재빌드·재배포 — 기존 컨테이너를 수리하지 않고 깨끗한 image에서 다시 시작
  4. memory limit 512MiB 유지 — limit 자체를 원인으로 오해하지 않고 격리 장치로 유지
  5. 새 Pod 검증 — 의심 로그 재발 여부, restart, cgroup oom_kill, 실제 Next.js 프로세스 메모리를 확인 패치 후 새 Pod에서는 oom_kill=0이었고, 실제 Next.js 런타임 로그도 rss 약 79~82MiB, heapUsed 약 26~30MiB 수준으로 안정적이었다.

침해 범위도 따로 확인했다

컨테이너 내부에서 임의 프로세스 실행 가능성이 높아졌기 때문에 OOM이 해결됐다는 이유로 조사를 끝내지 않았다. 호스트와 Kubernetes 쪽으로 확장됐는지 확인했다.

  • 현재 호스트에서 XIN-unix, xmrig, javae, kinsing 계열 프로세스: 발견되지 않음
  • cron persistence: 발견되지 않음 — 검색 결과는 정상 netdata-updater
  • 최근 systemd 변경: k3s.service, k3s.service.env만 확인
  • default:default ServiceAccount: Kubernetes 리소스 조회·수정 권한이 사실상 없고 API discovery/self review 수준
  • Deployment의 env, envFrom, secretKeyRef: 없음 다만 이전 Pod에는 kube-api-access-* projected volume이 실제로 마운트돼 있었다. 앱이 Kubernetes API를 사용하지 않으므로 후속 hardening으로 아래 설정을 적용할 예정이다.
yaml
spec:
  template:
    spec:
      automountServiceAccountToken: false

즉 현재 증거에서는 컨테이너 내부의 비정상 실행은 강하게 확인됐지만, 호스트까지 탈출하거나 Kubernetes 권한으로 확장됐다는 증거는 발견하지 못했다.

실행 중인 Next.js가 15.5.6이었다

Pod 안에서 실제 런타임 버전을 확인했다.

bash
sudo kubectl exec <pod> -- node -p 'require("next/package.json").version'

결과:

plain
15.5.6

Next.js App Router의 React Server Components 계열에는 과거 원격 코드 실행 취약점이 공개된 적이 있고, Next.js 공식 보안 공지도 패치되지 않은 환경에서 attacker-controlled request 처리 시 RCE가 가능하다고 설명한다.

먼저 서비스를 격리했다 — 그런데 Argo CD가 다시 살려냈다

Deployment를 0으로 줄였다.

bash
sudo kubectl scale deployment web-app --replicas=0

그런데 잠시 후 Pod가 다시 생성됐다. 원인은 이미 만들어둔 GitOps 구조였다.

plain
Git의 desired state  replicas: 2
        ↓
Argo CD Auto-Sync + Self Heal
        ↓
수동으로 replicas: 0
        ↓
Drift 감지
        ↓
다시 replicas: 2

평상시에는 이게 원하는 동작이다. 누군가 클러스터 상태를 수동으로 변경해도 Git 기준으로 복구된다. 하지만 사고 대응 중에는 자동 복구가 격리 조치를 되돌릴 수도 있다. 그래서 Auto-Sync를 먼저 끄고 다시 scale down 했다.

bash
sudo kubectl patch application ddongmy-os -n argocd \
  --type=json \
  -p='[{"op":"remove","path":"/spec/syncPolicy/automated"}]'

sudo kubectl scale deployment web-app --replicas=0

이 경험으로 GitOps의 selfHeal은 단순 편의 기능이 아니라 운영 상태를 강제로 Git에 수렴시키는 제어 루프라는 걸 체감했다.

긴급 패치와 정식 의존성 업데이트를 분리했다

문제는 package.json이 다음처럼 느슨하게 잡혀 있었다는 점이다.

json
"next": "^15.0.0"

lockfile에는 실제로 15.5.6이 고정돼 있었다. 보안 대응에서는 우선 새 이미지에 패치 버전이 확실히 들어가는 것이 먼저였다. 연결된 환경에서 lockfile을 바로 재생성하기 어려웠기 때문에, 첫 단계에서는 Dockerfile의 deps stage에 긴급 패치를 넣었다.

docker
RUN npm ci \
  && npm install --no-save --package-lock=false next@15.5.25

이 방식은 장기적으로 좋은 구조가 아니다. package.jsonpackage-lock.json이 말하는 버전과 실제 image 내부 버전이 달라지기 때문이다. 그래서 이것을 긴급 대응용 임시 조치로 명확히 구분했다.

plain
1차: 실제 image를 빠르게 15.5.25로 패치
2차: package.json / package-lock.json을 15.5.25로 정식 갱신
3차: Dockerfile의 임시 덮어쓰기 제거

배포 전에 "이미지 안의 버전"부터 검증했다

GitHub Actions가 새 이미지를 GHCR에 push했다고 바로 서비스에 올리지는 않았다. 기존 Pod는 이미 내려둔 상태였고 Argo Auto-Sync도 꺼둔 상태였다. 먼저 새 이미지를 임시 Pod로 한 번 실행해 버전을 확인했다.

bash
sudo kubectl run next-verify \
  --image=ghcr.io/dongmin3891/ddongmy-os:4cd38cd7f45592492f8e3e4ba208af6b30b83ad0 \
  --restart=Never \
  --rm -i \
  --command -- node -p 'require("next/package.json").version'

결과:

plain
15.5.25

여기서 중요한 점은 Git commit, Docker build 성공, 실제 image 내용은 각각 별도의 검증 대상이라는 것이다.

Auto-Sync를 끈 채 수동으로 새 이미지를 배포했다

Git에는 새 SHA가 반영됐지만 Auto-Sync를 꺼둔 상태였기 때문에 직접 Sync를 실행했다.

bash
sudo kubectl patch application ddongmy-os -n argocd \
  --type merge \
  -p '{"operation":{"sync":{"revision":"HEAD"}}}'

새 Pod 두 개가 정상 기동했다.

plain
READY   STATUS    RESTARTS
1/1     Running   0
1/1     Running   0

실행 중인 Pod에서도 다시 버전을 확인했다.

plain
Next.js 15.5.25

그리고 이전 로그에 나타났던 패턴을 다시 검색했다.

bash
sudo kubectl logs -l app=web --prefix=true --tail=300 | \
grep -E 'XIN-unix|config\.json|Command failed|child_process|exec'

새 Pod에서는 해당 패턴이 나타나지 않았다. 이 결과는 새 버전에서 현재 같은 증상이 재현되지 않았다는 의미이지, 과거 Pod가 실제로 침해됐는지 여부를 확정해주는 증거는 아니다.

cgroup에서 현재값뿐 아니라 peak와 OOM event도 확인했다

새 Pod의 cgroup v2 정보를 직접 확인했다.

bash
sudo kubectl exec "$POD" -- sh -c '
echo "current:"; cat /sys/fs/cgroup/memory.current
echo "peak:"; cat /sys/fs/cgroup/memory.peak
echo "limit:"; cat /sys/fs/cgroup/memory.max
echo "events:"; cat /sys/fs/cgroup/memory.events
'

결과:

plain
current: 61362176
peak:    74043392
limit:   536870912

events:
max 0
oom 0
oom_kill 0

MiB로 보면 대략 다음과 같았다.

항목의미
`memory.current`약 58.5MiB현재 cgroup 전체 메모리
`memory.peak`약 70.6MiB현재 Pod lifetime의 최대 사용량
`memory.max`512MiB컨테이너 memory limit
`oom_kill`0새 Pod에서는 아직 OOM 없음

패치 이후 새 Pod에서는 적어도 초반부터 메모리가 limit에 접근하는 모습은 없었다.

node -e로 본 메모리는 Next.js 메모리가 아니었다

6편에서 Web Terminal을 붙인 뒤 다음 명령으로 V8 값을 확인한 적이 있었다.

bash
node -e 'const v8=require("v8"),m=process.memoryUsage(); ...'

여기서 heapLimit=259MiB는 같은 컨테이너/cgroup 조건에서 새 Node 프로세스가 보는 V8 limit을 파악하는 데는 유용했다. 하지만 heapUsed, heapTotal, rss새로 실행한 진단용 Node 프로세스의 값이었다. 이미 동작 중인 Next.js 서버의 heap을 보여주는 것이 아니었다. 그래서 실제 프로세스를 먼저 확인했다.

bash
sudo kubectl exec "$POD" -- ps -o pid,ppid,rss,vsz,args
plain
PID  PPID  RSS  VSZ  COMMAND
1    0     60m  689m npm start
19   1     83m  10g  next-server (v15.5.25)

/proc/19/status도 확인했다.

plain
VmRSS:    87328 kB
RssAnon:  40144 kB
RssFile:  47184 kB
VmSwap:       0 kB
Threads:     11

여기까지는 실제 Next.js 프로세스의 RSS를 볼 수 있다. 하지만 /proc만으로는 V8 내부의 heapUsed, heapTotal, external을 알 수 없다.

결국 Next.js 프로세스 안에서 직접 메모리를 찍었다

정확한 값을 얻으려면 **실행 중인 Next.js 프로세스 자신의 ****process.memoryUsage()**를 호출해야 했다. 기존 /api/health Route Handler에 관측 코드를 넣되, API 응답에는 메모리 정보를 노출하지 않고 서버 로그에만 남기도록 했다. 또 health probe가 5~20초마다 호출되기 때문에 로그가 과도하게 쌓이지 않도록 약 30초 단위로 제한했다. 응답은 기존처럼 유지했다.

json
{"status":"ok"}

서버 로그에는 다음 값만 주기적으로 기록했다.

plain
[memory] rss=...
         heapUsed=...
         heapTotal=...
         external=...
         arrayBuffers=...
         heapLimit=...

실제 두 Pod에서 확인된 초기 값은 다음과 같았다.

plain
rss          79 ~ 82MiB
heapUsed     26 ~ 30MiB
heapTotal    31 ~ 33MiB
external      3MiB
arrayBuffers  0MiB
heapLimit    259MiB

즉 드디어 진단용 별도 Node 프로세스가 아니라 실제 요청을 처리하는 Next.js 서버의 V8 heap 상태를 볼 수 있게 됐다.

패치 직후 기준에서는 heapUsed 26~30MiB, rss 79~82MiB로 안정적이었다. 하지만 이 몇 개의 초기 샘플만으로 과거 OOM 원인이 해결됐다고 결론 내리지는 않았다. 이후 볼 것은 절대값보다 시간이 지날수록 baseline이 계속 상승하는지, 특정 요청 뒤 peak가 커지는지, 재시작 없이 회수되는지다.

긴급 패치를 정식 dependency 상태로 되돌렸다

런타임 계측이 정상 동작하는 것을 확인한 다음 임시 조치를 정리했다. npm install next@15.5.25 --save-exact을 실행해 최종적으로 다음처럼 고정했다.

json
"next": "15.5.25"

package-lock.json도 같은 버전으로 갱신했다. 이 작업을 위해 사용한 one-time GitHub Actions workflow는 작업 후 삭제했다. 그 다음 Dockerfile에서 긴급 패치 코드를 제거했다.

docker
# Before
RUN npm ci \
  && npm install --no-save --package-lock=false next@15.5.25

# After
RUN npm ci

6편에서 Argo CD Web Terminal 셸 테스트를 위해 임시로 추가했던 bash도 함께 제거했다.

docker
# 제거
RUN apk add --no-cache bash

Alpine의 기본 /bin/sh만으로 운영과 probe에는 충분했고, 테스트용 도구를 production image에 계속 남겨둘 이유가 없었다. 최종 정리 이미지를 다시 Build → GHCR push → 수동 Sync 한 뒤 실행 중인 Pod에서 다시 확인했다.

plain
Next.js 15.5.25

마지막으로 GitOps 자동 복구를 다시 켰다

보안 패치, 메모리 계측, Dockerfile 정리까지 끝난 뒤 사고 대응을 위해 잠시 꺼뒀던 Auto-Sync를 원래대로 복구했다.

bash
sudo kubectl patch application ddongmy-os -n argocd \
  --type merge \
  -p '{"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true}}}}'

확인 결과:

json
{"prune":true,"selfHeal":true}

즉 운영 구조는 다시 원래의 GitOps 상태로 돌아왔다.

plain
main push
   ↓
GitHub Actions
   ↓
GHCR immutable SHA image
   ↓
Git manifest image SHA update
   ↓
Argo CD Auto-Sync / Self Heal
   ↓
K3s RollingUpdate

새 Pod의 Startup Probe 1회 실패는 장애가 아니었다

최종 rollout 후 Argo CD Events에 빨간 Unhealthy 이벤트가 하나 남았다.

plain
Startup probe failed:
Get "http://10.42.0.88:3000/api/health":
connect: connection refused
COUNT: 1

처음 보면 다시 장애처럼 보이지만, 발생 시점은 container가 막 시작된 직후였고 이후 추가 실패는 없었다. 현재 startupProbe에는 initialDelaySeconds가 없기 때문에 kubelet이 빠르게 /api/health를 호출할 수 있다. Next.js가 아직 port 3000을 listen하기 전이면 첫 요청은 connection refused가 될 수 있다. 설정은 다음과 같았다.

yaml
startupProbe:
  httpGet:
    path: /api/health
    port: 3000
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 12

최대 약 60초 동안 기동을 기다릴 수 있고, 실제로는 1회 실패 뒤 Ready 상태가 유지됐기 때문에 별도 설정 변경은 하지 않았다. 이 역시 "빨간 Event가 있다"가 아니라 발생 횟수, 마지막 발생 시각, 현재 Health를 같이 봐야 한다는 사례였다.

이번에 확정할 수 있는 것과 없는 것

판단현재 증거
마지막 구버전 Pod 종료는 OOM이었다**확정** — `OOMKilled / 137`
과거 47회 재시작이 전부 OOM이었다**확정 불가**
이전 Pod에서 예상하지 못한 shell command가 실행됐다**확인됨** — previous log
그 shell command가 외부 공격 때문이었다**확정 불가**
실행 버전이 Next.js 15.5.6이었다**확정**
새 image / Pod는 Next.js 15.5.25다**확정** — image와 runtime 모두 확인
새 Pod에서 같은 shell command가 다시 나타났다**현재는 재현되지 않음**
새 Pod에서 OOM이 다시 발생했다**현재는 없음** — cgroup `oom_kill=0`
OOM의 직접 원인이 비정상 프로세스의 메모리 사용이었다**확인됨** — 커널 memory cgroup OOM 로그에서 랜덤/비정상 프로세스가 약 300\~380MiB 사용
정확한 악성코드 종류와 최초 침투 경로가 무엇이었다**확정 불가** — React2Shell 가능성은 높지만 결정적 진입 로그는 없음
호스트 또는 Kubernetes 리소스까지 침해가 확장됐다**현재 증거 없음** — persistence 미발견, default ServiceAccount 권한 제한적

이번 장애에서 가장 크게 배운 것

1. 현재 상태와 장애 이력은 다르다

Pod가 지금 초록색이어도 lastState, restartCount, previous log에는 중요한 단서가 남아 있을 수 있다.

2. kubectl top 한 장면만으로 메모리를 판단하면 안 된다

메모리 조사는 최소한 세 층으로 나눠봐야 했다.

plain
Container / cgroup
- memory.current
- memory.peak
- memory.events

OS Process
- /proc/<pid>/status
- VmRSS / RssAnon / RssFile

Node.js / V8
- process.memoryUsage()
- heapUsed / heapTotal / external
- heap_size_limit

각 지표가 보는 범위가 다르기 때문에 하나를 다른 하나처럼 해석하면 안 된다.

3. 사고 대응 중에는 자동화도 제어 대상이다

Argo CD selfHeal은 평상시에는 안정성을 높였지만, scale-to-zero 격리 조치도 되돌렸다. 자동화가 강할수록 비상시 끄는 방법도 알고 있어야 한다.

4. 보안 사고 가능성과 메모리 문제를 억지로 하나의 원인으로 묶지 않았다

OOMKilled, 취약한 Next.js 버전, 의심스러운 shell command가 같은 Pod에서 발견됐지만 이것만으로 인과관계가 증명되지는 않는다. 그래서 대응은 보수적으로 했지만 결론은 보수적으로 남겼다.

5. Git에 적힌 버전보다 실제 image와 runtime을 확인해야 한다

이번에는 package.json → lockfile → Docker image → running Pod를 모두 확인했다. 특히 긴급 Dockerfile patch처럼 중간 단계가 생기면 소스의 dependency 선언과 실제 런타임 버전이 달라질 수 있다.

6. 임시 진단 도구는 목적이 끝나면 제거한다

Argo CD Terminal 확인을 위해 넣었던 bash, 보안 대응을 위해 넣었던 Dockerfile의 Next.js 덮어쓰기는 모두 최종 image에서 제거했다.

현재 운영 상태

plain
Next.js              15.5.25
Replicas              2
RollingUpdate         정상
Restart               0
Memory limit          512MiB
V8 heap limit         259MiB
초기 heapUsed         약 26~30MiB
초기 RSS              약 79~82MiB
cgroup OOM event      0
메모리 로그           /api/health 호출을 이용해 주기적으로 기록
Argo CD Auto-Sync     ON
Prune                 ON
Self Heal             ON

다음에 볼 것

OOMKilled의 직접 원인과 대응 방향은 정리됐다. 이제는 재발 여부를 관찰하면서 공격 표면을 더 줄이는 단계다. 우선 애플리케이션 Pod에 필요 없는 ServiceAccount token 자동 마운트를 끄고, 기존의 런타임 메모리 로그와 cgroup 지표를 유지해 비정상 프로세스나 메모리 급증이 다시 나타나는지 확인한다.

  • [memory] 로그의 heapUsed baseline이 장기적으로 상승하는지
  • rss만 증가하고 heap이 안정적인지
  • external 또는 arrayBuffers가 증가하는지
  • memory.peak가 특정 요청 이후 크게 뛰는지
  • Pod restartCount가 다시 증가하는지
  • 이전의 XIN-unix / Command failed 패턴이 재발하는지 이 값들이 안정적으로 유지된다면 "패치 이후에는 현상이 재현되지 않는다"는 증거가 쌓인다. 반대로 다시 증가한다면 그때는 메모리 로그 시점과 요청/트래픽을 맞춰 어떤 workload가 할당을 증가시키는지로 조사 범위를 좁힐 수 있다.

이번 작업에서 가장 중요한 변화는 OOM을 한 번 고친 것이 아니라, 다음 OOM이 생겼을 때 이전보다 훨씬 많은 증거를 남길 수 있게 된 것이다. lastState → previous logs → cgroup → process → V8 heap 순서로 관측 경로가 생겼고, 보안 이슈가 의심될 때 GitOps 자동화까지 포함해 서비스를 격리하고 검증된 이미지로 복구하는 절차도 경험했다.