얼마 전에도 비슷한 걸로 호출을 받았다. 멀쩡히 돌던 파드가 한밤중에 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- 노드 파일시스템의 남은 용량과 inodeimagefs.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부터 보는 게 맞다.