끄적끄적 코딩
728x90

들어가며

이번 글에서는 Kubespray 기반 Kubernetes 클러스터를 최소 중단(Minimal Downtime) 으로 업그레이드하는 전체 흐름을 실습하며 정리해보았습니다. 업그레이드는 단순히 kube_version만 올리는 작업이 아니라, Control Plane / Worker / Network(CNI) / Add-on / 런타임(containerd)까지 여러 구성 요소가 순차적으로 교체·재기동되면서 서비스 영향이 발생할 수 있었습니다.

아래 세 가지 관점으로 전체 흐름을 먼저 잡아보는 방식으로 구성해보았습니다.

  • 업그레이드 순서: Control Plane → Network(CNI) → Worker → Add-on
  • 영향 포인트: apiserver 교체 시점, kube-proxy/DaemonSet 재기동, CNI 이미지 Pull 구간 등
  • 제어 방식: upgrade-cluster.yml, serial, limit, 필요 시 pause/confirm 옵션

이번 실습의 목표는 패치(1.32.9→1.32.10), 마이너(1.32→1.33→1.34) 업그레이드를 단계적으로 진행하면서, 각 단계에서 발생하는 흔들림을 모니터링으로 관찰하고 원인과 대응 포인트를 정리해보는 것입니다.


전체 흐름 다시보기

이번 실습은 “Kubespray로 HA 클러스터를 만들고 → 운영(노드/API) → 모니터링 → 업그레이드”까지 처음부터 끝까지 이어지는 흐름으로 구성했습니다. 2장에서는 각 단계를 자세히 파기보다는, 전체를 한 번에 훑을 수 있도록 핵심만 정리해보겠습니다.

인프라 및 운영 도구 준비

  • 구성: admin-lb(192.168.10.10) + control-plane 3대(11~13) + worker 2대(14~15)
  • admin-lb 역할: Kubespray 실행(Ansible 호스트), External LB(HAProxy :6443), NFS(/srv/nfs/share), 운영 도구(kubectl, k9s, helm) 준비
  • 노드 초기화(init_cfg.sh): 설치 전에 OS/네트워크 설정을 Kubernetes 요구 상태로 정리했습니다.

Kubespray로 클러스터 설치

  • inventory 구성: [kube_control_plane] 3대 + [kube_node] worker 구성
  • 설치 커스터마이징: CNI calico → flannel, kube-proxy ipvs → iptables 등
  • 설치 후 확인: 노드 Ready, etcd 상태, kube-system 파드 정상 여부를 구성요소 단위로 확인했습니다.

API 엔드포인트 이해

이번 실습에서 가장 중요한 포인트는 “각 컴포넌트가 어느 API 엔드포인트를 바라보는가”였습니다.

  • Case 1) Worker Client-side LB(기본): 워커는 https://127.0.0.1:6443 로컬에서 분산
  • Case 2) 운영자만 External LB: 운영자 kubeconfig를 192.168.10.10:6443로 통일(SAN 포함 필요)
  • Case 3) 워커도 External LB만 사용: loadbalancer_apiserver_localhost: false 기반(External LB 흔들림 영향 고려)

노드 운영과 모니터링, 업그레이드

  • 노드 운영
    • 추가: scale.yml
    • 삭제: remove-node.yml (필요 시 강제 옵션 이해)
    • 초기화: reset.yml (전체 초기화)
  • 모니터링
    • NFS 기반 PVC 사용 + kube-prometheus-stack 구성
    • HAProxy / etcd metrics 추가로 업그레이드 흔들림을 관찰 가능하게 했습니다.
  • 업그레이드
    • 패치(1.32.9 → 1.32.10) → 마이너(1.32.10 → 1.33.7) → Kubespray(v2.30.0) + k8s(→ 1.34.3)
    • k8s뿐 아니라 containerd/애드온/kubectl까지 정합성을 맞추는 것을 목표로 했습니다.

실습 환경 배포

본 장에서는 Kubespray 실습을 진행하기 위한 가상머신(Virtual Machine) 기반 환경을 먼저 구성하고, admin-lb 노드에서 Kubespray 배포까지 수행한 과정을 정리했습니다.


사전 요구사항 및 VM Spec

실습 환경은 아래 전제를 기준으로 구성했습니다.

  • 가상머신 Spec : VM OS(Rocky Linux 10) → VirtualBox 7.2.4 과 Vagrant 2.4.9 버전 이상 필요

여기서 핵심은 다음과 같습니다.

  • 실습 VM OS는 Rocky Linux 10 기반입니다.
  • VM 배포는 버추얼박스(VirtualBox) + 배이그런트(Vagrant) 조합으로 진행합니다.
  • admin-lb 노드는 Kubespray 실행과 API LB 역할을 함께 수행하도록 구성되어 있습니다.

실습 토폴로지 구성

아래 토폴로지는 이번 실습 환경에서 노드 간 연결 관계를 보여줍니다.

  • admin-lb(192.168.10.10)는 6443/TCP로 들어오는 요청을 Control Plane의 kube-apiserver(k8s-node1~3)로 분산 전달합니다.
  • Worker 노드(k8s-node4~5)는 클라이언트 사이드 로드밸런싱(Client-Side LB) 형태로 nginx static pod가 함께 배치되는 구성을 가정하고 있습니다.
graph TD
    subgraph External_Access [External Access & LB]
        LB["**admin-lb**<br/>L4 LB in front of k8s-api<br/>(192.168.10.10)"]
    end

    subgraph K8S_Control_Plane [Control Plane - HA<br/>node에 kcm,scheduler 생략]
        subgraph CTR1_Node [k8s-node1: 192.168.10.11]
            API1["**kube-apiserver**"]
            KUBELET_CTR1["kubelet"]
            KPROXY_CTR1["kube-proxy"]

            KUBELET_CTR1 -->|local connect| API1
            KPROXY_CTR1 -->|svc/ep - watch/list| API1
        end
        subgraph CTR2_Node [k8s-node2: 192.168.10.12]
            API2["**kube-apiserver**"]
            KUBELET_CTR2["kubelet"]
            KPROXY_CTR2["kube-proxy"]

            KUBELET_CTR2 -->|local connect| API2
            KPROXY_CTR2 -->|svc/ep - watch/list| API2
        end
        subgraph CTR3_Node [k8s-node3: 192.168.10.13]
            API3["**kube-apiserver**"]
            KUBELET_CTR3["kubelet"]
            KPROXY_CTR3["kube-proxy"]

            KUBELET_CTR3 -->|local connect| API3
            KPROXY_CTR3 -->|svc/ep - watch/list| API3
        end
    end

    subgraph K8S_Workers [Worker Nodes]
        subgraph W1_Node [k8s-node4: 192.168.10.14]
            KUBELET1["kubelet"]
            KPROXY1["kube-proxy"]
            NGX1["**nginx static pod<br/>(Client-Side LB)**"]

            KUBELET1 -->|node mgmt| NGX1
            KPROXY1 -->|local connect| NGX1
        end
        subgraph W2_Node [k8s-node5: 192.168.10.15]
            KUBELET2["kubelet"]
            KPROXY2["kube-proxy"]
            NGX2["**nginx static pod<br/>(Client-Side LB)**"]

            KUBELET2 -->|node mgmt| NGX2
            KPROXY2 -->|local connect| NGX2
        end
    end

    %% Connections
    %% 1. LB에서 각 Control Plane의 API Server로 로드밸런싱
    LB ==>|6443/TCP| API1
    LB ==>|6443/TCP| API2
    LB ==>|6443/TCP| API3

    %% 2. Worker Node(kubelet/kube-proxy)에서 API Server로 연결 (일반적으로 LB 경유)
    W1_Node -.->|Watch/Update| API1
    W1_Node -.->|Watch/Update| API2
    W1_Node -.->|Watch/Update| API3

    W2_Node -.->|Watch/Update| API1
    W2_Node -.->|Watch/Update| API2
    W2_Node -.->|Watch/Update| API3

    %% Styling
    style LB fill:#f9f,stroke:#333,stroke-width:2px
    style API1 fill:#fff9c4,stroke:#fbc02d
    style API2 fill:#fff9c4,stroke:#fbc02d
    style API3 fill:#fff9c4,stroke:#fbc02d

    style KUBELET_CTR1 fill:#e3f2fd,stroke:#1e88e5
    style KUBELET_CTR2 fill:#e3f2fd,stroke:#1e88e5
    style KUBELET_CTR3 fill:#e3f2fd,stroke:#1e88e5
    style KPROXY_CTR1 fill:#ede7f6,stroke:#5e35b1
    style KPROXY_CTR2 fill:#ede7f6,stroke:#5e35b1
    style KPROXY_CTR3 fill:#ede7f6,stroke:#5e35b1

    style KUBELET1 fill:#e3f2fd,stroke:#1e88e5
    style KUBELET2 fill:#e3f2fd,stroke:#1e88e5
    style KPROXY1 fill:#ede7f6,stroke:#5e35b1
    style KPROXY2 fill:#ede7f6,stroke:#5e35b1

    style NGX1 fill:#c8e6c9,stroke:#388e3c
    style NGX2 fill:#c8e6c9,stroke:#388e3c

    style K8S_Control_Plane fill:#e1f5fe,stroke:#01579b
    style K8S_Workers fill:#f1f8e9,stroke:#33691e

노드별 스펙 정리

NAME Description CPU RAM NIC1 NIC2 Init Script
admin-lb kubespary 실행, API LB 2 1GB 10.0.2.15 192.168.10.10 admin-lb.sh
k8s-node1 K8S ControlPlane 4 2GB 10.0.2.15 192.168.10.11 init-cfg.sh
k8s-node2 K8S ControlPlane 4 2GB 10.0.2.15 192.168.10.12 init-cfg.sh
k8s-node3 K8S ControlPlane 4 2GB 10.0.2.15 192.168.10.13 init-cfg.sh
k8s-node4 K8S Worker 4 2GB 10.0.2.15 192.168.10.14 init-cfg.sh
k8s-node5 K8S Worker 4 2GB 10.0.2.15 192.168.10.15 init-cfg.sh

실습 환경 배포 구성 파일

Vagrantfile

아래 Vagrantfile은 총 2가지 유형의 VM을 배포합니다.

  • k8s-node1~k8s-node5: 클러스터 노드들 (공통으로 init_cfg.sh 적용)
  • admin-lb: 관리/로드밸런서 노드 (admin-lb.sh 적용)
# Base Image  https://portal.cloud.hashicorp.com/vagrant/discover/bento/rockylinux-10.0
BOX_IMAGE = "bento/rockylinux-10.0" # "bento/rockylinux-9"
BOX_VERSION = "202510.26.0"
N = 5 # max number of Node

Vagrant.configure("2") do |config|

# Nodes 
  (1..M).each do |i|
    config.vm.define "k8s-node#{i}" do |subconfig|
      subconfig.vm.box = BOX_IMAGE
      subconfig.vm.box_version = BOX_VERSION
      subconfig.vm.provider "virtualbox" do |vb|
        vb.customize ["modifyvm", :id, "--groups", "/Kubespary-Lab"]
        vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
        vb.name = "k8s-node#{i}"
        vb.cpus = 4
        vb.memory = 2048
        vb.linked_clone = true
      end
      subconfig.vm.host_name = "k8s-node#{i}"
      subconfig.vm.network "private_network", ip: "192.168.10.1#{i}"
      subconfig.vm.network "forwarded_port", guest: 22, host: "6000#{i}", auto_correct: true, id: "ssh"
      subconfig.vm.synced_folder "./", "/vagrant", disabled: true
      subconfig.vm.provision "shell", path: "init_cfg.sh"
    end
  end

# Admin & LoadBalancer Node
    config.vm.define "admin-lb" do |subconfig|
      subconfig.vm.box = BOX_IMAGE
      subconfig.vm.box_version = BOX_VERSION
      subconfig.vm.provider "virtualbox" do |vb|
        vb.customize ["modifyvm", :id, "--groups", "/Kubespary-Lab"]
        vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
        vb.name = "admin-lb"
        vb.cpus = 2
        vb.memory = 1024
        vb.linked_clone = true
      end
      subconfig.vm.host_name = "admin-lb"
      subconfig.vm.network "private_network", ip: "192.168.10.10"
      subconfig.vm.network "forwarded_port", guest: 22, host: "60000", auto_correct: true, id: "ssh"
      subconfig.vm.synced_folder "./", "/vagrant", disabled: true
      subconfig.vm.provision "shell", path: "admin-lb.sh"
    end

end

여기서 확인할 포인트는 다음과 같습니다.

  • 각 노드는 private_network로 192.168.10.0/24 대역을 사용합니다.
  • 각 노드는 forwarded_port로 SSH 접근 포트를 다르게 잡습니다.
  • admin-lb는 별도 스크립트로 LB 및 Kubespray 실행 환경을 준비합니다.

admin-lb.sh

admin-lb.sh는 admin-lb 노드에서 다음 준비를 자동으로 수행합니다.

  • 시간대/NTP 설정 및 보안 정책 정리(방화벽/SELinux)
  • /etc/hosts 기반 내부 DNS 구성
  • kube-apiserver용 HAProxy(HAProxy) 설정 (6443/TCP → backend k8s-node1~3:6443)
  • NFS(Network File System) 서버 설정(/srv/nfs/share)
  • SSH root 접속 및 키 기반 인증 구성
  • Kubespray clone 및 인벤토리 생성
  • kubectl, k9s, kubecolor, helm 설치
#!/usr/bin/env bash

echo ">>>> Initial Config Start <<<<"

echo "[TASK 1] Change Timezone and Enable NTP"
timedatectl set-local-rtc 0
timedatectl set-timezone Asia/Seoul

echo "[TASK 2] Disable firewalld and selinux"
systemctl disable --now firewalld >/dev/null 2>&1
setenforce 0
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config

echo "[TASK 3] Setting Local DNS Using Hosts file"
sed -i '/^127\.0\.\(1\|2\)\.1/d' /etc/hosts
echo "192.168.10.10 k8s-api-srv.admin-lb.com admin-lb" >> /etc/hosts
for (( i=1; i<=$1; i++  )); do echo "192.168.10.1$i k8s-node$i" >> /etc/hosts; done

echo "[TASK 4] Delete default routing - enp0s9 NIC" # setenforce 0 설정 필요
nmcli connection modify enp0s9 ipv4.never-default yes
nmcli connection up enp0s9 >/dev/null 2>&1

echo "[TASK 5] Install kubectl"
cat << EOF > /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.32/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.32/rpm/repodata/repomd.xml.key
exclude=kubectl
EOF
dnf install -y -q kubectl --disableexcludes=kubernetes >/dev/null 2>&1

echo "[TASK 6] Install HAProxy"
dnf install -y haproxy >/dev/null 2>&1

cat << EOF > /etc/haproxy/haproxy.cfg
#---------------------------------------------------------------------
# Global settings
#---------------------------------------------------------------------
global
    log         127.0.0.1 local2

    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    maxconn     4000
    user        haproxy
    group       haproxy
    daemon

    # turn on stats unix socket
    stats socket /var/lib/haproxy/stats

    # utilize system-wide crypto-policies
    ssl-default-bind-ciphers PROFILE=SYSTEM
    ssl-default-server-ciphers PROFILE=SYSTEM

#---------------------------------------------------------------------
# common defaults that all the 'listen' and 'backend' sections will
# use if not designated in their block
#---------------------------------------------------------------------
defaults
    mode                    http
    log                     global
    option                  httplog
    option                  tcplog
    option                  dontlognull
    option http-server-close
    #option forwardfor       except 127.0.0.0/8
    option                  redispatch
    retries                 3
    timeout http-request    10s
    timeout queue           1m
    timeout connect         10s
    timeout client          1m
    timeout server          1m
    timeout http-keep-alive 10s
    timeout check           10s
    maxconn                 3000

# ---------------------------------------------------------------------
# Kubernetes API Server Load Balancer Configuration
# ---------------------------------------------------------------------
frontend k8s-api
    bind *:6443
    mode tcp
    option tcplog
    default_backend k8s-api-backend

backend k8s-api-backend
    mode tcp
    option tcp-check
    option log-health-checks
    timeout client 3h
    timeout server 3h
    balance roundrobin
    server k8s-node1 192.168.10.11:6443 check check-ssl verify none inter 10000
    server k8s-node2 192.168.10.12:6443 check check-ssl verify none inter 10000
    server k8s-node3 192.168.10.13:6443 check check-ssl verify none inter 10000

# ---------------------------------------------------------------------
# HAProxy Stats Dashboard - http://192.168.10.10:9000/haproxy_stats
# ---------------------------------------------------------------------
listen stats
    bind *:9000
    mode http
    stats enable
    stats uri /haproxy_stats
    stats realm HAProxy\ Statistic
    stats admin if TRUE

# ---------------------------------------------------------------------
# Configure the Prometheus exporter - curl http://192.168.10.10:8405/metrics
# ---------------------------------------------------------------------
frontend prometheus
    bind *:8405
    mode http
    http-request use-service prometheus-exporter if { path /metrics }
    no log
EOF
systemctl enable --now haproxy >/dev/null 2>&1

echo "[TASK 7] Install nfs-utils"
dnf install -y nfs-utils >/dev/null 2>&1
systemctl enable --now nfs-server >/dev/null 2>&1
mkdir -p /srv/nfs/share
chown nobody:nobody /srv/nfs/share
chmod 755 /srv/nfs/share
echo '/srv/nfs/share *(rw,async,no_root_squash,no_subtree_check)' > /etc/exports
exportfs -rav

echo "[TASK 8] Install packages"
dnf install -y python3-pip git sshpass >/dev/null 2>&1

echo "[TASK 9] Setting SSHD"
echo "root:qwe123" | chpasswd

cat << EOF >> /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
EOF
systemctl restart sshd >/dev/null 2>&1

echo "[TASK 10] Setting SSH Key"
ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa >/dev/null 2>&1

sshpass -p 'qwe123' ssh-copy-id -o StrictHostKeyChecking=no root@192.168.10.10  >/dev/null 2>&1  # cat /root/.ssh/authorized_keys
for (( i=1; i<=$1; i++  )); do sshpass -p 'qwe123' ssh-copy-id -o StrictHostKeyChecking=no root@192.168.10.1$i >/dev/null 2>&1 ; done

ssh -o StrictHostKeyChecking=no root@admin-lb hostname >/dev/null 2>&1
for (( i=1; i<=$1; i++  )); do sshpass -p 'qwe123' ssh -o StrictHostKeyChecking=no root@k8s-node$i hostname >/dev/null 2>&1 ; done

echo "[TASK 11] Clone Kubespray Repository"
git clone -b v2.29.1 https://github.com/kubernetes-sigs/kubespray.git /root/kubespray >/dev/null 2>&1

cp -rfp /root/kubespray/inventory/sample /root/kubespray/inventory/mycluster
cat << EOF > /root/kubespray/inventory/mycluster/inventory.ini
[kube_control_plane]
k8s-node1 ansible_host=192.168.10.11 ip=192.168.10.11 etcd_member_name=etcd1
k8s-node2 ansible_host=192.168.10.12 ip=192.168.10.12 etcd_member_name=etcd2
k8s-node3 ansible_host=192.168.10.13 ip=192.168.10.13 etcd_member_name=etcd3

[etcd:children]
kube_control_plane

[kube_node]
k8s-node4 ansible_host=192.168.10.14 ip=192.168.10.14
#k8s-node5 ansible_host=192.168.10.15 ip=192.168.10.15
EOF

echo "[TASK 12] Install Python Dependencies"
pip3 install -r /root/kubespray/requirements.txt >/dev/null 2>&1 # pip3 list

echo "[TASK 13] Install K9s"
CLI_ARCH=amd64
if [ "$(uname -m)" = "aarch64" ]; then CLI_ARCH=arm64; fi
wget -P /tmp https://github.com/derailed/k9s/releases/latest/download/k9s_linux_${CLI_ARCH}.tar.gz  >/dev/null 2>&1
tar -xzf /tmp/k9s_linux_${CLI_ARCH}.tar.gz -C /tmp
chown root:root /tmp/k9s
mv /tmp/k9s /usr/local/bin/
chmod +x /usr/local/bin/k9s

echo "[TASK 14] Install kubecolor"
dnf install -y -q 'dnf-command(config-manager)' >/dev/null 2>&1
dnf config-manager --add-repo https://kubecolor.github.io/packages/rpm/kubecolor.repo >/dev/null 2>&1
dnf install -y -q kubecolor >/dev/null 2>&1

echo "[TASK 15] Install Helm"
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | DESIRED_VERSION=v3.18.6 bash >/dev/null 2>&1

echo "[TASK 16] ETC"
echo "sudo su -" >> /home/vagrant/.bashrc

echo ">>>> Initial Config End <<<<"

init_cfg.sh

각 k8s-node* 노드는 공통적으로 init_cfg.sh를 통해 Kubernetes 배포에 필요한 OS 초기 상태를 정리합니다.

  • 방화벽/SELinux 정리
  • SWAP 제거
  • 커널 모듈 및 sysctl 반영(overlay, br_netfilter, ip_forward)
  • /etc/hosts 정리 및 노드 호스트명 등록
  • SSH root 접속 허용 및 패스워드 설정
#!/usr/bin/env bash

echo ">>>> Initial Config Start <<<<"

echo "[TASK 1] Change Timezone and Enable NTP"
timedatectl set-local-rtc 0
timedatectl set-timezone Asia/Seoul

echo "[TASK 2] Disable firewalld and selinux"
systemctl disable --now firewalld >/dev/null 2>&1
setenforce 0
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config

echo "[TASK 3] Disable and turn off SWAP & Delete swap partitions"
swapoff -a
sed -i '/swap/d' /etc/fstab
sfdisk --delete /dev/sda 2 >/dev/null 2>&1
partprobe /dev/sda >/dev/null 2>&1

echo "[TASK 4] Config kernel & module"
cat << EOF > /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
modprobe overlay >/dev/null 2>&1
modprobe br_netfilter >/dev/null 2>&1

cat << EOF >/etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF
sysctl --system >/dev/null 2>&1

echo "[TASK 5] Setting Local DNS Using Hosts file"
sed -i '/^127\.0\.\(1\|2\)\.1/d' /etc/hosts
echo "192.168.10.10 k8s-api-srv.admin-lb.com admin-lb" >> /etc/hosts
for (( i=1; i<=$1; i++  )); do echo "192.168.10.1$i k8s-node$i" >> /etc/hosts; done

echo "[TASK 6] Delete default routing - enp0s9 NIC" # setenforce 0 설정 필요
nmcli connection modify enp0s9 ipv4.never-default yes
nmcli connection up enp0s9 >/dev/null 2>&1

echo "[TASK 7] Setting SSHD"
echo "root:qwe123" | chpasswd

cat << EOF >> /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
EOF
systemctl restart sshd >/dev/null 2>&1

echo "[TASK 8] Install packages"
dnf install -y git nfs-utils >/dev/null 2>&1

echo "[TASK 9] ETC"
echo "sudo su -" >> /home/vagrant/.bashrc

echo ">>>> Initial Config End <<<<"

실습 환경 배포 수행

아래 명령은 실습 디렉터리 생성 → 파일 다운로드 → VM 배포까지를 수행합니다. 목적은 “실습 VM을 한 번에 동일한 형태로 재현”하는 것입니다.

# 실습용 디렉터리 생성
mkdir k8s-ha-kubespary
cd k8s-ha-kubespary

# 파일 다운로드
curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-ha-kubespary/Vagrantfile
curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-ha-kubespary/admin-lb.sh
curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-ha-kubespary/init_cfg.sh

# 실습 환경 배포
vagrant up
vagrant status

admin-lb 기본 정보 확인

VM이 올라온 뒤에는 admin-lb로 접속해 "노드 통신/도구 설치/Kubespray 인벤토리/LB 및 NFS 설정 상태"를 순서대로 확인했습니다. 아래 명령은 확인 작업의 전체 흐름을 그대로 수행합니다.

# 관리 대상 노드 통신 확인
cat /etc/hosts
for i in {0..5}; do echo ">> k8s-node$i <<"; ssh 192.168.10.1$i hostname; echo; done
for i in {1..5}; do echo ">> k8s-node$i <<"; ssh k8s-node$i hostname; echo; done

# 파이썬 버전 정보 확인
python -V && pip -V

# kubespary 작업 디렉터리 및 파일 확인
tree /root/kubespray/ -L 2
**cd /root/kubespray/**
cat ansible.cfg
**cat /root/kubespray/inventory/mycluster/inventory.ini**

# NFS Server 정보 확인
systemctl status nfs-server --no-pager
tree /srv/nfs/share/

**exportfs -rav**
*exporting *:/srv/nfs/share*

**cat /etc/exports**
*/srv/nfs/share *(rw,async,no_root_squash,no_subtree_check)*

# admin-lb IP에 TCP 6443 호출(인입)시, 백엔드 대상인 k8s-node1~3에 각각 분산 전달 설정 확인
**cat /etc/haproxy/haproxy.cfg**
...
# ---------------------------------------------------------------------
# Kubernetes API Server Load Balancer Configuration
# ---------------------------------------------------------------------
**frontend k8s-api
    bind *:6443**
    **mode tcp**
    option tcplog
    **default_backend k8s-api-backend

backend k8s-api-backend**
    **mode tcp**
    option tcp-check
    option log-health-checks
    timeout client 3h
    timeout server 3h
    **balance roundrobin
    server k8s-node1 192.168.10.11:6443 check check-ssl verify none inter 10000
    server k8s-node2 192.168.10.12:6443 check check-ssl verify none inter 10000
    server k8s-node3 192.168.10.13:6443 check check-ssl verify none inter 10000**

# HAProxy 상태 확인
systemctl status haproxy.service --no-pager
journalctl -u haproxy.service --no-pager
**ss -tnlp | grep haproxy**
*LISTEN 0      3000         0.0.0.0:6443       0.0.0.0:*    users:(("haproxy",pid=4915,fd=7))  # k8s api loadbalancer
LISTEN 0      3000         0.0.0.0:9000       0.0.0.0:*    users:(("haproxy",pid=4915,fd=8))  # haproxy stat dashbaord
LISTEN 0      3000         0.0.0.0:8405       0.0.0.0:*    users:(("haproxy",pid=4915,fd=9))  # metrics exporter*

# 통계 페이지 접속
open http://192.168.10.10:9000/haproxy_stats

# (참고) 프로테우스 메트릭 엔드포인트 접속
curl http://192.168.10.10:8405/metrics

이 시점에서는 클러스터가 아직 배포되기 전이므로, haproxy_stats 화면에서 backend 대상 서버가 모두 DOWN으로 보였다고 정리하셨습니다. 즉, LB 설정 자체는 존재하지만 실제로 kube-apiserver(:6443)가 올라오기 전 상태라고 이해할 수 있습니다.


Kubespray 기반 배포 수행

인벤토리 확인 및 배포 변수 수정

먼저 Kubespray 버전과 인벤토리를 확인한 뒤, group_vars 값을 변경해 원하는 배포 형태로 조정했습니다. 여기서 핵심 입력은 인벤토리(Inventory)그룹 변수(Group Vars) 입니다.

# 작업용 inventory 디렉터리 확인
**cd /root/kubespray/**
git describe --tags
**git --no-pager tag
...
*v2.29.1**
v2.3.0
**v2.30.0***
**...**

**tree inventory/mycluster/**
*...*

# inventory.ini 확인
**cat /root/kubespray/inventory/mycluster/inventory.ini**
*[**kube_control_plane**]
k8s-node1 ansible_host=192.168.10.11 ip=192.168.10.11 **etcd_member_name=etcd1**
k8s-node2 ansible_host=192.168.10.12 ip=192.168.10.12 **etcd_member_name=etcd2**
k8s-node3 ansible_host=192.168.10.13 ip=192.168.10.13 **etcd_member_name=etcd3**

[**etcd**:children]
kube_control_plane

[**kube_node**]
k8s-node4 ansible_host=192.168.10.14 ip=192.168.10.14
~~#k8s-node5 ansible_host=192.168.10.15 ip=192.168.10.15~~*

# 아래 hostvars 에 선언 적용된 값 찾기는 아래 코드 블록 참고
**ansible-inventory -i /root/kubespray/inventory/mycluster/inventory.ini --list**
        *"**hostvars**": {
            "**k8s-node1"**: {
                "**allow_unsupported_distribution_setup**": false,
                "**ansible_host**": "192.168.10.11", # 해당 값은 바로 위 인벤토리에 host에 직접 선언
                "**bin_dir**": "/usr/local/bin",
...*

**ansible-inventory -i /root/kubespray/inventory/mycluster/inventory.ini --graph**
*@all:
  |--@ungrouped:
  |--**@etcd:**
  |  |--**@kube_control_plane**:
  |  |  |--k8s-node1
  |  |  |--k8s-node2
  |  |  |--k8s-node3
  |--**@kube_node**:
  |  |--k8s-node4*

# **k8s_cluster.yml** # for every node in the cluster (not etcd when it's separate)
sed -i 's|*kube_owner: kube|kube_owner: **root***|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|*kube_network_plugin: **calico***|*kube_network_plugin: **flannel***|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|*kube_proxy_mode: **ipvs***|*kube_proxy_mode: **iptables***|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|*enable_nodelocaldns: **true***|*enable_nodelocaldns: **false***|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
grep -iE '*kube_owner**|**kube_network_plugin:**|**kube_proxy_mode|enable_nodelocaldns:*' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
## coredns autoscaler 미설치
echo "**enable_dns_autoscaler: false**" >> inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
******
# flannel 설정 수정
echo "**flannel_interface: enp0s9**" >> inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml
grep "^[^#]" inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml

# addons
sed -i 's|metrics_server_enabled: false|**metrics_server_enabled: true**|g' inventory/mycluster/group_vars/k8s_cluster/addons.yml
grep -iE 'metrics_server_enabled:' inventory/mycluster/group_vars/k8s_cluster/addons.yml
## cat roles/kubernetes-apps/metrics_server/defaults/main.yml                            # 메트릭서버 관련 디폴트 변수 참고
## cat roles/kubernetes-apps/metrics_server/templates/metrics-server-deployment.yaml.j2  # jinja2 템플릿 파일 참고
echo "**metrics_server_requests_cpu: 25m**"     >> inventory/mycluster/group_vars/k8s_cluster/addons.yml
echo "**metrics_server_requests_memory: 16Mi**" >> inventory/mycluster/group_vars/k8s_cluster/addons.yml

# 지원 버전 정보 확인
**cat roles/kubespray_defaults/vars/main/checksums.yml | grep -i kube -A40**

위 흐름은 “배포 전에 어떤 변수들을 바꿨는지”를 명확히 남기는 데 의미가 있습니다. 특히 kube_network_plugin, kube_proxy_mode, enable_nodelocaldns, enable_dns_autoscaler, metrics_server_enabled처럼 결과 리소스와 동작에 직접 영향을 주는 항목들을 먼저 정리해두는 방식입니다.

ansible-playbook로 배포 실행 및 결과 확인

아래는 Task 목록 확인 후 실제 배포를 실행하고, 로그를 저장한 과정입니다. -e kube_version="1.32.9"처럼 추가 변수(Extra Vars) 를 사용해 배포 버전을 지정했습니다.

# 배포: 아래처럼 반드시 **~/kubespray** 디렉토리에서 **ansible-playbook** 를 실행하자! **8분** 정도 소요
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml **--list-tasks** # 배포 전, Task 목록 확인
ANSIBLE_FORCE_COLOR=true **ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml -e kube_version="1.32.9" | tee kubespray_install.log**

# 설치 확인
**more kubespray_install.log**

# facts 수집 정보 확인
**tree /tmp**
*├── k8s-node1
├── k8s-node2
├── k8s-node3
...*

# local_release_dir: "/tmp/releases" 확인
ssh k8s-node1 tree /tmp/releases
ssh k8s-node4 tree /tmp/releases

이 단계에서 보는 포인트는 다음과 같습니다.

  • kubespray_install.log로 전체 배포 흐름을 기록해, 이후 Role/Task 단위 분석이 가능해집니다.
  • /tmp 아래에 노드별 fact가 수집된 흔적이 남는 구조를 확인할 수 있습니다.
  • /tmp/releases를 통해 다운로드/배치된 바이너리 레이어를 확인할 수 있습니다.

배포 이후 상태 확인

배포가 끝난 후에는 클러스터가 정상 생성되었는지, 그리고 “접속 경로가 무엇인지”를 점검했습니다.

# sysctl 적용값 확인
ssh k8s-node1 grep "^[^#]" /etc/sysctl.conf
ssh k8s-node4 grep "^[^#]" /etc/sysctl.conf

# etcd 백업 확인
for i in {1..3}; do echo ">> k8s-node$i <<"; ssh k8s-node$i tree /var/backups; echo; done

# k8s api 호출 확인 : IP, Domain
cat /etc/hosts
for i in {1..3}; do echo ">> k8s-node$i <<"; curl -sk https://**192.168.10.1$i**:6443/version | grep Version; echo; done
for i in {1..3}; do echo ">> k8s-node$i <<"; curl -sk https://**k8s-node$i**:6443/version | grep Version; echo; done

여기서 curl -sk https://...:6443/version 확인은 “각 Control Plane 노드의 API 서버가 실제로 올라왔는지”를 가장 단순하게 검증하는 방법으로 사용하였습니다.

또한 컨트롤 플레인 노드의 kubectl 설정은 기본적으로 https://127.0.0.1:6443를 바라보는 형태임을 로그로 확인하였습니다.

# k8s admin 자격증명 확인 : 컨트롤 플레인 노드들은 apiserver 파드가 배치되어 있으니 127.0.0.1:6443 엔드포인트 설정됨
for i in {1..3}; do echo ">> k8s-node$i <<"; ssh k8s-node$i **kubectl cluster-info -v=6**; echo; done
*I0127 17:06:29.481012   27149 loader.go:402] Config loaded from file:  **/root/.kube/config**
...
Kubernetes control plane is running at **https://127.0.0.1:6443***

**mkdir /root/.kube
scp k8s-node1:/root/.kube/config /root/.kube/
cat /root/.kube/config | grep server**

# API Server 주소를 localhost에서 컨트롤 플레인 1번 node P로 변경 : 1번 node 장애 시, 직접 수동으로 다른 node IP 변경 필요.
kubectl get node -owide -v=6
**sed -i 's/127.0.0.1/192.168.10.11/g' /root/.kube/config**
*혹은 
sed -i 's/127.0.0.1/**192.168.10.12**/g' /root/.kube/config
sed -i 's/127.0.0.1/**192.168.10.13**/g' /root/.kube/config*
****
**
**kubectl get node -owide -v=6**
*I0127 17:08:03.347290   14006 loader.go:402] Config loaded from file:  /root/.kube/config
...
NAME        STATUS   ROLES           AGE     VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE                        KERNEL-VERSION                  CONTAINER-RUNTIME
k8s-node1   Ready    control-plane   3m37s   v1.32.9   192.168.10.11   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
k8s-node2   Ready    control-plane   3m31s   v1.32.9   192.168.10.12   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
k8s-node3   Ready    control-plane   3m29s   v1.32.9   192.168.10.13   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
k8s-node4   Ready    <none>          3m3s    v1.32.9   192.168.10.14   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5*

또한 [kube_control_plane]과 [kube_node] 그룹에 따라 taint가 다르게 적용되는 점을 함께 확인하였습니다.

****# [kube_control_plane] 과 [kube_node] 포함 노드 비교
ansible-inventory -i /root/kubespray/inventory/mycluster/inventory.ini --graph
**kubectl describe node | grep -E 'Name:|Taints'**
*Name:               k8s-node1
Taints:             node-role.kubernetes.io/control-plane:NoSchedule
Name:               k8s-node2
Taints:             node-role.kubernetes.io/control-plane:NoSchedule
Name:               k8s-node3
Taints:             node-role.kubernetes.io/control-plane:NoSchedule
Name:               k8s-node4
Taints:             <none>*
****
**kubectl get pod -A**
...

네트워크 및 etcd 상태 확인

Pod CIDR 및 etcd 멤버 구성을 확인하면서, “인벤토리에서 지정한 etcd_member_name이 실제 etcd 구성에 반영되었는지”도 확인하였습니다.

# 노드별 파드 CIDR 확인
**kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.podCIDR}{"\n"}{end}'**
*k8s-node1	10.233.64.0/24
k8s-node2	10.233.65.0/24
k8s-node3	10.233.66.0/24
k8s-node4	10.233.67.0/24*

# etcd 정보 확인 : etcd name 확인
**ssh k8s-node1 etcdctl.sh member list -w table**
*+------------------+---------+-------+----------------------------+----------------------------+------------+
|        ID        | STATUS  | **NAME**  |         PEER ADDRS         |        CLIENT ADDRS        | IS LEARNER |
+------------------+---------+-------+----------------------------+----------------------------+------------+
|  8b0ca30665374b0 | started | **etcd3** | https://192.168.10.13:2380 | https://192.168.10.13:2379 |      false |
| 2106626b12a4099f | started | **etcd2** | https://192.168.10.12:2380 | https://192.168.10.12:2379 |      false |
| c6702130d82d740f | started | **etcd1** | https://192.168.10.11:2380 | https://192.168.10.11:2379 |      false |
+------------------+---------+-------+----------------------------+----------------------------+------------+*

**for i in {1..3}; do echo ">> k8s-node$i <<"; ssh k8s-node$i etcdctl.sh endpoint status -w table; echo; done**
*>> k8s-node1 <<
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|    ENDPOINT    |        ID        | VERSION | DB SIZE | **IS LEADER** | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| **127.0.0.1:2379** | c6702130d82d740f |  **3.5.25** |  8.3 MB |      **true** |      false |         4 |       2834 |               2834 |        |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
...*

편의 도구 및 셸 설정

마지막으로 운영 편의를 위해 k9s 실행 및 kubectl 자동완성/alias 설정을 추가하셨습니다.

# k9s 실행
**k9s**

# 자동완성 및 단축키 설정
****source <(kubectl completion bash)
alias k=kubectl
alias kc=kubecolor
complete -F __start_kubectl k
****echo 'source <(kubectl completion bash)' >> /etc/profile
echo 'alias k=kubectl' >> /etc/profile
echo 'alias kc=kubecolor' >> /etc/profile
echo 'complete -F __start_kubectl k' >> /etc/profile

K8S API 엔드포인트

Kubespray로 HA 구성을 만들었을 때, 각 컴포넌트가 어떤 K8S API 엔드포인트(Kubernetes API Endpoint) 를 바라보는지 케이스별로 정리했습니다. 특히 워커 노드에서 클라이언트 사이드 로드밸런싱(Client-Side Load Balancing) 이 어떻게 동작하는지, 그리고 External LB를 붙였을 때 SAN(Subject Alternative Name) 이슈가 어떻게 발생하고 해결되는지까지 확인했습니다.


단일 컨트플 플레인 노드 1대 + 워커 노드 1대

  • 실습의 초점은 HA 구성에 있으므로, 단일 구성은 이번 정리에서 제외했습니다.

HA 컨트롤 플레인 3대 + Worker Client-Side Load Balancing

        Case 1은 Kubespray 기본 HA 형태에 가깝습니다. 컨트롤 플레인은 3대로 구성되어 있지만, 워커가 API 서버에 붙는 방식은 “외부 LB로 직접 접근”이 아니라,

워커 노드 로컬의 프록시를 통해 분산 접근 하는 구조입니다.

워커 노드 기준 엔드포인트

워커 노드에는 nginx-proxy가 static pod(Static Pod) 형태로 배치되고, upstream으로 컨트롤 플레인 3대를 등록해 트래픽을 분산합니다. 따라서 워커 입장에서는 엔드포인트가 127.0.0.1:6443로 고정되는 특징이 있습니다.

아래 위치에서 워커 노드의 nginx-proxy 및 로컬 리스닝 상태를 확인했습니다.

# worker(kubeclt, kube-proxy) -> k8s api

# 워커노드에서 정보 확인
**ssh k8s-node4 crictl ps**
*CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD                               NAMESPACE
3c09f930b22b0       5a91d90f47ddf       15 minutes ago      Running             nginx-proxy         0                   81b36842732ba       nginx-proxy-k8s-node4             kube-system*
...

**ssh k8s-node4 cat /etc/nginx/nginx.conf**
*error_log stderr notice;

worker_processes 2;
**worker_rlimit_nofile 130048;**
worker_shutdown_timeout 10s;*
*...
stream {
  **upstream kube_apiserver** {
    **least_conn**;
    server 192.168.10.11:6443;
    server 192.168.10.12:6443;
    server 192.168.10.13:6443;
    }

  **server** {
    listen        **127.0.0.1:6443**;
    **proxy_pass    kube_apiserver**;
    proxy_timeout 10m;
    proxy_connect_timeout 1s;
  }
}

http {
...
  server {
    listen **8081**;
    location **/healthz** {
      access_log off;
      return 200;
...*

**ssh k8s-node4 curl -s localhost:8081/healthz -I**
*HTTP/1.1 200 OK
Server: nginx*

# 워커노드에서 -> Client-Side LB를 사용해서 k8s api 호출 시도
**ssh k8s-node4 curl -sk https://127.0.0.1:6443/version | grep Version**
  "gitVersion": "v1.32.9",
  "goVersion": "go1.23.12",

**ssh k8s-node4 ss -tnlp | grep nginx**
*LISTEN 0      511          0.0.0.0:8081       0.0.0.0:*    users:(("nginx",pid=15043,fd=6),("nginx",pid=15042,fd=6),("nginx",pid=15016,fd=6))
LISTEN 0      511        127.0.0.1:6443       0.0.0.0:*    users:(("nginx",pid=15043,fd=5),("nginx",pid=15042,fd=5),("nginx",pid=15016,fd=5))*

# kubelet(client) -> api-server 호출 시 엔드포인트 정보 확인 : https://localhost:6443
ssh k8s-node4 cat /etc/kubernetes/kubelet.conf
**ssh k8s-node4 cat /etc/kubernetes/kubelet.conf | grep server**
    *server: **https://localhost:6443***

# kube-proxy(client) -> api-server 호출 시 엔드포인트 정보 확인
kc get cm -n kube-system kube-proxy -o yaml
**kubectl get cm -n kube-system kube-proxy -o yaml | grep 'kubeconfig.conf:' -A18**
  *kubeconfig.conf: |-
    apiVersion: v1
    kind: Config
    clusters:
    - cluster:
        certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
        server: **https://127.0.0.1:6443**
      name: default
...*

또한 kubelet과 kube-proxy가 바라보는 API 엔드포인트가 로컬로 고정되어 있는지 확인했습니다.

 

# nginx.conf 생성 Task
tree roles/kubernetes/node/tasks/loadbalancer
**cat roles/kubernetes/node/tasks/loadbalancer/nginx-proxy.yml**
...
*- name: Nginx-proxy | Write nginx-proxy configuration
  template:
    src: "**loadbalancer/nginx.conf.j2**"
    dest: "{{ nginx_config_dir }}**/nginx.conf**"
    owner: root
    mode: "0755"
    backup: true*

# nginx.conf jinja2 템플릿 파일
**cat roles/kubernetes/node/templates/loadbalancer/nginx.conf.j2**
*error_log stderr notice;

worker_processes 2;
**worker_rlimit_nofile 130048;**
worker_shutdown_timeout 10s;

events {
  multi_accept on;
  use epoll;
  worker_connections 16384;
}

stream {
  upstream kube_apiserver {
    **least_conn**;                 # RoundRobin 이 아닌 Lease_conn이 기본 설정인 이유는?
    **{% for host in groups['kube_control_plane'] -%}
    server {{ hostvars[host]['main_access_ip'] | ansible.utils.ipwrap }}:{{ kube_apiserver_port }};
    {% endfor -%}**
  }

  server {
    **listen        127.0.0.1:{{ loadbalancer_apiserver_port|default(kube_apiserver_port) }};**
    {% if ipv6_stack -%}
    listen        [::1]:{{ loadbalancer_apiserver_port|default(kube_apiserver_port) }};
    {% endif -%}
    proxy_pass    kube_apiserver;
    proxy_timeout 10m;
    proxy_connect_timeout 1s;
  }
}*

# nginx static pod 매니페스트 파일 확인
**cat roles/kubernetes/node/templates/manifests/nginx-proxy.manifest.j2**
*apiVersion: v1
kind: Pod
metadata:
  name: {{ loadbalancer_apiserver_pod_name }}
  namespace: kube-system
  labels:
    addonmanager.kubernetes.io/mode: Reconcile
    k8s-app: kube-nginx
  annotations:
    nginx-cfg-checksum: "{{ nginx_stat.stat.checksum }}"
...*

정리하면, 워커는 고정된 로컬 엔드포인트로만 접근하고, 실제 분산은 워커 노드 내부의 nginx-proxy가 upstream으로 처리하는 구조입니다.

결론

Case 1에서 워커 노드의 API 접근은 Client-Side LB로 설계되어 있으며, 그 결과 워커의 엔드포인트는 127.0.0.1:6443로 단순화됩니다. Kubespray는 이 Client-Side LB 구성에서 nginx뿐 아니라 haproxy, kube-vip 같은 선택지를 제공합니다.

참고: nginx alert와 containerd rlimit 이슈

워커에서 nginx-proxy 로그를 확인하는 과정에서 worker_rlimit_nofile 설정과 런타임 rlimit 값이 맞지 않아 alert가 발생하는 상황을 확인했습니다. 이 문제는 런타임(containerd)의 OCI 스펙 적용과 연결되어 있었고, containerd 관련 설정을 조정한 뒤 특정 태스크만 재적용하는 방식으로 반영했습니다.

아래 위치에서 로그 및 런타임 설정을 확인했습니다.

# nginx proxy 파드 로그 확인
**kubectl logs -n kube-system nginx-proxy-k8s-node4**
...
2026/01/28 04:02:40 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 65535:65535
2026/01/28 04:02:40 [notice] 1#1: start worker processes
2026/01/28 04:02:40 [notice] 1#1: start worker process 20
2026/01/28 04:02:40 [alert] 20#20: setrlimit(RLIMIT_NOFILE, 130048) failed (1: Operation not permitted)
2026/01/28 04:02:40 [notice] 1#1: start worker process 21
2026/01/28 04:02:40 [alert] 21#21: setrlimit(RLIMIT_NOFILE, 130048) failed (1: Operation not permitted)
...

#
**ssh k8s-node4 cat /etc/nginx/nginx.conf**
*error_log stderr notice;

worker_processes 2;
**worker_rlimit_nofile 130048;**
worker_shutdown_timeout 10s;*
*...*

#
ssh k8s-node4 **crictl info | jq**
...

ssh k8s-node4 **cat /etc/containerd/config.toml | grep base_runtime_spec**
         base_runtime_spec = "/etc/containerd/cri-base.json"

ssh k8s-node4 **cat /etc/containerd/cri-base.json | jq | grep rlimits -A 6**
    "rlimits": [
      {
        "type": "RLIMIT_NOFILE",
        "hard": 65535,
        "soft": 65535
      }
    ],

ssh k8s-node4 crictl inspect --name nginx-proxy | jq
ssh k8s-node4 **crictl inspect --name nginx-proxy | grep rlimits -A6**
        "rlimits": [
          {
            "hard": 65535,
            "soft": 65535,
            "type": "RLIMIT_NOFILE"
          }
        ],

Kubespray에서 이 값을 만드는 기본 변수를 확인하였습니다.

# 관련 변수명 확인
**cat roles/container-engine/containerd/defaults/main.yml**
*...
**containerd_base_runtime_spec_rlimit_nofile: 65535**
**containerd_default_base_runtime_spec_patch:**
  **process:**
    **rlimits:**
      - type: RLIMIT_NOFILE
        hard: "{{ containerd_base_runtime_spec_rlimit_nofile }}"
        soft: "{{ containerd_base_runtime_spec_rlimit_nofile }}"*

기본 OCI 스펙 패치 값을 override 하여 rlimits 자체를 비우는 방식으로 조정하였습니다.

# 기본 OCI Spec(Runtime Spec)을 수정(Patch)
cat << EOF >> inventory/mycluster/group_vars/all/containerd.yml
containerd_default_base_runtime**_**spec**_**patch:
  process:
    **rlimits: []**
EOF
grep "^[^#]" inventory/mycluster/group_vars/all/containerd.yml

이후 --tags "containerd"로 containerd 관련 태스크만 재실행하여 반영하는 흐름입니다.

# **containerd** tag : configuring containerd engine runtime for hosts
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "containerd" --list-tasks**
*...
  container-engine/containerd : Containerd | Download containerd	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | Unpack containerd archive	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | Generate systemd service for containerd	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | Ensure containerd directories exist	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | Write containerd proxy drop-in	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | **Generate default base_runtime_spec**	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | **Store generated default base_runtime_spec**	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | **Write base_runtime_specs**	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | **Copy containerd config file**	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | Create registry directories	TAGS: [container-engine, containerd]
  container-engine/containerd : Containerd | Write hosts.toml file	TAGS: [container-engine, containerd]
  container-engine/containerd : Flush handlers	TAGS: [container-engine, containerd]
  container-engine/containerd : Ensure containerd is started and enabled	TAGS: [container-engine, containerd]
...*

****# (신규터미널) 모니터링
[k8s-node4]
**journalctl -u containerd.service -f**
*Jan 28 17:45:07 k8s-node4 systemd[1]: **Stopping containerd.**service - containerd container runtime...
...
Jan 28 17:45:07 k8s-node4 systemd[1]: Stopped containerd.service - containerd container runtime.
Jan 28 17:45:07 k8s-node4 systemd[1]: Starting containerd.service - containerd container runtime...
...
Jan 28 17:45:07 k8s-node4 systemd[1]: **Started containerd**.service - containerd container runtime.*

**while true; do curl -sk https://127.0.0.1:6443/version | grep gitVersion ; date ; sleep 1; echo ; done**

# 1분 이내 수행 완료
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "containerd" --limit k8s-node4 -e kube_version="1.32.9"**

# 확인
ssh k8s-node4 cat /etc/containerd/cri-base.json | jq
****ssh k8s-node4 **cat /etc/containerd/cri-base.json | jq | grep rlimits
    "rlimits": [],**

ssh k8s-node4 **crictl inspect --name nginx-proxy | grep rlimits -A6**

# 적용을 위해서 컨테이너를 다시 기동
[k8s-node4] 
watch -d crictl ps # (신규터미널) 모니터링
**crictl pods --namespace kube-system --name 'nginx-proxy-*' -q | xargs crictl rmp -f**

ssh k8s-node4 **crictl inspect --name nginx-proxy | grep rlimits -A6**

# 로그 확인
**kubectl logs -n kube-system nginx-proxy-k8s-node4 -f**

# (참고) 나머지 현재 Ready 중인 모든 컨테이너 재기동
crictl pods --state ready -q | xargs crictl rmp -f

playbook 파일에 tags 정보 출력

# playbooks/ 파일 중 tags 
tree playbooks/
grep -Rni "tags" playbooks -A2 -B1

# roles/ 파일 중 tags
tree roles/ -L 2
grep -Rni "tags" roles --include="*.yml" -A2 -B1
grep -Rni "tags" roles --include="*.yml" -A3 | less

컨트롤 플레인 노드 기준 엔드포인트

컨트롤 플레인에서는 kube-apiserver가 :6443으로 바인딩되어 있고, 각 컴포넌트의 kubeconfig가 어떤 server를 바라보는지 확인했습니다. 이 확인을 통해 “컨트롤 플레인 내부 컴포넌트들도 로컬 엔드포인트(127.0.0.1:6443)를 사용한다”는 점을 정리할 수 있었습니다.

아래 위치에서 apiserver 바인딩 및 kubeconfig의 엔드포인트를 확인했습니다.

# apiserver static 파드의 bind-address 에 '::' 확인 
**kubectl describe pod -n kube-system kube-apiserver-k8s-node1 | grep -E 'address|secure-port'**
*Annotations:          kubeadm.kubernetes.io/kube-apiserver.advertise-address.endpoint: **192.168.10.11:6443**
      --advertise-address=**192.168.10.11**
      --secure-port=**6443**
      **--bind-address=::***

**ssh k8s-node1 ss -tnlp | grep 6443**
*LISTEN 0      4096               ***:6443**             *:*    users:((**"kube-apiserver**",pid=26124,fd=3))*

ssh k8s-node1 ip -br -4 addr
ssh k8s-node1 curl -sk https://**127.0.0.1:6443**/version | grep gitVersion
ssh k8s-node1 curl -sk https://**192.168.10.11:6443**/version | grep gitVersion
ssh k8s-node1 curl -sk https://**10.0.2.15:6443**/version | grep gitVersion
  *"gitVersion": "v1.32.9",*


# admin 자격증명(client) -> api-server 호출 시 엔드포인트 정보 확인
**ssh k8s-node1 cat /etc/kubernetes/admin.conf | grep server**
    *server: https://127.0.0.1:6443*

# super-admin 자격증명(client) -> api-server 호출 시 엔드포인트 정보 확인
**ssh k8s-node1 cat /etc/kubernetes/super-admin.conf | grep server**
    *server: **https://192.168.10.11:6443***

# kubelet(client) -> api-server 호출 시 엔드포인트 정보 확인 : https://127.0.0.1:6443
ssh k8s-node1 cat /etc/kubernetes/kubelet.conf
**ssh k8s-node1 cat /etc/kubernetes/kubelet.conf | grep server**
    *server: **https://127.0.0.1:6443***

# kube-proxy(client) -> api-server 호출 시 엔드포인트 정보 확인
kc get cm -n kube-system kube-proxy -o yaml
**k get cm -n kube-system kube-proxy -o yaml | grep server**
        *server: **https://127.0.0.1:6443***

# kube-controller-manager(client) -> api-server 호출 시 엔드포인트 정보 확인
**ssh k8s-node1 cat /etc/kubernetes/controller-manager.conf | grep server**
    *server: **https://127.0.0.1:6**443*

# kube-scheduler(client) -> api-server 호출 시 엔드포인트 정보 확인
**ssh k8s-node1 cat /etc/kubernetes/scheduler.conf | grep server**
    *server: **https://127.0.0.1:6443***

External LB → HA 컨트플 플레인 노드(3대) + (Worker Client-Side LoadBalancing)

이 케이스에서는 admin-lb에 구성한 External LB(External Load Balancer)(HAProxy)를 Control Plane의 API 엔드포인트로 사용해보는 흐름을 확인했습니다. 특히 “LB IP로 접근할 때 인증서 SAN(Subject Alternative Name) 에 포함되지 않으면 TLS 검증이 실패한다”는 점을 실습으로 확인했습니다.

핵심은 다음과 같습니다.

  • 운영자(kubectl)가 특정 컨트롤 플레인 노드 IP에 종속되면, 해당 노드 장애 시 조작이 끊길 수 있습니다.
  • External LB IP/도메인을 API 엔드포인트로 쓰려면, apiserver 인증서 SAN에 해당 값이 포함되어야 합니다.

kube-ops-view 설치

먼저 클러스터 상태를 눈으로 확인하기 위해 kube-ops-view를 설치하고 NodePort로 접속했습니다. 이후 샘플 애플리케이션 배포 및 장애 재현 단계에서, 파드 배치나 노드 상태 변화를 빠르게 확인하는 용도로 활용했습니다.

# kube-ops-view
#*# helm show values geek-cookbook/kube-ops-view*
helm repo add geek-cookbook https://geek-cookbook.github.io/charts/

**
# macOS 사용자
helm install kube-ops-view geek-cookbook/kube-ops-view --version 1.2.2 \
  --set service.main.type=**NodePort**,service.main.ports.http.nodePort=**30000 \**
  --set env.TZ="Asia/Seoul" --namespace kube-system \
  --set image.repository="**abihf/kube-ops-view**" --set image.tag="latest"

# Windows 사용자
helm install kube-ops-view geek-cookbook/kube-ops-view --version 1.2.2 \
  --set service.main.type=**NodePort**,service.main.ports.http.nodePort=**30000 \**
  --set env.TZ="Asia/Seoul" --namespace kube-system 

# 설치 확인
kubectl get deploy,pod,svc,ep -n kube-system -l app.kubernetes.io/instance=kube-ops-view

# kube-ops-view 접속 URL 확인 (1.5 , 2 배율) : nodePor 이므로 IP는 all node 의 IP 가능!
open "http://**192.168.10.14**:30000/#scale=1.5"
open "http://**192.168.10.14**:30000/#scale=2"

샘플 애플리케이션 배포, 반복 호출

다음으로 NodePort 서비스 형태의 샘플 애플리케이션을 배포한 뒤, 반복 호출로 분산/스케줄링 상태를 관찰했습니다. 이 단계는 “External LB/SAN”을 다루기 전에, 클러스터가 정상 동작 중인 기준 상태를 확보하는 의미가 있습니다.

  • 배포 후 deploy/svc/ep 상태를 확인했습니다.
  • admin-lb에서 NodePort로 반복 호출하며 응답이 정상적으로 반환되는지 확인했습니다.
# 샘플 애플리케이션 배포
**cat << EOF | kubectl apply -f -**
apiVersion: apps/v1
kind: Deployment
metadata:
  name: **webpod**
spec:
  **replicas: 2**
  selector:
    matchLabels:
      app: **webpod**
  template:
    metadata:
      labels:
        app: **webpod**
    spec:
      **affinity**:
****        **podAntiAffinity**:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - sample-app
            **topologyKey**: "kubernetes.io/hostname"
      containers:
      - name: **webpod**
        image: **traefik/whoami**
        ports:
        - containerPort: **80**
---
apiVersion: v1
kind: Service
metadata:
  name: **webpod**
  labels:
    app: **webpod**
spec:
  selector:
    app: **webpod**
  ports:
  - protocol: TCP
    port: **80**
    targetPort: **80
    nodePort: 30003**
  type: **NodePort
EOF**

배포 후 상태 확인 및 반복 호출은 아래와 같습니다.

# 배포 확인
kubectl get deploy,svc,ep webpod -owide

**[admin-lb]** # IP는 node 작업에 따라 변경
**while true; do curl -s http://**192.168.10.14**:30003 | grep Hostname; sleep 1; done**

또한 (옵션으로) 노드에서 서비스명 DNS 해석 흐름을 함께 확인했습니다.

# (옵션) k8s-node 에서 service 명 호출 확인
**ssh k8s-node1 cat /etc/resolv.conf**
# Generated by NetworkManager
***search default.svc.cluster.local svc.cluster.local**
nameserver 10.233.0.3
nameserver 168.126.63.1
nameserver 8.8.8.8
**options ndots:2** timeout:2 attempts:2*

# 성공
**ssh k8s-node1 curl -s webpod -I**
HTTP/1.1 200 OK

# 성공
**ssh k8s-node1 curl -s webpod.default -I**
HTTP/1.1 200 OK

# 실패
**ssh k8s-node1 curl -s webpod.default.svc -I**
**ssh k8s-node1 curl -s webpod.default.svc.cluster -I**

# 성공
**ssh k8s-node1 curl -s webpod.default.svc.cluster.local -I**
HTTP/1.1 200 OK

컨트롤 플레인 1번 노드 장애 시 영향도 (장애 재현) 

이 단계에서는 admin-lb의 kubeconfig가 특정 control-plane 노드 IP를 바라보는 경우, 해당 노드 장애 시 kubectl이 실패하는 상황을 재현했습니다. 반면 워커 노드는 로컬 nginx-proxy가 남은 control-plane으로 우회하므로 API 호출이 유지되는 흐름도 함께 확인했습니다.

먼저 admin-lb kubeconfig의 server가 특정 컨트롤 플레인 노드 IP로 고정된 상태를 확인했습니다.

# [admin-lb] kubeconfig 자격증명 사용 시 정보 확인
**cat /root/.kube/config | grep server**
    *server: https://192.168.10.11:6443*

    
**# 모니터링 : 신규 터미널 4개**
# ----------------------
## [admin-lb]
while true; do kubectl get node ; echo ; curl -sk https://192.168.10.12:6443/version | grep gitVersion ; sleep 1; echo ; done

## [k8s-node2]
watch -d kubectl get pod -n kube-system
kubectl logs -n kube-system nginx-proxy-k8s-node4 -f

## [k8s-node4] 
while true; do curl -sk https://127.0.0.1:6443/version | grep gitVersion ; date; sleep 1; echo ; done
# ----------------------

**# 장애 재현** 
[k8s-node1] **poweroff**

# [k8s-node2]
**kubectl logs -n kube-system nginx-proxy-k8s-node4 -f**
*2026/01/28 12:47:08 [error] 20#20: *3145 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: 127.0.0.1:6443, upstream: "192.168.10.11:6443", bytes from/to client:0/0, bytes from/to upstream:0/0
2026/01/28 12:47:08 [warn] 20#20: *3145 upstream server temporarily disabled while connecting to upstream, client: 127.0.0.1, server: 127.0.0.1:6443, upstream: "192.168.10.11:6443", bytes from/to client:0/0, bytes from/to upstream:0/0*

# [k8s-node4] 하지만 백엔드 대상 서버가 나머지 2대가 있으니 아래 요청 처리 정상!
**while true; do curl -sk https://127.0.0.1:6443/version | grep gitVersion ; date; sleep 1; echo ; done**
  *"gitVersion": "v1.32.9",*

# [admin-lb] 아래 자격증명 서버 정보 수정 필요
**while true; do kubectl get node ; echo ; curl -sk https://192.168.10.12:6443/version | grep gitVersion ; sleep 1; echo ; done**
*Unable to connect to the server: dial tcp 192.168.10.11:6443: connect: no route to host  # << 요건 실패!
  "gitVersion": "v1.32.9",  # << 요건 성공!*

**sed -i 's/192.168.10.11/192.168.10.12/g' /root/.kube/config**

**while true; do kubectl get node ; echo ; curl -sk https://192.168.10.12:6443/version | grep gitVersion ; sleep 1; echo ; done**
*NAME        STATUS     ROLES           AGE     VERSION
**k8s-node1   NotReady**   control-plane   4h35m   v1.32.9
k8s-node2   Ready      control-plane   4h35m   v1.32.9
k8s-node3   Ready      control-plane   4h35m   v1.32.9
k8s-node4   Ready      <none>          4h34m   v1.32.9
  "gitVersion": "v1.32.9",*

장애 이후에는 다음 차이를 확인할 수 있습니다.

  • 워커(k8s-node4)는 로컬 nginx-proxy를 통해 남아 있는 컨트롤 플레인으로 우회하여 API 호출이 유지될 수 있습니다.
  • admin-lb는 kubeconfig가 다운된 노드 IP를 바라보면 kubectl 호출이 실패할 수 있습니다.

그리고 admin-lb의 kubeconfig를 “살아있는 다른 컨트롤 플레인 IP”로 바꾸면 다시 동작한다는 점도 확인했습니다. 다만 이 방식은 장애 때마다 수동 조치가 필요하므로 운영 부담이 생길 수 있습니다.

External LB 엔드포인트 적용 시 SAN 이슈 및 해결

이제 운영자 kubeconfig의 server를 https://192.168.10.10:6443처럼 External LB로 바꾸면, 운영자 관점에서 “단일 엔드포인트로 HA 확보”가 가능해집니다.
하지만 이때 apiserver 인증서 SAN에 LB IP가 포함되어 있지 않으면 TLS 검증이 실패하는 상황이 발생할 수 있습니다.

먼저 LB 엔드포인트로 전환한 뒤 TLS 실패를 확인했습니다.

#
**curl -sk https://192.168.10.10:6443/version | grep gitVersion**
  "gitVersion": "v1.32.9",

#
**sed -i 's/192.168.10.12/192.168.10.10/g' /root/.kube/config**

# 인증서 SAN list 확인
**kubectl get node**
*E0128 23:53:41.079370   70802 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://192.168.10.10:6443/api?timeout=32s\": tls: failed to verify certificate: x509: certificate is valid for 10.233.0.1, 192.168.10.11, 127.0.0.1, ::1, 192.168.10.12, 192.168.10.13, 10.0.2.15, fd17:625c:f037:2:a00:27ff:fe90:eaeb, not 192.168.10.10"*

이후 SAN 목록을 확인하여, 기존 인증서가 LB IP(192.168.10.10)를 포함하지 않는다는 점을 확인했습니다.

 

# 인증서에 SAN 정보 확인
**ssh k8s-node1 cat /etc/kubernetes/ssl/apiserver.crt | openssl x509 -text -noout
...**

**ssh k8s-node1 kubectl get cm -n kube-system kubeadm-config -o yaml**
    apiServer:
      certSANs:
      *- kubernetes
      - kubernetes.default
      - kubernetes.default.svc
      - kubernetes.default.svc.cluster.local
      - 10.233.0.1
      - localhost
      - 127.0.0.1
      - ::1
      - k8s-node1
      - k8s-node2
      - k8s-node3
      - lb-apiserver.kubernetes.local
      - 192.168.10.11
      - 192.168.10.12
      - 192.168.10.13
      - 10.0.2.15
      - fd17:625c:f037:2:a00:27ff:fe90:eaeb*

해결은 Kubespray 변수로 SAN에 LB IP/도메인을 추가하는 방식으로 진행했습니다. 이때 전체 재배포가 아니라 control-plane 관련 태스크만 재적용하여 반영했습니다.

 

# 인증서 SAN 에 'IP, Domain' 추가
echo "supplementary_addresses_in_ssl_keys: **[192.168.10.10, k8s-api-srv.admin-lb.com]**" >> inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
grep "^[^#]" inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
  
~~# ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "**kube-apiserver**" --list-tasks
# ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "**kubeadm"** --list-tasks
# ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "**facts**" --list-tasks~~
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "control-plane" --list-tasks**
...
 *play #10 (**kube_control_plane**): **Install the control plane**	TAGS: []
    tasks:
      ...
      kubernetes/control-plane : **Kubeadm | aggregate all SANs**	TAGS: [control-plane, facts]*
**...**

재적용 중에는 워커에서 로컬 엔드포인트 호출이 유지되는지 함께 관찰했습니다.

 

# (신규터미널) 모니터링
[k8s-node4]
**while true; do curl -sk https://127.0.0.1:6443/version | grep gitVersion ; date ; sleep 1; echo ; done**

# 1분 이내 완료
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "control-plane" --limit kube_control_plane -e kube_version="1.32.9"**
*Gather minimal facts ------------------------------------------------------------------------------------------------------- 2.00s
kubernetes/control-plane : Kubeadm | Check apiserver.crt SAN hosts --------------------------------------------------------- 1.57s
kubernetes/control-plane : Kubeadm | Check apiserver.crt SAN IPs ----------------------------------------------------------- 1.33s
kubernetes/control-plane : Backup old certs and keys ----------------------------------------------------------------------- 1.26s
Gather necessary facts (hardware) ------------------------------------------------------------------------------------------ 0.98s
kubernetes/control-plane : Install | Copy kubectl binary from download dir ------------------------------------------------- 0.95s
kubernetes/preinstall : Create other directories of root owner ------------------------------------------------------------- 0.92s
win_nodes/kubernetes_patch : debug ----------------------------------------------------------------------------------------- 0.84s
kubernetes/control-plane : Backup old confs -------------------------------------------------------------------------------- 0.83s
kubernetes/control-plane : Update server field in component kubeconfigs ---------------------------------------------------- 0.78s
kubernetes/control-plane : Kubeadm | Create kubeadm config ----------------------------------------------------------------- 0.76s
kubernetes/preinstall : Create kubernetes directories ---------------------------------------------------------------------- 0.67s
kubernetes/control-plane : Kubeadm | regenerate apiserver cert 2/2 --------------------------------------------------------- 0.50s
kubernetes/control-plane : Renew K8S control plane certificates monthly 2/2 ------------------------------------------------ 0.46s
kubernetes/control-plane : Create kube-scheduler config -------------------------------------------------------------------- 0.41s
Gather necessary facts (network) ------------------------------------------------------------------------------------------- 0.38s
kubernetes/control-plane : Install script to renew K8S control plane certificates ------------------------------------------ 0.37s
kubernetes/control-plane : Kubeadm | regenerate apiserver cert 1/2 --------------------------------------------------------- 0.34s
kubernetes/control-plane : Kubeadm | aggregate all SANs -------------------------------------------------------------------- 0.29s
kubernetes/control-plane : Check which kube-control nodes are already members of the cluster ------------------------------- 0.28s*

재적용 이후에는 External LB 엔드포인트로 kubectl 호출이 성공하는 것을 확인했습니다. 또한 IP뿐 아니라 도메인 기반 접근도 함께 확인했습니다.

 

# 192.168.10.10 엔드포인트 요청 성공!
**kubectl get node -v=6**
*...
I0129 00:17:13.825729   81610 round_trippers.go:560] GET https://**192.168.10.10**:6443/api/v1/nodes?limit=500 200 OK in 8 milliseconds
NAME        STATUS   ROLES           AGE     VERSION
k8s-node1   Ready    control-plane   7h      v1.32.9
k8s-node2   Ready    control-plane   7h      v1.32.9
k8s-node3   Ready    control-plane   7h      v1.32.9
k8s-node4   Ready    <none>          6h59m   v1.32.9*

추가로, kubeadm-config ConfigMap은 최초 설치 이후 자동 갱신되지 않을 수 있으므로, kubeadm 설정 변경 시 ConfigMap 정합성도 함께 관리할 필요가 있음을 정리했습니다.

# ip, domain 둘 다 확인
**sed -i 's/192.168.10.10/k8s-api-srv.admin-lb.com/g' /root/.kube/config**

# 추가 확인
**ssh k8s-node1 cat /etc/kubernetes/ssl/apiserver.crt | openssl x509 -text -noout**
*X509v3 Subject Alternative Name:
  DNS:**k8s-api-srv.admin-lb.com**, DNS:k8s-node1, DNS:k8s-node2, DNS:k8s-node3, DNS:kubernetes, DNS:kubernetes.default,
  DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:lb-apiserver.kubernetes.local, 
  DNS:localhost, IP Address:10.233.0.1, IP Address:192.168.10.11, IP Address:127.0.0.1, 
  IP Address:0:0:0:0:0:0:0:1, **IP Address:192.168.10.10**, IP Address:192.168.10.12, IP Address:192.168.10.13, 
  IP Address:10.0.2.15, IP Address:FD17:625C:F037:2:A00:27FF:FE90:EAEB*

****# 해당 cm은 최초 설치 후 자동 업데이트 X, 업그레이드에 활용된다고 하니, 위 처럼 kubeadm config 변경 시 직접 cm도 같이 변경해두자.
**kubectl get cm -n kube-system kubeadm-config -o yaml**
...

**kubectl edit cm -n kube-system kubeadm-config** # or **k9s** -> cm kube-system
...

노드 관리

본 장에서는 Kubespray로 구성한 클러스터에서 노드를 추가/삭제/초기화(reset) 하는 흐름을 정리했습니다. Kubespray는 “기존 클러스터 전체를 다시 설치”하는 방식이 아니라, playbook 단위로 필요한 대상(host)만 골라서 단계적으로 작업할 수 있다는 점을 확인했습니다.

관련 문서는 아래 링크를 참고했습니다.


노드 추가 흐름

Kubespray에서 노드 추가는 scale.yml → playbooks/scale.yml 흐름으로 진행됩니다. 핵심은 기존 클러스터는 건드리지 않고, 새 노드가 kubelet 설치 → kubeadm join → 네트워크(CNI) 적용 순서로 합류하도록 설계되어 있다는 점입니다.

scale.yml / playbooks/scale.yml 구조 확인

scale.yml은 실제 로직이 있는 플레이북을 import하는 래퍼(wrapper) 역할을 합니다.

#
**cat scale.yml**
*---
- name: Scale the cluster
  ansible.builtin.import_playbook: **playbooks/scale.yml***

playbooks/scale.yml은 “공통 준비 → facts → etcd(조건부) → 다운로드(조건부) → 워커 준비/설치 → 인증서 공유 → join/network → DNS 반영” 순서로 구성되어 있습니다. 특히 플레이마다 hosts:가 명확히 나뉘어 있어, “어떤 단계가 어떤 대상에 적용되는지”를 구조만으로도 파악할 수 있었습니다.

 

**cat playbooks/scale.yml**
---
- name: **Common tasks for every playbooks**
  import_playbook: boilerplate.yml

- name: **Gather facts**
  import_playbook: internal_facts.yml

- name: **Install etcd**  # 기존 etcd 클러스터는 변경하지 않음, 새 노드가 etcd 멤버일 경우에만 join
  vars:
    etcd_cluster_setup: false
    etcd_events_cluster_setup: false
  import_playbook: install_etcd.yml

- name: **Download images to ansible host cache via first kube_control_plane node** # download_run_once 설정이 되어 있다면, 첫 번째 control-plane 노드에서만 실행 : 이미지/바이너리 캐시를 ansible host에 적재
  **hosts: kube_control_plane[0]**
  gather_facts: false
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: kubespray_defaults, when: "not skip_downloads and **download_run_once** and not download_localhost" }
    - { role: kubernetes/preinstall, tags: preinstall, when: "not skip_downloads and **download_run_once** and not download_localhost" }
    - { role: download, tags: download, when: "not skip_downloads and **download_run_once** and not download_localhost" }

- name: T**arget only workers to get kubelet installed and checking in on any new nodes(engine)** # 워커 노드 준비
  **hosts: kube_node**
  gather_facts: false
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: **kubespray_defaults** }
    - { role: **kubernetes/preinstall**, tags: preinstall }
    - { role: **container-engine**, tags: "container-engine", when: deploy_container_engine }
    - { role: **download**, tags: download, when: "not skip_downloads" }
    - role: **etcd**  # (조건부): Calico 같은 네트워크 플러그인이 etcd를 직접 사용하는 경우, 워커 노드에서도 접속 가능하도록 설정합니다.
      tags: etcd
      vars:
        etcd_cluster_setup: false
      when:
        - etcd_deployment_type != "kubeadm"
        - kube_network_plugin in ["calico", "flannel", "canal", "cilium"] or cilium_deploy_additionally | default(false) | bool
        - kube_network_plugin != "calico" or calico_datastore == "etcd"

- name: **Target only workers to get kubelet installed and checking in on any new nodes(node**) # kubelet 설치
  hosts: kube_node
  gather_facts: false
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: kubespray_defaults }
    - { **role: kubernetes/node**, tags: node } # kubelet 설치, systemd 등록, 아직 클러스터 join X

- name: **Upload control plane certs and retrieve encryption key** # kubeadm 인증서 공유
  ## 새 노드가 클러스터에 안전하게 조인할 수 있도록 kubeadm을 통해 인증서를 업로드하고, 조인에 필요한 certificate_key를 추출하여 변수로 저장합니다.
  hosts: kube_control_plane | first # 대상: 첫 번째 마스터 노드
  environment: "{{ proxy_disable_env }}"
  gather_facts: false
  tags: kubeadm
  roles:
    - { role: kubespray_defaults }
  tasks:
    - name: **Upload control plane certificates**
      command: >-
        {{ bin_dir }}/kubeadm init phase       # kubeadm init phase upload-certs --upload-certs
        --config {{ kube_config_dir }}/kubeadm-config.yaml
        upload-certs
        --upload-certs
      environment: "{{ proxy_disable_env }}"
      register: kubeadm_upload_cert
      changed_when: false
    - name: **Set fact 'kubeadm_certificate_key' for later use**
      set_fact:
        kubeadm_certificate_key: "{{ kubeadm_upload_cert.stdout_lines[-1] | trim }}"
      when: kubeadm_certificate_key is not defined

- name: **Target only workers to get kubelet installed and checking in on any new nodes(network)** # 클러스터 조인 및 네트워크 설정
  hosts: kube_node
  gather_facts: false
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: kubespray_defaults }
    - { **role: kubernetes/kubeadm**, tags: kubeadm }       # 새 워커 노드에서 **kubeadm join** 명령을 실행하여 클러스터에 공식적으로 등록합니다.
    - { role: kubernetes/node-label, tags: node-label } # 노드에 지정된 라벨(Label)과 테인트(Taint)를 적용합니다.
    - { role: kubernetes/node-taint, tags: node-taint } # 상동
    - { **role: network_plugin**, tags: network }           # CNI(Calico, Flannel 등) 설정을 적용하여 노드 간 통신이 가능하게 합니다.

- name: **Apply resolv.conf changes now that cluster DNS is up** # DNS 설정
  hosts: k8s_cluster
  gather_facts: false
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: kubespray_defaults }
    - { role: kubernetes/preinstall, when: "dns_mode != 'none' and resolvconf_mode == 'host_resolvconf'", tags: resolvconf, dns_late: true }
  # resolvconf: 클러스터 내부 DNS(CoreDNS 등)가 활성화되었으므로, 각 노드의 /etc/resolv.conf를 업데이트하여 노드들이 내부 도메인을 해석할 수 있도록 수정합니다.

워커 노드 추가 실습: k8s-node5

워커 노드 k8s-node5를 추가하는 실습을 진행했습니다. 흐름은 인벤토리 반영 → 연결 확인 → scale 실행 → 결과 확인 순서입니다.

inventory.ini에 신규 노드 추가

먼저 inventory.ini에 k8s-node5를 kube_node 그룹에 추가하고, 인벤토리 그래프로 반영 여부를 확인했습니다.

# inventory.ini 수정
**cat << EOF > /root/kubespray/inventory/mycluster/inventory.ini**
*[**kube_control_plane**]
k8s-node1 ansible_host=192.168.10.11 ip=192.168.10.11 etcd_member_name=etcd1
k8s-node2 ansible_host=192.168.10.12 ip=192.168.10.12 etcd_member_name=etcd2
k8s-node3 ansible_host=192.168.10.13 ip=192.168.10.13 etcd_member_name=etcd3

[**etcd**:children]
kube_control_plane

[**kube_node**]
k8s-node4 ansible_host=192.168.10.14 ip=192.168.10.14
**k8s-node5 ansible_host=192.168.10.15 ip=192.168.10.15***
EOF

**ansible-inventory -i /root/kubespray/inventory/mycluster/inventory.ini --graph**
*@all:
  |--@ungrouped:
  |--@etcd:
  |  |--@kube_control_plane:
  |  |  |--k8s-node1
  |  |  |--k8s-node2
  |  |  |--k8s-node3
  **|--@kube_node:
  |  |--k8s-node4
  |  |--k8s-node5***

Ansible 연결 확인 및 모니터링 준비

신규 노드에 Ansible 연결이 되는지 확인한 뒤, 노드 합류 과정을 보기 위해 모니터링을 준비했습니다.

 

# ansible 연결 확인
ansible -i inventory/mycluster/inventory.ini k8s-node5 -m ping

# 모니터링
watch -d kubectl get node
kube-ops-view

scale.yml 실행

--limit=k8s-node5로 신규 노드만 대상으로 지정하여 scale.yml을 실행했습니다. 이 방식으로 기존 노드에는 불필요한 변경을 최소화하면서, 신규 노드만 클러스터에 합류시키는 흐름을 확인했습니다.

# 워커 노드 추가 수행 : 3분 정도 소요
ansible-playbook -i inventory/mycluster/inventory.ini -v scale.yml --list-tasks
****ANSIBLE_FORCE_COLOR=true **ansible-playbook -i inventory/mycluster/inventory.ini -v scale.yml --limit=k8s-node5 -e kube_version="1.32.9" | tee kubespray_add_worker_node.log**

결과 확인

노드가 합류한 뒤, 신규 노드에 CNI/프록시/클라이언트 사이드 LB(nginx-proxy) 관련 파드가 함께 배치된 것을 확인했습니다.

# 확인
**kubectl get node -owide**
*NAME        STATUS   ROLES           AGE   VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE                        KERNEL-VERSION                  CONTAINER-RUNTIME
k8s-node1   Ready    control-plane   48m   v1.32.9   192.168.10.11   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
k8s-node2   Ready    control-plane   48m   v1.32.9   192.168.10.12   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
k8s-node3   Ready    control-plane   48m   v1.32.9   192.168.10.13   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
k8s-node4   Ready    <none>          47m   v1.32.9   192.168.10.14   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5
**k8s-node5   Ready**    <none>          66s   **v1.32.9**   192.168.10.15   <none>        Rocky Linux 10.0 (Red Quartz)   6.12.0-55.39.1.el10_0.aarch64   containerd://2.1.5*

**kubectl get pod -n kube-system -owide |grep k8s-node5**
*kube-flannel-ds-arm64-2djxl         1/1     Running   1 (80s ago)   114s   192.168.10.15   k8s-node5   <none>           <none>
kube-proxy-x6cmm                    1/1     Running   0             114s   192.168.10.15   k8s-node5   <none>           <none>
nginx-proxy-k8s-node5               1/1     Running   0             113s   192.168.10.15   k8s-node5   <none>           <none>*

추가로 신규 노드에 생성된 구성 파일 및 프로세스 흔적도 확인했습니다.

 

# 변경 정보 확인
ssh k8s-node5 tree /etc/kubernetes
ssh k8s-node5 tree /var/lib/kubelet
ssh k8s-node5 pstree -a

마지막으로 샘플 파드의 replicas를 조정하여, 스케줄링이 신규 노드까지 확장되는지도 확인했습니다.

 

# 샘플 파드 분배
kubectl get pod -owide
kubectl scale deployment webpod --replicas 1
kubectl get pod -owide
kubectl scale deployment webpod --replicas 2

노드 삭제 흐름

노드 삭제는 remove-node.yml → playbooks/remove_node.yml로 진행됩니다. “삭제 대상 노드 지정 강제”와 “최종 확인(pause)” 단계가 포함되어 있어, 실수로 전체 노드를 지우는 상황을 방지하도록 구성되어 있습니다.

remove-node.yml / playbooks/remove_node.yml 구조 확인

remove-node.yml은 remove 플레이북을 import하는 래퍼 역할을 합니다.

playbooks/remove_node.yml 흐름은 다음 포인트를 포함합니다.

  • Validate: node 변수가 반드시 지정되었는지 강제 체크
  • Confirm: 실제 삭제 전에 pause로 최종 확인(옵션으로 skip 가능)
  • Reset node: drain/cordon, 필요 시 etcd 멤버 제거, reset 역할 수행
  • Post removal: 클러스터 메타데이터에서 해당 노드 삭제(kubectl delete node)
#
**cat remove-node.yml**
---
- name: Remove node
  ansible.builtin.import_playbook: **playbooks/remove_node.yml**

#
**cat playbooks/remove_node.yml**
---
- name: **Validate nodes for removal** # “어떤 노드를 지울 건지 명확히 지정했는지” 강제 체크
  hosts: localhost
  gather_facts: false
  become: false
  tasks:
    - name: Assert that nodes are specified for removal
      assert:
        that:
          - node is defined
          - node | length > 0
        msg: "No nodes specified for removal. The `node` variable must be set explicitly."

- name: **Common tasks for every playbooks** # 공통 설정 로드(모든 playbook에서 반복되는 준비 단계) : kubespray 공통 변수, handler, 기본 설정 로딩 등
  import_playbook: boilerplate.yml

- name: **Confirm node removal** # 실제로 노드가 삭제되기 전 사용자에게 최종 확인을 받습니다. 사용자가 yes라고 입력해야만 다음 단계로 넘어갑니다. 자동화 스크립트 등에서 이 단계를 건너뛰려면 -e skip_confirmation=true 옵션을 사용합니다.
  hosts: "{{ node | default('this_is_unreachable') }}"
  gather_facts: false
  tasks:
    - name: Confirm Execution
      pause:
        prompt: "Are you sure you want to delete nodes state? Type 'yes' to delete nodes."
      register: pause_result
      **run_once: true** # 노드 여러 개여도 한 번만 묻기
      when:
        - not (**skip_confirmation** | default(false) | bool)

    - name: Fail if user does not confirm deletion
      fail:
        msg: "Delete nodes confirmation failed"
      when: pause_result.user_input | default('yes') != 'yes'

- name: **Gather facts**
  import_playbook: internal_facts.yml
  when: reset_nodes | default(True) | bool

- name: **Reset node** # 실제 노드 제거*
  hosts: "{{ node | default('this_is_unreachable') }}"
  gather_facts: false
  environment: "{{ proxy_disable_env }}"
  pre_tasks:
    - name: Gather information about installed services
      service_facts:
      when: reset_nodes | default(True) | bool
  roles:
    - { role: kubespray_defaults, when: reset_nodes | default(True) | bool } # 기본 변수 로딩
    - { role: **remove_node/pre_remove**, tags: pre-remove }  # 노드에서 실행 중인 파드들을 다른 노드로 옮기고(Drain), 더 이상 스케줄링되지 않게 만듭니다(Cordon). kubelet 중지.
    - role: **remove-node/remove-etcd-node**                  # 해당 노드가 etcd 멤버인 경우, etcd 클러스터 정족수에서 해당 노드를 안전하게 제거합니다. (데이터 무결성 유지)
      when: "'etcd' in group_names"
    - { role: **reset**, tags: reset, when: reset_nodes | default(True) | bool } # 노드에 설치된 쿠버네티스 구성 요소(**kubeadm reset -f**, binaries, configs, network interfaces)를 삭제하여 클린 상태로 만듭니다.

# Currently cannot remove first control plane node or first etcd node # 첫 번째 마스터 노드나 첫 번째 etcd 노드는 이 플레이북으로 제거할 수 없습니다(클러스터 파괴 위험)
- name: **Post node removal**          # 클러스터의 마스터 노드(Control Plane) 설정에서 제거된 노드에 대한 잔재 정보를 완전히 삭제합니다.
  hosts: "{{ node | default('this_is_unreachable') }}"
  gather_facts: false
  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: kubespray_defaults, when: reset_nodes | default(True) | bool }
    - { role: **remove-node/post-remove**, tags: post-remove } # kubectl delete node <node명> 로 노드 메타데이터 삭제

노드 삭제 실패 사례: PDB 때문에 drain 실패

노드 제거는 내부적으로 kubectl drain을 포함하므로, PDB(PodDisruptionBudget) 설정에 따라 eviction이 불가능하면 drain 단계에서 멈출 수 있음을 확인했습니다.

PDB 적용 후 삭제 실패 재현

샘플 워크로드에 PDB를 적용해 “축출 불가” 조건을 만든 뒤, remove-node.yml 실행 시 drain 단계에서 재시도 후 실패하는 흐름을 확인했습니다.

# webpod deployment 에 pdb 설정 : 해당 정책은 항상 최소 2개의 Pod가 Ready 상태여야 함 , drain / eviction 시 단 하나의 Pod도 축출 불가
kubectl scale deployment webpod --replicas 1
kubectl scale deployment webpod --replicas 2

cat <<EOF | kubectl apply -f -
apiVersion: policy/v1
kind: **PodDisruptionBudget**
metadata:
  name: webpod
  namespace: default
spec:
  **maxUnavailable: 0**
  selector:
    matchLabels:
      app: webpod
EOF

# 확인
**kubectl get pdb**
*NAME     MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
webpod   N/A             0                 0                     6s*

 

**# 삭제 실패**
ansible-playbook -i inventory/mycluster/inventory.ini -v remove-node.yml --list-tags
**ansible-playbook -i inventory/mycluster/inventory.ini -v remove-node.yml -e node=k8s-node5**
*...
**PLAY [Confirm node removal]** *******************************************************************************************************
Thursday 29 January 2026  14:10:10 +0900 (0:00:00.106)       0:00:01.562 ******
**[Confirm Execution]**
Are you sure you want to delete nodes state? Type 'yes' to delete nodes.: **yes**
...*
*****TASK [remove_node/pre_remove : Remove-node | List nodes] **************************************************************************
ok: [k8s-node5 -> k8s-node1(192.168.10.11)] => {"changed": false, "cmd": ["/usr/local/bin/kubectl", "--kubeconfig", "/etc/kubernetes/admin.conf", "get", "nodes", "-o", "go-template={{ range .items }}{{ .metadata.name }}{{ \"\\n\" }}{{ end }}"], "delta": "0:00:00.159970", "end": "2026-01-31 15:02:13.863633", "msg": "", "rc": 0, "start": "2026-01-31 15:02:13.703663", "stderr": "", "stderr_lines": [], "stdout": "k8s-node1\nk8s-node2\nk8s-node3\nk8s-node4\nk8s-node5", "stdout_lines": ["k8s-node1", "k8s-node2", "k8s-node3", "k8s-node4", "k8s-node5"]}
Saturday 31 January 2026  15:02:13 +0900 (0:00:00.552)       0:00:22.561 ******
**FAILED - RETRYING: [k8s-node5 -> k8s-node1]: Remove-node | Drain node except daemonsets resource (3 retries left).**
**CTRL+C 취소***

PDB 삭제 후 재시도 성공 + inventory 정리

PDB를 삭제한 뒤 다시 삭제를 시도하면 정상적으로 진행되는 흐름을 확인했습니다. 이후 inventory.ini에서도 제거한 노드를 정리했습니다.

# pdb 삭제
kubectl delete pdb webpod

**# 다시 삭제 시도 : 2분 20초 소요
ansible-playbook -i inventory/mycluster/inventory.ini -v remove-node.yml -e node=k8s-node5**
*...
**PLAY [Confirm node removal]** *******************************************************************************************************
Thursday 29 January 2026  14:10:10 +0900 (0:00:00.106)       0:00:01.562 ******
**[Confirm Execution]**
Are you sure you want to delete nodes state? Type 'yes' to delete nodes.: **yes**
...*

****# 확인
**kubectl get node -owide**

# 삭제 확인
ssh k8s-node5 tree /etc/kubernetes
ssh k8s-node5 tree /var/lib/kubelet
ssh k8s-node5 pstree -a

****# inventory.ini 수정
**cat << EOF > /root/kubespray/inventory/mycluster/inventory.ini**
*[**kube_control_plane**]
k8s-node1 ansible_host=192.168.10.11 ip=192.168.10.11 etcd_member_name=etcd1
k8s-node2 ansible_host=192.168.10.12 ip=192.168.10.12 etcd_member_name=etcd2
k8s-node3 ansible_host=192.168.10.13 ip=192.168.10.13 etcd_member_name=etcd3

[**etcd**:children]
kube_control_plane

[**kube_node**]
k8s-node4 ansible_host=192.168.10.14 ip=192.168.10.14*
EOF

비정상 노드 강제 삭제 후 재합류 설명

문서에는 비정상 노드(NotReady)에서는 drain 자체가 실패할 수 있고, 이 경우 강제 제거 옵션을 조합해야 한다는 시나리오가 있습니다. 실습은 생략하되, 어떤 변수들이 어떤 의미인지 흐름으로 정리했습니다.

비정상 상태 만들기 및 drain 실패 지점

kubelet/containerd를 중지해 노드를 NotReady 상태로 만든 뒤, 삭제 시도 시 drain 단계에서 실패할 수 있는 지점을 확인했습니다.

# 모니터링
watch -d kubectl get node

# k8s-node5 비정상 노드 상태 만들기
ssh k8s-node5 systemctl stop kubelet
ssh k8s-node5 systemctl stop containerd

# 확인
**kubectl get node**
*NAME        STATUS     ROLES           AGE     VERSION
k8s-node1   Ready      control-plane   5h6m    v1.32.9
k8s-node2   Ready      control-plane   5h6m    v1.32.9
k8s-node3   Ready      control-plane   5h6m    v1.32.9
k8s-node4   Ready      <none>          5h5m    v1.32.9
**k8s-node5   NotReady**   <none>          3h42m   v1.32.9*

# taint 추가 확인
**kc describe node k8s-node5**
*...
Taints:             node.kubernetes.io/unreachable:NoExecute
                    node.kubernetes.io/unreachable:NoSchedule*

**# 삭제 시도
ansible-playbook -i inventory/mycluster/inventory.ini -v remove-node.yml -e node=k8s-node5 -e skip_confirmation=true**
**# 아래 해당 노드 drain 에서 실패!**
*- name: Remove-node | Drain node except daemonsets resource
  command: >-
    **{{ kubectl }} drain**
      --force
      --ignore-daemonsets
      --grace-period {{ drain_grace_period }}
      --timeout {{ drain_timeout }}
      --delete-emptydir-data {{ kube_override_hostname | default(inventory_hostname) }}
  when:
    - groups['kube_control_plane'] | length > 0
    # ignore servers that are not nodes
    - kube_override_hostname | default(inventory_hostname) in nodes.stdout_lines
  register: result
  failed_when: result.rc != 0 and **not allow_ungraceful_removal**
  delegate_to: "{{ groups['kube_control_plane'] | first }}"
  until: result.rc == 0 or allow_ungraceful_removal
  retries: "{{ drain_retries }}"
  delay: "{{ drain_retry_delay_seconds }}"*

관련 기본값(예: allow_ungraceful_removal, drain_timeout)은 role defaults에서 확인했습니다.

 

# role 내에 defaults 에 변수 확인
**cat roles/remove_node/pre_remove/defaults/main.yml**
*---
allow_ungraceful_removal: false
drain_grace_period: 300
**drain_timeout: 360s**
drain_retries: 3
drain_retry_delay_seconds: 10*

강제 삭제 옵션 정리

강제 삭제에서 핵심 변수 의미는 다음과 같습니다.

  • reset_nodes=false : 클러스터 제어부에서 메타데이터만 정리하고, 노드 자체 초기화(kubeadm reset 등)는 시도하지 않습니다.
  • allow_ungraceful_removal=true : drain/eviction이 정상적으로 완료되지 않아도 제거를 진행하도록 허용합니다.

강제 삭제 실행 및 결과 확인 흐름은 아래 위치에서 진행했습니다.

# 모니터링
watch -d kubectl get node

**# 삭제 시도
ansible-playbook -i inventory/mycluster/inventory.ini -v remove-node.yml -e node=k8s-node5 -e reset_nodes=false -e allow_ungraceful_removal=true -e skip_confirmation=true**
*...
TASK [**remove-node/post-remove : Remove-node | Delete node]** ************************************************************************
changed: [k8s-node5 -> k8s-node1(192.168.10.11)] => {"attempts": 1, "changed": true, "cmd": ["/usr/local/bin/kubectl", "--kubeconfig", "/etc/kubernetes/admin.conf", "delete", "node", "k8s-node5"], "delta": "0:00:00.132328", "end": "2026-01-30 23:13:44.943928", "msg": "", "rc": 0, "start": "2026-01-30 23:13:44.811600", "stderr": "", "stderr_lines": [], "stdout": "node \"k8s-node5\" deleted", "stdout_lines": ["node \"k8s-node5\" deleted"]}

Friday 30 January 2026  23:13:44 +0900 (0:00:00.354)       0:**06:05**.545 ********
===============================================================================
remove_node/pre_remove : Remove-node | Drain node except daemonsets resource ------- 360.30s # **drain_timeout: 360s 반영***

# 확인
**kubectl get node**
*NAME        STATUS   ROLES           AGE     VERSION
k8s-node1   Ready    control-plane   5h31m   v1.32.9
k8s-node2   Ready    control-plane   5h31m   v1.32.9
k8s-node3   Ready    control-plane   5h31m   v1.32.9
k8s-node4   Ready    <none>          5h30m   v1.32.9*

**# k8s-node5 상태 확인 : 아직 이전 상태로 남아 있음**
ssh k8s-node5 systemctl status kubelet --no-pager
ssh k8s-node5 tree /etc/kubernetes

# (참고) 노드 제거 실행 task
**cat roles/remove-node/post-remove/tasks/main.yml**
*---
- name: Remove-node | Delete node
  command: "**{{ kubectl }} delete node {{ kube_override_hostname | default(inventory_hostname) }}**"
  delegate_to: "{{ groups['kube_control_plane'] | first }}"
  when:
    - groups['kube_control_plane'] | length > 0
    # ignore servers that are not nodes
    - ('k8s_cluster' in group_names) and kube_override_hostname | default(inventory_hostname) in nodes.stdout_lines
  retries: "{{ delete_node_retries }}"
  # Sometimes the api-server can have a short window of indisponibility when we delete a control plane node
  delay: "{{ delete_node_delay_seconds }}"
  register: result
  until: result is not failed*

강제 삭제 이후 노드 재합류 준비

reset_nodes=false로 강제 삭제한 경우, “클러스터에서 노드 메타데이터만 지운 상태”이므로 노드 자체에는 이전 구성 흔적이 남을 수 있습니다. 다음 실습을 위해 2가지 방안을 정리했습니다.

방안 1 제거 대상 노드에서 초기화

노드에서 kubeadm reset 및 관련 파일/규칙 정리를 수행하는 방식입니다.

[k8s-node5]

# kubeadm reset 실행
**kubeadm reset -f**

***# 아래 업데이트 필요***
# 디렉터리/파일 삭제
**rm -rf /etc/cni/net.d
rm -rf /etc/kubernetes/**
**rm -rf /var/lib/kubelet**

# iptables 정리
iptables -t nat -S
iptables -t filter -S
**iptables -F
iptables -t nat -F
iptables -t mangle -F
iptables -X**

# 서비스 중지
systemctl status containerd --no-pager 
systemctl status kubelet --no-pager

# kubelet 서비스 중지 -> 제거해보자
systemctl stop kubelet && systemctl disable kubelet

# contrainrd 서비스 중지 -> 제거해보자
systemctl stop containerd && systemctl disable containerd

# reboot
**reboot**

방안 2 Vagrant로 VM 제거/재생성

VM 자체를 destroy 후 다시 생성해 “깨끗한 상태”로 되돌리는 방식입니다.

 

# vagrant 로 k8s-node5 제거
vagrant destroy -f k8s-node5
vagrant status

# vagrant 로 k8s-node5 다시 생성(프로비저닝, 스크립트 반영)
vagrant up k8s-node5

admin-lb에서 재연결 후 scale로 재추가

SSH 키/Ansible 연결을 다시 맞춘 뒤, scale.yml로 노드를 재합류시키는 흐름입니다.

 

# k8s-node5 ping 통신 확인
**ping -c 1 192.168.10.15**
*PING 192.168.10.15 (192.168.10.15) 56(84) bytes of data.
64 bytes from 192.168.10.15: icmp_seq=1 ttl=64 time=0.461 ms*

# 실패!
**ansible -i inventory/mycluster/inventory.ini k8s-node5 -m ping**

# 성공!
**sshpass -p 'qwe123' ssh-copy-id -o StrictHostKeyChecking=no root@192.168.10.15**
**nsible -i inventory/mycluster/inventory.ini k8s-node5 -m ping**
*k8s-node5 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}*

# 워커 노드 추가 수행 : 3분 정도 소요
****ANSIBLE_FORCE_COLOR=true **ansible-playbook -i inventory/mycluster/inventory.ini -v scale.yml --limit=k8s-node5 -e kube_version="1.32.9" | tee kubespray_add_worker_node.log**

클러스터 reset

클러스터 전체 초기화는 reset.yml → playbooks/reset.yml로 진행되며, etcd를 포함해 클러스터를 “설치 전 상태”로 되돌립니다. 실행 후 복구가 어렵기 때문에 pause 기반 확인 절차가 포함되어 있습니다.

먼저 reset.yml이 reset 플레이북을 import하는 구조를 확인했습니다.

playbooks/reset.yml에서는 reset 전에 확인(pause)과 서비스 정보 수집을 수행하고, 이후 reset 역할을 통해 구성 요소를 정리하는 흐름임을 확인했습니다.

#
**cat reset.yml**
*---
- name: Reset the cluster
  ansible.builtin.import_playbook: playbooks/reset.yml*

**cat playbooks/reset.yml**
---
- name: Common tasks for every playbooks
  import_playbook: boilerplate.yml

- name: Gather facts
  import_playbook: internal_facts.yml

- name: **Reset cluster**
  hosts: etcd:k8s_cluster:calico_rr
  gather_facts: false
  **pre_tasks**:
    - name: **Reset Confirmation**
      pause:
        prompt: "Are you sure you want to reset cluster state? Type 'yes' to reset your cluster."
      register: reset_confirmation_prompt
      run_once: true
      when:
        - not (skip_confirmation | default(false) | bool)
        - reset_confirmation is not defined

    - name: **Check confirmation**
      fail:
        msg: "Reset confirmation failed"
      when:
        - not reset_confirmation | default(false) | bool
        - not reset_confirmation_prompt.user_input | default("") == "yes"

    - name: **Gather information about installed services**
      service_facts:

  environment: "{{ proxy_disable_env }}"
  roles:
    - { role: kubespray_defaults}
    - { role: kubernetes/preinstall, when: "dns_mode != 'none' and resolvconf_mode == 'host_resolvconf'", tags: resolvconf, dns_early: true }
    - { **role: reset, tags: reset** }

모니터링 설정

본 장에서는 모니터링 스택을 올리기 위해, 먼저 NFS(NFS) 기반 동적 볼륨 프로비저닝을 준비한 뒤 kube-prometheus-stack(kube-prometheus-stack) 을 설치하고, 그라파나(Grafana) 대시보드를 추가하는 흐름으로 진행했습니다. 마지막으로 etcd(etcd) 메트릭을 외부에서 수집할 수 있도록 설정을 변경하고, 프로메테우스(Prometheus) 스크래핑 대상에 포함시켰습니다.


NFS subdir external provisioner 설치

admin-lb에는 이미 NFS 서버(/srv/nfs/share)가 구성되어 있으므로, 쿠버네티스에서 PVC 요청 시 자동으로 NFS 하위 디렉터리를 만들어 붙여주는 NFS Subdir External Provisioner(nfs-subdir-external-provisioner) 를 설치했습니다.
또한 storageClass.defaultClass=true로 설정하여, 별도 스토리지클래스를 지정하지 않은 PVC도 기본으로 NFS를 사용하도록 구성했습니다.

# NFS subdir external provisioner 설치 : admin-lb 에 NFS Server(/srv/nfs/share) 설정 되어 있음
**kubectl create ns nfs-provisioner**
helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner -n nfs-provisioner \
    --set nfs.server=**192.168.10.10** \
    --set nfs.path=**/srv/nfs/share** \
    --set **storageClass.defaultClass=true**

# 스토리지 클래스 확인
**kubectl get sc**
*NAME                   PROVISIONER                                                     RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
nfs-client (default)   cluster.local/nfs-provisioner-nfs-subdir-external-provisioner   Delete          Immediate           true                   30s*    

# 파드 확인
**kubectl get pod -n nfs-provisioner -owide**
*NAME                                                              READY   STATUS    RESTARTS   AGE   IP           NODE     NOMINATED NODE   READINESS GATES
nfs-provisioner-nfs-subdir-external-provisioner-b549b9dff-b2bsn   1/1     Running   0          57s   10.244.1.4   k8s-w1   <none>           <none>*

설치 후에는 다음 두 가지를 우선 확인했습니다.

  • nfs-client가 기본 스토리지클래스(default) 로 잡히는지
  • 프로비저너 파드가 Running 상태인지

 


kube-prometheus-stack 설치

다음으로 kube-prometheus-stack(kube-prometheus-stack) 을 Helm으로 설치했습니다. 설치 시 monitor-values.yaml로 주요 파라미터를 조정했습니다.

  • prometheus.prometheusSpec.scrapeInterval, evaluationInterval을 20s로 설정
  • storageSpec으로 PVC 요청(storage: 10Gi)
  • additionalScrapeConfigs에 haproxy-metrics(192.168.10.10:8405) 추가
  • Prometheus/Grafana는 NodePort로 접근하도록 설정

참고 링크는 아래와 같습니다.

배포 및 확인을 진행한 터미널 위치는 아래와 같습니다.

# repo 추가
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

# 파라미터 파일 생성
cat <<EOT > monitor-values.yaml
**prometheus**:
  prometheusSpec:
    **scrapeInterval**: "**20s**"
    **evaluationInterval**: "**20s**"
    **storageSpec**:
      volumeClaimTemplate:
        spec:
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              **storage: 10Gi**
    **additionalScrapeConfigs**:
      - job_name: 'haproxy-metrics'
        static_configs:
          - targets:
              - '192.168.10.10:8405'
    externalLabels:
      cluster: "myk8s-cluster"
  **service**:
    type: NodePort
    nodePort: **30001**

**grafana**:
  defaultDashboardsTimezone: Asia/Seoul
  adminPassword: **prom-operator**
  **service**:
    type: NodePort
    nodePort: **30002**

alertmanager:
  enabled: false
defaultRules:
  create: false
****kubeProxy:
  enabled: false
****prometheus-windows-exporter:
  prometheus:
    monitor:
      enabled: false
EOT
cat monitor-values.yaml

# 배포
helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack --version **80.13.3** \
-f **monitor-values.yaml** --create-namespace --namespace **monitoring**

# 확인
helm list -n monitoring
kubectl get pod,svc,ingress,pvc -n monitoring
**kubectl get prometheus,servicemonitors**,**alertmanagers -n monitoring**
~~~~**kubectl get crd | grep monitoring**

# 각각 웹 접속 실행 : NodePort 접속
**open http://192.168.10.14:30001** # prometheus
**open http://192.168.10.14:30002** # grafana : 접속 계정 admin / prom-operator

****# 프로메테우스 버전 확인
kubectl exec -it sts/prometheus-kube-prometheus-stack-prometheus -n monitoring -c prometheus -- **prometheus --version**
*prometheus, version 3.9.1*

****# 그라파나 버전 확인
****kubectl exec -it -n monitoring deploy/kube-prometheus-stack-grafana -- **grafana --version**
*grafana version 12.3.1*

여기서는 pod, svc, pvc가 정상 생성되는지와, NodePort(30001, 30002)로 접속이 되는지를 우선 확인했습니다.


Grafana Dashboard 추가

기본 대시보드 외에도, 대시보드 JSON을 내려받아 컨피그맵(ConfigMap) 으로 주입하는 방식으로 구성했습니다.
kube-prometheus-stack의 Grafana는 사이드카 컨테이너가 grafana_dashboard="1" 라벨이 붙은 ConfigMap을 감지해 /tmp/dashboards 경로로 자동 반영하는 구조였습니다.

대시보드 파일 다운로드 및 ConfigMap 생성/라벨링은 아래 터미널에서 진행했습니다.

# 대시보드 다운로드
**curl -o 12693_rev12.json https://grafana.com/api/dashboards/12693/revisions/12/download**
**curl -o 15661_rev2.json https://grafana.com/api/dashboards/15661/revisions/2/download**
**curl -o k8s-system-api-server.json https://raw.githubusercontent.com/dotdc/grafana-dashboards-kubernetes/refs/heads/master/dashboards/k8s-system-api-server.json**

# sed 명령어로 uid 일괄 변경 : 기본 데이터소스의 uid 'prometheus' 사용
sed -i -e 's/${DS_PROMETHEUS}/**prometheus**/g' **12693_rev12.json**
sed -i -e 's/${DS__VICTORIAMETRICS-PROD-ALL}/**prometheus**/g' **15661_rev2.json**
sed -i -e 's/${DS_PROMETHEUS}/**prometheus**/g' **k8s-system-api-server.json**

# my-dashboard 컨피그맵 생성 : Grafana 포드 내의 사이드카 컨테이너가 grafana_dashboard="1" 라벨 탐지!
kubectl create configmap my-dashboard **--from-file=12693_rev12.json --from-file=15661_rev2.json --from-file=k8s-system-api-server.json** -n monitoring
kubectl label configmap my-dashboard **grafana_dashboard="1"** -n monitoring

# 대시보드 경로에 추가 확인
**kubectl exec -it -n monitoring deploy/kube-prometheus-stack-grafana -- ls -l /tmp/dashboards**
*-rw-r--r--    1 grafana  472         333790 Jan 22 06:27 12693_rev12.json
-rw-r--r--    1 grafana  472         198839 Jan 22 06:27 15661_rev2.json
...*

이 방식은 Grafana UI에서 수동 Import를 반복하지 않아도 되므로, 대시보드를 “클러스터 리소스”로 관리할 수 있다는 점에서 재현성과 운영 편의가 좋았습니다.

 


etcd 메트릭 활성화 및 Prometheus 스크래핑 추가

기본 상태에서는 etcd 메트릭 포트(2381)가 열려있지 않아 Prometheus가 수집할 수 없는 상태였습니다. 따라서 Kubespray 변수로 etcd 메트릭을 활성화하고, etcd를 재시작하여 포트를 노출한 뒤 Prometheus에 스크래핑 설정을 추가했습니다.

참고 링크는 아래와 같습니다.

기본 상태 확인

먼저 etcd가 기본 포트(2379/2380)만 리슨하고, 2381 메트릭 포트가 열려있지 않은 상태를 확인했습니다. 또한 Kubespray 템플릿에서 ETCD_METRICS, ETCD_LISTEN_METRICS_URLS가 변수로 제어되는 구조임도 함께 확인했습니다.

# 2381 메트릭 포트/설정 없음
**ssh k8s-node1 ss -tnlp | grep etcd**
*LISTEN 0      4096       127.0.0.1:2379       0.0.0.0:*    users:(("etcd",pid=762,fd=7))
LISTEN 0      4096   192.168.10.11:2380       0.0.0.0:*    users:(("etcd",pid=762,fd=6))
LISTEN 0      4096   192.168.10.11:2379       0.0.0.0:*    users:(("etcd",pid=762,fd=8))*

**ssh k8s-node1 ps -ef | grep etcd
...**

**cat roles/etcd/templates/etcd.env.j2 | grep -i metric**
***ETCD_METRICS**={{ etcd_metrics }}
{% if etcd_listen_metrics_urls is defined %}
**ETCD_LISTEN_METRICS_URLS**={{ etcd_listen_metrics_urls }}
{% elif etcd_metrics_port is defined %}
ETCD_LISTEN_METRICS_URLS=http://{{ etcd_address | ansible.utils.ipwrap }}:{{ etcd_metrics_port }},http://127.0.0.1:{{ etcd_metrics_port }}*

위 템플릿에서 ETCD_METRICS, ETCD_LISTEN_METRICS_URLS가 변수로 제어되는 구조임을 확인했습니다.

Kubespray 변수 수정 후 etcd 재적용

etcd 메트릭을 활성화하기 위해 Kubespray 변수(etcd_metrics, etcd_listen_metrics_urls)를 추가하고, --tags "etcd" + --limit etcd 조합으로 etcd 관련 작업만 재적용했습니다. 이 과정에서 etcd가 재시작되는 흐름임을 확인했습니다.

모니터링은 아래 위치에서 진행했습니다.

# 변수 수정
cat << EOF >> inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
etcd_metrics: true
etcd_listen_metrics_urls: "http://0.0.0.0:2381"
EOF
tail -n 5 inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml

# 모니터링
[k8s-node1] **watch -d "etcdctl.sh member list -w table"**
[admin-lb]
while true**; do echo ">> k8s-node1 <<"; ssh k8s-node1 etcdctl.sh endpoint status -w table; echo; echo ">> k8s-node2 <<"; ssh k8s-node2 etcdctl.sh endpoint status -w table; echo ">> k8s-node3 <<"; ssh k8s-node3 etcdctl.sh endpoint status -w table; sleep 1; done**

# 2분 소요 : etcd 재시작
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "etcd" --list-tasks
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml --tags "etcd" --limit etcd -e kube_version="1.32.9"**

포트 오픈 및 metrics endpoint 확인

재적용 이후에는 *:2381 리슨이 추가된 것을 확인했고, 각 노드의 /metrics 엔드포인트에 실제로 응답이 오는지도 함께 확인했습니다.

 

# 확인
ssh k8s-node1 **etcdctl.sh member list -w table**
**for i in {1..3}; do echo ">> k8s-node$i <<"; ssh k8s-node$i etcdctl.sh endpoint status -w table; echo; done**

# etcd 백업 확인
for i in {1..3}; do echo ">> k8s-node$i <<"; ssh k8s-node$i tree /var/backups; echo; done

# 확인
**ssh k8s-node1 ss -tnlp | grep etcd**
*LISTEN 0      4096       127.0.0.1:2379       0.0.0.0:*    users:(("etcd",pid=28100,fd=7))
LISTEN 0      4096   192.168.10.11:2380       0.0.0.0:*    users:(("etcd",pid=28100,fd=6))
LISTEN 0      4096   192.168.10.11:2379       0.0.0.0:*    users:(("etcd",pid=28100,fd=8))
LISTEN 0      4096               ***:2381**             *:*    users:(("etcd",pid=28100,fd=26))*

# 호출 확인
curl -s http://192.168.10.11:2381/metrics
curl -s http://192.168.10.12:2381/metrics
curl -s http://192.168.10.13:2381/metrics

Prometheus 스크래핑 설정 추가 및 Helm upgrade

마지막으로 etcd 메트릭을 Prometheus가 수집하도록 additionalScrapeConfigs에 etcd job을 추가하고, helm upgrade --reuse-values로 반영했습니다.

# 스크래핑 설정 추가
cat <<EOF > monitor-add-values.yaml
**prometheus**:
  prometheusSpec:
    **additionalScrapeConfigs**:
      **- job_name: 'etcd'
        metrics_path: /metrics 
        static_configs:
          - targets:
              - '192.168.10.11:2381'
              - '192.168.10.12:2381'
              - '192.168.10.13:2381'**
EOF

# helm upgrade 로 적용
helm get values -n monitoring kube-prometheus-stack
helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack --version **80.13.3** \
**--reuse-values -f monitor-add-values.yaml** --namespace monitoring

****# 확인
****helm get values -n monitoring kube-prometheus-stack

# (옵션) 불필요 servicemonitor etcd 제거 : 반영에 다소 시간 소요
kubectl get servicemonitors.monitoring.coreos.com -n monitoring kube-prometheus-stack-kube-etcd -o yaml
kubectl delete servicemonitors.monitoring.coreos.com -n monitoring kube-prometheus-stack-kube-etcd

정리

  • NFS 기반 동적 프로비저닝을 먼저 구성해, 모니터링 스택의 PVC가 안정적으로 생성되도록 했습니다.
  • kube-prometheus-stack은 NodePort로 노출해, Prometheus/Grafana 접속과 초기 확인을 빠르게 진행했습니다.
  • Grafana 대시보드는 ConfigMap 라벨 방식으로 주입해, UI 수동 작업 없이 재현 가능하게 구성했습니다.
  • etcd는 Kubespray 변수로 메트릭 포트(2381)를 열고, Prometheus의 스크래핑 대상에 포함시켜 외부 수집이 가능하도록 했습니다.

업그레이드

이 장은 (1) CNI(Flannel) 사전 업그레이드 → (2) Kubespray 업그레이드 방식 이해 → (3) Kubernetes 패치/마이너 업그레이드aㅡ링 업그레이드 실습 순서로 구성합니다. 핵심은 업그레이드 과정에서 kube-apiserver, kubelet, kube-proxy, CNI가 순차적으로 재기동되며, 그 과정의 흔들림을 모니터링으로 확인하는 것입니다.


사전 작업 Flannel CNI 플러그인 업그레이드

버전 관리 포인트

Kubespray는 기본 Flannel 버전을 roles/kubespray_defaults/defaults/main/download.yml에 정의해두고, inventory의 group_vars로 override하는 방식이 권장 흐름입니다.

# 기본값 확인 (Kubespray defaults)
grep -Rni "flannel_version\|flannel_cni_version" roles/kubespray_defaults/defaults/main/download.yml

권장 방식(override)은 아래 위치에서 진행했습니다.

 

cat << EOF >> inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml
# Flannel 본체 버전만 우선 업그레이드 (안전)
flannel_version: 0.27.4
EOF
grep "^[^#]" inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml

노드 단위로 flannel만 업그레이드가 어려운 이유

Flannel은 DaemonSet(DS)이라, playbook에서 --limit k8s-node3처럼 특정 노드만 대상으로 “그 노드만” 적용하기가 구조적으로 제한적입니다.
따라서 운영 환경에서 더 세밀한 제어가 필요하다면, CNI 배포를 Kubespray 밖에서 별도 관리하거나 DS의 updateStrategy, maxUnavailable 등을 활용해 롤링을 설계하는 방식이 필요합니다.

실제 적용 전체 대상

Flannel은 DS 특성상 전체 반영 흐름으로 적용했습니다.

# flannel 태그로 전체 적용 (DS 특성상 전체 반영 흐름)
ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  --tags "flannel" -e kube_version="1.32.9"

검증 업그레이드 전후 체크

업그레이드 전/후 확인은 아래 위치에서 진행했습니다.

 

# DS 이미지/파드 확인
kubectl get ds -n kube-system -owide | grep flannel
kubectl get pod -n kube-system -l app=flannel -owide

# 노드 이미지 캐시 확인
ssh k8s-node1 crictl images | grep -E 'flannel|flannel-cni'

자주 겪는 실패 케이스 flannel-cni-plugin 이미지 경로/태그 불일치

flannel_cni_version만 올리면 기본 repo(docker.io/flannel/...)에 해당 태그가 없어 ImagePullBackOff가 발생할 수 있습니다. 이 경우는 “버전만”이 아니라 repo까지 같이 맞춰야 합니다.

# (예시) repo 변경까지 함께 고려해야 하는 케이스
# flannel_image_repo: ghcr.io/flannel-io/flannel
# flannel_init_image_repo: ghcr.io/flannel-io/flannel-cni-plugin

Kubespray 업그레이드 방식 2가지 요약

Unsafe upgrade 즉시 업그레이드

  • cluster.yml 사용
  • -e upgrade_cluster_setup=true로 업그레이드 과정에서만 수행되는 일부 작업을 즉시 반영
  • 빠르지만 운영 환경에서는 리스크가 큽니다.

Graceful upgrade 롤링 최소중단 업그레이드

  • upgrade-cluster.yml 사용(노드 cordon/drain/uncordon 지원)
  • serial로 한 번에 처리할 노드 수를 제어
  • 기본 serial: 20%
  • 워커를 1대씩 하려면 -e "serial=1"

운영 관점에서는 Graceful upgrade + 모니터링 + (필요 시) pause 옵션 조합이 기본 전략이었습니다.


핵심 upgrade-cluster.yml 동작 흐름

업그레이드의 큰 흐름은 아래처럼 정리할 수 있습니다.

  • 공통 초기화/팩트 수집
  • 이미지 사전 다운로드(첫 control-plane에서 캐시)
  • 노드 업그레이드 준비(Preinstall/Download)
  • etcd 업그레이드(필요 시)
  • Control Plane 롤링 업그레이드(기본 1대씩)
  • CNI/외부 컨트롤러 업그레이드
  • Worker 롤링 업그레이드(serial=20% 또는 1)
  • 애드온 재적용 + DNS 이후 resolv.conf 반영

포인트는 다음과 같습니다.

  • Control Plane은 기본적으로 “1대씩” 업그레이드하도록 설계되어 있습니다.
  • 워커는 serial로 배치 크기를 조절합니다.

실습 목표 버전 플랜

버전 플랜은 아래 순서로 진행했습니다.

  • v1.32.9 → v1.32.10 (패치)
  • v1.32.10 → v1.33.7 (마이너)
  • v1.33.7 → v1.34.3 (마이너)

목표는 최소 중단(롤링) 이며, 모니터링에서 kube-apiserver 교체 순간에 지표 흔들림(스크래핑 실패 등)이 발생하는지도 함께 관찰합니다.


Patch 업그레이드 1.32.9 → 1.32.10

Control Plane + etcd 먼저 롤링

모니터링은 아래 위치에서 진행했습니다.

# 모니터링 (예시)
watch -d kubectl get node
watch -d kubectl get pod -n kube-system -owide

# control-plane + etcd 업그레이드 (약 14분)
ANSIBLE_FORCE_COLOR=true ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.32.10" --limit "kube_control_plane:etcd" | tee kubespray_upgrade.log

확인 포인트는 다음과 같습니다.

  • Control Plane부터 v1.32.10으로 바뀌는지
  • kube-apiserver/kube-controller-manager/kube-scheduler/kube-proxy 이미지가 신규 버전으로 들어오는지
  • etcd는 이번 패치에서 업그레이드가 필요 없으면 그대로 유지되는지
kubectl get node -owide
ssh k8s-node1 crictl images | grep -E 'kube-apiserver|kube-controller|kube-scheduler|kube-proxy'
ssh k8s-node1 systemctl status etcd --no-pager | grep active

또한 kube-apiserver 파드 교체 구간에서 메트릭 scrape 실패가 잠깐 발생할 수 있으므로, “비정상 장애인지/정상적인 흔들림인지”를 구분하는 데 집중했습니다.

Worker는 개별 순차 적용

워커는 1대씩 적용해 확인 후 다음 노드로 넘어갔습니다.

# 워커 1대씩 업그레이드(검증 후 다음 노드)
ANSIBLE_FORCE_COLOR=true ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.32.10" --limit "k8s-node5"

ANSIBLE_FORCE_COLOR=true ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.32.10" --limit "k8s-node4"

Minor 업그레이드 1.32.10 → 1.33.7

마이너 업그레이드는 패치보다 함께 변하는 컴포넌트가 늘어날 수 있어 모니터링을 더 꼼꼼히 확인했습니다.

Control Plane + etcd:

# control-plane + etcd
ANSIBLE_FORCE_COLOR=true ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.33.7" --limit "kube_control_plane:etcd" | tee kubespray_upgrade-2.log

# worker 전체
ANSIBLE_FORCE_COLOR=true ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.33.7" --limit "kube_node"

admin kubectl 업데이트 버전 스큐 방지

  • Client/Server 마이너 버전 차이가 커지면 경고가 발생할 수 있으므로, 업그레이드 이후에는 admin-lb의 kubectl도 같은 마이너로 맞추는 것이 안전했습니다.
kubectl version

# repo를 target minor로 맞추고 kubectl 설치
cat << EOF > /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.33/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.33/rpm/repodata/repomd.xml.key
exclude=kubectl
EOF
dnf install -y -q kubectl --disableexcludes=kubernetes

kubectl version

Kubespray v2.30.0 릴리즈 체크 포인트(운영 주의)

업그레이드 전에 릴리즈 노트를 읽고 “운영 영향”이 있는 항목은 반드시 사전 검증합니다.

  • ingress-nginx sunset(향후 제거 예정)
  • Kubernetes Dashboard 지원 종료(마지막 지원 버전)
  • containerd 옵션 변경(2.1 미만에만 discard 적용)
  • RockyLinux 10 실험 지원
  • Kubernetes 1.34.x 지원 범위
  • containerd/nerdctl 기본 버전 상향 등

결론: “K8S 버전만 올리는 작업”이 아니라, 부가 컴포넌트(ingress/dashboard/containerd) 정책 변경이 같이 올 수 있으니 릴리즈 노트는 필수입니다.


Kubespray 자체 업그레이드 + K8S 업그레이드 1.33.7 → 1.34.3

업그레이드 전 상태 확인 기준점

# etcd 버전/상태
for i in {1..3}; do echo ">> k8s-node$i <<"; ssh k8s-node$i etcdctl.sh endpoint status -w table; echo; done

# containerd 버전은 노드 정보에서 확인
kubectl get node -owide

Kubespray 소스 태그 전환 + 파이썬 의존성 업데이트

git checkout v2.30.0
git describe --tags

cat /root/kubespray/requirements.txt | grep -v "^#"
pip3 install -r /root/kubespray/requirements.txt

control-plane + etcd 업그레이드

 

ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.34.3" --limit "kube_control_plane:etcd"

이후 worker 업그레이드는 아래 위치에서 진행했습니다.

ansible-playbook -i inventory/mycluster/inventory.ini -v upgrade-cluster.yml \
  -e kube_version="1.34.3" --limit "kube_node"

업그레이드 후 운영 단말 기준으로 kubectl / kubeconfig도 정리했습니다.

kubectl version
# WARNING: client-server minor skew 경고가 있으면 kubectl도 v1.34로 올린다.

cat << EOF > /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.34/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.34/rpm/repodata/repomd.xml.key
exclude=kubectl
EOF
dnf install -y -q kubectl --disableexcludes=kubernetes
kubectl version

# admin kubeconfig 최신화 + 외부 LB 엔드포인트로 정리
scp k8s-node1:/root/.kube/config /root/.kube/
sed -i 's/127.0.0.1/192.168.10.10/g' /root/.kube/config
cat /root/.kube/config | grep server

Helm 업그레이드

curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \
  | DESIRED_VERSION=v3.20.0 bash

helm version

정리

업그레이드는 단순히 “K8S 버전만 올리는 작업”이 아니라, 컴포넌트 교체/재기동 이벤트가 연쇄적으로 발생하는 작업입니다. 따라서 실무에서는 아래 3가지를 세트로 가져가야 합니다.

  • 롤링 제어(serial/limit/pause)
  • 모니터링으로 흔들림 관찰(특히 kube-apiserver 교체 구간)
  • 클라이언트(kubectl) / kubeconfig / External LB 엔드포인트 정리

마무리하며

이번 업그레이드 실습을 통해 얻어야 하는 결론은 명확합니다.
“업그레이드는 기술적으로는 자동화할 수 있지만, 운영적으로는 관찰하고 통제해야 합니다.”

정리하면, Kubespray의 Graceful upgrade(upgrade-cluster.yml) 는 최소 중단 업그레이드의 기본 골격을 제공합니다.

  • Control Plane은 기본적으로 1대씩 롤링 처리합니다.
  • Worker는 serial로 배치 크기를 조절할 수 있습니다.
  • drain/uncordon 흐름이 포함되어, 운영 중에도 단계적으로 반영할 수 있습니다.

다만 실제 운영에서는 자동화만으로 해결되지 않는 구간이 분명히 존재합니다.

  • CNI(Flannel/Calico 등)는 DaemonSet 특성상 “노드 단위 적용”이 어렵습니다.
  • 이미지 repo/태그 불일치로 ImagePullBackOff 같은 이슈가 발생할 수 있습니다.
  • kube-apiserver 교체 구간에는 짧은 메트릭 수집 실패나 일시 오류가 나타날 수 있습니다.

즉, 자동화를 믿고 “눈 감고 실행”하면 위험합니다.
따라서 실무에서는 업그레이드를 아래 세트로 가져가는 것이 안전합니다.

  • 제어: serial, limit, (필요 시) pause/confirm 옵션
  • 관찰: 노드/파드 상태, kube-apiserver/etcd 지표, 로그/이벤트
  • 정리: admin kubectl 버전 스큐 해소, kubeconfig 엔드포인트(External LB) 일원화

마지막으로, 최악의 상황(업그레이드 실패, 일부 노드 불능, etcd 이슈)을 가정하면 “플레이북을 다시 돌리면 되겠지”만으로는 부족하다는 점을 느꼈습니다. 결국 kubeadm과 etcd가 어떤 역할을 하고, 어떤 순서로 복구 판단을 해야 하는지까지 이해하고 있어야 실제 대응이 가능해집니다.

검색 태그