TT Lab
블로그 시작하기
배우기 러닝패스 코스

영어로 배우는 Cluster API: KubeVirt 위에 클러스터 찍어 내기

From manifest to Machine: 어디서 멈췄는지 영어로 읽기

TT Lab 에서 이어서 보기

한 줄 요약

워크로드 클러스터는 MachineDeployment → MachineSet → Machine → (KubevirtMachine, KubeadmConfig) 순으로 내려가며 만들어지고, 어디서 멈췄는지는 각 층의 조건과 컨트롤러 로그가 영어로 말해 줍니다.

왜 이게 필요했나

선언형 도구는 실패도 조용합니다. kubectl apply 는 성공했다고 하고, 오브젝트는 다 있고, 그런데 노드가 하나 모자랍니다. 명령형 스크립트라면 실패한 줄에서 멈췄겠지만, 컨트롤러는 실패를 기록하고 잠시 뒤 다시 시도할 뿐입니다. 그래서 Cluster API 를 쓰는 사람은 "오브젝트가 어떤 순서로 무엇을 만드는지" 와 "그 실패가 어디에 영어로 적히는지" 를 알아야 합니다.

어떻게 동작하나

clusterctl generate cluster 가 만든 매니페스트에는 두 갈래가 있습니다. 컨트롤 플레인 갈래는 KubeadmControlPlane 이 Machine 을 직접 만들고, 워커 갈래는 MachineDeployment 가 MachineSet 을, MachineSet 이 Machine 을 만듭니다. 어느 쪽이든 Machine 하나를 만들 때 템플릿 두 개를 복제합니다. 인프라 템플릿(KubevirtMachineTemplate)에서 KubevirtMachine 을, 부트스트랩 템플릿(KubeadmConfigTemplate)에서 KubeadmConfig 를 만듭니다.

MachineDeployment ─► MachineSet ─► Machine ─┬─► KubevirtMachine ─► KubeVirt VM (노드 디스크 = containerDisk)
                                            └─► KubeadmConfig   ─► cloud-init (kubeadm join)

Machine 의 phase 는 이 흐름을 따라 Pending → Provisioning → Provisioned → Running 으로 바뀝니다. 실측에서 컨트롤 플레인은 적용 뒤 61초에 Provisioned, 71초에 Running 이었고, 워커는 컨트롤 플레인이 초기화된 뒤에야 Provisioning 으로 넘어가 92초에 Running 이었습니다. 워커가 기다린 것은 우연이 아니라 설계입니다. 워커가 join 할 API 서버가 먼저 있어야 하기 때문입니다.

Running 은 "노드와 연결됐다" 이지 "노드가 Ready 다" 가 아닙니다. Cluster API 는 CNI 를 깔지 않으므로, 이 시점의 노드 조건은 container runtime network not ready ... cni plugin not initialized 입니다. CNI 는 사용자가(혹은 ClusterResourceSet 같은 추가 기능이) 올립니다.

이 실습에서 만날 실패는 복제 단계에서 납니다. CAPK v0.11.2 의 기본 템플릿은 옛 API 판(v1beta1)으로 적혀 있고, 워커의 부트스트랩 템플릿에는 kubeletExtraArgs: {} 한 줄뿐입니다. CAPI v1.14.3 은 이것을 v1beta2 로 바꿔 저장하는데, v1beta2 에서 kubeletExtraArgs 는 맵이 아니라 목록이라 빈 맵은 사라지고 nodeRegistration: {} 만 남습니다. 템플릿 자체는 받아들여지지만, MachineSet 이 그것을 복제해 KubeadmConfig 를 만들려는 순간 v1beta2 스키마가 "속성이 하나 이상 있어야 한다" 며 거부합니다. 그래서 Machine 은 만들어지지도 못하고, 오류는 core 컨트롤러 로그에만 남습니다. 같은 빈 객체를 v1beta2 로 직접 적으면 API 서버가 템플릿 단계에서 같은 문장으로 거부합니다(실측). 옛 판으로 들어온 값이 바뀌는 길에서만 검증을 비켜 간다는 뜻이라, 업스트림 이슈로 올릴 만한 지점입니다.

현장에서 만나는 모습

이 코스를 만들며 처음 적용했을 때 정확히 이렇게 됐습니다. 컨트롤 플레인은 72초 만에 Running 이 됐는데 워커는 10분이 지나도 생기지 않았고, MachineDeployment 는 ScalingUp 에 머물렀습니다. clusterctl describe 의 조건만 보면 "0 available replicas" 라고만 나와서 원인이 보이지 않았고, capi-controller-manager 로그의 Reconciler error 에서야 필드 경로가 나왔습니다. criSocket 한 줄로 풀렸습니다.

같은 날 두 번째 실패도 있었습니다. 메모리를 줄이려고 guest: "4Gi" 를 sed 로 바꿨는데, clusterctl 이 다시 써 낸 YAML 에는 따옴표가 없어서 패턴이 빗나갔습니다. 노드마다 4Gi 그대로 나갔고, virt-launcher 가 4372Mi 를 요청해 두 번째 노드가 Insufficient memory 로 Pending 에 머물렀습니다. 그 뒤로는 YAML 을 파서로 고칩니다. 둘 다 "조용한 실패" 이고, 둘 다 영어 메시지 한 줄이 답을 쥐고 있었습니다.

English corner

🏓 티티: 오류 메시지는 영어 문장이지만 읽는 순서가 있어요. 무엇이(주어) → 어디가(필드 경로) → 왜(should ...) 순서로 끊어 읽어 봐요.

provision (동사). "필요한 자원을 마련해 쓸 수 있게 하다" 입니다. Provisioning 은 마련하는 중, Provisioned 는 마련이 끝난 상태입니다. "The VM has been provisioned, but the node has not joined yet." 처럼 씁니다.

Reconciler error. controller-runtime 이 reconcile 함수가 오류를 돌려줄 때마다 찍는 머리말입니다. 같은 줄이 반복되면 "it keeps failing on every reconcile" 이라고 이슈에 적습니다.

X is invalid: field: Invalid value: ...: field in body should have at least 1 properties. 쿠버네티스 API 서버의 스키마 검증 메시지입니다. "is invalid" 가 판정, 콜론 뒤가 필드 경로, "should ..." 가 규칙입니다. properties 는 JSON 스키마 용어(객체의 키)라서 문법과 달리 복수형 그대로 나옵니다. 이슈에는 이 문장을 고치지 말고 그대로 붙입니다. 그래야 다른 사람이 같은 문장으로 검색해 찾아옵니다.

X is deprecated; use Y instead. 적용할 때 본 경고 cluster.x-k8s.io/v1beta1 Cluster is deprecated; use cluster.x-k8s.io/v1beta2 Cluster 가 이 꼴입니다. deprecated 는 "아직 되지만 곧 없어질 예정" 이라 removed(이미 없앰)와 다릅니다.

not initialized / NotReady. cni plugin not initialized 는 "아직 초기화되지 않았다", 즉 누군가 설치해야 한다는 뜻입니다. 오류가 아니라 대기 상태를 말할 때 영어는 not yet 을 즐겨 씁니다.

이슈를 쓸 때의 틀(What happened / What did you expect / How to reproduce)은 블로그 「오늘의 영어 표현」의 Expected vs Actual 편과 I can't reproduce this 편에 예문이 더 있습니다.

다음 실습에서 할 것

노드 디스크 이미지를 먼저 받아 두고, 매니페스트를 이 VM 크기에 맞게 고쳐 적용합니다. 컨트롤 플레인 Machine 의 phase 변화를 지켜본 뒤, 생기지 않는 워커의 원인을 core 컨트롤러 로그에서 찾아 템플릿을 고칩니다. kubeconfig 를 받아 Calico 를 올려 노드를 Ready 로 만들고, 마지막으로 이 실패를 영어 이슈로 정리합니다.