[하이브리드 Kubernetes 구축기 5] DNS, etcd, Cilium, iptables를 끝까지 추적한 기록
RKE2 부팅 시 DNS 경합, etcd learner 잔류, Cilium 초기화 실패와 iptables 차단을 겪었다. 각 증상을 어디서 확인했고 무엇을 바꿔 검증했는지 명령어와 함께 남겼다.
하이브리드 Kubernetes 구축기 5/6 · ← 이전 글 · 다음 글 →
기록 시기: 2025년
이 글은 2025년에 진행한 구축·장애 대응 기록을 바탕으로 정리했다. 명령 출력은 읽기 쉽도록 일부 열을 생략하거나 재구성했다.*.example,198.51.100.0/24,100.64.0.0/24, 합성 member ID·Pod 이름·시각은 공개용 예시이며 실제 환경과 무관하다.EXAMPLE_ONLY_NOT_A_REAL_TOKEN은 의도적으로 사용할 수 없는 가짜 token이다. 명령 블록의$는 일반 사용자 프롬프트이며 입력하지 않는다. 운영체제 관리자 권한이 필요한 명령에만sudo를 표시했다.
클러스터를 구축하면서 설치보다 장애 원인을 찾는 데 시간이 더 걸렸다. OCI 사설망의 네 노드와 온프레미스 한 노드를 Tailscale 서브넷 라우팅으로 연결하고, 그 위에 Cilium VXLAN을 올렸다. DNS나 호스트 방화벽 문제도 Pod 오류로 드러나서, 실패한 지점을 하나씩 좁혀야 했다.
OCI private network
├─ core-a control-plane / etcd
├─ core-b control-plane / etcd
├─ core-c control-plane / etcd
└─ edge-a worker
▲
│ Tailscale subnet routing (underlay)
▼
On-Premise
└─ edge-b worker
Pod network: Cilium VXLAN over the underlay
이 글에서는 실제로 만난 네 가지 장애를 문제 → 진단 → 원인 → 해결 → 재검증 → 교훈 순서로 정리했다. 명령은 당시 홈랩 기준이다. 멤버십, 방화벽, 노드 서비스를 바꾸는 작업이라 백업과 콘솔 접속 수단을 먼저 준비했다. 다른 환경에 적용할 때도 대상과 롤백 방법을 확인하고 유지보수 시간에 실행해야 한다.
Case 1. 부팅 시 DNS 경합으로 RKE2 노드가 다시 합류하지 못했다
문제
재부팅 전까지 정상인 노드가 부팅 후 NotReady로 남았다. 노드에서 RKE2 로그를 보면 bootstrap endpoint의 이름을 풀지 못하고 있었다.
$ systemctl status rke2-agent --no-pager
$ sudo journalctl -u rke2-agent -b --no-pager | tail -n 30
rke2-agent.service: Main process exited, status=1/FAILURE
failed to get CA certs:
Get "https://rke2-bootstrap.example:9345/cacerts":
dial tcp: lookup rke2-bootstrap.example: Temporary failure in name resolution
구성 파일의 endpoint나 token이 틀린 것처럼 보였지만, 같은 설정으로 서비스를 조금 늦게 다시 시작하면 정상 합류했다.
$ getent ahostsv4 rke2-bootstrap.example
$ systemctl is-active tailscaled
$ tailscale ping core-b
부팅 직후에는 첫 명령이 아무 주소도 반환하지 않았고, 잠시 뒤에는 Tailscale 주소가 반환됐다. 아래 주소와 endpoint는 실제 값 대신 공개용 합성 값을 사용했다.
# 부팅 직후
$ getent ahostsv4 rke2-bootstrap.example
$ echo $?
2
# 네트워크와 Tailscale 준비 후
100.64.0.2 STREAM rke2-bootstrap.example
100.64.0.2 DGRAM rke2-bootstrap.example
pong from core-b (100.64.0.2) via 198.51.100.24:41641 in 12ms
진단
서비스의 부팅 순서를 확인했다.
$ systemctl show rke2-agent.service -p After -p Wants
$ systemctl show tailscaled.service -p ActiveState -p SubState
$ systemd-analyze critical-chain rke2-agent.service
RKE2는 시작됐지만 DNS와 Tailscale 경로가 아직 준비되지 않은 짧은 구간이 있었다. network.target 도달은 네트워크 스택이 존재한다는 뜻이지, 이름 해석과 원격 endpoint 도달이 끝났다는 뜻은 아니다. network-online.target 역시 해당 배포판의 wait-online 서비스가 실제 연결 상태를 제대로 판별하도록 구성돼 있어야 의미가 있다.
원인
edge-b는 bootstrap 단계에서 Tailscale 경로의 endpoint에 연결해야 한다. 그런데 부팅 시 RKE2가 DNS와 tailscaled보다 먼저 접속을 시도해 조인이 실패했다. 같은 설정으로 늦게 시작하면 정상 합류했으므로 서비스 준비 순서의 경합(race condition)으로 판단했다.
해결
1차 대응: @reboot에서 endpoint 준비 후 재시작
당시에는 영향 범위를 빠르게 줄이기 위해, DNS와 9345/TCP가 준비될 때까지 기다린 뒤 에이전트를 한 번 재시작하는 스크립트를 사용했다.
#!/usr/bin/env bash
set -euo pipefail
host="rke2-bootstrap.example"
port="9345"
until getent ahostsv4 "${host}" >/dev/null 2>&1; do
sleep 3
done
until timeout 3 bash -c "</dev/tcp/${host}/${port}" 2>/dev/null; do
sleep 3
done
systemctl restart rke2-agent
일반 사용자 홈에서 작성한 스크립트를 root 소유 경로에 설치하고, sudo crontab -e로 연 root crontab에 등록했다. 따라서 스크립트 안의 systemctl restart는 root crontab 문맥에서 실행된다.
$ sudo install -o root -g root -m 0755 ./wait-and-restart-rke2-agent \
/usr/local/sbin/wait-and-restart-rke2-agent
$ sudo crontab -e
@reboot /usr/local/sbin/wait-and-restart-rke2-agent
이 스크립트로 증상은 멈췄다. 다만 서비스 의존 관계가 cron으로 나뉘고, 준비되지 않으면 무한히 기다릴 수 있었다. 그래서 1차 대응으로만 분류했다.
개선안: systemd 의존성과 ExecStartPre로 준비 상태를 모델링
장기적으로는 edge-b의 RKE2 drop-in에 네트워크와 Tailscale 의존성을 명시하고, 시작 전에 제한 시간 동안 실제 endpoint를 확인하는 편이 낫다.
# /etc/systemd/system/rke2-agent.service.d/20-network-ready.conf
[Unit]
Wants=network-online.target tailscaled.service
After=network-online.target tailscaled.service
[Service]
ExecStartPre=/usr/local/sbin/check-rke2-endpoint
#!/usr/bin/env bash
set -euo pipefail
host="rke2-bootstrap.example"
port="9345"
for _ in $(seq 1 60); do
if getent ahostsv4 "${host}" >/dev/null 2>&1 \
&& timeout 3 bash -c "</dev/tcp/${host}/${port}" 2>/dev/null; then
exit 0
fi
sleep 3
done
echo "RKE2 endpoint was not ready within 180 seconds" >&2
exit 1
drop-in 적용 전에는 기존 unit과 /etc/rancher/rke2/config.yaml을 백업했다. 적용과 재부팅 검증은 Tailscale이 끊겨도 접속할 수 있는 OCI/하이퍼바이저 콘솔을 확보한 상태에서 수행했다.
$ sudo systemctl daemon-reload
$ sudo systemctl restart rke2-agent
$ systemctl status rke2-agent --no-pager
재검증
$ sudo journalctl -u rke2-agent -b --no-pager | grep -Ei 'dns|9345|error|ready'
$ kubectl get node edge-b -o wide
NAME STATUS ROLES VERSION
edge-b Ready - v1.33.5+rke2r1
재부팅 후 수동 재시작 없이 노드가 Ready로 돌아오는지, 9345 접속 오류가 반복되는지 확인했다.
교훈
부팅 순서만 지정해서는 접속 준비가 끝났는지 알 수 없었다. 실제 준비 상태(readiness)를 시작 전에 확인하도록 정리했다. 임시 cron으로 복구한 뒤에는 systemd 의존 관계와 사전 검사로 옮겨 시작 조건을 한곳에서 볼 수 있게 했다.
Case 2. etcd 확장 중 too many learner members가 발생했다
문제
세 번째 control plane을 추가하는 과정에서 RKE2 server가 반복해서 실패했다.
$ sudo journalctl -u rke2-server -n 200 --no-pager | grep -i learner
failed to add member: etcdserver: too many learner members in cluster
Kubernetes 노드 목록만 보면 새 노드의 등록 시도 흔적이 남았지만, etcd 관점에서는 멤버십 변경이 끝나지 않은 상태였다.
진단
먼저 etcd 정적 Pod와 멤버 목록을 함께 확인했다. 아래 인증서 경로는 RKE2 기본 배치의 예시이며, 설치 방식에 따라 다를 수 있다.
$ kubectl -n kube-system get pods -l component=etcd -o wide
$ kubectl -n kube-system exec etcd-core-a -- \
etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/var/lib/rancher/rke2/server/tls/etcd/server-ca.crt \
--cert=/var/lib/rancher/rke2/server/tls/etcd/server-client.crt \
--key=/var/lib/rancher/rke2/server/tls/etcd/server-client.key \
member list -w table
장애 당시에는 이전 조인에서 남은 learner가 보였다.
+-------------+---------+------------------+-----------------------+------------+
| ID | STATUS | NAME | PEER ADDRS | IS LEARNER |
+-------------+---------+------------------+-----------------------+------------+
| 1a2b3c4d5e6f7001 | started | core-a-a1b2c3d4 | https://10.0.0.3:2380 | false |
| 1a2b3c4d5e6f7002 | started | core-b-b2c3d4e5 | https://10.0.0.2:2380 | false |
| deadbeef00000004 | started | core-c-old-deadbeef | https://10.0.1.99:2380 | true |
+-------------+---------+------------------+-----------------------+------------+
etcd는 새 멤버를 learner로 받아 로그를 따라잡게 한 뒤 voter로 승격한다. 기본적으로 learner는 한 번에 하나만 허용되므로, learner 승격이 끝나기 전에 다른 control plane을 동시에 추가하면 새 learner를 받을 수 없다.
원인
여러 control plane을 동시에 조인시켰다. 먼저 시작한 노드가 voter로 승격되기 전에 실패하면서 learner가 남았고, 다음 노드의 추가도 막혔다.
해결
위험 작업: etcd 멤버 제거는 잘못된 ID를 선택하면 quorum과 데이터를 잃을 수 있다. 운영 환경에서 블로그의 명령을 복사해 실행하지 않는다. RKE2가 embedded etcd를 관리하므로 먼저 RKE2의 백업·복원 및 멤버 관리 절차를 따른다. 정상 voter 수, stale 노드의 프로세스 중단 여부, peer IP와 member ID의 대응을 모두 확인하고 스냅샷을 외부에 복사한 뒤에만 진행한다.
첫 단계는 추가 조인을 모두 중단하고 스냅샷을 남기는 것이었다.
$ sudo rke2 etcd-snapshot save --name pre-learner-recovery
$ sudo rke2 etcd-snapshot list
정리할 멤버를 찾기 위해 아래 세 정보를 대조했다.
$ kubectl get nodes -o wide
$ kubectl -n kube-system get pods -l component=etcd -o wide
$ kubectl -n kube-system exec etcd-core-a -- \
etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/var/lib/rancher/rke2/server/tls/etcd/server-ca.crt \
--cert=/var/lib/rancher/rke2/server/tls/etcd/server-client.crt \
--key=/var/lib/rancher/rke2/server/tls/etcd/server-client.key \
member list -w table
stale learner임이 확정된 멤버만 RKE2 운영 절차에 따라 정리했다. 직접 명령의 형태는 다음과 같지만, 식별과 백업 없이 실행해서는 안 된다. 아래 member ID는 실행 불가능한 공개용 합성 예시다.
# 실행 예제가 아니라 위험 지점을 보여 주기 위한 형태다.
# RKE2 절차, 백업, member/IP 식별을 완료하기 전에는 실행 금지.
$ kubectl -n kube-system exec etcd-core-a -- \
etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/var/lib/rancher/rke2/server/tls/etcd/server-ca.crt \
--cert=/var/lib/rancher/rke2/server/tls/etcd/server-client.crt \
--key=/var/lib/rancher/rke2/server/tls/etcd/server-client.key \
member remove deadbeef00000004
이후에는 control plane을 한 번에 한 대씩 추가했다. token은 출력·히스토리·저장소에 남기지 않았다.
# /etc/rancher/rke2/config.yaml (일부)
server: https://rke2-bootstrap.example:9345
token: "EXAMPLE_ONLY_NOT_A_REAL_TOKEN"
$ sudo systemctl start rke2-server
$ sudo journalctl -fu rke2-server
한 노드가 learner에서 voter로 승격되고 Ready가 된 것을 확인한 뒤 다음 노드를 추가했다.
재검증
+-------------+---------+-----------------+-----------------------+------------+
| ID | STATUS | NAME | PEER ADDRS | IS LEARNER |
+-------------+---------+-----------------+-----------------------+------------+
| 1a2b3c4d5e6f7001 | started | core-a-a1b2c3d4 | https://10.0.0.3:2380 | false |
| 1a2b3c4d5e6f7002 | started | core-b-b2c3d4e5 | https://10.0.0.2:2380 | false |
| 1a2b3c4d5e6f7003 | started | core-c-c3d4e5f6 | https://10.0.1.3:2380 | false |
+-------------+---------+-----------------+-----------------------+------------+
$ kubectl get nodes -l node-role.kubernetes.io/etcd=true
$ kubectl -n kube-system get pods -l component=etcd
최종적으로 세 멤버가 모두 started, IS LEARNER=false인 것을 확인했다.
교훈
etcd 노드를 추가하면 합의 그룹의 멤버십 변경이 발생한다. 이후에는 한 대 추가 → catch-up → voter 승격 확인 → 다음 대 추가 순서로 변경했다. 멤버를 세 개로 구성한 뒤에도 스냅샷은 따로 남겼다.
Case 3. Cilium이 CrashLoop에 빠졌다: 최소 OS 도구와 k8sServiceHost
문제
Cilium을 배포한 직후 일부 agent가 CrashLoopBackOff에 빠지고 Pod 네트워크가 만들어지지 않았다.
$ kubectl -n kube-system get pods -l k8s-app=cilium -o wide
$ kubectl -n kube-system describe pod cilium-demo1
$ kubectl -n kube-system logs cilium-demo1 -c cilium-agent --previous
NAME READY STATUS NODE
cilium-demo1 0/1 CrashLoopBackOff edge-demo
exec: "conntrack": executable file not found in $PATH
Unable to contact k8s api-server:
dial tcp 127.0.0.1:6443: connect: connection refused
두 오류가 섞여 있었다. 최소 설치한 Ubuntu 노드에 필요한 네트워크 도구 일부가 없었고, 당시 커스텀 chart bootstrap 문맥에서는 127.0.0.1:6443에 API 요청을 받아 줄 local endpoint가 준비되지 않았다.
진단
먼저 init container와 agent 로그를 분리해서 확인했다.
$ kubectl -n kube-system get pod cilium-demo1 \
-o jsonpath='{.spec.initContainers[*].name}{"\n"}{.spec.containers[*].name}{"\n"}'
$ kubectl -n kube-system logs cilium-demo1 -c mount-cgroup --previous
$ kubectl -n kube-system logs cilium-demo1 -c cilium-agent --previous
노드별 도구 설치 편차도 확인했다.
$ for node in core-a core-b core-c edge-a edge-b; do
echo "[$node]"
ssh "user@${node}" 'command -v ip; command -v iptables; command -v conntrack; command -v nsenter'
done
[core-a]
/usr/sbin/ip
/usr/sbin/iptables
/usr/sbin/conntrack
/usr/bin/nsenter
[edge-demo]
/usr/sbin/ip
NOT FOUND: iptables
NOT FOUND: conntrack
/usr/bin/nsenter
다음으로 RKE2의 Cilium Helm 값을 확인했다.
$ kubectl -n kube-system get helmchartconfig rke2-cilium -o yaml
$ kubectl -n kube-system get configmap rke2-cilium-config -o yaml 2>/dev/null || true
kubeProxyReplacement=true인 상태에서 k8sServiceHost=127.0.0.1, k8sServicePort=6443을 사용하고 있었다. 여기서 localhost라는 값만 보고 설정 오류라고 결론 내리면 안 된다. RKE2는 agent가 local client-side load balancer를 제공할 수 있고, Cilium DaemonSet이 hostNetwork=true로 실행되면 Pod가 그 host endpoint를 사용할 수 있다.
당시에는 해당 실행 환경에서 어떤 프로세스가 127.0.0.1:6443 포트를 열고 있는지를 확인했다.
$ kubectl -n kube-system get daemonset cilium \
-o jsonpath='{.spec.template.spec.hostNetwork}{"\n"}'
$ kubectl -n kube-system get daemonset cilium \
-o jsonpath='{range .spec.template.spec.containers[?(@.name=="cilium-agent")].env[*]}{.name}={.value}{"\n"}{end}' \
| grep KUBERNETES_SERVICE
$ sudo ss -lntp | grep ':6443'
$ systemctl status rke2-agent --no-pager
장애 시점에는 커스텀 chart의 bootstrap 순서와 local LB 준비 상태가 맞지 않아, Cilium agent가 보는 127.0.0.1:6443에 연결할 수 없었다. 당시에는 endpoint를 제공할 프로세스가 아직 준비되지 않은 상태였다.
원인
로그와 노드 상태를 대조하니 두 문제가 겹쳐 있었다.
- 최소 OS 이미지 간 패키지 편차 때문에 Cilium 초기화에 필요한 도구가 일부 노드에서 누락됐다.
- 당시 커스텀 chart bootstrap 문맥에서는 RKE2 local client-side LB가 준비되지 않아
127.0.0.1:6443연결이 거절됐다.
첫 번째만 고치면 agent가 API 연결 단계에서 다시 실패했고, 두 번째만 고치면 init 단계의 도구 오류가 남았다.
해결
누락 패키지는 장애 로그와 Cilium 요구 사항을 기준으로 목록을 확정한 뒤 Ansible로 노드 간 상태를 맞췄다. 아래 목록은 이 환경의 예시이지 모든 배포판의 고정 정답은 아니다.
$ ansible k8s -b -m ansible.builtin.apt \
-a "name=iproute2,iptables,conntrack,socat,ethtool,util-linux state=present update_cache=yes"
그리고 장애 당시에는 bootstrap 순환을 끊기 위해 RKE2의 HelmChartConfig에서 API endpoint를 모든 노드가 underlay로 접근할 수 있는 control-plane VIP/FQDN으로 우회했다. 실제 endpoint와 인증 정보는 공개하지 않았다. 이것은 localhost를 영구 금지한 것이 아니라, 당시 local LB가 준비되지 않은 구간을 우회한 복구 조치였다.
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-cilium
namespace: kube-system
spec:
valuesContent: |-
kubeProxyReplacement: true
k8sServiceHost: "k8s-api.example"
k8sServicePort: 6443
routingMode: tunnel
tunnelProtocol: vxlan
RKE2가 관리하는 chart이므로 임의의 helm upgrade와 서버 manifests를 혼용하지 않고, 한 가지 선언 경로로 관리했다.
재검증
$ kubectl -n kube-system rollout status daemonset/cilium --timeout=5m
$ kubectl -n kube-system get daemonset cilium
$ kubectl -n kube-system exec daemonset/cilium -c cilium-agent -- \
cilium-dbg status --brief
daemon set "cilium" successfully rolled out
NAME DESIRED CURRENT READY AVAILABLE
cilium 5 5 5 5
OK
현재 구성과 대조
이후 RKE2가 관리하는 기본 local client-side LB 경로로 정리한 현재 구성에서는 Cilium DaemonSet이 hostNetwork=true이고, API 환경 변수는 다시 127.0.0.1:6443을 가리킨다.
$ kubectl -n kube-system get daemonset cilium \
-o jsonpath='{.spec.template.spec.hostNetwork}{"\n"}'
$ kubectl -n kube-system get daemonset cilium \
-o jsonpath='{range .spec.template.spec.containers[?(@.name=="cilium-agent")].env[*]}{.name}={.value}{"\n"}{end}' \
| grep KUBERNETES_SERVICE
$ kubectl -n kube-system exec daemonset/cilium -c cilium-agent -- \
cilium-dbg status --brief
true
KUBERNETES_SERVICE_HOST=127.0.0.1
KUBERNETES_SERVICE_PORT=6443
OK
현재는 RKE2 agent의 local LB가 endpoint를 제공한다. Cilium도 host network namespace를 공유하므로 localhost로 접근할 수 있다. 과거에 외부 VIP/FQDN으로 우회했던 이유는 당시 bootstrap 순서에서 local LB가 준비되지 않았기 때문이다.
교훈
YAML 확인만으로는 두 오류를 찾지 못했다. 호스트 도구와 API bootstrap 경로까지 같이 확인해야 했다. 127.0.0.1을 사용해도 되는지는 어느 network namespace에서 실행되는지, hostNetwork를 사용하는지, 누가 그 포트를 listen하는지, 그 제공자가 먼저 준비되는지에 따라 달라졌다.
Case 4. Cilium은 정상인데 ClusterIP가 안 됐다: iptables INPUT DROP
문제
Cilium agent는 정상이고 서비스 backend도 등록돼 있었지만 Pod에서 CoreDNS와 Kubernetes API에 접근하지 못했다. Hubble Relay도 peer service에 연결하지 못했다.
$ kubectl run net-debug --rm -it --restart=Never \
--image=nicolaka/netshoot -- sh
$ nslookup kubernetes.default.svc.cluster.local 10.43.0.10
$ curl -sk https://10.43.0.1/healthz
;; connection timed out; no servers could be reached
curl: (7) Failed to connect to 10.43.0.1 port 443: No route to host
CoreDNS와 Hubble Relay에도 같은 계열의 증상이 나타났다.
$ kubectl -n kube-system logs deploy/rke2-coredns-rke2-coredns --tail=50
$ kubectl -n kube-system logs deploy/hubble-relay --tail=50
plugin/ready: Still waiting on: "kubernetes"
dial tcp 10.43.0.1:443: connect: no route to host
dial tcp 10.43.4.26:443: connect: no route to host
진단
먼저 Cilium 자체와 BPF service map을 확인했다.
$ kubectl -n kube-system exec daemonset/cilium -c cilium-agent -- \
cilium-dbg status
$ kubectl -n kube-system exec daemonset/cilium -c cilium-agent -- \
cilium-dbg service list | grep -E '10\.43\.0\.(1|10)'
KubeProxyReplacement: True
Routing: Network: Tunnel [vxlan]
Cilium health daemon: Ok
10.43.0.1:443/TCP ClusterIP => 10.0.0.99:6443/TCP (active)
호스트와 Pod에서 같은 ClusterIP를 호출해 비교했다. 호스트에서 호출하면 API까지 도달했지만, Pod에서 호출하면 No route to host였다.
# host namespace
$ curl -sk -o /dev/null -w '%{http_code}\n' https://10.43.0.1/healthz
401
환경에 따라 /healthz는 200이 나올 수도 있다. 응답 코드와 별개로 TCP/TLS 연결이 성립해 No route to host가 사라졌는지 확인했다.
호스트 방화벽을 확인하자 INPUT 기본 정책이 DROP이었고, cilium_host, cilium_vxlan, Pod CIDR에서 들어오는 패킷이 허용 목록에 없었다.
$ sudo iptables -nvL INPUT --line-numbers
$ ip -br link show | grep -E 'cilium_host|cilium_vxlan'
Chain INPUT (policy DROP)
num pkts bytes target prot in source
...
1842 110K DROP all -- cilium_host * 10.42.0.0/16 0.0.0.0/0
cilium_host UP
cilium_vxlan UNKNOWN
원인
Cilium의 BPF service map은 정상이었지만, 패킷이 실제 backend로 전달되는 과정에서 호스트의 iptables INPUT DROP 정책에 걸렸다. BPF service map을 확인한 뒤에도 호스트 방화벽에서 패킷을 허용하는지 봐야 했다.
해결
위험 작업: 방화벽 변경은 SSH와 control-plane 접속을 동시에 끊을 수 있다. 이 예시는 홈랩 장애 기록이다. 적용 전 iptables-save 백업, out-of-band 콘솔, 정확한 Pod/Service CIDR과 인터페이스 확인, 자동 롤백 수단을 먼저 준비한다. 클라우드 보안 목록, UFW, firewalld와 iptables를 중복 관리하지 않는다.먼저 기존 규칙을 백업했다.
$ sudo install -d -m 0700 /var/backups/iptables
$ sudo iptables-save | sudo tee \
/var/backups/iptables/pre-cilium-change-20250115-0930.rules >/dev/null
당시 환경의 Pod CIDR은 10.42.0.0/16, Service CIDR은 10.43.0.0/16이었다. 패킷 카운터로 차단 지점을 확인한 뒤 다음 최소 허용 규칙을 추가했다.
# Kubernetes API backend
$ sudo iptables -I INPUT 1 -i cilium_host -p tcp --dport 6443 -j ACCEPT
$ sudo iptables -I INPUT 1 -i cilium_vxlan -p tcp --dport 6443 -j ACCEPT
$ sudo iptables -I INPUT 1 -s 10.42.0.0/16 -p tcp --dport 6443 -j ACCEPT
# CoreDNS
$ sudo iptables -I INPUT 1 -i cilium_host -p udp --dport 53 -j ACCEPT
$ sudo iptables -I INPUT 1 -i cilium_host -p tcp --dport 53 -j ACCEPT
$ sudo iptables -I INPUT 1 -i cilium_vxlan -p udp --dport 53 -j ACCEPT
$ sudo iptables -I INPUT 1 -i cilium_vxlan -p tcp --dport 53 -j ACCEPT
$ sudo iptables -I INPUT 1 -s 10.42.0.0/16 -p udp --dport 53 -j ACCEPT
$ sudo iptables -I INPUT 1 -s 10.42.0.0/16 -p tcp --dport 53 -j ACCEPT
# Hubble peer가 필요한 노드에만 적용
$ sudo iptables -I INPUT 1 -s 10.42.0.0/16 -p tcp --dport 4244 -j ACCEPT
SourceIPVerification 완화도 당시 1차 진단과 복구 과정에서 사용했다.
# 당시의 1차 완화. 영구 표준으로 복사하기 전에 영향 검토가 필요하다.
$ cilium config set enable-source-ip-verification false
이 설정은 source 검증을 약화시키므로 방화벽 규칙의 대체재로 보지 않았다. 정상화 후에는 패킷 경로와 현재 Cilium 버전의 권장값을 다시 검토하고 재활성화 가능 여부를 별도 확인해야 한다.
수동 규칙으로 복구를 확인한 뒤에는 배포판에서 선택한 하나의 방화벽 관리 도구와 Ansible에 규칙을 옮겨 재부팅 후에도 같은 정책이 적용되도록 했다. iptables -I만 남겨두면 재부팅 후 적용 여부와 중복 규칙을 관리하기 어려웠다.
재검증
$ kubectl run net-debug --rm -it --restart=Never \
--image=nicolaka/netshoot -- sh
$ nslookup kubernetes.default.svc.cluster.local 10.43.0.10
$ curl -sk -o /dev/null -w '%{http_code}\n' https://10.43.0.1/healthz
Name: kubernetes.default.svc.cluster.local
Address 1: 10.43.0.1 kubernetes.default.svc.cluster.local
401
$ kubectl -n kube-system rollout status deploy/rke2-coredns-rke2-coredns
$ kubectl -n kube-system rollout status deploy/hubble-relay
$ kubectl -n kube-system logs deploy/hubble-relay --since=5m | tail
DNS 조회가 성공했고 API 응답이 application layer까지 도달했다. CoreDNS의 readiness 경고와 Hubble Relay의 no route to host도 사라졌다.
교훈
Node Ready, Cilium OK, service map은 정상이었지만 실제 요청은 실패했다. 같은 목적지를 host namespace와 Pod namespace에서 호출해보니 차단 구간을 좁힐 수 있었다. 이후에는 cilium_host, cilium_vxlan, Pod CIDR과 실제 backend port를 방화벽 점검 항목에 포함했다.
네 장애를 관통한 디버깅 순서
네 장애를 처리하면서 아래 순서로 확인했다.
- 오류가 발생한 계층과 그 아래 의존성을 확인했다.
- host와 Pod, control plane과 worker에서 같은 요청을 보내 차이를 비교했다.
Ready같은 상태 출력과 실제 통신 결과를 따로 확인했다.- 설정을 바꾸기 전에 현재 상태와 백업을 남겼다.
- 변경 범위를 좁혀 원인을 확인하고, 효과가 있었던 수동 조치는 선언형 설정과 운영 문서에 반영했다.
- 변경 후에는 재부팅, 재조인, DNS, API, Hubble을 다시 확인했다.
개별 서비스가 실행 중이어도 서비스 사이의 통신은 실패할 수 있었다. 다음에 같은 증상을 만나면 다시 처음부터 추측하지 않도록, 실패한 요청과 정상인 요청을 비교한 명령을 남겼다. 임시로 복구한 설정과 이후 정리한 설정도 구분했다.
참고 자료
- etcd Runtime Reconfiguration: learner 추가, catch-up과 순차 멤버십 변경
- systemd Network Targets:
network.target과network-online.target의 의미 - RKE2 Backup and Restore: embedded etcd 스냅샷과 복구 절차
- Cilium kube-proxy-free 설치:
k8sServiceHost,k8sServicePort와 API bootstrap 경로
하이브리드 Kubernetes 구축기 5/6 · ← 이전 글 · 다음 글 →