Skip to content

40 · 검증 매트릭스

이 도구가 "검증됐다"고 말할 때 그 말이 무엇을 뜻하는지 정의한다. 정의는 문서가 아니라 internal/matrix/matrix.go 의 데이터이고, 이 문서는 그 데이터를 읽는 법과 돌리는 법을 적는다.

1. 왜 있는가

이 도구는 사람이 머릿속에 담을 수 있는 것보다 형태가 많다. 구축은 맨바닥에서 시작할 수도, 이미 있는 클러스터 위일 수도 있고, 노드는 1대이거나 여러 대이며, 데이터플레인 3종 · 인증서 모드 4종 · 게이트웨이 노출 2종 · 레지스트리 4종이 곱해진다. 전수 조합은 수백 가지고, 수백 번 구축하는 사람은 없다.

그래서 정직한 질문은 "다 돌렸는가"가 아니라 "몇 개를 돌리면 모든 값과, 실제로 깨졌던 모든 조합을 밟는가" 이다.

2026-08 에 시나리오 6개를 손으로 써서 돌렸더니 결함 13건이 나왔다. 그중에는 모든 신규 구축을 실패시키던 것(set -e 아래의 명령 치환)도 있었다. 결함들은 난해하지 않았다 — 아무도 걸어보지 않은 길이었을 뿐이다. 매트릭스는 그 길이 "아무도 안 걸어본 길"이 아니게 만드는 장치다.

2. 차원

차원 값
nodes 1, 2, 3
dataplane cilium-gw, cilium-traefik, canal-traefik
pki none, private-ca, byo-cert
exposure node-ips, lb-pool, none
registry embedded, upstream
gitops true, false
network online, airgap
operation build, grow, resume, reapply, upgrade, failover, lifecycle

operation 이 차원인 이유: 이번에 나온 결함 대부분은 첫 구축이 아니라 그 다음에 한 일에서 나왔다. 노드 증설, 중단 후 재개, 같은 문서 재적용은 각각 다른 코드 경로다.

network 가 차원인 이유: 문서에 단어 하나 바꿔 적는 것이 아니라 하네스가 실행 전에 노드의 egress 를 DROP 한다. 밖으로 나가려는 스텝은 고객사에서가 아니라 여기서 실패한다. 차단은 nftables 의 별도 테이블로 건다. 처음에는 iptables 체인이었고, 그때는 노드 자신의 트래픽만 막혔다. Cilium 이 기동하면서 파드 인터페이스(lxc+)를 ACCEPT 하는 규칙을 그 앞에 끼워 넣어 파드 트래픽은 그대로 나갔고, 관측 스택의 sync-job 이 차단 중에 GitHub 에서 대시보드와 규칙을 받아 온 것으로 드러났다. nftables 에서는 한 기본 체인의 drop 이 다른 체인의 accept 와 무관하게 최종이라 Cilium 이 앞지를 수 없다. 랩에서 Cilium 을 차단 뒤에 재시작해도 파드가 github.com 에 닿지 못하고 세그먼트에는 닿는 것을 확인했다. 이렇게 막으면 sync-job 은 재시도 한도를 다 쓰고 실패한다. airgap-pair 는 0.96.7 에서 이 방식으로 다시 돌려 통과했다. 이 축 하나가 스토리지 이미지 다섯 개 누락, 프리플라이트가 자기 반입물을 거부하는 결함 등을 드러냈다 — 온라인에서는 전부 보이지 않는 것들이다.

3. 선정 규칙

케이스는 다음 두 조건을 만족해야 한다. 둘 다 internal/matrix 의 테스트가 하드웨어 없이 매 커밋 검사한다.

  1. 모든 차원의 모든 값이 최소 한 케이스에 등장한다 (Uncovered)
  2. RequiredPairs 의 모든 조합이 한 케이스 안에서 함께 등장한다 (UncoveredPairs)

2번이 핵심이다. 값별 커버리지만으로는 이번 교착을 못 잡는다 — 단일 노드도 통과하고 Gateway API 도 통과하는데, 둘이 만나면 cilium-operator 가 복제본 2를 한 노드에 놓지 못해 재시작이 영원히 안 끝났다. 그래서 실제로 깨졌던 조합은 목록으로 고정한다.

각 케이스에는 Why 가 필수다. 근거를 적지 않은 케이스는 다음에 누가 지운다.

4. 돌리는 법

scripts/build.sh                                   # 운영자가 쓰는 그 바이너리
NODE_PASSWORD=... scripts/matrix.sh                # 전 케이스
NODE_PASSWORD=... scripts/matrix.sh -run idc-single # 한 케이스

노드 주소 외에 지정하는 값들. 기본값이 문서용 대역(RFC 5737)인 것은 공개 저장소의 기본값이 실재하는 주소를 겨누지 않게 하기 위해서다 — 그대로 두면 kube-vip 이 붙을 인터페이스가 없어 VIP 케이스가 vip-interface 에서 멈춘다.

변수 뜻
MALMOK_LAB_VIP VIP 케이스가 쓸 주소. 노드와 같은 세그먼트의 빈 주소
MALMOK_LAB_LB_POOL 로드밸런서 풀 CIDR
MALMOK_LAB_AIRGAP_VERSION 노드에 반입해 둔 RKE2 릴리스. 없으면 에어갭 케이스는 무엇을 놓아야 하는지 말하고 skip 한다
MALMOK_LAB_STABLE · MALMOK_LAB_LATEST 채널 서버 대신 쓸 stable·latest 릴리스. 둘 다 주거나 둘 다 비운다. 기본은 채널 서버(update.rke2.io)를 읽고, 그 서버가 죽었을 때만 쓴다 — 2026-09-30 에 모든 경로가 404 였다
MALMOK_LAB_AIRGAP_FROM lifecycle 케이스가 구축할 릴리스. MALMOK_LAB_AIRGAP_VERSION 의 것과 함께 세 노드 모두에 반입해 둔다 — 아티팩트 경로에 하나, 그 옆 <경로>-<버전> 에 다른 하나. 하네스가 두 디렉터리를 이름으로 바꿔 끼우고, 어느 릴리스인지는 tarball 안 rke2 바이너리로 읽는다. 없으면 그 케이스는 skip
MALMOK_LAB_UPGRADE_FROM 업그레이드 케이스의 시작 릴리스. 비우면 채널 서버에서 이전 마이너의 최신을 읽는다
MALMOK_LAB_MIRROR pull-through 캐시 주소. 기본 꺼짐 — 캐시를 통과한 초록은 캐시 없는 고객의 설치를 증명하지 않는다
MALMOK_LAB_THIRD 세 번째 머신. 서버 3대 케이스만 쓰고, 지정되면 매 케이스마다 같이 와이프한다 — HA 케이스가 남긴 서버가 VIP 를 계속 주장하면 다음 케이스의 kube-vip 과 싸운다. 없으면 그 케이스는 skip

에어갭 케이스는 두 가지를 노드에 미리 놓아야 한다. RKE2 릴리스 아티팩트와, 플랫폼 이미지 번들이다. 번들은 MALMOK_IMAGE_ARCHES=amd64 scripts/airgap-images.sh <tag> 로 만들어 아티팩트 옆에 둔다 — 케이스가 와이프 뒤에 /var/lib/rancher/rke2/agent/images/ 로 옮긴다. 미리 거기 넣어둘 수 없다: 와이프가 /var/lib/rancher 를 통째로 지운다. 옮길 때 이름 앞에 zz- 를 붙인다 — RKE2 가 이름 순서로 적재하므로, 원래 이름이면 번들이 먼저 들어가 pause 이미지가 늦고 containerd 가 docker.io 로 한 번 나가려 한다. 케이스가 끝나면 노드마다 차단된 외부 접속을 목적지별로 airgap-drops-<노드>.txt 에 남긴다. 차단은 부팅 때도 다시 걸린다 — lifecycle 케이스가 장애 시험으로 서버를 재부팅하는데, 메모리에만 있던 차단은 재부팅과 함께 사라져 그 서버가 외부 통신이 열린 채 돌아왔을 것이다. 와이프가 이를 지우므로, 차단을 건 채 죽은 실행이 다음 케이스에 남기지 않는다. 실패한 케이스의 실행 디렉터리는 out/matrix/ 에 남는다.

  • 케이스마다 두 노드를 완전 초기화하고 재부팅한다. 따라서 파괴해도 되는 구간에만 겨눈다 (MALMOK_LAB_SERVER / MALMOK_LAB_AGENT 로 지정).
  • 재부팅은 장식이 아니다. rke2-uninstall 은 Cilium 이 NIC 에 붙인 tc/eBPF 프로그램을 걷어내지 않고, 남은 프로그램이 다음 구축의 노드 간 통신을 떨군다 — 포트 검사가 그것을 방화벽으로 보고한다.
  • 하네스는 빌드된 바이너리를 실행한다. 엔진 패키지를 직접 부르면 아무도 쓰지 않는 경로를 검증하게 된다.
  • RKE2 버전은 채널 서버에서 읽는다. 버전을 상수로 박은 스위트는 사람들이 실제로 설치하는 버전을 검증하지 않는다.
  • 인증서 자재는 매번 생성한다 (root → intermediate → wildcard leaf). 디스크에 둔 자재는 만료되고, 아무도 안 건드렸는데 반년 뒤 실패하는 스위트는 사람들이 믿지 않게 된다.

5. 케이스가 통과했다는 것

작업별로 하는 일이 다르다.

operation 하는 일
build 맨바닥에서 구축
grow 1노드로 구축 후, 문서에 에이전트를 추가해 재적용
resume l1-bootstrap/service 시작 시점에 SIGKILL, 같은 명령으로 재실행
reapply 구축 후 같은 문서를 다시 적용 — 바뀐 스텝이 0이어야 통과
upgrade 이전 마이너로 구축한 뒤 현재 릴리스로 이동 — 전 노드의 kubelet 이 새 버전을 보고해야 통과. stable 에서 출발하면 stable 과 latest 가 같은 릴리스를 가리키는 동안 매번 skip 했고, 실제로 매트릭스 전 실행에서 한 번도 돌지 않았다
failover 서버 3대로 구축한 뒤 VIP 를 들고 있는 서버를 재부팅한다 — VIP 가 남은 서버로 넘어가야 하고, 그 서버가 내려가 있는 동안 VIP 로 쓰기 요청이 들어가야 하고(etcd 3개 중 2개의 합의), 재부팅한 서버가 누가 손대지 않아도 돌아와 셋 다 Ready 가 돼야 통과. RKE2 를 강제 종료하는 방식은 쓰지 않는다: 기계가 살아 있어 VIP 가 그 서버에 남고, 그건 서버가 사라지는 장애와 다른 장애다. 아무 서버나 멈추면 아무도 쓰지 않던 노드를 잃어도 버틴다는 것밖에 증명하지 못한다
lifecycle 한 클러스터에서 차례로: 이전 릴리스로 구축 → failover 와 같은 장애 → 옛 릴리스가 남은 상태의 업그레이드가 UP-006 으로 거부되고 아무것도 안 바뀜 → 새 릴리스로 바꾼 뒤 두 번째 서버의 drain 이 시작될 때 tarball 을 치운 업그레이드 — 실행이 그 서버에서 멈춰야 하고, VIP 경유 쓰기가 들어가야 하고(쿼럼 유지), tarball 을 되돌린 두 번째 실행이 cordon 없이 마쳐야 함 → 스냅숏으로 서버 전부 복구 — 모두 rke2-killall.sh 로 멈추고 남은 VIP 를 떼고, 첫 서버를 reset 해 시작, VIP 가 응답한 뒤 나머지를 DB 를 지우고 재합류, 서버마다 /readyz 가 ok, 스냅숏 전 표식은 있고 후 표식은 없어야 통과

그 뒤 공통 확인 (전부 운영자 계정에서 sudo 없이):

  • Ready 노드 수가 케이스가 말한 수와 같다
  • kubectl 이 PATH 에 있다 (kubeconfig 만 있고 도구가 없으면 그 클러스터는 구축한 기계에서 못 쓴다)
  • cilium-gw 케이스는 GatewayClass 가 Accepted
  • 모든 파드가 Running 또는 Completed 로 안정된다. Pending 은 통과가 아니다 — 이 도구가 요청했는데 클러스터가 주지 못한 파드다. 30초 간격으로 30회까지 기다린다: 세 초짜리 ContainerCreating 을 결함으로 보고하지 않기 위해서다

6. 매트릭스를 넓힐 때

  1. Values() 에 값을 추가한다 → 커버리지 테스트가 실패한다
  2. 그 값을 밟는 케이스를 Cases() 에 추가하고 Why 를 적는다
  3. 그 값이 다른 값과 만나야만 깨지는 성질이면 RequiredPairs() 에 넣는다

이 순서를 지키면 "값을 추가했는데 아무도 안 돌리는" 상태가 존재할 수 없다.

7. 아직 매트릭스 밖인 것

정직하게 적는다. 이것들은 하드웨어나 외부 서비스가 없어서 빠졌다.

  • 프록시 망 모드 — 프록시 필요. 폐쇄망은 network 축으로 들어왔다
  • acme-dns01 — 공인 DNS 와 ACME 계정 필요
  • external / internal 레지스트리 — 매트릭스 케이스는 없다. external은 비공개 Harbor 프로젝트를 두고 손으로 한 번 돌렸다(0.96.6 4929e20, 서버 1대 + 에이전트 1대, 외부 통신 허용). 클러스터가 받은 이미지가 전부 그 프로젝트에서 왔다. 케이스가 되려면 매트릭스가 관리하는 레지스트리가 필요하고, 외부 통신 차단·사설 CA와의 조합은 아직 돌려보지 않았다. internal은 Hauler 필요
  • 에어갭 업그레이드(서버 1대 + 에이전트 1대) — network=airgap과 operation=upgrade의 조합 중 서버 3대는 airgap-ha(lifecycle)가 다룬다. 에이전트가 있는 모양은 케이스가 없고 손으로 돌렸다: 5ab979e에서 노드 자신의 통신만 막고 v1.35.8로 구축, 교체 전 업그레이드는 UP-006이 거부, 교체 후 v1.36.4로 이동. 이어서 파드 통신까지 막고(db1a073, 21e09c0) 같은 거부를 확인한 뒤, 서버 설치와 에이전트 재시작 단계에서 실행을 끊고 다시 실행해 마쳤다 — 이 과정에서 UP-002가 재개를 막는 결함이 드러나 21e09c0에서 고쳤다. 같은 차단 상태에서 etcd 스냅숏 복구(같은 서버, 초기화한 서버)도 리허설했다. 두 버전의 반입 자재를 바꿔 끼우는 하네스는 이제 있으므로, 남은 것은 이 모양의 케이스 행이다
  • byo-csi — 현장 CSI 필요. 이 단계는 StorageClass 존재만 확인한다
  • longhorn · nfs 스토리지 — 이번 릴리스가 설치하지 않는다. 문서가 지정하면 StorageClass 없는 클러스터를 만드는 대신 실행을 멈춘다