Terraform Platform 구축기의 2단계에서 Terrakube를 Kubernetes 위로 옮기기 위해, 집에 있는 Raspberry Pi 4 두 대로 kubeadm 클러스터를 만들고 있다. 메모리 4GB인 Pi를 컨트롤 플레인으로, 2GB인 Pi를 워커로 쓴다.
지금까지 컨트롤 플레인 Pi에 Kubernetes 1.37.1 컨트롤 플레인과 Flannel을 올렸다. 중간에 kube-proxy가 k8s-api 이름을 찾지 못해 Flannel과 CoreDNS가 멈춘 문제가 있었고, kube-proxy와 Flannel Pod를 다시 만들어 해결했다(8절, 9절). 지금은 노드가 Ready이고 kube-system과 kube-flannel의 Pod가 모두 Running이다. 이때 컨트롤 플레인 Pi는 메모리 3.7GB 중 약 0.8GB, microSD는 12%만 쓰고 있었지만, 전압 부족으로 CPU 속도가 제한되고 있었다(10절). 워커 Pi는 OS 준비 뒤 재부팅에서 네트워크로 돌아오지 않아 아직 클러스터에 붙이지 못했다. Terrakube를 어느 노드에 둘지는 클러스터가 완성된 뒤 정한다.
항목
값
노드
Raspberry Pi 4 Model B 두 대 (메모리 4GB, 2GB), microSD 29GB, Wi-Fi
OS
Raspberry Pi Imager로 설치한 Debian 13(trixie) 기반, 커널 6.18.50+rpt-rpi-v8, arm64
Kubernetes
1.37.1 (kubeadm, kubelet, kubectl)
컨테이너 런타임
containerd 2.4.2
Pod 네트워크
Flannel, 10.244.0.0/16
노드 주소
공유기 내부 IP
API 서버 주소
k8s-api:6443
지금 구성은 아래와 같다. 두 Pi와 노트북은 모두 Tailscale에 들어 있고, 두 Pi끼리는 같은 공유기 Wi-Fi의 내부 IP로 통신한다(4절). 파란 상자는 Pod가 속한 namespace이고, 그 밖의 kubelet과 containerd는 Pod가 아니라 노드에서 systemd로 도는 서비스다. 워커 Pi 상자는 join한 뒤의 모습이다. containerd와 kubelet은 6절처럼 설치하고, kube-proxy와 Flannel은 DaemonSet이라 join하면 그 노드에도 뜬다. 점선 상자와 점선 화살표는 아직 하지 않았거나 확인하지 않은 부분이다. 노트북의 kubectl 접속은 API 서버 인증서에 Tailscale IP를 넣어 두었지만(7절) 접속 확인은 기록하지 않았고, 워커 Pi는 아직 kubeadm join 전이다.
아래에서 <CP_LAN>은 컨트롤 플레인 Pi의 공유기 내부 IP, <CP_TS>는 그 Pi의 Tailscale IP, <CP 이름>은 그 Pi의 호스트 이름이다. 실제 IP와 호스트 이름은 가렸다.
1. kubeadm을 고른 이유
클러스터를 만드는 방법으로 k3s와 kubeadm을 비교했다. k3s는 하나의 실행 파일로 묶인 가벼운 배포판이라 작은 노드에 잘 맞는다. kubeadm은 Kubernetes 프로젝트가 직접 만드는 설치 도구라서, 컨트롤 플레인 구성 요소를 하나씩 조립하는 과정이 드러난다.
회사에서는 EKS를 써서 컨트롤 플레인이 가려져 있고, 다른 개인 프로젝트에서는 k3s 단일 노드를 이미 써 봤다. 그래서 이번에는 etcd와 API 서버가 static Pod로 뜨는 구조, 인증서, kubeadm join, CNI 설치를 직접 볼 수 있는 kubeadm을 골랐다. kubeadm 문서의 최소 사양은 노드마다 메모리 2GB, 컨트롤 플레인 CPU 2개라서 워커 Pi는 최소치에 가깝다.
2. 서버 점검
설치 전에 두 Pi에서 읽기만 하는 점검 명령을 돌렸다.
echo "== host"; hostname; grep PRETTY_NAME /etc/os-release; uname -rm; echo "== cpu mem disk"; nproc; free -m; df -h /; echo "== swap"; swapon --show; echo "== cgroup"; stat -fc %T /sys/fs/cgroup; echo "== modules"; lsmod | grep -E '^(overlay|br_netfilter)'; echo "== sysctl"; sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables 2>&1; echo "== tools"; for b in containerd docker crictl runc kubeadm kubelet kubectl k3s; do printf '%s: ' $b; command -v $b || echo none; done; echo "== services"; for s in containerd docker kubelet k3s ufw firewalld; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo "== net"; ip -4 -br addr; ip route show default; echo "== ports"; ss -ltn | grep -E ':(6443|2379|2380|10250|10257|10259) ' || echo "none in use"; echo "== time"; timedatectl show -p NTPSynchronized; echo "== machine-id"; cat /etc/machine-id
항목
컨트롤 플레인 Pi
워커 Pi
판단
CPU
4
4
충족
메모리
3795MB
1844MB
워커는 최소치보다 약간 적음
디스크
microSD 29GB, 24GB 남음
microSD 29GB, 24GB 남음
용량은 충분
swap
zram 2GB
zram 1.8GB
꺼야 함
cgroup
v2
v2
좋음
overlay, br_netfilter 모듈
안 올라옴
안 올라옴
올려야 함
net.ipv4.ip_forward
0
0
1로 바꿔야 함
컨테이너 런타임, Kubernetes 도구
없음
없음
깨끗함
네트워크
Wi-Fi(공유기 내부) + Tailscale
Wi-Fi(공유기 내부) + Tailscale
같은 공유기
사용 중인 Kubernetes 포트
없음
없음
좋음
시계 동기화, machine-id
정상, 서로 다름
정상, 서로 다름
좋음
Raspberry Pi 커널이라 메모리 cgroup과 swap 관리 방식을 한 번 더 확인했다.
== modelRaspberry Pi 4 Model B Rev 1.4== cgroup controllerscpuset cpu io pids== cmdlinecoherent_pool=1M 8250.nr_uarts=0 snd_bcm2835.enable_headphones=0 cgroup_disable=memory numa_policy=interleave ... ds=nocloud;i=rpi-imager-... cfg80211.ieee80211_regdom=KR== modules available/lib/modules/6.18.50+rpt-rpi-v8/kernel/fs/overlayfs/overlay.ko.xz/lib/modules/6.18.50+rpt-rpi-v8/kernel/net/bridge/br_netfilter.ko.xz== swap units rpi-setup-loop@var-swap.service loaded active exited rpi-setup-loop - set up file on loop devi… systemd-zram-setup@zram0.service loaded active exited Create swap on /dev/zram0 dev-zram0.swap loaded active active rpi-swap managed swap device (zram+file)
항목
값
해석
cgroup.controllers
cpuset cpu io pids
memory가 없다. kubelet이 Pod 메모리를 제한할 수 없는 상태
/proc/cmdline
cgroup_disable=memory
부팅 옵션에서 메모리 cgroup을 끄고 있다
swap
rpi-swap managed swap device (zram+file)
rpi-swap이 zram과 파일 방식으로 관리한다. swapoff로 끄면 재부팅 때 다시 켜진다
모듈
두 파일 모두 있음
등록만 하면 올릴 수 있다
ds=nocloud;i=rpi-imager-...
cloud-init
Raspberry Pi Imager가 cloud-init으로 첫 설정을 했다. 8절에서 문제가 된다
다른 한 대도 보드 버전(Rev 1.2)과 기기 식별 값만 다르고 결과는 같았다.
3. 이미지와 버전 확인
Pi는 arm64라서, 나중에 올릴 Terrakube 이미지가 arm64를 지원하는지 노트북에서 먼저 확인했다.
Kubernetes 버전은 https://dl.k8s.io/release/stable.txt 기준 최신 안정 버전인 v1.37.1로 정했고, pkgs.k8s.io의 1.37 저장소에 arm64 패키지가 있는 것도 확인했다.
containerd는 버전 때문에 Debian 기본 저장소 대신 Docker 공식 저장소에서 받았다. Debian trixie의 containerd는 1.7.24~ds1-6+deb13u1이고, Docker 저장소에는 trixie arm64용 containerd.io2.4.2가 있다. Kubernetes 문서는 containerd 1.x 같은 오래된 런타임을 위한 대체 동작을 “In Kubernetes 1.38, this fallback behavior will be dropped”라고 안내한다. 처음부터 2.x를 쓰는 편이 다음 업그레이드에 안전하다고 판단했다. Docker 엔진은 설치하지 않고 containerd만 받는다.
4. 노드 주소 정하기
Pi 두 대는 항상 집에 있고, 노트북은 나중에 WSL 노드로 붙일 수도 있지만 가끔 집 밖으로 나간다. 그래서 노드 주소를 Tailscale IP로 할지 공유기 내부 IP로 할지 고민했다.
먼저 Tailscale 주소로 통신할 때 집 안에서 실제로 어느 길로 가는지 노트북에서 확인했다.
pong from <컨트롤 플레인 Pi> (<CP_TS>) via <CP_LAN>:41641 in 17mspong from <워커 Pi> (<WORKER_TS>) via <WORKER_LAN>:41641 in 15ms
via <CP_LAN>은 중계 서버 없이 공유기 Wi-Fi로 직접 연결됐다는 뜻이다. Tailscale 주소를 써도 집 안에서는 Wi-Fi로 다니고, 차이는 WireGuard 암호화와 MTU(Tailscale 인터페이스 1280)뿐이다.
노트북만 Tailscale 주소를 쓰고 Pi는 공유기 주소를 쓰는 구성도 따져 봤다. 밖에 있는 노트북이 Pi의 공유기 주소로 Pod 트래픽을 보내려면 Pi 하나를 Tailscale 서브넷 라우터로 만들어야 하고, 노드마다 Flannel 인터페이스와 MTU가 달라진다. 결국 이번에는 Pi 두 대만 공유기 내부 IP로 만들고, 노트북은 나중에 정하기로 했다.
대신 API 서버 주소는 IP가 아니라 이름 k8s-api로 정했다. API 서버 인증서에는 공유기 IP, Tailscale IP, 호스트 이름, k8s-api를 모두 넣는다. 이름은 노드마다 /etc/hosts로 풀리게 해서, 나중에 노트북을 붙이거나 주소를 바꿀 때 클러스터를 다시 만들지 않아도 되게 했다. 인증서에 Tailscale IP가 있으니 노트북이 밖에 있어도 kubectl로 접속할 수 있다.
컨트롤 플레인 Pi는 다시 켜졌지만, 워커 Pi는 재부팅 뒤 Tailscale에서 오프라인으로 남고 공유기 주소로도 닿지 않았다. 이번에 바꾼 것 중 부팅 자체를 막을 수 있는 것은 부팅 옵션 파일뿐이라 그 파일부터 확인할 예정이다. 컨트롤 플레인 Pi에서 메모리 cgroup과 swap이 실제로 바뀌었는지 확인한 출력은 남기지 않았다. 7절의 kubeadm init은 사전 점검을 통과했다. 두 설정이 적용된 것은 10절의 자원 점검에서 간접적으로 확인했다.
6. containerd와 kubeadm 설치
여기부터는 컨트롤 플레인 Pi에서만 실행했다. 먼저 Docker 저장소를 등록하고 containerd만 설치했다.
패키지에 들어 있는 containerd 설정은 Kubernetes가 쓰는 CRI 기능을 꺼 둔 경우가 있어서, Kubernetes 문서대로 기본 설정을 새로 만들고 cgroup 드라이버를 systemd로 바꿨다. cgroup v2에서는 systemd 드라이버가 권장된다.
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
error: error validating "https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml": errorvalidating data: failed to download openapi: Get "https://k8s-api:6443/openapi/v2?timeout=32s": dial tcp: lookupk8s-api on 100.100.100.100:53: no such host; if you choose to ignore these errors, turn validation off with --validate=false
매니페스트를 받는 단계가 아니라 API 서버 이름 k8s-api를 찾는 단계에서 실패했다. 100.100.100.100은 Tailscale의 DNS(MagicDNS)다. /etc/hosts에서 이름을 찾지 못해 DNS에 물었다가 실패한 것이다. 상태를 확인했다.
echo "== hosts"; grep -n 'k8s-api' /etc/hosts || echo "no k8s-api line"; echo "== cloud-init"; grep -rn 'manage_etc_hosts' /etc/cloud/cloud.cfg /etc/cloud/cloud.cfg.d/ /boot/firmware/user-data 2>/dev/null; echo "== kubelet"; systemctl is-active kubelet; echo "== control plane"; sudo crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps --name 'kube-apiserver|etcd'; echo "== api by ip"; kubectl --server=https://<CP_LAN>:6443 get nodes
상태 확인 결과
== hostsno k8s-api line== cloud-init/boot/firmware/user-data:5:manage_etc_hosts: true== kubeletactive== control planesudo: crictl: command not found== api by ipNAME STATUS ROLES AGE VERSION<CP 이름> NotReady control-plane 4m55s v1.37.1
항목
값
해석
/etc/hosts
k8s-api 줄 없음
5절에서 넣은 줄이 재부팅 뒤 사라졌다
/boot/firmware/user-data
manage_etc_hosts: true
Raspberry Pi Imager가 만든 cloud-init 설정이 /etc/hosts를 관리한다
IP로 API 접속
control-plane, NotReady
init은 노드에 역할을 붙이는 데까지 진행됐다. NotReady는 아직 CNI가 없어서다
cloud-init 문서는 manage_etc_hosts: true이면 템플릿으로 /etc/hosts를 만들고, 부팅할 때마다 다시 쓴다고 설명한다. 그래서 직접 넣은 줄은 재부팅 때 지워지고, 바꾼 내용을 남기려면 템플릿을 고쳐야 한다. /etc/cloud/cloud.cfg.d에 manage_etc_hosts: false를 넣어 끄는 방법은 통하지 않는다. cloud-init은 user-data를 cloud.cfg.d 같은 이미지 설정보다 우선하기 때문이다.
그래서 지금 쓸 줄은 /etc/hosts에, 재부팅 뒤에도 남길 줄은 Debian용 템플릿에 넣었다.
echo '<CP_LAN> k8s-api' | sudo tee -a /etc/hosts
echo '<CP_LAN> k8s-api' | sudo tee -a /etc/cloud/templates/hosts.debian.tmpl
getent hosts k8s-api; kubectl get nodes; kubectl -n kube-system get cm kubeadm-config; kubectl -n kube-system get deploy coredns; kubectl -n kube-system get ds kube-proxy
이름이 풀리고 kubeadm-config ConfigMap, CoreDNS, kube-proxy가 모두 있어서 init이 끝까지 된 것을 확인했다. 템플릿에 넣은 줄이 재부팅 뒤에도 남는지는 아직 확인하지 않았다. 워커 Pi도 같은 방식으로 만들었으므로 join 전에 같은 작업이 필요하다.
kubectl get nodes -o wide; kubectl get pods -A -o wide
컨트롤 플레인 노드가 Ready가 되고, kubectl get pods로 Pod가 뜨는 것을 확인했다. 확인 출력은 남기지 않았다.
9-1. Pod 목록이 나온 것을 정상으로 보고 넘어갔다
kubectl get pods가 목록을 돌려준 것만 보고 끝난 것으로 판단했다. 다시 보니 Flannel Pod는 오류 상태였고, CoreDNS는 Pod 네트워크가 준비되지 않아 ContainerCreating에 머물러 있었다. 목록이 나온다는 것은 API 서버에 연결된다는 뜻일 뿐, 각 Pod가 Running인지는 STATUS 열을 따로 봐야 한다.
Flannel 로그를 보니 Pod 네트워크를 만들기 전에 클러스터 안의 API 서버 주소에 닿지 못하고 있었다.
W1021 05:47:17.118660 1 client_config.go:667] Neither --kubeconfig nor --master was specified. Using the inClusterConfig. This might not work.E1021 05:47:47.151383 1 main.go:251] Failed to create SubnetManager: error retrieving pod spec for 'kube-flannel/kube-flannel-ds-xsmbf': Get "https://10.96.0.1:443/api/v1/namespaces/kube-flannel/pods/kube-flannel-ds-xsmbf": dial tcp 10.96.0.1:443: i/o timeout
10.96.0.1은 kubernetes Service의 가상 주소(ClusterIP)다. 이 주소로 가는 요청을 실제 API 서버로 바꿔 주는 규칙은 kube-proxy가 노드에 넣는다. 그래서 kube-proxy를 봤다.
kube-system Pod와 kube-proxy 로그 (노드 이름과 IP는 가렸고, 모두 <none>인 열과 URL 뒷부분은 줄였다)
NAME READY STATUS RESTARTS AGE IP NODEcoredns-559f6c778d-27kbr 0/1 ContainerCreating 0 80m <none> <CP 이름>coredns-559f6c778d-l2f6f 0/1 ContainerCreating 0 80m <none> <CP 이름>etcd-<CP 이름> 1/1 Running 0 81m <CP_LAN> <CP 이름>kube-apiserver-<CP 이름> 1/1 Running 0 81m <CP_LAN> <CP 이름>kube-controller-manager-<CP 이름> 1/1 Running 0 81m <CP_LAN> <CP 이름>kube-proxy-4hpzk 1/1 Running 0 80m <CP_LAN> <CP 이름>kube-scheduler-<CP 이름> 1/1 Running 0 81m <CP_LAN> <CP 이름>E1021 05:37:01.632747 1 reflector.go:227] "Failed to watch" err="failed to list *v1.Node: Get \"https://k8s-api:6443/api/v1/nodes?...\": dial tcp: lookup k8s-api on 100.100.100.100:53: no such host"
kube-proxy는 Running이고 재시작도 없었지만, API 서버 이름 k8s-api를 찾지 못해 Service 목록을 받지 못하고 있었다. 노드에서 직접 친 kubectl은 8절에서 고친 뒤로 잘 됐는데 kube-proxy만 실패한 이유는 kubelet이 /etc/hosts를 다루는 방식에 있다.
kubelet은 노드 네트워크를 쓰는 Pod(kube-proxy, Flannel)의 컨테이너를 띄울 때 노드의 /etc/hosts를 읽어서 그 Pod 전용 파일로 복사해 넣는다. kubelet 소스의 ensureHostsFile에서 확인했다. kube-proxy 컨테이너는 kubeadm init 때 한 번 뜬 뒤 재시작이 없었으므로, k8s-api 줄이 없던 때의 복사본을 계속 쓰고 있었다. 결과적으로 kube-proxy가 규칙을 넣지 못했고, Flannel은 10.96.0.1에 닿지 못했으며, CoreDNS는 Pod 네트워크가 없어 ContainerCreating에 머물렀다.
kube-proxy와 Flannel Pod를 지워서 DaemonSet이 새로 만들게 했다. 새 컨테이너는 지금의 /etc/hosts를 받는다.
kubectl -n kube-system delete pod -l k8s-app=kube-proxy
이 노드의 모든 Pod 합계. 컨트롤 플레인 static Pod, CoreDNS, Flannel, kube-proxy가 들어 있다
sudo du
결과 없음
비밀번호가 두 번 거절된 뒤 결과 없이 끝났다. 다시 실행한 기록은 없다
메모리를 가장 많이 쓰는 프로세스는 kube-apiserver(305MB)였고, kube-controller-manager(105MB), kubelet(89MB), containerd(68MB), etcd(62MB)가 뒤를 이었다. 목록의 RSS를 더하면 938MB로 free의 used(764MB)보다 크다. RSS는 여러 프로세스가 함께 쓰는 메모리를 프로세스마다 따로 세므로, 실제 사용량은 free 쪽으로 본다.
swap이 0이고 kubepods.slice의 memory.current가 읽힌 것은 5절의 두 설정이 적용됐다는 간접 근거다. 메모리 cgroup이 꺼져 있으면 memory.current 파일이 생기지 않는다.
걸리는 것은 throttled 값이다. Raspberry Pi 문서의 vcgencmd get_throttled 설명대로 비트를 나누면 다음과 같다.
비트
뜻
0x1
지금 전압이 부족하다
0x4
지금 CPU 속도를 제한하고 있다
0x10000
부팅 뒤 전압 부족이 있었다
0x40000
부팅 뒤 속도 제한이 있었다
Pi가 입력 전압이 낮다고 감지했고, 그래서 CPU 속도를 낮춰 버티고 있다는 뜻이다. 부하가 낮아서 지금은 드러나지 않을 뿐이다. Raspberry Pi 4의 공식 전원 사양은 5V, 최소 3A인데, 지금 쓰는 어댑터와 케이블이 이를 맞추는지는 아직 확인하지 않았다. 이 노드에는 microSD에 계속 쓰는 etcd가 있어서, 전압 부족이 이어지면 그 영향을 받을 수 있다.
다음은 워커 Pi를 살려서 같은 OS 준비와 설치를 하고 kubeadm join으로 붙이는 일이다.