본문 바로가기
삵
Cloud Computing & MSA

노드가 DiskPressure로 파드를 축출할 때

oopennova·2026년 10월 21일·조회 0

얼마 전에도 비슷한 걸로 호출을 받았다. 멀쩡히 돌던 파드가 한밤중에 Evicted 상태로 줄줄이 떨어지고, 노드를 보면 DiskPressure=True가 찍혀 있다. 재시작하면 잠깐 괜찮아졌다가 며칠 뒤 똑같이 재발한다. 디스크를 먹는 범인이 로그인지 emptyDir인지 이미지 레이어인지 안 짚고 넘어가면 이 사이클은 끝나지 않는다. 그 추적 방법을 정리한다.

결론부터 말하자면, DiskPressure 축출은 kubelet이 노드 디스크가 임계치 아래로 떨어진 걸 감지하고 파드를 강제로 내리는 동작이다. 범인은 거의 컨테이너 쓰기 레이어, 컨테이너 로그, emptyDir 셋 중 하나다. kubectl describe node로 어느 파일시스템이 찼는지 보고, 노드에 들어가 crictl로 어느 컨테이너와 이미지가 먹는지 짚은 뒤, ephemeral-storage 요청과 제한을 걸어 재발을 막는다. 아래에서 신호 정의부터 추적, 설정까지 차례로 살펴본다.

1. DiskPressure가 뭐고 언제 켜지나

kubelet은 노드 디스크를 주기적으로 들여다보면서 몇 가지 축출 신호(eviction signal)를 감시한다. 신호가 정해진 임계치 아래로 내려가면 노드에 DiskPressure=True 조건을 달고, 필요하면 파드를 축출한다.

디스크와 관련된 신호는 크게 두 파일시스템을 본다. nodefs는 kubelet이 쓰는 노드 기본 파일시스템으로 파드 로그와 emptyDir이 여기 올라간다. imagefs는 컨테이너 런타임이 이미지와 쓰기 레이어를 저장하는 파일시스템이다. 런타임 설정에 따라 둘이 같은 디스크일 수도, 분리돼 있을 수도 있다.

  • nodefs.available / nodefs.inodesFree - 노드 파일시스템의 남은 용량과 inode
  • imagefs.available / imagefs.inodesFree - 이미지 파일시스템의 남은 용량과 inode

kubelet의 기본 하드 축출 임계치는 다음과 같다. 이 값 아래로 떨어지면 유예 시간 없이 바로 축출이 시작될 수 있다.

evictionHard:
  memory.available:  "100Mi"
  nodefs.available:  "10%"
  nodefs.inodesFree: "5%"
  imagefs.available: "15%"
  imagefs.inodesFree: "5%"

순서가 중요하다. kubelet은 파드를 내리기 전에 먼저 스스로 디스크를 회수하려 한다. 사용하지 않는 이미지를 지우고(image GC), 죽은 컨테이너를 정리한다. 그래도 임계치를 못 넘기면 그때 파드를 축출한다. 축출 대상은 QoS 등급 순서를 탄다. BestEffort가 가장 먼저, Burstable이 다음, Guaranteed가 마지막이다. 같은 등급 안에서는 우선순위가 낮고 해당 자원(디스크)을 많이 쓰는 파드가 먼저 내려간다.

2. 어느 파일시스템이 찼는지부터 확인

축출이 QoS 때문에 애먼 파드를 먼저 때릴 수 있어서, 증상만 보고 범인을 특정하면 헛짚는다. 먼저 노드 상태를 본다.

$ kubectl get nodes
NAME            STATUS                     ROLES    AGE   VERSION
worker-02   Ready,SchedulingDisabled   <none>   87d   v1.31.4

$ kubectl describe node worker-02

출력에서 두 군데를 본다. 하나는 Conditions의 DiskPressure, 다른 하나는 아래쪽 Events다.

Conditions:
  Type             Status    Reason                Message
  ----             ------    ------                -------
  DiskPressure     True      KubeletHasDiskPressure kubelet has disk pressure
  ...

Events:
  Type     Reason                Age    From     Message
  ----     ------                ----   ----     -------
  Warning  EvictionThresholdMet  2m     kubelet  Attempting to reclaim ephemeral-storage
  Warning  ImageGCFailed         1m     kubelet  failed to garbage collect required amount of images
  Normal   NodeHasDiskPressure   1m     kubelet  Node worker-02 status is now: NodeHasDiskPressure

메시지의 Attempting to reclaim ephemeral-storage는 nodefs 쪽이 찼다는 뜻이고, Attempting to reclaim imagefs면 이미지 파일시스템 쪽이다. 여기서 nodefs인지 imagefs인지만 갈라도 추적 범위가 절반으로 준다. 축출된 파드는 이렇게 사유가 남는다.

$ kubectl get pod -A | grep Evicted
default   batch-worker-7c9f8-abcde   0/1   Evicted   0   13m

$ kubectl describe pod batch-worker-7c9f8-abcde
Status:   Failed
Reason:   Evicted
Message:  The node was low on resource: ephemeral-storage.
          Container worker was using 6402Mi, request is 0, which exceeds its request of 0.

마지막 줄이 핵심이다. request is 0, 즉 ephemeral-storage 요청을 안 건 파드는 kubelet 눈에 디스크를 공짜로 쓰는 것으로 보여서 축출 1순위가 된다. 요청을 안 걸어두면 정작 범인이 아니어도 먼저 맞는다.

3. 노드에 들어가 crictl로 범인 짚기

어느 파일시스템인지 알았으면 노드에 들어가 실제로 뭐가 먹는지 본다. Docker 없는 요즘 노드(containerd, CRI-O)에서는 crictl을 쓴다. 먼저 노드 디스크 전체 그림부터 본다.

$ df -h /var/lib/kubelet /var/lib/containerd
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p1  100G   94G  6.0G  95% /

여기서 한 번 걸렸다. nodefs와 imagefs가 같은 루트 디스크라 df만 봐서는 안 갈라진다. 그래서 세 범인을 각각 따로 센다.

이미지 레이어

crictl로 이미지 파일시스템 사용량과 이미지 목록을 본다.

$ sudo crictl imagefsinfo
{
  "status": {
    "timestamp": "...",
    "fsId": { "mountpoint": "/var/lib/containerd/..." },
    "usedBytes": { "value": "48213557248" }
  }
}

$ sudo crictl images
IMAGE                         TAG       IMAGE ID       SIZE
registry.local/batch-worker   <none>    8f1a2b3c4d5e   1.9GB
registry.local/batch-worker   <none>    7e0f1a2b3c4d   1.9GB
registry.local/api            v142      a1b2c3d4e5f6   820MB

태그가 <none>인 dangling 이미지가 여러 개 쌓여 있으면 image GC가 못 따라잡고 있다는 신호다. CI가 매 커밋마다 태그를 밀어넣는 레지스트리에서 자주 본다. 안 쓰는 이미지를 정리한다.

$ sudo crictl rmi --prune
Deleted: registry.local/batch-worker@sha256:8f1a...
Deleted: registry.local/batch-worker@sha256:7e0f...

kubelet 자동 정리를 쓰려면 imageGCHighThresholdPercent(기본 85)와 imageGCLowThresholdPercent(기본 80)를 조정한다. 디스크가 85%를 넘으면 GC가 돌아 80%까지 내린다. 디스크가 작은 노드면 이 값을 70/60 정도로 낮춰 더 일찍 돌게 한다.

컨테이너 쓰기 레이어

컨테이너가 자기 쓰기 레이어에 파일을 쏟아내는 경우다. 임시 파일을 /tmp에 쌓고 안 지우는 배치 작업이 흔한 예다. crictl stats로 컨테이너별 쓰기 레이어 사용량을 본다.

$ sudo crictl stats
CONTAINER     CPU %   MEM      DISK      INODES
3f2a1b0c9d8e  2.14    412MB    6.1GB     214
9d8e7f6a5b4c  0.03    88MB     12MB      41

DISK 열이 쓰기 레이어 사용량이다. 6GB를 혼자 쓰는 컨테이너가 보이면 그 파드를 crictl inspect로 역추적한다.

$ sudo crictl inspect 3f2a1b0c9d8e | grep -i -E 'io.kubernetes.pod.(name|namespace)'
        "io.kubernetes.pod.name": "batch-worker-7c9f8-abcde",
        "io.kubernetes.pod.namespace": "default",

컨테이너 로그

로그는 nodefs의 /var/log/pods 아래에 쌓인다. 애플리케이션이 stdout으로 로그 폭탄을 쏟으면 여기가 순식간에 찬다.

$ sudo du -sh /var/log/pods/* | sort -rh | head -5
7.8G  /var/log/pods/default_batch-worker-7c9f8-abcde_...
240M  /var/log/pods/kube-system_...
18M   /var/log/pods/default_api-6b7c...

kubelet은 containerLogMaxSize(기본 10Mi)와 containerLogMaxFiles(기본 5)로 로그를 돌린다. 기본값이면 컨테이너당 로그는 50Mi 선에서 묶여야 한다. 그런데 위처럼 7.8G가 나오면 로그 회전이 안 먹고 있다는 뜻이다. 대개 애플리케이션이 kubelet 로그 파일이 아니라 자기 손으로 컨테이너 안 경로에 로그를 쓰고 있는 경우다. 그건 쓰기 레이어나 emptyDir로 잡힌다.

4. ephemeral-storage 요청과 제한으로 재발 막기

범인을 지웠어도 설정을 안 바꾸면 또 찬다. ephemeral-storage에 요청과 제한을 거는 게 재발 방지의 핵심이다. 이 자원은 컨테이너 쓰기 레이어, 로그, emptyDir 세 가지를 합산해서 센다. 2절에서 축출 1순위가 됐던 request is 0 문제를 여기서 푼다.

apiVersion: v1
kind: Pod
metadata:
  name: batch-worker
spec:
  containers:
  - name: worker
    image: registry.local/batch-worker:v142
    resources:
      requests:
        ephemeral-storage: "2Gi"
      limits:
        ephemeral-storage: "4Gi"

요청(requests.ephemeral-storage)은 스케줄러가 이 파드를 어느 노드에 올릴지 계산할 때 쓴다. 요청 합이 노드 용량을 넘으면 거기 스케줄 안 한다. 제한(limits.ephemeral-storage)은 상한이다. 컨테이너가 제한을 넘기면 kubelet이 그 파드만 축출한다. 노드 전체가 DiskPressure로 가기 전에 범인 파드 하나만 먼저 내려가므로, 다른 파드가 애꿎게 휩쓸리는 걸 막는다.

emptyDir에는 별도로 상한을 걸 수 있다. 캐시나 임시 파일 용도로 emptyDir을 쓰면 sizeLimit을 반드시 지정한다. 넘으면 그 파드가 축출된다.

    volumeMounts:
    - name: scratch
      mountPath: /scratch
  volumes:
  - name: scratch
    emptyDir:
      sizeLimit: "1Gi"

네임스페이스 단위로 강제하려면 LimitRange로 기본 요청과 제한을 박아둔다. 개발자가 ephemeral-storage를 깜빡해도 자동으로 값이 들어가서 request is 0 파드가 애초에 안 생긴다.

apiVersion: v1
kind: LimitRange
metadata:
  name: ephemeral-storage-defaults
spec:
  limits:
  - type: Container
    default:
      ephemeral-storage: "2Gi"
    defaultRequest:
      ephemeral-storage: "1Gi"

5. 주의사항

제한을 걸 때 값을 너무 빡빡하게 잡으면 정상 작업까지 축출된다. 축출 메시지의 실제 사용량(was using 6402Mi)을 보고 피크의 1.5배쯤 여유를 둔다.

이미 축출된 파드 객체는 자동으로 안 사라진다. Deployment나 Job이 새 파드를 재생성해 서비스는 복구되지만, Evicted 껍데기는 쌓여서 kubectl 출력을 지저분하게 만든다. 주기적으로 정리한다.

$ kubectl get pods -A --field-selector status.phase=Failed -o name \
    | xargs -r kubectl delete

nodefs와 imagefs가 같은 디스크인 단일 파일시스템 노드에서는 로그와 emptyDir이 이미지 공간까지 같이 잡아먹는다. 이미지만 열심히 지워도 로그가 계속 차면 DiskPressure가 안 풀린다. 2절에서 어느 파일시스템인지 가른 결과를 믿고, nodefs가 범인이면 로그와 emptyDir부터 보는 게 맞다.

자주 묻는 질문

파드가 Evicted 됐는데 왜 같은 노드에서 또 뜨나요?

Deployment나 StatefulSet 같은 컨트롤러가 선언한 복제 수를 맞추려고 파드를 재생성하기 때문이다. 축출의 근본 원인인 디스크 포화를 해결하지 않으면, 재생성된 파드가 다시 디스크를 채워 똑같이 축출된다. ephemeral-storage 제한을 걸어 범인 파드 하나만 상한에서 내려가게 하거나, 로그와 이미지 정리로 노드 디스크 자체를 확보해야 사이클이 끊긴다.

nodefs와 imagefs 신호는 어떻게 구분하나요?

nodefs는 kubelet이 쓰는 노드 기본 파일시스템으로 파드 로그와 emptyDir이 올라간다. imagefs는 컨테이너 런타임이 이미지와 쓰기 레이어를 저장하는 파일시스템이다. kubectl describe node의 Events에서 reclaim ephemeral-storage 메시지면 nodefs, reclaim imagefs 메시지면 imagefs 쪽이다. 두 파일시스템이 같은 디스크인 노드도 있어서, 분리 여부는 crictl imagefsinfo의 마운트 경로로 확인한다.

ephemeral-storage 제한을 넘기면 노드 전체가 DiskPressure로 가나요?

아니다. 컨테이너가 자기 limits.ephemeral-storage를 넘기면 kubelet이 그 파드 하나만 축출한다. 노드 전체가 임계치 아래로 떨어지는 DiskPressure 축출과는 다른 경로다. 오히려 개별 제한을 걸어두면 범인 파드가 노드를 꽉 채우기 전에 먼저 내려가므로, 다른 파드가 휩쓸리는 광역 축출을 예방하는 효과가 있다.

crictl이 없거나 Docker 노드면 어떻게 하나요?

crictl은 CRI 호환 런타임(containerd, CRI-O)과 통신하는 도구로, 요즘 쿠버네티스 노드에 기본 포함돼 있다. 설정 경로를 못 잡으면 crictl --runtime-endpoint unix:///run/containerd/containerd.sock 처럼 소켓을 직접 지정한다. 오래된 Docker 노드라면 docker system df와 docker ps -s로 같은 정보를 볼 수 있지만, 쿠버네티스 1.24부터 dockershim이 제거됐으므로 현행 노드에서는 crictl이 맞다.

컨테이너 로그가 containerLogMaxSize보다 큰데 왜 그런가요?

kubelet의 로그 회전은 stdout과 stderr로 나오는 로그, 즉 /var/log/pods 아래 파일에만 적용된다. 기본값은 파일당 10Mi, 최대 5개다. 애플리케이션이 컨테이너 내부 경로에 직접 로그 파일을 쓰면 kubelet 회전 대상이 아니라서 무한정 커진다. 이 경우 그 사용량은 컨테이너 쓰기 레이어나 emptyDir로 집계되므로, crictl stats와 du로 실제 위치를 찾아야 한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.