OCI를 Always Free로 운영할 때 생기는 이슈 — Block Volume VPU 0
Always Free 한도를 맞추려고 Block Volume 성능 등급을 0으로 내렸습니다. 173일 동안 아무 일도 없다가, 클러스터 업그레이드로 파드가 재생성되는 순간 컨테이너 생성이 kubelet 타임아웃을 넘겼습니다. 원인에 도달하기까지 세운 가설 5개와 전부 틀린 이유를 정리했습니다.
Garage는 S3 호환 오브젝트 스토리지인데, Velero 백업 데이터를 저장하는 용도다. 높은 IOPS가 필요하지 않으므로 vpus=0으로 충분하다.
Terminal window
1
ocibvvolumeupdate--volume-id<OCID>--vpus-per-gb0
2026년 2월 5일에 그 명령을 실행했고, 글은 이틀 뒤에 발행했습니다.
173일 뒤인 7월 28일, 그 볼륨이 클러스터를 내렸습니다.
조언이 틀린 것은 아니었습니다. 조건이 빠져 있었습니다. 그 빠진 조건이 무엇이고 어떻게 찾았는지가 이 글의 내용입니다.
먼저, VPU가 뭔가
클라우드 블록 스토리지는 보통 용량을 사면 성능이 딸려옵니다. 100GB를 사면 100GB만큼의 IOPS가 정해지는 식입니다. OCI Block Volume은 그 둘을 분리해서 팝니다. AWS로 치면 gp3에서 용량과 IOPS를 따로 지정하는 것과 같은 구조입니다.
이때 성능 쪽 다이얼이 VPU(Volume Performance Units per GB) 입니다. 이름 그대로 GB당 성능 단위를 몇 개 살지 정하는 값이고, 등급은 이렇게 나뉩니다.
VPU
등급
0
Lower Cost
성능 단위 과금 없음
10
Balanced
기본값
20 이상
Higher Performance / Ultra High Performance
Always Free tier에서 성능 단위 과금을 피하려면 VPU 0이어야 합니다. 5개월 전 글에서 내린 것이 이 값입니다.
여기서 중요한 것이 per GB라는 부분입니다. 실제 성능은 VPU 하나로 정해지지 않고 VPU × 볼륨 크기로 정해집니다. 같은 VPU 0이어도 1,500GB 볼륨은 3,000 IOPS가 나오고, 50GB 볼륨은 100 IOPS가 나옵니다. 등급은 같은데 절대 성능은 30배 차이입니다. 뒤에서 다시 나오지만, 이 사건의 볼륨이 하필 50GB였다는 게 우연이 아닙니다.
그리고 VPU는 볼륨을 재생성하지 않고 온라인으로 바꿀 수 있습니다. 명령 한 줄이면 몇 분 안에 등급이 바뀌고 그동안 서비스는 계속 돕니다. 이 사고를 만든 것도, 90초 만에 푼 것도 같은 성질입니다.
업그레이드는 잘 되고 있었다
Oracle이 OKE v1.33.10의 지원 종료(2026-08-10)를 공지해서, v1.32.1로 돌던 클러스터를 올려야 했습니다. 컨트롤플레인은 세 홉을 거쳐야 했습니다.
Kubernetes 1.35부터 kubelet은 cgroup v1 호스트에서 기동을 거부합니다(KEP-5573). 노드 OS가 Oracle Linux 8.10이라 cgroup v1이었고, 9.7로 올려야 했습니다. 그런데 OKE에서 노드 OS는 제자리에서 갈아끼우는 대상이 아닙니다. 새 노드풀을 만들어 워크로드를 옮기고 구 노드풀을 반납하는 방식입니다.
그래서 “컨트롤플레인 세 홉”으로 끝날 줄 알았던 작업이 노드풀 통째 교체가 됐습니다. 업그레이드 범위를 처음부터 잘못 잡은 셈인데, 그 오산이 아니었다면 173일 동안 한 번도 재생성되지 않은 파드를 이번에도 안 건드렸을 겁니다.
문제는 그 노드 교체 단계에서 터졌습니다. 노드를 drain하자 garage-0 파드가 새 노드에서 뜨지 않았습니다.
증상은 스토리지를 전혀 가리키지 않았다
관측된 에러는 이랬습니다. 어느 것도 “디스크가 느리다”고 말하지 않습니다.
1
Error: context deadline exceeded
2
Error: stream terminated by RST_STREAM with error code: CANCEL
3
warning: error reserving ctr name k8s_garage_garage-0_garage_<uid>_0: name is reserved
파드 상태는 CreateContainerError, 컨테이너 런타임 계층의 에러입니다. 그래서 처음 몇 시간을 컨테이너 런타임과 이미지를 파는 데 썼습니다.
세 번째 줄이 특히 고약합니다. 한 번 이 상태가 되면 그 파드는 영구히 막힙니다. kubelet이 요청을 취소하면서 cri-o가 잡아둔 컨테이너 이름 예약이 해제되지 않고 남고, 같은 이름으로 들어오는 모든 재시도가 name is reserved로 거부됩니다. cri-o 이슈 #8887에 같은 증상이 보고돼 있습니다. 재시도가 쌓일수록 상태가 나빠지지 좋아지지 않는다는 뜻입니다.
이분 탐색이 가설 다섯 개보다 빨랐습니다. 가설은 “무엇이 원인일까”를 묻고 답이 틀리면 처음으로 돌아갑니다. 최소 재현은 “무엇을 빼면 증상이 사라지나”를 묻고 매 시도마다 탐색 공간이 절반이 됩니다. 틀린 가설이 세 개쯤 쌓였을 때가 방식을 바꿀 시점이었는데, 다섯 개까지 갔습니다.
임계점은 VPU가 아니다
여기서 끝냈다면 “VPU 0은 위험하다”는 결론을 냈을 겁니다. 그런데 그건 틀린 결론입니다. 같은 클러스터의 다른 VPU 0 볼륨은 멀쩡했습니다.
먼저 방금 본 반례가 있습니다. meta-garage-0도 50GB, VPU 0인데 20초 만에 떴습니다. 차이는 담긴 것입니다. meta는 garage의 LMDB 메타데이터로 큰 파일 몇 개고, data는 백업 객체 1,871개(88.6 MiB)에 n8n 바이너리입니다.
그리고 복구 다음 날, 더 확실한 반례를 실측했습니다.
같은 조건의 PostgreSQL은 넘어갔다
남은 노드풀을 반납하려면 PostgreSQL도 옮겨야 했습니다. 그 볼륨 n8n-pg-1도 VPU 0이었습니다. 전날 garage를 무너뜨린 것과 같은 조건입니다.
VPU를 올리고 옮길 수도 있었지만, 올리지 않고 그대로 시도했습니다. 실패하면 강제 삭제하지 않고 원래 노드로 되돌린다는 대응을 먼저 정해두고 실행했습니다.
1
09:41 n8n-pg-1 Terminating (구 노드)
2
09:42 Init:0/2 (신 노드) — 볼륨 detach/attach 대기
3
09:43 Running
약 2분 만에 성공. 이 중 대부분은 블록 볼륨의 detach/attach 대기 시간이고, 컨테이너 생성 자체는 금방 끝났습니다. 데이터도 이전 전 baseline과 일치했습니다.
볼륨
VPU
담긴 것
재마운트
data-garage-0
0
백업 객체 1,871개 (88.6 MiB)
실패 (영구 차단)
meta-garage-0
0
LMDB 메타데이터, 큰 파일 소수
성공 (20초)
n8n-pg-1
0
17MB DB + WAL·카탈로그
성공 (약 2분)
셋 다 50GB, 셋 다 VPU 0입니다. VPU 0 자체가 문제가 아닙니다. 그래서 이 함정이 위험합니다. “우리도 VPU 0인데 잘 도는데요”라는 반례가 흔하고, 그 반례가 맞기 때문입니다. 다만 데이터가 쌓이다가 어느 날 넘습니다.
두 등급 사이에서 대역폭은 2배 차이인데 IOPS는 30배 차이입니다. 등급을 내릴 때 머릿속으로 재는 것은 보통 대역폭인데, 실제로 벌어지는 격차는 IOPS 쪽에 있습니다.
게다가 비용 절감은 보통 다이얼 두 개를 같은 방향으로 돌립니다. OCI Block Volume의 최소 크기가 50GB라 무료 한도를 맞추느라 모든 PV를 최소 크기로 두고 있었고, 거기에 VPU까지 0으로 내렸습니다. 성능이 VPU × 크기이므로 2 IOPS/GB × 50GB = 100 IOPS, VPU 0이 낼 수 있는 값 중에서도 바닥입니다. 같은 VPU 0이어도 1,500GB 볼륨이었다면 3,000 IOPS라 이 사고는 나지 않습니다. 작게 쓰는 것과 싸게 쓰는 것은 곱해집니다.
그리고 이 사건은 대역폭으로 설명되지 않습니다. 볼륨에 담긴 데이터는 88.6 MiB입니다. VPU 0의 순차 대역폭 12 MB/s로도 전부 읽는 데 8초면 됩니다. 그런데 컨테이너 생성은 2분을 넘겼습니다. 총량은 범인이 아닙니다.
남는 설명은 파일 개수에 비례하는 작은 랜덤 I/O입니다. 100 IOPS는 초당 100번의 작업이라는 뜻이고, 객체 1,871개에 대해 각각 몇 번의 메타데이터 접근만 발생해도 2분은 쉽게 넘습니다. VPU 10이었다면 같은 작업량이 30분의 1의 시간에 끝납니다.
여기서 정직하게 선을 그어야 합니다. VPU가 원인이라는 것은 확정됐습니다. 다른 것은 아무것도 바꾸지 않고 VPU만 올렸더니 떴기 때문입니다. 하지만 재마운트 구간에서 정확히 어떤 작업이 IOPS를 소진했는지는 계측하지 않았습니다. 파일 수에 비례하는 흔한 후보는 두 가지입니다.
fsGroup 재귀 chown — 이 파드는 fsGroupChangePolicy: OnRootMismatch라 최상위 디렉터리 소유권이 맞으면 재귀 순회를 건너뜁니다. 그래서 이쪽일 가능성은 낮습니다
SELinux 재라벨링 — 노드가 Oracle Linux 9이고 런타임이 cri-o입니다. 볼륨 내용을 재귀적으로 다시 라벨링하면 파일 수에 정비례합니다. 확인하지 않았습니다
가설 다섯 개를 틀린 글에서 여섯 번째 가설을 사실처럼 쓸 수는 없습니다. 확정된 것은 “VPU 0 × 많은 파일”이 임계점이라는 관측이고, 그 아래의 기전은 열려 있습니다.
왜 2분인가
kubelet의 --runtime-request-timeout이 기본값 2분입니다. 이 시간을 넘으면 kubelet은 컨테이너 런타임에 보낸 요청을 취소합니다. 관측된 context deadline exceeded와 RST_STREAM ... CANCEL이 그 취소입니다.
즉 이 장애는 “느려서 오래 걸렸다”가 아니라 “느려서 취소됐고, 취소가 이름 예약을 남겨서 그 뒤로 영구히 막혔다”입니다. 그래서 기다린다고 풀리지 않았습니다.
왜 173일이나 숨어 있었나
이 사건에서 가장 옮길 만한 교훈은 임계점 계산이 아니라 잠복 구조입니다.
이미 떠 있는 파드는 마운트 비용을 이미 지불한 상태입니다. 볼륨의 성능 등급을 내려도 이미 마운트된 파일시스템에는 아무 일도 일어나지 않습니다. 그 볼륨의 PVC는 173일 됐고, 그동안 파드가 한 번도 재생성되지 않았습니다.
1
flowchart TD
2
A["2026-02-05<br>비용 절감으로 VPU 10 → 0"] --> B["아무 일도 일어나지 않음<br>running pod는 이미 마운트됨"]
3
B --> C["173일 잠복"]
4
C --> D["2026-07-28<br>업그레이드 drain → 파드 재생성"]
5
D --> E["컨테이너 생성이 2분 타임아웃 초과"]
6
E --> F["kubelet 취소 → cri-o 이름 예약 누수"]
7
F --> G["그 파드의 모든 재시도 영구 차단"]
변경 직후에 확인했다면 무엇을 봤을까요. 파드는 Running이고, 서비스는 200을 반환하고, 데이터도 멀쩡합니다. “바꾸고 확인했더니 정상”이 검증이 아니었습니다.
이건 VPU만의 이야기가 아닙니다. 효과가 다음 재기동까지 유예되는 설정은 여러 곳에 있습니다. 이미지 태그 변경, 리소스 요청량, 노드 셀렉터, 스토리지클래스 파라미터가 그렇습니다. 공통점은 컨트롤 플레인의 선언은 즉시 바뀌지만 실제 적용은 다음 생성 때 일어난다는 것입니다.
그래서 이런 종류의 변경에서 물어야 할 질문은 “지금 잘 도는가”가 아니라 “지금 이 파드를 지우면 다시 뜨는가”입니다. 확인하려면 실제로 한 번 지워보는 것 말고 다른 방법이 없고, 사고 없이 그걸 할 수 있는 시점은 장애 대응 중이 아니라 변경 직후입니다.
재생성 전에 확인하는 명령
노드를 drain하거나 노드풀을 교체하기 전에, 대상 PVC의 성능 등급을 전부 조회합니다.
파드는 그 IP로 Running인데 CNI는 이미 IP를 free 풀로 회수한 상태였습니다. 이전 파드의 늦은 DEL이 새 파드의 IP를 반납시킨 것입니다. 결과는 Running인데 아무도 도달할 수 없는 파드였습니다. 노드에서 보면 라우팅 규칙은 있는데 veth 라우트가 없었습니다.
cri-o를 재시작하자 CNI DaemonSet 파드가 함께 재시작하면서 iptables 체인을 flush했습니다. 그 노드의 파드 네트워킹이 통째로 어긋났습니다. 파드 재생성만으로 충분했을 일에 런타임을 건드려 노드 하나를 오염시켰습니다.
원인을 찾겠다고 노드를 옮겨 다녔습니다. 네 개 노드를 돌았습니다. RWO 블록 볼륨은 노드를 옮길 때마다 detach/attach 사이클을 새로 시작하고, 그게 약 6분입니다. 볼륨이 아직 안 붙은 노드로 스케줄되면 그 대기 중에 타임아웃이 납니다. 노드를 바꾸는 행위 자체가 실패를 보장하고 있었습니다. 진단이 아니라 증상 재생산이었습니다.
규칙으로 정리하면 이렇습니다.
RWO 블록 볼륨 워크로드가 안 뜨면 노드를 옮기지 말고, 볼륨이 어디 붙어 있는지부터 확인합니다
강제 삭제는 최후 수단입니다 — CSI detach와 CNI DEL을 건너뛰어 볼륨·네트워크 상태를 오염시킵니다
컨테이너 런타임을 재시작하지 않습니다 — CNI DaemonSet이 함께 재시작하며 그 노드의 파드 네트워킹을 flush합니다
CreateContainerError가 반복되면 컨테이너가 아니라 볼륨을 의심합니다 — 변수를 하나만 남긴 최소 재현이 가장 빠릅니다
명령 한 줄, 온라인 변경 약 5분. 다른 것은 아무것도 바꾸지 않고 파드가 90초 만에 떴습니다. 강제 삭제 없이 정상 경로로 만들어졌기 때문에 그동안 쫓던 네트워크 라우트 문제도 같이 사라졌습니다.
비용은 50GB 기준 월 $0.85입니다. VPU당 GB당 $0.0017이니 10 VPU × 50GB로 나오는 값입니다.
밤새 다섯 개의 가설을 세우고, 노드 네 개를 오염시키고, 서비스를 내린 것의 값이 월 0.85달러였습니다.
원글 정정, 그리고 아직 안 고친 것
5개월 전 글의 “높은 IOPS가 필요하지 않으므로 vpus=0으로 충분하다”는 문장은 정상 운영 중의 IOPS만 본 것입니다. 백업 저장소는 실제로 평소 IOPS를 거의 쓰지 않으니 그 판단 자체는 맞습니다. 빠진 것은 재마운트 시점의 작업량이고, 그건 저장된 파일 개수를 따라갑니다. 백업 저장소는 평소 IOPS는 낮게 쓰면서 객체 수는 계속 늘어나는, 하필 이 함정에 가장 잘 걸리는 조합입니다.
그래서 원글의 조언은 이렇게 고쳐 쓰는 게 맞습니다.
vpus=0은 파일 개수가 적은 볼륨에 충분합니다. 객체가 수천 개 쌓이는 볼륨은 다음 재생성 때 컨테이너 생성이 kubelet의 2분 타임아웃을 넘길 수 있습니다. 성능 등급을 내렸다면 그 자리에서 파드를 한 번 지워 다시 뜨는지 확인하세요.
그리고 저도 아직 다 안 고쳤습니다. 이 사건 이후 VPU를 올린 것은 data-garage-0 하나고, 같은 클러스터의 나머지 네 개 볼륨(meta-garage-0, n8n-pg-1, node-modules-n8n-0, node-modules-n8n-worker-0)은 지금도 VPU 0입니다.
전부 올리지 않은 이유는 이 글의 결론 그대로입니다. VPU 0 자체는 문제가 아니고, 지금 저 볼륨들은 파일이 적습니다. 올리는 순간 월 $0.85씩 붙습니다. 대신 다음 노드 교체 전에 위의 조회 명령을 돌리는 것을 절차로 넣었습니다. 이건 해결이 아니라 유예이고, 유예인 걸 알고 있다는 게 5개월 전과 다른 점입니다.
핵심 정리
성능 등급을 내려도 되는지는 “정상 운영 IOPS”가 아니라 “재마운트 시 파일 개수”로 판단합니다. 총 용량은 기준이 아닙니다 — 88.6 MiB는 VPU 0의 대역폭으로도 8초면 읽습니다
OCI Block Volume에서 VPU 0과 10의 격차는 대역폭 2배, IOPS 30배입니다. 등급을 내릴 때 대역폭만 보면 실제 위험을 30분의 1로 과소평가합니다
VPU는 GB당 단위라 성능은 VPU × 볼륨 크기입니다. 최소 크기 볼륨에 최소 등급을 걸면 두 결정이 곱해져 절대 IOPS가 바닥으로 갑니다 — 비용 절감이 자연스럽게 만드는 조합이 하필 가장 위험한 조합입니다
효과가 다음 재기동까지 유예되는 설정은 변경 직후에 파드를 한 번 지워봐야 검증됩니다. “바꾸고 봤더니 정상”은 그 설정에 대해 아무것도 말해주지 않습니다
CreateContainerError + name is reserved가 반복되면 볼륨을 의심하고, 재시도를 멈춥니다. 이름 예약이 누수된 상태에서 재시도는 상태를 나쁘게만 만듭니다
RWO 볼륨 장애에서 강제 삭제·런타임 재시작·노드 이동은 진단이 아닙니다. 각각 볼륨 상태 오염, 노드 네트워킹 flush, 타임아웃 재생산을 만듭니다
가설이 세 개 틀렸으면 방식을 바꿉니다. 최소 재현(busybox + PVC 하나)은 매 시도마다 탐색 공간을 절반으로 줄이지만, 가설 소진은 오답 개수만큼 시간을 씁니다