본문으로 건너뛰기

Raspberry Pi에 kubeadm으로 Kubernetes 클러스터 만들기

목표와 현재 결과

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
OSRaspberry Pi Imager로 설치한 Debian 13(trixie) 기반, 커널 6.18.50+rpt-rpi-v8, arm64
Kubernetes1.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판단
CPU44충족
메모리3795MB1844MB워커는 최소치보다 약간 적음
디스크microSD 29GB, 24GB 남음microSD 29GB, 24GB 남음용량은 충분
swapzram 2GBzram 1.8GB꺼야 함
cgroupv2v2좋음
overlay, br_netfilter 모듈안 올라옴안 올라옴올려야 함
net.ipv4.ip_forward001로 바꿔야 함
컨테이너 런타임, Kubernetes 도구없음없음깨끗함
네트워크Wi-Fi(공유기 내부) + TailscaleWi-Fi(공유기 내부) + Tailscale같은 공유기
사용 중인 Kubernetes 포트없음없음좋음
시계 동기화, machine-id정상, 서로 다름정상, 서로 다름좋음

Raspberry Pi 커널이라 메모리 cgroup과 swap 관리 방식을 한 번 더 확인했다.

echo "== model"; tr -d '\0' < /proc/device-tree/model; echo; echo "== cgroup controllers"; cat /sys/fs/cgroup/cgroup.controllers; echo "== cmdline"; cat /proc/cmdline; echo "== modules available"; modinfo -F filename overlay br_netfilter 2>&1; echo "== swap units"; systemctl list-units --all --no-legend '*zram*' '*swap*' 2>/dev/null
항목값해석
cgroup.controllerscpuset cpu io pidsmemory가 없다. kubelet이 Pod 메모리를 제한할 수 없는 상태
/proc/cmdlinecgroup_disable=memory부팅 옵션에서 메모리 cgroup을 끄고 있다
swaprpi-swap managed swap device (zram+file)rpi-swap이 zram과 파일 방식으로 관리한다. swapoff로 끄면 재부팅 때 다시 켜진다
모듈두 파일 모두 있음등록만 하면 올릴 수 있다
ds=nocloud;i=rpi-imager-...cloud-initRaspberry Pi Imager가 cloud-init으로 첫 설정을 했다. 8절에서 문제가 된다

다른 한 대도 보드 버전(Rev 1.2)과 기기 식별 값만 다르고 결과는 같았다.

3. 이미지와 버전 확인

Pi는 arm64라서, 나중에 올릴 Terrakube 이미지가 arm64를 지원하는지 노트북에서 먼저 확인했다.

docker manifest inspect azbuilder/api-server:2.33.2
이미지아키텍처
azbuilder/api-server, executor, open-registry, terrakube-ui (2.33.2)amd64, arm64
ghcr.io/dexidp/dex:v2.42.0amd64, arm, arm64 외
osixia/openldap:1.5.0amd64, arm, arm64
pgsty/silo:latest (MinIO)amd64, arm64
valkey/valkey:8-alpine, postgres:17amd64, arm64 외

모두 arm64를 지원해서 Pi 클러스터에 그대로 올릴 수 있다.

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.io 2.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 주소로 통신할 때 집 안에서 실제로 어느 길로 가는지 노트북에서 확인했다.

& 'C:\Program Files\Tailscale\tailscale.exe' ping -c 3 <Pi 이름>

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로 접속할 수 있다.

5. OS 준비

두 Pi에서 같은 명령을 실행했다. 부팅 옵션 파일은 먼저 백업했다.

sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak

메모리 cgroup을 켜는 옵션을 부팅 옵션 파일의 같은 줄 끝에 붙였다. 이 파일은 반드시 한 줄이어야 한다.

sudo sed -i '1 s/$/ cgroup_memory=1 cgroup_enable=memory/' /boot/firmware/cmdline.txt

swap은 rpi-swap의 설정으로 껐다. rpi-swap 문서(swap.conf(5))는 [Main] 섹션의 Mechanism=none을 넣고 재부팅하라고 안내한다. systemctl daemon-reload만으로는 이미 켜진 swap이 꺼지지 않는다.

sudo mkdir -p /etc/rpi/swap.conf.d && printf '[Main]\nMechanism=none\n' | sudo tee /etc/rpi/swap.conf.d/80-k8s-no-swap.conf

Pod 네트워크에 필요한 커널 모듈과 커널 설정을 부팅 때 적용되게 등록했다.

printf 'overlay\nbr_netfilter\n' | sudo tee /etc/modules-load.d/k8s.conf
printf 'net.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\n' | sudo tee /etc/sysctl.d/99-k8s.conf

API 서버 이름도 등록하고 재부팅했다.

echo '<CP_LAN> k8s-api' | sudo tee -a /etc/hosts
sudo reboot

컨트롤 플레인 Pi는 다시 켜졌지만, 워커 Pi는 재부팅 뒤 Tailscale에서 오프라인으로 남고 공유기 주소로도 닿지 않았다. 이번에 바꾼 것 중 부팅 자체를 막을 수 있는 것은 부팅 옵션 파일뿐이라 그 파일부터 확인할 예정이다. 컨트롤 플레인 Pi에서 메모리 cgroup과 swap이 실제로 바뀌었는지 확인한 출력은 남기지 않았다. 7절의 kubeadm init은 사전 점검을 통과했다. 두 설정이 적용된 것은 10절의 자원 점검에서 간접적으로 확인했다.

6. containerd와 kubeadm 설치

여기부터는 컨트롤 플레인 Pi에서만 실행했다. 먼저 Docker 저장소를 등록하고 containerd만 설치했다.

sudo install -m 0755 -d /etc/apt/keyrings && sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update && sudo apt-get install -y containerd.io

패키지에 들어 있는 containerd 설정은 Kubernetes가 쓰는 CRI 기능을 꺼 둔 경우가 있어서, Kubernetes 문서대로 기본 설정을 새로 만들고 cgroup 드라이버를 systemd로 바꿨다. cgroup v2에서는 systemd 드라이버가 권장된다.

containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml && grep -n 'SystemdCgroup' /etc/containerd/config.toml
sudo systemctl restart containerd && systemctl is-active containerd

다음으로 pkgs.k8s.io의 1.37 저장소를 등록하고 kubelet, kubeadm, kubectl을 설치한 뒤 버전을 고정했다.

sudo apt-get install -y apt-transport-https ca-certificates curl gpg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.37/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update && sudo apt-get install -y kubelet kubeadm kubectl && sudo apt-mark hold kubelet kubeadm kubectl
sudo systemctl enable --now kubelet

이 시점의 kubelet은 kubeadm init이 설정을 만들어 줄 때까지 재시작을 반복한다.

7. kubeadm init

설정을 파일로 만들어 init에 넘겼다.

cat <<'EOF' > ~/kubeadm-config.yaml
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: <CP_LAN>
  bindPort: 6443
nodeRegistration:
  criSocket: unix:///run/containerd/containerd.sock
  kubeletExtraArgs:
    - name: node-ip
      value: <CP_LAN>
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.37.1
controlPlaneEndpoint: k8s-api:6443
networking:
  podSubnet: 10.244.0.0/16
apiServer:
  certSANs:
    - k8s-api
    - <CP 이름>
    - <CP_LAN>
    - <CP_TS>
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
EOF
설정뜻
advertiseAddress, node-ip이 노드가 클러스터 안에서 쓰는 주소는 공유기 내부 IP
controlPlaneEndpointAPI 서버 주소는 이름 k8s-api
certSANs인증서에 Tailscale IP도 넣어 밖에서도 kubectl 사용
podSubnetFlannel 기본 대역
cgroupDrivercontainerd와 같은 systemd
sudo kubeadm config validate --config ~/kubeadm-config.yaml
sudo kubeadm config images pull --config ~/kubeadm-config.yaml
sudo kubeadm init --config ~/kubeadm-config.yaml
mkdir -p $HOME/.kube && sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config && sudo chown $(id -u):$(id -g) $HOME/.kube/config

init 출력은 따로 남기지 않았다. 마지막에 나오는 kubeadm join 명령에는 클러스터에 들어올 수 있는 토큰이 들어 있어서, 기록할 때는 토큰과 해시를 지워야 한다.

8. kubectl이 k8s-api 이름을 찾지 못했다

Flannel을 설치하려고 하자 kubectl이 API 서버에 연결하지 못했다.

kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml

매니페스트를 받는 단계가 아니라 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
항목값해석
/etc/hostsk8s-api 줄 없음5절에서 넣은 줄이 재부팅 뒤 사라졌다
/boot/firmware/user-datamanage_etc_hosts: trueRaspberry Pi Imager가 만든 cloud-init 설정이 /etc/hosts를 관리한다
IP로 API 접속control-plane, NotReadyinit은 노드에 역할을 붙이는 데까지 진행됐다. 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 전에 같은 작업이 필요하다.

9. Flannel 설치

kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
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 서버 주소에 닿지 못하고 있었다.

kubectl -n kube-flannel logs -l app=flannel --all-containers --tail=40

10.96.0.1은 kubernetes Service의 가상 주소(ClusterIP)다. 이 주소로 가는 요청을 실제 API 서버로 바꿔 주는 규칙은 kube-proxy가 노드에 넣는다. 그래서 kube-proxy를 봤다.

kubectl -n kube-system get pods -o wide; kubectl -n kube-system logs -l k8s-app=kube-proxy --tail=40

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
kubectl -n kube-system logs -l k8s-app=kube-proxy --tail=5; curl -k -s -m 5 https://10.96.0.1:443/livez; echo
kubectl -n kube-flannel delete pod -l app=flannel
kubectl get nodes -o wide; kubectl get pods -A -o wide
항목값해석
노드 STATUSReadyPod 네트워크까지 준비됐다
CONTAINER-RUNTIMEcontainerd://2.4.2Docker 저장소에서 받은 containerd 2.x로 돈다
CoreDNS IP10.244.0.2, 10.244.0.3Flannel이 이 노드에 나눠 준 Pod 대역에서 주소를 받았다
kube-proxy, Flannel AGE5분, 3분다시 만든 Pod다. 이전 Pod와 이름이 다르다
모든 Pod STATUSRunning9-1에서 놓쳤던 STATUS 열까지 확인했다

워커 Pi는 kubeadm join보다 먼저 /etc/hosts와 템플릿에 k8s-api 줄을 넣어야, 그 노드에 생길 kube-proxy와 Flannel이 처음부터 올바른 복사본을 받는다.

10. 컨트롤 플레인 Pi 자원 점검

컨트롤 플레인을 다 올린 뒤, 이 Pi에 메모리와 디스크가 얼마나 남았는지 확인했다. metrics-server를 설치하지 않아 kubectl top은 쓸 수 없으므로, Pi에서 읽기만 하는 명령을 직접 실행했다.

echo "== uptime"; uptime; echo "== temp"; vcgencmd measure_temp 2>/dev/null; vcgencmd get_throttled 2>/dev/null; echo "== memory"; free -m; echo "== swap"; swapon --show; echo "== disk"; df -h / /boot/firmware; sudo du -sh /var/lib/containerd /var/lib/etcd /var/log 2>/dev/null; echo "== top rss"; ps -eo rss,comm --sort=-rss | head -12 | awk 'NR==1{print "RSS(MB) COMMAND"; next}{printf "%7.0f %s\n", $1/1024, $2}'; echo "== kubepods memory"; awk '{printf "%.0f MB\n", $1/1048576}' /sys/fs/cgroup/kubepods.slice/memory.current; echo "== cpu"; top -bn1 | head -5
항목값해석
부하 평균 (1분, 5분, 15분)0.13, 0.59, 0.67CPU 4개에 비해 낮다
CPU85.7 id85.7%가 쉬고 있다
온도52.5'Cthrottled에 온도 제한 비트가 없어, 속도 제한은 온도 때문이 아니다
throttled0x50005전압 부족으로 CPU 속도가 제한되고 있다. 비트별 뜻은 아래 표에 나눴다
메모리전체 3795MB, used 764MB, available 3031MB컨트롤 플레인을 올리고도 약 3GB가 남는다
swap05절에서 끈 swap이 재부팅 뒤에도 꺼져 있다
디스크 /29G 중 3.3G 사용 (12%)여유가 있다
디스크 /boot/firmware505M 중 75M 사용 (15%)여유가 있다
kubepods.slice 메모리411MB이 노드의 모든 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으로 붙이는 일이다.