들어가며
Kubespray를 활용한 Kubernetes 클러스터 배포 과정을 직접 진행하고, 그 구성 요소와 동작 방식을 분석한 내용을 정리한 포스팅입니다.
이번 주차에서는 Kubespray가 어떤 목적과 구조를 가진 도구인지 먼저 정리한 뒤, 실습 환경에 실제로 배포를 수행해보며 배포 과정 전반을 확인했습니다.
이후에는 Kubespray가 내부적으로 어떤 작업을 자동화하는지 이해하기 위해 플레이북(Playbook) 과 롤(Role) 구성을 단계적으로 살펴보았고, 마지막으로 실습에 사용한 배포 환경을 기준으로 어떤 설정과 구성으로 클러스터가 만들어졌는지 분석하는 것을 목표로 했습니다.
Kubespray 실습 전체 흐름 살펴보기
Kubespray 기반 실습이 어떤 단계로 구성되어 있고, 각 단계가 무엇을 준비/생성하며, 다음 단계와 어떻게 연결되는지 전체 흐름을 먼저 정리하고 넘어가겠습니다.
Kubespray는 “kubeadm을 실행해 주는 도구”에 그치지 않고, 배포 전 검증 → OS 부트스트랩 → 런타임 구성 → 바이너리/이미지 다운로드 → etcd → control-plane → CNI → 애드온 → DNS 마무리까지 클러스터 구성 전 과정을 단계적으로 수행합니다.
이번 실습의 목적은 빠른 설치가 아니라, 변수/인벤토리 설정이 실제 배포 결과에 어떤 영향을 주는지, 그리고 로그 기준으로 어떤 Role/Task가 어떤 책임을 갖는지를 명확히 이해하는 데 있었습니다.
실습 환경 준비 (Vagrant + Rocky Linux)
가장 먼저 Kubespray를 실행할 수 있도록 VM 기반 환경을 준비합니다.
- Vagrant로 실습용 노드 생성 (단일 노드: control-plane + worker)
- 시간대/NTP, 방화벽/SELinux, SWAP, 커널 모듈/ sysctl, 라우팅 같은 기본 OS 상태 정리
- /etc/hosts와 사설 IP 기반 통신 확인
이 단계는 아직 Kubernetes를 설치하지 않지만, 이후 모든 과정의 기반이므로 호스트명/네트워크/커널 설정이 특히 중요합니다.
Kubespray 실행 준비 (SSH + Python/Ansible 의존성)
Kubespray는 Ansible로 노드에 접속해 작업을 수행하므로, 먼저 실행 환경을 갖춰야 합니다.
- root SSH 접속 및 키 기반 인증 구성
- Kubespray repo clone 및 특정 버전 체크아웃
- requirements.txt 기반으로 Ansible/Python 패키지 설치
- ansible.cfg를 통해 SSH 최적화, fact cache 정책, roles_path 등 실행 환경 확인
Kubespray를 실행할 수 있는 Ansible 기반 자동화 환경을 구성하는 단계입니다.
인벤토리/변수 설계 (배포 결과를 결정하는 핵심)
Kubespray에서 가장 중요한 입력은 인벤토리와 group_vars입니다.
- inventory.ini로 노드 역할 정의
- kube_control_plane, etcd, kube_node
- group_vars로 배포 성격 결정
- 네트워크(CNI), kube-proxy 모드, DNS 방식, 애드온, 인증서 자동 갱신 등
실제로 생성되는 파일/디렉터리/서비스 구성을 바꾸는 결정 요소입니다.
배포 전 검증 단계 (boilerplate: version + inventory validation)
실제 배포는 cluster.yml → playbooks/cluster.yml로 이어지며, 가장 먼저 “검증”부터 시작합니다.
- Ansible 버전 및 필수 패키지(netaddr/jinja) 조건 확인
- 인벤토리 그룹 자동 보정(dynamic_groups)
- CIDR 충돌, etcd 노드 수, 지원 옵션 여부 검증(validate_inventory)
이 단계의 목적은 간단합니다.
설치 중간에 깨지면 복구 비용이 큰 오류를 초반에 강하게 차단합니다.
OS 부트스트랩 & 런타임 준비 (preinstall + container-engine)
검증이 끝나면 본격적으로 노드를 Kubernetes가 설치 가능한 상태로 바꿉니다.
- preinstall: swap, sysctl, DNS/resolv.conf, NetworkManager 감지/처리, 디렉터리 생성
- container-engine: 기존 흔적 검증 → runc/containerd/crictl/nerdctl 설치 → systemd 등록
여기서 중요한 건, Kubespray는 kubelet이 민감해하는 영역(OS 설정/커널/네트워크)을 선언적으로 정리한다는 점입니다.
공통 다운로드 레이어 (download Role)
다음으로 필요한 바이너리/이미지를 먼저 받아서 캐시합니다.
- local_release_dir 아래에 kubeadm/kubelet/kubectl, etcd, cni-plugins, containerd, runc 등 수집
- kubeadm으로 필요한 control-plane 이미지 목록을 계산해 pull 대상 결정
- 이미 존재하는 이미지는 스킵(check_pull_required)하여 재실행 속도 개선
이 구조 덕분에 “설치”와 “다운로드”가 분리되고, 폐쇄망/프록시/캐시 정책까지 고려한 운영이 가능해집니다.
etcd 구성 (기본: systemd 서비스 + TLS + 스냅샷)
Kubernetes 상태 저장소인 etcd를 구성합니다.
- TLS 인증서 생성 및 배치(/etc/ssl/etcd/ssl)
- systemd 기반 etcd 서비스 구성(/etc/systemd/system/etcd.service, /etc/etcd.env)
- 설치 직후 v2 backup / v3 snapshot handler 실행(/var/backups/etcd-*)
- etcdctl.sh 제공으로 TLS 옵션을 감춘 운영 편의 제공
kubeadm에서 흔히 보는 “static pod etcd”와 달리, Kubespray 기본은 host(systemd) 기반 etcd라는 점도 같이 확인할 수 있었습니다.
노드 구성 (kubernetes/node: kubelet + 커널 요구사항 반영)
etcd 이후에는 모든 노드에 공통 컴포넌트를 설치합니다.
- kubelet 바이너리 배치 및 systemd unit 생성
- br_netfilter / bridge-nf-call 설정, nodePort 예약 포트 등 커널 요구사항 반영
- kubelet config 생성(/etc/kubernetes/kubelet-config.yaml)
- (조건부) 로컬 LB(nginx/haproxy) 구성으로 control-plane HA 연결 지원
여기까지가 “노드가 Kubernetes 노드로 동작할 준비를 마치는 단계”입니다.
Control Plane 구성 (kubeadm init + kubeconfig + RBAC)
노드 준비가 끝나면 control-plane을 올립니다.
- kubeadm config 생성(/etc/kubernetes/kubeadm-config.yaml)
- kubeadm init 실행 → static pod manifests 생성(/etc/kubernetes/manifests)
- kubectl 접근 환경 구성(/etc/kubernetes/admin.conf, /root/.kube/config)
- cluster-roles로 기본 RBAC 세팅
- (옵션) 인증서 자동 갱신 timer/service + 스크립트 배치
즉 control-plane은 kubeadm 결과물 + static pod 모델 + 운영 자동화(renew timer)까지 포함해서 완성됩니다.
CNI 및 애드온 적용 (network_plugin + kubernetes-apps)
마지막으로 “클러스터가 실제로 쓸 수 있는 상태”를 만듭니다.
- CNI 플러그인 바이너리 설치(/opt/cni/bin)
- 선택한 네트워크 플러그인 매니페스트 적용(예: flannel → subnet.env 생성 확인)
- CoreDNS/DNS Autoscaler, Metrics-server, Helm 등 애드온 적용
- 최종 DNS(resolv.conf) 정리(dns_late 흐름과 연결)
이 단계까지 끝나면, 클러스터는 Pod 네트워킹 + 기본 운영 애드온까지 갖춘 상태가 됩니다.
이 흐름을 한 줄로 정리하면 다음과 같습니다.
검증 → OS 준비 → 런타임 → 다운로드 → etcd → kubelet(node) → control-plane → CNI → 애드온 → DNS 마무리
이제 다음 장부터는, 이 흐름을 “로그/코드(Role/Task)” 기준으로 더 촘촘하게 따라가며 각 단계가 실제로 무엇을 만들고, 어떤 설정이 어떤 결과로 연결되는지를 계속 확인해보겠습니다.
Kubespray란
앞서 이번 주차에서 Kubespray 배포 과정을 분석해보려는 목표를 잡았습니다. 본 장에서는 실습에 들어가기 전에, 쿠브스프레이(Kubespray) 가 어떤 도구인지와 “어디까지 자동화해주는지”를 먼저 정리했습니다.
Kubespray는 어떤 도구인가
쿠브스프레이(Kubespray) 는 앤서블(Ansible) 기반으로 쿠버네티스(Kubernetes) 클러스터를 자동으로 설치·업그레이드·관리하기 위한 오픈소스 배포 도구입니다.
저는 이번 실습에서 “클러스터가 실제로 만들어지는 과정”을 이해하기 위해, Kubespray가 내부에서 수행하는 작업을 플레이북(Playbook) 과 롤(Role) 단위로 확인해보는 것을 목표로 잡았습니다.
- kubespray 소개 : How to Deploy an AI-Optimized K8s Cluster with Kubespray
- https://www.youtube.com/watch?v=AErBh8baVY8
- https://www.youtube.com/watch?v=xj56NvrvN5A
Kubespray 주요 기능 정리
퍼블릭/폐쇄망 환경 지원
kubespray는 퍼블릭 서버 환경뿐 아니라 에어갭(Air-Gap) 과 같은 폐쇄망 환경에서도 배포를 지원합니다.
네트워크가 자유로운 환경뿐 아니라 외부 연결이 제한된 환경에서도 배포가 가능하도록 고려되어 있다는 점이 특징입니다.
- 폐쇄망(Air-Gap) 설치 소개 : Managing Kubernetes in Air Gap/Offline Environments
- https://www.youtube.com/watch?v=BkzIl5YL1b0
- https://www.youtube.com/watch?v=k_4gNrKSp6o
컨트롤 플레인/ETCD 고가용성 지원
kubespray는 컨트롤 플레인과 고가용성(HA) 구성을 지원합니다. 정리해보면 다음과 같습니다.
- ControlPlane(Control component, ETCD) HA 설정 지원 → 사실 이 부분은 kubeadm 기능
- Worker Node(kubelet, kube-proxy)에서 api-server 파드들로 분산 접속을 위한 클라이언트 사이드 로드밸런싱(Client-Side LB) 기능(Nginx) 지원
- 외부에서 api-server 파드들로 분산 접속을 위한 LB 엔드포인트 설정 제공 → LB는 직접 구성 필요
- kubeadm cert auto renew 기능 제공
다양한 옵션과 권장 설정 제공
배포에 필요한 다양한 설정 옵션을 제공하면서, 운영 관점에서 권장되는 Best Practice 설정도 함께 제시합니다.
이번 실습에서는 “설정 파일/변수들이 실제 배포 결과에 어떤 영향을 주는지”를 이후 장에서 함께 확인해보려 했습니다.
다양한 Linux 배포판 지원 및 CI 테스트
kubespray는 다양한 Linux 배포판을 지원하며, 지속적인 통합/연동 테스트(Awesome CI Test)를 통해 배포 안정성을 검증합니다.
클러스터 운영 전반 지원
클러스터 라이프사이클 전반을 지원합니다.
- 신규 클러스터 생성
- (컨트롤 플레인) 클러스터 업그레이드
- 클러스터 스케일링
- 노드 관리 - 노드 추가, 노드 제거
- 클러스터 재설정
- 설정 관리
- 백업/복구, 업그레이드 시 etcd 스냅샷 수행
릴리스/버전 지원 정책 정리
Release Cycle in Kubespray : ‘26.01.24
| Kubespray | Kube Version (N-2) |
| 2.29.x (master-branch) | 1.31 ~ 1.33 (1.34) |
| 2.28.x | 1.30 ~ 1.32 |
| 2.27.x | 1.29 ~ 1.31 |
- Kubespray 한 버전당 Kubernetes 3개 minor 지원
- 항상 1~2 버전 늦춰서 안정화 후 포함
- 운영 시 버전 추천
- Dev 환경 : Kubespray 최신 + K8s N-1
- Prd 환경 : Kubespray 최신-1 + K8s N-2
Kubean
Kubean : kubespray를 활용하는 클러스터 라이프사이클 관리 툴
설치 사전 요건 정리
실습에 앞서, kubespray에서 안내하는 사전 요건은 아래와 같습니다.
실습 환경 배포
Kubespray를 실제로 실행해볼 수 있도록, Vagrant 기반으로 실습용 VM을 먼저 구성하고, Kubespray 실행에 필요한 사전 준비(SSH, Python/Ansible 의존성, 인벤토리 및 변수 설정)를 순서대로 진행했습니다.
VM 배포
먼저 로컬에 실습용 디렉터리를 만들고, Vagrantfile과 초기 설정 스크립트인 init_cfg.sh를 내려받아 VM을 배포했습니다. 아래 명령으로 VM을 올린 뒤, 상태까지 확인했습니다.
# 디렉터리 생성
mkdir k8s-kubespary
cd k8s-kubespary
# 파일 다운로드
https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-kubespary/Vagrantfile
https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-kubespary/init_cfg.sh
# 환경 배포
vagrant up
vagrant status
Vagrantfile은 아래와 같으며, 단일 노드(k8s-ctr)를 Control Plane과 Worker 역할을 함께 수행하도록 구성했습니다.
# Base Image https://portal.cloud.hashicorp.com/vagrant/discover/bento/rockylinux-10.0
BOX_IMAGE = "bento/rockylinux-10.0"
BOX_VERSION = "202510.26.0"
Vagrant.configure("2") do |config|
# ControlPlane Nodes
config.vm.define "k8s-ctr" do |subconfig|
subconfig.vm.box = BOX_IMAGE
subconfig.vm.box_version = BOX_VERSION
subconfig.vm.provider "virtualbox" do |vb|
vb.customize ["modifyvm", :id, "--groups", "/Kubespray-Lab"]
vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
vb.name = "k8s-ctr"
vb.cpus = 4
vb.memory = 4096
vb.linked_clone = true
end
subconfig.vm.host_name = "k8s-ctr"
subconfig.vm.network "private_network", ip: "192.168.10.10"
subconfig.vm.network "forwarded_port", guest: 22, host: "60100", auto_correct: true, id: "ssh"
subconfig.vm.synced_folder "./", "/vagrant", disabled: true
subconfig.vm.provision "shell", path: "init_cfg.sh"
end
end
초기 설정 스크립트 확인
VM이 기동될 때 init_cfg.sh가 함께 실행되도록 구성되어 있어, OS 초기 상태를 Kubernetes 배포에 맞게 정리했습니다. 여기에는 시간대/NTP 설정, 방화벽 및 SELinux, SWAP, 커널 모듈 및 sysctl, /etc/hosts, 라우팅 설정 등이 포함되어 있습니다.
#!/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
cat << EOF >> /etc/hosts
192.168.10.10 k8s-ctr
EOF
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 "sudo su -" >> /home/vagrant/.bashrc
echo ">>>> Initial Config End <<<<"
VM 접속 후 사전 준비
VM 접속 후에는 커널/파이썬/패키지 상태를 확인하고, Kubespray 실행에 필요한 pip, git을 설치했습니다. 또한 Kubespray가 노드에 접속할 수 있도록 root 로그인과 SSH 키 기반 접속을 준비했습니다.
# user 확인
whoami
pwd
# Linux Kernel Requirements : 5.8+ 이상 권장
**uname -a**
*Linux k8s-ctr **6.12**.0-55.39.1.el10_0.aarch64 #1 SMP PREEMPT_DYNAMIC Wed Oct 15 11:18:23 EDT 2025 aarch64 GNU/Linux*
# Python : 3.10 ~ 3.12 : (참고) bento/rockylinux-9 경우 3.9
which python && python -V
which python3 && python3 -V
*3.12.9*
# pip , git 설치
**dnf install -y python3-pip git**
which pip && pip -V
which pip3 && pip3 -V
*pip 23.3.2 from /usr/lib/python3.12/site-packages/pip (python 3.12)*
# /etc/hosts 확인
ip -br -c -4 addr
cat /etc/hosts
ping -c 1 k8s-ctr
# SSH 접속을 위한 설정
# -----------------
**echo "root:qwe123" | chpasswd**
cat << EOF >> /etc/ssh/sshd_config
**PermitRootLogin yes
PasswordAuthentication yes**
EOF
**systemctl restart sshd**
# Setting SSH Key
**ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa**
ls -l ~/.ssh
# ssh-copy-id
**ssh-copy-id -o StrictHostKeyChecking=no root@192.168.10.10**
*root@192.168.10.10's password: **qwe123***
# ssh 접속 확인 : IP, hostname
cat /root/.ssh/authorized_keys
**ssh root@192.168.10.10 hostname**
**ssh -o StrictHostKeyChecking=no root@k8s-ctr hostname**
**ssh root@k8s-ctr hostname**
# -----------------
Kubespray 코드 확인 및 의존성 설치
사전 준비가 끝난 뒤 Kubespray 리포지토리를 특정 버전으로 내려받아 구조를 확인했습니다. 이후 requirements.txt 기반으로 Python 의존성을 설치하고, ansible --version으로 버전 조건을 만족하는지 확인했습니다.
# Clone Kubespray Repository
git clone **-b v2.29.1** https://github.com/kubernetes-sigs/kubespray.git /root/kubespray
**cd /root/kubespray**
*# (옵션) IDE에서 VM SSH 접속(root/qwe123)해서 편집 창 열기*
# 최상단 plybook 확인 -> 각각 import_playbook 확인
**ls -l *.yml**
*-rw-r--r--. 1 root root 88 Jan 24 20:55 **cluster.yml # ansible.builtin.import_playbook: playbooks/cluster.yml**
-rw-r--r--. 1 root root 30 Jan 24 20:55 _config.yml
-rw-r--r--. 1 root root 747 Jan 24 20:55 galaxy.yml
-rw-r--r--. 1 root root 105 Jan 24 20:55 recover-control-plane.yml
-rw-r--r--. 1 root root 85 Jan 24 20:55 remove-node.yml
-rw-r--r--. 1 root root 85 Jan 24 20:55 **remove_node.yml**
-rw-r--r--. 1 root root 85 Jan 24 20:55 reset.yml
-rw-r--r--. 1 root root 85 Jan 24 20:55 **scale.yml # ansible.builtin.import_playbook: playbooks/scale.yml**
-rw-r--r--. 1 root root 93 Jan 24 20:55 upgrade-cluster.yml
-rw-r--r--. 1 root root 93 Jan 24 20:55 **upgrade_cluster.yml***
#
**tree -L 2**
*...
├── **inventory**
│ ├── local
│ └── sample
...
├── **playbooks**
│ ├── ansible_version.yml
│ ├── boilerplate.yml
│ ├── **cluster.yml***
│ ├── facts.yml
│ ├── install_etcd.yml
│ ├── internal_facts.yml
│ ├── recover_control_plane.yml
│ ├── **remove_node.yml**
│ ├── reset.yml
│ ├── **scale.yml**
│ └── **upgrade_cluster.yml**
...
├── **roles**
│ ├── adduser
│ ├── bastion-ssh-config
│ ├── bootstrap-os
│ ├── bootstrap_os
│ ├── container-engine
│ ├── download
│ ├── dynamic_groups
│ ├── etcd
│ ├── etcdctl_etcdutl
│ ├── etcd_defaults
│ ├── helm-apps
│ ├── kubernetes
│ ├── kubernetes-apps
│ ├── kubespray-defaults
│ ├── kubespray_defaults
│ ├── network_facts
│ ├── network_plugin
│ ├── recover_control_plane
│ ├── remove-node
│ ├── remove_node
│ ├── reset
│ ├── system_packages
│ ├── upgrade
│ ├── validate_inventory
│ └── win_nodes
...*
# Install Python Dependencies
**cat requirements.txt**
*ansible==10.7.0
# Needed for community.crypto module
cryptography==46.0.3
# Needed for jinja2 json_query templating
jmespath==1.0.1
# Needed for ansible.utils.ipaddr
netaddr==1.3.0*
**pip3 install -r /root/kubespray/requirements.txt**
*Successfully installed MarkupSafe-3.0.3 ansible-10.7.0 **ansible-core-2.17**.14 cffi-2.0.0 cryptography-46.0.2 **jinja2-3.1**.6 jmespath-1.0.1 **netaddr**-1.3.0 pycparser-3.0 resolvelib-1.0.1*
# ansible 버전 확인 : Ansible 2.17.3 이상
which ansible
**ansible --version**
*ansible [**core 2.17**.14]
**config file** = /root/kubespray/ansible.cfg
...
python version = 3.12.9 (main, Aug 14 2025, 00:00:00) [GCC 14.2.1 20250110 (Red Hat 14.2.1-7)] (/usr/bin/python3)
**jinja** version = 3.1.6
libyaml = True*
# pip list 확인
**pip list**
*Package Version
------------------------- -----------
ansible 10.7.0
ansible-core 2.17.14
...
Jinja2 3.1.6
jmespath 1.0.1
...
netaddr 1.3.0
...*
# 해당 폴더에서 ansible-playbook 실행 시 적용되는 ansible.cfg
**cat ansible.cfg**
***[ssh_connection]** # 통신 속도 및 안정성 최적화
pipelining=True # SSH 세션을 여러 번 열지 않고 하나의 세션에서 여러 명령을 한꺼번에 실행
ssh_args = -o ControlMaster=auto -o ControlPersist=30m -o ConnectionAttempts=100 -o UserKnownHostsFile=/dev/null
## ControlMaster=auto -o ControlPersist=30m: 한 번 연결된 SSH 커넥션을 30분 동안 유지합니다. 매번 로그인할 필요가 없어 성능이 향상됩니다.
## ConnectionAttempts=100: 네트워크 불안정으로 연결 실패 시 100번까지 재시도합니다.
## UserKnownHostsFile=/dev/null: 접속 대상의 지문(fingerprint)을 저장하지 않아 관리가 편해집니다.
#control_path = ~/.ssh/ansible-%%r@%%h:%%p
**[defaults]**
# https://github.com/ansible/ansible/issues/56930 (to ignore group names with - and .)
force_valid_group_names = ignore # Ansible은 원래 그룹 이름에 -나 . 사용을 제한하지만, 쿠버네티스 리소스 명칭 규칙상 이를 허용하도록 설정
host_key_checking=False # 새 서버 접속 시 "Are you sure you want to continue connecting?"이라는 확인 창이 뜨지 않게 합니다.
**gathering = smart** # 대상 서버의 정보(Fact)를 한 번만 수집하고 /tmp에 JSON 파일로 저장합니다. (아래 설명 이어서)
fact_caching = jsonfile # 재실행 시 서버 정보를 다시 수집하지 않아 시간이 단축됩니다. 86400(24시간) 동안 캐시를 유지합니다.
**fact_caching_connection = /tmp**
fact_caching_timeout = 86400
timeout = 300
stdout_callback = default
display_skipped_hosts = no
library = ./library
callbacks_enabled = profile_tasks # 각 Task가 실행되는 데 걸리는 시간을 표시해 줍니다. 어떤 단계에서 병목이 생기는지 확인할 때 매우 유용합니다.
**roles_path** = roles:$VIRTUAL_ENV/usr/local/share/kubespray/roles:$VIRTUAL_ENV/usr/local/share/ansible/roles:/usr/share/kubespray/roles
deprecation_warnings=False
inventory_ignore_extensions = ~, .orig, .bak, .ini, .cfg, .retry, .pyc, .pyo, .creds, .gpg # 백업용이나 임시 파일을 인벤토리로 인식하여 에러가 발생하는 것을 방지합니다.
**[inventory]**
ignore_patterns = artifacts, credentials # 배포 결과물(artifacts)이나 중요 정보(credentials) 폴더 내의 파일을 인벤토리 스캔 대상에서 제외합니다.*
# (참고) Vagrantfile
cat Vagrantfile
인벤토리 및 변수 설정
다음으로 Kubespray가 참조할 인벤토리(Inventory) 를 구성했습니다. 샘플 인벤토리를 복사해 inventory/mycluster로 만들고, 단일 노드(k8s-ctr) 기준으로 inventory.ini를 작성했습니다. 이후 group_vars에서 네트워크 플러그인/프록시 모드/애드온 등 실습에서 확인할 항목을 일부 수정했습니다.
# inventory 디렉터리 복사
**cp -rfp /root/kubespray/inventory/sample /root/kubespray/inventory/mycluster**
**tree inventory/mycluster/**
*inventory/mycluster/
├── group_vars
│ ├── all
│ │ ├── all.yml
│ │ ├── aws.yml
│ │ ├── azure.yml
│ │ ├── containerd.yml
│ │ ├── coreos.yml
│ │ ├── cri-o.yml
│ │ ├── docker.yml
│ │ ├── etcd.yml
│ │ ├── gcp.yml
│ │ ├── hcloud.yml
│ │ ├── huaweicloud.yml
│ │ ├── oci.yml
│ │ ├── offline.yml
│ │ ├── openstack.yml
│ │ ├── upcloud.yml
│ │ └── vsphere.yml
│ └── k8s_cluster
│ ├── addons.yml
│ ├── k8s-cluster.yml
│ ├── k8s-net-calico.yml
│ ├── k8s-net-cilium.yml
│ ├── k8s-net-custom-cni.yml
│ ├── k8s-net-flannel.yml
│ ├── k8s-net-kube-ovn.yml
│ ├── k8s-net-kube-router.yml
│ ├── k8s-net-macvlan.yml
│ └── kube_control_plane.yml
└── inventory.ini*
# inventory.ini 작성
**cat << EOF > /root/kubespray/inventory/mycluster/inventory.ini
k8s-ctr ansible_host=192.168.10.10 ip=192.168.10.10
[kube_control_plane]
k8s-ctr
[etcd:children]
kube_control_plane
[kube_node]
k8s-ctr
EOF**
cat /root/kubespray/inventory/mycluster/inventory.ini
# https://github.com/kubernetes-sigs/kubespray/blob/master/docs/ansible/vars.md
## <your-favorite-editor> inventory/mycluster/group_vars/**all.yml** # for every node, including etcd
**grep "^[^#]" inventory/mycluster/group_vars/all/all.yml**
*---
**bin_dir**: /usr/local/bin
loadbalancer_apiserver_port: 6443
loadbalancer_apiserver_healthcheck_port: 8081
no_proxy_exclude_workers: false
kube_webhook_token_auth: false
kube_webhook_token_auth_url_skip_tls_verify: false
**ntp_enabled: false**
ntp_manage_config: false
ntp_servers:
- "0.pool.ntp.org iburst"
- "1.pool.ntp.org iburst"
- "2.pool.ntp.org iburst"
- "3.pool.ntp.org iburst"
unsafe_show_logs: false
allow_unsupported_distribution_setup: false*
## <your-favorite-editor> inventory/mycluster/group_vars/**k8s_cluster.yml** # for every node in the cluster (not etcd when it's separate)
**grep "^[^#]" inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml**
*---
**kube_config_dir: /etc/kubernetes**
kube_script_dir: "{{ bin_dir }}/kubernetes-scripts"
kube_manifest_dir: "{{ kube_config_dir }}/manifests"
kube_cert_dir: "{{ kube_config_dir }}/ssl"
kube_token_dir: "{{ kube_config_dir }}/tokens"
kube_api_anonymous_auth: true
**local_release_dir: "/tmp/releases"**
retry_stagger: 5
**kube_owner: kube**
kube_cert_group: kube-cert
kube_log_level: 2
credentials_dir: "{{ inventory_dir }}/credentials"
**kube_network_plugin: calico**
kube_network_plugin_multus: false
**kube_service_addresses: 10.233.0.0/18
kube_pods_subnet: 10.233.64.0/18**
**kube_network_node_prefix: 24**
kube_service_addresses_ipv6: fd85:ee78:d8a6:8607::1000/116
kube_pods_subnet_ipv6: fd85:ee78:d8a6:8607::1:0000/112
kube_network_node_prefix_ipv6: 120
kube_apiserver_ip: "{{ kube_service_subnets.split(',') | first | ansible.utils.ipaddr('net') | ansible.utils.ipaddr(1) | ansible.utils.ipaddr('address') }}"
kube_apiserver_port: 6443 # (https)
**kube_proxy_mode: ipvs**
kube_proxy_strict_arp: false
kube_proxy_nodeport_addresses: >-
{%- if kube_proxy_nodeport_addresses_cidr is defined -%}
[{{ kube_proxy_nodeport_addresses_cidr }}]
{%- else -%}
[]
{%- endif -%}
**kube_encrypt_secret_data: false**
cluster_name: cluster.local
**ndots: 2
dns_mode: coredns**
**enable_nodelocaldns: true**
enable_nodelocaldns_secondary: false
nodelocaldns_ip: 169.254.25.10
nodelocaldns_health_port: 9254
nodelocaldns_second_health_port: 9256
nodelocaldns_bind_metrics_host_ip: false
nodelocaldns_secondary_skew_seconds: 5
enable_coredns_k8s_external: false
coredns_k8s_external_zone: k8s_external.local
enable_coredns_k8s_endpoint_pod_names: false
resolvconf_mode: host_resolvconf
deploy_netchecker: false
skydns_server: "{{ kube_service_subnets.split(',') | first | ansible.utils.ipaddr('net') | ansible.utils.ipaddr(3) | ansible.utils.ipaddr('address') }}"
skydns_server_secondary: "{{ kube_service_subnets.split(',') | first | ansible.utils.ipaddr('net') | ansible.utils.ipaddr(4) | ansible.utils.ipaddr('address') }}"
dns_domain: "{{ cluster_name }}"
**container_manager: containerd**
kata_containers_enabled: false
kubeadm_certificate_key: "{{ lookup('password', credentials_dir + '/kubeadm_certificate_key.creds length=64 chars=hexdigits') | lower }}"
k8s_image_pull_policy: IfNotPresent
kubernetes_audit: false
default_kubelet_config_dir: "{{ kube_config_dir }}/dynamic_kubelet_dir"
volume_cross_zone_attachment: false
persistent_volumes_enabled: false
event_ttl_duration: "1h0m0s"
**auto_renew_certificates: false**
# auto_renew_certificates_systemd_calendar: "Mon *-*-1,2,3,4,5,6,7 03:00:00" # First Monday of each month
kubeadm_patches_dir: "{{ kube_config_dir }}/patches"
kubeadm_patches: []
remove_anonymous_access: false*
# 테스트할 기능 관련 수정
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
sed -i 's|*auto_renew_certificates: **false***|*auto_renew_certificates: **true***|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|*# auto_renew_certificates_systemd_calendar*|***auto_renew_certificates_systemd_calendar***|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
grep -iE '*kube_network_plugin:**|**kube_proxy_mode|enable_nodelocaldns:|^auto_renew_certificates*' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
## flannel 설정 수정 inventory/mycluster/group_vars/k8s_cluster/**k8s-net-flannel.yml**
cat inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml
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
## <your-favorite-editor> inventory/mycluster/group_vars/**kube_control_plane.yml** # for the control plane
**cat inventory/mycluster/group_vars/k8s_cluster/kube_control_plane.yml**
*# Reservation for control plane kubernetes components
# kube_memory_reserved: 512Mi
# kube_cpu_reserved: 200m
# kube_ephemeral_storage_reserved: 2Gi
# kube_pid_reserved: "1000"
# Reservation for control plane host system
# system_memory_reserved: 256Mi
# system_cpu_reserved: 250m
# system_ephemeral_storage_reserved: 2Gi
# system_pid_reserved: "1000"*
## <your-favorite-editor> addons.yml
**grep "^[^#]" inventory/mycluster/group_vars/k8s_cluster/addons.yml**
*---
**helm_enabled: false**
registry_enabled: false
**metrics_server_enabled: false**
local_path_provisioner_enabled: false
local_volume_provisioner_enabled: false
gateway_api_enabled: false
ingress_nginx_enabled: false
ingress_publish_status_address: ""
ingress_alb_enabled: false
cert_manager_enabled: false
metallb_enabled: false
metallb_speaker_enabled: "{{ metallb_enabled }}"
metallb_namespace: "metallb-system"
argocd_enabled: false
kube_vip_enabled: false
**node_feature_discovery_enabled: false***
# 테스트할 기능 관련 수정
sed -i 's|helm_enabled: false|**helm_enabled: true**|g' inventory/mycluster/group_vars/k8s_cluster/addons.yml
sed -i 's|metrics_server_enabled: false|**metrics_server_enabled: true**|g' inventory/mycluster/group_vars/k8s_cluster/addons.yml
sed -i 's|*node_feature_discovery_enabled: false*|***node_feature_discovery_enabled: true***|g' inventory/mycluster/group_vars/k8s_cluster/addons.yml
grep -iE 'helm_enabled:*|*metrics_server_enabled:*|node_feature_discovery_enabled:*' inventory/mycluster/group_vars/k8s_cluster/addons.yml
# etcd.yml : 파드가 아닌 systemd unit
**grep "^[^#]" inventory/mycluster/group_vars/all/etcd.yml**
*---
etcd_data_dir: /var/lib/etcd
**etcd_deployment_type: host***
# containerd.yml
**cat inventory/mycluster/group_vars/all/containerd.yml**
---
# Please see roles/container-engine/containerd/defaults/main.yml for more configuration options
# containerd_storage_dir: "/var/lib/containerd"
# containerd_state_dir: "/run/containerd"
# containerd_oom_score: 0
# containerd_default_runtime: "runc"
**# containerd_snapshotter: "native"**
# containerd_runc_runtime:
# name: runc
# type: "io.containerd.runc.v2"
# engine: ""
...(생략)...
# 기본 환경 정보 출력 저장
ip addr | tee -a ip_addr-1.txt
ss -tnlp | tee -a ss-1.txt
df -hT | tee -a df-1.txt
findmnt | tee -a findmnt-1.txt
sysctl -a | tee -a sysctl-1.txt
# 지원 버전 정보 확인
**cat roles/kubespray_defaults/vars/main/checksums.yml | grep -i kube -A40**
# 배포: 아래처럼 반드시 **~/kubespray** 디렉토리에서 **ansible-playbook** 를 실행하자!
# Deploy Kubespray with Ansible Playbook - run the playbook as root
# The option `--become` is required, as for example writing SSL keys in /etc/,
# installing packages and interacting with various systemd daemons.
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml -e kube_version="1.33.3" **--list-tasks** # 배포 전, Task 목록 확인
ANSIBLE_FORCE_COLOR=true **ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml -e kube_version="1.33.3" | tee kubespray_install.log**
# 설치 확인 : /root/.kube/config
more kubespray_install.log
**kubectl get node -v=6**
**cat /root/.kube/config**
# k8s
**kubectl get node -owide**
*NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
k8s-ctr Ready control-plane 113s **v1.33.3** 192.168.10.10 <none> Rocky Linux 10.0 (Red Quartz) 6.12.0-55.39.1.el10_0.aarch64 containerd://2.1.5*
****
**kubectl get pod -A**
...
# 기본 환경 정보 출력 저장
ip addr | tee -a ip_addr-2.txt
ss -tnlp | tee -a ss-2.txt
df -hT | tee -a df-2.txt
findmnt | tee -a findmnt-2.txt
sysctl -a | tee -a sysctl-2.txt
# 파일 출력 비교 : 빠져나오기 ':q' -> ':q' => 변경된 부분이 어떤 동작과 역할인지 조사해보기! , ctrl + f / b
vi -d ip_addr-1.txt ip_addr-2.txt
vi -d ss-1.txt ss-2.txt
vi -d df-1.txt df-2.txt
vi -d findmnt-1.txt findmnt-2.txt
vi -d sysctl-1.txt sysctl-2.txt
편의 설정
배포 이후에는 kubectl 사용 편의를 위해 자동 완성과 alias를 적용했고, 클러스터 상태를 빠르게 보기 위해 k9s도 설치했습니다.
# Source the completion
source <(kubectl completion bash)
source <(kubeadm completion bash)
# Alias kubectl to k
alias k=kubectl
complete -o default -F __start_kubectl k
# k9s 설치 : https://github.com/derailed/k9s
CLI_ARCH=amd64
if [ "$(uname -m)" = "aarch64" ]; then CLI_ARCH=arm64; fi
wget https://github.com/derailed/k9s/releases/latest/download/k9s_linux_${CLI_ARCH}.tar.gz
tar -xzf k9s_linux_*.tar.gz
ls -al k9s
chown root:root k9s
mv k9s /usr/local/bin/
chmod +x /usr/local/bin/k9s
**k9s**
Ansible Playbook & Role 분석
앞 장에서 Kubespray로 클러스터를 한 번 배포해보면서, 설치 로그(kubespray_install.log)에 어떤 PLAY와 TASK가 찍히는지 확인했습니다.
이번 장에서는 그 로그를 기준으로 Kubespray가 실제로 수행하는 흐름을 “플레이북(Playbook) → 롤(Role) → 태스크(Task)” 관점에서 따라가며 정리했습니다.
배포 로그로 보는 전체 흐름
설치 로그에서 확인한 Kubespray의 큰 흐름은 아래와 같이 정리할 수 있었습니다.
- 사전 검증 → 접근/부트스트랩 → etcd → node → control-plane → CNI → 애드온
graph TD
%% 사전 준비 단계
subgraph Preparation ["1. 사전 준비 및 검증"]
A["Check Ansible version"] --> B["Inventory setup and validation"]
B --> C["Install bastion ssh config"]
C --> D["Bootstrap hosts for Ansible"]
D --> E["Gather facts"]
end
%% etcd 설치 단계
subgraph ETCD ["2. 데이터베이스(etcd) 구축"]
E --> F["Prepare for etcd install"]
F --> G["Add worker nodes to etcd play"]
G --> H["Install etcd"]
end
%% K8s 코어 설치 단계
subgraph Core ["3. 쿠버네티스 코어 설치"]
H --> I["Install Kubernetes nodes"]
I --> J["Install the control plane"]
J --> K["Invoke kubeadm and install a CNI"]
end
%% 마무리 및 앱 설치
subgraph Finalization ["4. 부가 서비스 및 최적화"]
K --> L["Install Calico Route Reflector"]
L --> M["Patch Kubernetes for Windows"]
M --> N["Install Kubernetes apps"]
N --> O["Apply resolv.conf changes"]
end
%% 결과 리캡
O --> P{PLAY RECAP}
%% 스타일링
style Preparation fill:#f9f,stroke:#333,stroke-width:2px
style ETCD fill:#bbf,stroke:#333,stroke-width:2px
style Core fill:#bfb,stroke:#333,stroke-width:2px
style Finalization fill:#fdb,stroke:#333,stroke-width:2px
style P fill:#fff,stroke:#f00,stroke-width:4px
PLAY 단위로 보는 설치 단계
로그에서 PLAY 라인을 필터링하면, Kubespray가 어떤 단계로 배포를 진행하는지 바로 확인할 수 있었습니다.
#
**cat kubespray_install.log | grep -E 'PLAY'**
PLAY [Check Ansible version] ****************************** # Kubespray가 지원하는 Ansible 버전인지 확인
PLAY [Inventory setup and validation] ********************* # inventory 설정 정합성 검증 : kube_control_plane, etcd 그룹 존재 여부, etcd 노드 수 (odd 권장), Pod CIDR / Service CIDR 유효성, Kubernetes 버전 지원 여부
PLAY [Install bastion ssh config] ************************* # Bastion(점프 호스트) 환경 지원 : bastion 미사용 시 대부분 skip
PLAY [Bootstrap hosts for Ansible] ************************ # 모든 노드를 Ansible 실행 가능한 상태로 만듦 : Python 설치, sudo 권한 확보, 기본 패키지 설치, /usr/bin/python 보장
PLAY [Gather facts] *************************************** # Ansible fact 수집 : 이후 TASK들이: when: ansible_os_family == "Debian" 같은 조건 분기에서 사용됨
PLAY [Prepare for etcd install] *************************** # etcd 설치 전 사전 준비 : etcd user 생성, 디렉터리 생성, 방화벽 / 포트, cert 경로 준비
PLAY [Add worker nodes to the etcd play if needed] ******** # worker + etcd 겸용 노드 지원 : kube_node: + etcd: 둘 다 포함된 노드를 etcd PLAY에 추가
PLAY [Install etcd] *************************************** # etcd 설치 : etcd binary 설치, TLS 인증서 생성, systemd 등록, 클러스터 구성
PLAY [Install Kubernetes nodes] *************************** # 모든 노드에 공통 K8s 컴포넌트 설치, 아직 클러스터 join은 안 함
PLAY [Install the control plane] ************************** # kube_control_plane 그룹 : control-plane 노드 구성
PLAY [Invoke kubeadm and install a CNI] ******************* # kubeadm init / join 실행 , 네트워크(CNI) 설치
PLAY [Install Calico Route Reflector] ********************* # Calico BGP 미사용환경이면 skip
PLAY [Patch Kubernetes for Windows] *********************** # Linux-only 환경이면 skip
PLAY [Install Kubernetes apps] **************************** # 기본 애드온 설치 : CoreDNS, metrics-server 등
PLAY [Apply resolv.conf changes now that cluster DNS is up] # CoreDNS 설치 후 노드의 DNS 설정 최종 정리 : bootstrap 단계에선 임시 resolv.conf 사용, 클러스터 DNS 안정화 후 되돌림
PLAY RECAP ************************************************
이렇게 보면 Kubespray는 단순히 kubeadm init만 호출하는 도구가 아니라, 배포 전 검증부터 OS 준비, 런타임 설치, 바이너리/이미지 다운로드, etcd 구성, control-plane 구성, 네트워크/애드온 구성까지 전체를 단계적으로 실행한다는 점을 확인할 수 있었습니다.
TASK 개수와 구성 확인
이번 실습 기준으로 TASK는 총 559개가 실행되었습니다.
**cat kubespray_install.log | grep -E 'TASK' | wc -l**
559
TASK 라인을 그대로 보면 너무 길기 때문에, 실습에서는 흐름을 파악하는 데 도움이 되는 대표 Task들을 중심으로 확인했습니다.
**cat kubespray_install.log | grep -E 'TASK'**
TASK [**Check** 2.17.3 <= Ansible version < 2.18.0] ********************************
TASK [**dynamic_groups** : Match needed groups by their old names or definition] ***
TASK [**validate_inventory** : Stop if removed tags are used] **********************
TASK [**bootstrap_os** : Fetch /etc/os-release] ************************************
TASK [**system_packages** : Gather OS information] *********************************
TASK [**bootstrap_os** : Create remote_tmp for it is used by another module] *******
TASK [**network_facts** : Gather ansible_default_ipv4] *****************************
TASK [**Gather** minimal facts] ****************************************************
TASK [**adduser** : User | Create User Group] **************************************
TASK [**kubernetes/preinstall** : Check if /etc/fstab exists] **********************
TASK [**container-engine**/validate-container-engine : Validate-container-engine | check if fedora coreos] ***
TASK [**download** : Prep_download | Set a few facts] ******************************
TASK [**Gathering** Facts] *********************************************************
TASK [**Check** if nodes needs etcd client certs (depends on network_plugin)] ******
TASK [**etcd** : Check etcd certs] *************************************************
TASK [**etcdctl_etcdutl** : Download etcd binary] **********************************
TASK [**etcd** : Install etcd] *****************************************************
TASK [**kubernetes/node** : Set kubelet_cgroup_driver_detected fact for containerd] ***
TASK [**kubernetes/control-plane** : Pre-upgrade | Delete control plane manifests if etcd secrets changed] ***
TASK [**kubernetes/client** : Set external kube-apiserver endpoint] ****************
TASK [**kubernetes-apps/cluster_roles** : Kubernetes Apps | Wait for kube-apiserver] ***
TASK [**kubernetes/kubeadm** : Set kubeadm_discovery_address] **********************
TASK [**kubernetes/node-label** : Set role node label to empty list] ***************
TASK [**network_plugin/cni** : CNI | make sure /opt/cni/bin exists] ****************
TASK [**network_plugin/flannel** : Flannel | Create Flannel manifests] *************
TASK [**win_nodes/kubernetes_patch** : Ensure that user manifests directory exists] ***
TASK [**kubernetes-apps/ansible** : Kubernetes Apps | Wait for kube-apiserver] *****
TASK [**kubernetes-apps/helm** : Helm | Gather os specific variables] **************
TASK [**kubernetes-apps/metrics_server** : Metrics Server | Delete addon dir] ******
TASK [**kubernetes/preinstall** : Check resolvconf] ********************************
cluster.yml부터 시작되는 Playbook 체인
Kubespray 최상위에 있는 /root/kubespray/cluster.yml은 실제 작업을 직접 담고 있지 않고, playbooks/cluster.yml을 import 하는 형태였습니다.
/root/kubespray/cluster.yml 확인
**cat /root/kubespray/cluster.yml**
---
- name: Install Kubernetes
ansible.builtin.import_playbook: **playbooks/cluster.yml**
playbooks/cluster.yml의 전체 구성
playbooks/cluster.yml은 “큰 단계별로 Play를 나누고, 각 Play에서 필요한 Role들을 호출하는 구조”로 구성되어 있었습니다.
**cat playbooks/cluster.yml**
---
- name: **Common tasks for every playbooks**
import_playbook: **boilerplate.yml**
- name: **Gather facts**
import_playbook: **internal_facts.yml**
- name: **Prepare for etcd install**
hosts: k8s_cluster:etcd
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" }
- name: **Install etcd**
vars:
etcd_cluster_setup: true
etcd_events_cluster_setup: "{{ etcd_events_cluster_enabled }}"
import_playbook: **install_etcd.yml**
- name: **Install Kubernetes nodes**
hosts: k8s_cluster
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: **kubespray_defaults** }
- { role: ***kubernetes/node***, tags: node }
- name: **Install the control plane**
hosts: kube_control_plane
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: **kubespray_defaults** }
- { role: **kubernetes/control-plane**, tags: control-plane }
- { role: **kubernetes/client**, tags: client }
- { role: **kubernetes-apps/cluster_roles**, tags: cluster-roles }
- name: **Invoke kubeadm and install a CNI**
hosts: k8s_cluster
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: **kubernetes/kubeadm**, tags: kubeadm}
- { role: kubernetes/node-label, tags: node-label }
- { role: kubernetes/node-taint, tags: node-taint }
- { role: kubernetes-apps/common_crds }
- { role: **network_plugin**, tags: network }
- name: **Install Calico Route Reflector**
hosts: calico_rr
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: network_plugin/calico/rr, tags: ['network', 'calico_rr'] }
- name: **Patch Kubernetes for Windows**
hosts: kube_control_plane[0]
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: win_nodes/kubernetes_patch, tags: ["control-plane", "win_nodes"] }
- name: **Install Kubernetes apps**
hosts: kube_control_plane
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: kubernetes-apps/external_cloud_controller, tags: external-cloud-controller }
- { role: kubernetes-apps/policy_controller, tags: policy-controller }
- { role: kubernetes-apps/ingress_controller, tags: ingress-controller }
- { role: kubernetes-apps/external_provisioner, tags: external-provisioner }
- { role: kubernetes-apps, tags: apps }
- name: **Apply resolv.conf changes now that cluster DNS is up**
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 }
정리하면 playbooks/cluster.yml은 역할 분리가 명확했습니다.
초기에 boilerplate/internal_facts로 배포 조건을 정리한 뒤, preinstall → container-engine → download → etcd → node → control-plane → kubeadm/CNI → apps → DNS 마무리 순서로 진행됩니다.
boilerplate.yml에서 시작되는 검증 단계
playbooks/boilerplate.yml은 설치 전 단계에서 버전 확인 + 인벤토리 자동 보정 + 인벤토리 검증을 수행하는 구성이었습니다.
**cat playbooks/boilerplate.yml**
---
- name: Check ansible version
import_playbook: **ansible_version.yml**
- name: **Inventory setup and validation**
hosts: all
gather_facts: false
tags: always
roles:
- **dynamic_groups**
- **validate_inventory**
- name: **Install bastion ssh config**
hosts: **bastion[0]**
gather_facts: false
environment: "{{ proxy_disable_env }}"
roles:
- { role: **kubespray_defaults** }
- { role: **bastion-ssh-config**, tags: ["localhost", "bastion"] }
ansible_version.yml : 실행 환경 최소 조건 확인
run_once: true가 들어가 있어 “모든 노드에서 돌리지는 않지만, 배포 시작 전에 한 번은 반드시 확인”하는 구조였습니다.
**cat playbooks/ansible_version.yml**
---
- name: Check Ansible version
**hosts: all**
gather_facts: false
become: false
**run_once: true**
vars:
minimal_ansible_version: 2.17.3
maximal_ansible_version: 2.18.0
**tags: always**
tasks:
- name: "Check {{ minimal_ansible_version }} <= Ansible version < {{ maximal_ansible_version }}"
assert:
msg: "Ansible must be between {{ minimal_ansible_version }} and {{ maximal_ansible_version }} exclusive - you have {{ ansible_version.string }}"
that:
- ansible_version.string is version(minimal_ansible_version, ">=")
- ansible_version.string is version(maximal_ansible_version, "<")
tags:
- check
- name: "Check that python netaddr is installed"
assert:
msg: "Python netaddr is not present"
that: "'127.0.0.1' | ansible.utils.ipaddr"
tags:
- check
- name: "Check that jinja is not too old (install via pip)"
assert:
msg: "Your Jinja version is too old, install via pip"
that: "{% set test %}It works{% endset %}{{ test == 'It works' }}"
tags:
- check
dynamic_groups : 인벤토리 그룹 자동 표준화
dynamic_groups은 사용자 인벤토리에 과거 그룹명이 섞여 있더라도, Kubespray가 내부적으로 쓰는 표준 그룹명으로 매핑해 주는 역할을 합니다.
또한 kube_control_plane과 kube_node를 합친 k8s_cluster 그룹도 자동으로 만들어 이후 Play에서 “전체 노드”를 쉽게 대상으로 잡을 수 있게 합니다.
**tree roles/dynamic_groups/**
roles/dynamic_groups/
└── tasks
└── **main.yml**
**cat roles/dynamic_groups/tasks/main.yml**
---
- name: Match needed groups by their old names or definition
vars:
group_mappings:
kube_control_plane:
- kube-master
kube_node:
- kube-node
calico_rr:
- calico-rr
no_floating:
- no-floating
k8s_cluster:
- kube_node
- kube_control_plane
- calico_rr
group_by:
key: "{{ item.key }}"
when: group_names | intersect(item.value) | length > 0
loop: "{{ group_mappings | dict2items }}"
validate_inventory : 배포 전에 논리 오류를 차단
validate_inventory는 “배포가 진행되기 전에 잡을 수 있는 오류는 최대한 초반에 잡는다”는 목적의 Role이었습니다.
특히 Pod CIDR / Service CIDR 충돌이나, etcd 노드 수, 변수 타입(불리언 문자열 문제) 같은 부분은 배포 진행 중에 깨지면 복구 비용이 커서, 이 단계에서 강하게 막는 구조로 보였습니다.
**tree roles/validate_inventory/**
roles/validate_inventory/
├── meta
│ └── main.yml
└── tasks
└── main.yml
**cat roles/validate_inventory/meta/main.yml**
---
dependencies:
- role: kubespray_defaults
**cat roles/validate_inventory/tasks/main.yml**
---
- name: Stop if removed tags are used
assert:
msg: The tag 'master' is removed. Use 'control-plane' instead
that:
- ('master' not in ansible_run_tags)
- ('master' not in ansible_skip_tags)
- name: Stop if kube_control_plane group is empty
assert:
that: groups.get( 'kube_control_plane' )
run_once: true
when: not ignore_assert_errors
- name: Stop if even number of etcd hosts
assert:
that: groups.get('etcd', groups.kube_control_plane) | length is not divisibleby 2
run_once: true
when:
- not ignore_assert_errors
- name: "Check that kube_pods_subnet does not collide with kube_service_addresses"
assert:
that:
- kube_pods_subnet | ansible.utils.ipaddr(kube_service_addresses) | string == 'None'
msg: "kube_pods_subnet cannot be the same network segment as kube_service_addresses"
run_once: true
when: ipv4_stack | bool
- name: Stop if unsupported options selected
assert:
that:
- kube_network_plugin in ['calico', 'flannel', 'cloud', 'cilium', 'cni', 'kube-ovn', 'kube-router', 'macvlan', 'custom_cni', 'none']
- dns_mode in ['coredns', 'coredns_dual', 'manual', 'none']
- kube_proxy_mode in ['iptables', 'ipvs', 'nftables']
- etcd_deployment_type in ['host', 'docker', 'kubeadm']
- container_manager in ['docker', 'crio', 'containerd']
msg: The selected choice is not supported
run_once: true
kubespray_defaults가 하는 일
설치 과정 내내 가장 자주 등장하는 Role이 kubespray_defaults였습니다.
이 Role은 Kubespray 전체에서 참조하는 “기본 변수 집합”을 제공하고, 이후 Role에서 쓰기 편하도록 파생 변수까지 계산해줍니다.
기본값: defaults/main/main.yml
**cat roles/kubespray_defaults/defaults/main/main.yml**
...
kube_network_plugin: calico
kube_service_addresses: 10.233.0.0/18
kube_pods_subnet: 10.233.64.0/18
kube_network_node_prefix: 24
ipv4_stack: true
ipv6_stack: "{{ enable_dual_stack_networks | default(false) }}"
kube_apiserver_bind_address: "**::**"
**sysctl_file_path**: "/etc/sysctl.d/99-sysctl.conf"
...
다운로드 정책: defaults/main/download.yml
Kubernetes 바이너리(kubelet, kubeadm, kubectl)와 CNI/etcd/containerd 같은 구성 요소를 어디서 어떻게 다운로드할지 정의합니다.
**cat roles/kubespray_defaults/defaults/main/download.yml**
*...
github_url: https://github.com
**dl_k8s_io_url: https://dl.k8s.io**
storage_googleapis_url: https://storage.googleapis.com
get_helm_url: https://get.helm.sh
**kubelet_download_url:** "**{{ dl_k8s_io_url }}/release/v{{ kube_version }}/bin/linux/{{ image_arch }}/kubelet**"
kubectl_download_url: "{{ dl_k8s_io_url }}/release/v{{ kube_version }}/bin/linux/{{ image_arch }}/kubectl"
kubeadm_download_url: "{{ dl_k8s_io_url }}/release/v{{ kube_version }}/bin/linux/{{ image_arch }}/kubeadm"
...*
그리고 실제로 다운로드가 끝나면 local_release_dir에 해당 파일들이 모이는 것을 확인할 수 있었습니다.
**tree /tmp/releases/**
/tmp/releases/
├── **cni-plugins**-linux-arm64-1.8.0.tgz
├── **containerd**-2.1.5-linux-arm64.tar.gz
├── **crictl**
├── **etcd**-3.5.25-linux-arm64.tar.gz
├── **helm-**3.18.4
├── **kubeadm-**1.33.7-arm64
├── **kubectl**-1.33.7-arm64
├── **kubelet**-1.33.7-arm64
└── **runc**-1.3.4.arm64
파생 변수 계산: vars/main/main.yml
기본값만 제공하는 것이 아니라, 입력된 kube_version을 기반으로 major/minor를 계산하거나, 지원 버전(화이트리스트)과 매핑되는 버전을 고르는 로직도 포함되어 있었습니다.
**cat roles/kubespray_defaults/vars/main/main.yml**
---
kube_major_version: "{{ (kube_version | split('.'))[:-1] | join('.') }}"
kube_next: "{{ ((kube_version | split('.'))[1] | int) + 1 }}"
kube_major_next_version: "1.{{ kube_next }}"
pod_infra_supported_versions:
'1.33': '3.10'
'1.32': '3.10'
'1.31': '3.10'
etcd_supported_versions:
'1.33': "{{ (etcd_binary_checksums['amd64'].keys() | select('version', '3.6', '<'))[0] }}"
'1.32': "{{ (etcd_binary_checksums['amd64'].keys() | select('version', '3.6', '<'))[0] }}"
'1.31': "{{ (etcd_binary_checksums['amd64'].keys() | select('version', '3.6', '<'))[0] }}"
또한 checksums.yml을 통해 “허용된 버전 목록 + 무결성 검증”을 함께 관리한다는 점도 확인했습니다.
**cat roles/kubespray_defaults/vars/main/checksums.yml | head -n 20**
---
crictl_checksums:
arm64:
1.33.0: sha256:e1f34918d77d5b4be85d48f5d713ca617698a371b049ea1486000a5e86ab1ff3
...
network_facts : 대표 IP를 확정하는 단계
internal_facts.yml에서 network_facts Role이 호출되는데, 이 단계는 이후 모든 컴포넌트가 참조할 “노드 대표 IP”를 결정하는 역할을 합니다.
특히 access_ip/ip가 인벤토리에 명시되어 있을 때의 우선순위와, 최악의 경우를 대비한 fallback_ip(IPv4는 127.0.0.1, IPv6는 ::1)가 포함되어 있었습니다.
**cat roles/network_facts/tasks/main.yaml**
---
- name: **Set facts variables**
tags:
- always
**block**:
- name: **Gather ansible_default_ipv4**
setup:
gather_subset: '!all,network'
filter: "ansible_default_ipv4"
when: ansible_default_ipv4 is not defined
ignore_unreachable: true
- name: **Set fallback_ip**
set_fact:
**fallback_ip**: "{{ ansible_default_ipv4.address | d('127.0.0.1') }}"
when: fallback_ip is not defined
- name: **Set main access ip(access_ip based on ipv4_stack/ipv6_stack options).**
set_fact:
cacheable: true
**main_access_ip**: >-
{%- if ipv4_stack -%}
{{ access_ip | default(ip | default(fallback_ip)) }}
{%- else -%}
{{ access_ip6 | default(ip6 | default(fallback_ip6)) }}
{%- endif -%}
- name: **Set main ip(ip based on ipv4_stack/ipv6_stack options).**
set_fact:
cacheable: true
**main_ip**: "{{ (ip | default(fallback_ip)) if ipv4_stack else (ip6 | default(fallback_ip6)) }}"
프록시 환경이라면 no_proxy.yml을 통해 클러스터 내부 통신이 프록시를 타지 않도록 no_proxy 목록을 구성하는 것도 함께 포함되어 있었습니다.
이 장까지 정리하면서, Kubespray 배포 과정은 인벤토리 검증 → OS 부트스트랩 → 런타임/다운로드 → etcd → control plane → CNI → 애드온 → DNS 마무리까지 분리되어 있음을 확인했습니다.
실습 배포 환경 분석
Kubespray 배포 이후 노드에 실제로 남는 다운로드 아티팩트, 설치된 바이너리, CNI 관련 파일/권한, 그리고 인증서 자동 갱신 구성을 중심으로 “내 환경에서 무엇이 어떻게 구성됐는지”를 확인했습니다.
다운로드 아티팩트 경로 확인
Kubespray는 다운로드한 바이너리/이미지 아티팩트를 local_release_dir에 모아두고, 이후 Role들이 여기서 파일을 가져가 설치하는 흐름을 갖습니다.
이번 실습 환경에서는 k8s-cluster.yml에서 local_release_dir: "/tmp/releases"로 설정되어 있어 /tmp/releases 아래에 모든 구성요소가 모였습니다.
# **local_release_dir**: "/tmp/releases" # cat inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
**tree /tmp/releases/**
*/tmp/releases/
├── **cni-plugins**-linux-arm64-1.8.0.tgz
├── containerd-2.1.5-linux-arm64.tar.gz
├── containerd-rootless-setuptool.sh
├── containerd-rootless.sh
├── crictl
├── crictl-1.33.0-linux-arm64.tar.gz
├── etcd-3.5.25-linux-arm64.tar.gz
├── **etcd**-v3.5.25-linux-arm64
│ ├── etcd
│ ├── etcdctl
│ ├── etcdutl
│ └── ...
├── **helm-**3.18.4
│ ├── helm-3.18.4-linux-arm64.tar.gz
│ └── linux-arm64
│ ├── helm
│ └── ...
├── images
├── **kubeadm-**1.33.7-arm64
├── **kubectl-**1.33.7-arm64
├── **kubelet-**1.33.7-arm64
├── nerdctl
├── nerdctl-2.1.6-linux-arm64.tar.gz
└── **runc**-1.3.4.arm64*
- Kubespray는 배포 과정에서 필요한 구성요소를 먼저 local_release_dir로 내려받고, 이후 /usr/local/bin 등 실제 설치 경로로 배치합니다.
- arm64 환경이라 파일명/아카이브가 linux-arm64로 떨어지는 것도 확인할 수 있습니다.
설치된 바이너리 확인
설치된 바이너리의 기본 설치 경로는 bin_dir 변수로 결정되며, 이번 실습에서는 bin_dir: /usr/local/bin입니다.
**cat inventory/mycluster/group_vars/all/all.yml | grep 'bin_dir'**
*bin_dir: /usr/local/bin*
# 설치된 바이너리 확인
**tree /usr/local/bin/**
*/usr/local/bin/
├── **containerd**
├── **crictl**
├── **etcd**
├── **etcdctl**
├── **helm**
├── **kubeadm**
├── **kubectl**
├── **kubelet**
├── **nerdctl**
├── **runc**
└── ...
여기서 흥미로운 점은 “addon enable 여부”에 따라 바이너리가 함께 설치된다는 점입니다. 예를 들어 helm_enabled: true를 켜면 Helm 바이너리도 동일한 bin_dir 아래에 배치됩니다.
**cat inventory/mycluster/group_vars/k8s_cluster/addons.yml | grep helm**
helm_enabled: true
**helm version**
*version.BuildInfo{Version:"v3.18.4", ...}*
**etcdctl version**
*etcdctl version: 3.5.25
API version: 3.5*
**containerd --version**
*containerd ... v2.1.5 ...*
**kubeadm version -o yaml**
*clientVersion:
gitVersion: v1.33.7
platform: linux/arm64*
- 다운로드된 파일(/tmp/releases)이 “설치 후 실제 실행 파일(/usr/local/bin)”로 옮겨졌음을 확인했습니다.
- 특히 kubeadm/kubelet/kubectl 버전은 배포 시점에 내려받은 아티팩트 기준(예: v1.33.7)으로 고정되며, 로그/명령에서 보이는 버전과 설치된 바이너리 버전이 다르면 이후에 “실제로 사용 중인 바이너리” 기준으로 최종 진단하는 게 안전합니다.
CNI 관련 파일 구성 및 kube_owner 기반 권한 확인
이번 배포에서는 아래처럼 설정했습니다.
- kube_owner: kube
- kube_network_plugin: flannel
- Service CIDR / Pod CIDR: 10.233.0.0/18, 10.233.64.0/18
- Node prefix: /24
# cat inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
**kube_owner: kube**
**kube_network_plugin: flannel**
kube_service_addresses: 10.233.0.0/18
kube_pods_subnet: 10.233.64.0/18
kube_network_node_prefix: 24
이 설정의 효과는 “Kubernetes 관련 주요 디렉터리/파일이 kube_owner(=kube) 소유로 생성”된다는 점에서 확인할 수 있습니다.
# kube uid 파일 검색
**find / -user kube 2>/dev/null**
**/etc/cni**
/etc/cni/net.d
**/etc/kubernetes**
/etc/kubernetes/manifests
**/usr/libexec/kubernetes**
/usr/local/bin/kubernetes-scripts
**/opt/cni**
/opt/cni/bin
/opt/cni/bin/...
/opt/cni 디렉터리 자체도 kube 소유로 생성되었고, CNI 플러그인 바이너리들이 kube:root 소유로 배치된 것을 볼 수 있습니다.
**ls -l /opt**
*drwxr-xr-x. 3 **kube** root 17 Jan 24 21:29 **cni**
drwx--x--x. 4 root root 28 Jan 24 21:29 containerd*
**tree -ug /opt/cni**
*[**kube** root ] /opt/cni
└── [**kube** root ] bin
├── [kube root] bridge
├── [kube root] host-local
├── ...
├── [**root** root] **flannel**
└── ...
여기서 flannel 바이너리만 root 소유로 떨어지는 것도 확인했는데, 이는 flannel 설치 과정에서 “특정 바이너리 배치”가 별도 로직으로 수행되거나(혹은 패키징 방식 차이) 권한 정책이 다르게 적용되었을 가능성을 시사합니다.
또한 /etc/cni와 설정 파일(conflist)도 생성됩니다.
**ls -l /etc | grep cni**
*drwxr-xr-x. 3 **kube** root 19 Jan 24 21:29 cni*
**tree -ug /etc/cni**
***[kube** root ] /etc/cni
└── [**kube** root ] net.d
└── [root root] 10-flannel.conflist*
cat /etc/cni/net.d/10-flannel.conflist
- Kubespray의 kube_owner는 “클러스터 구성 파일들이 누구 소유로 깔리는지”에 직접 영향을 줍니다.
- CNI 구성은 크게
- /opt/cni/bin에 CNI 바이너리 배치
- /etc/cni/net.d/*.conflist로 CNI 구성 파일 배치
흐름으로 정리할 수 있습니다.
Kubespray + Cilium 조합에서 kube_owner: root가 필요한 이유
Cilium을 Kubespray로 설치할 때, 문서/이슈에서 자주 언급되는 패턴이 아래입니다.
- kube_network_plugin: cni : “기본 CNI 환경(바이너리/디렉터리)만 구성”하고 특정 네트워크 플러그인은 별도로 설치
- kube_owner: root : 권한이 제한된 보안 컨텍스트에서 동작할 때 파일 소유권 문제를 피하기 위한 선택지
# Configure Kubernetes networking: Use CNI without any network plugin
sed -e 's/^kube_network_plugin:.*$/**kube_network_plugin: cni**/' \
-e 's/^kube_owner:.*$/**kube_owner: root**/' \
inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
인증서 자동 갱신 구성 확인 (k8s-certs-renew.sh)
이번 실습에서는 아래 옵션을 활성화했습니다.
- auto_renew_certificates: true
- auto_renew_certificates_systemd_calendar: "Mon *-*-1,2,3,4,5,6,7 03:00:00" (매달 “첫 번째 월요일” 03:00)
배포 후 systemd timer/service가 생성되며, timer가 실제로 등록되어 있는지 확인했습니다.
# List timer units currently in memory, ordered by next elapse
**systemctl list-timers --all --no-pager**
*...
Mon 2026-02-02 03:01:48 … **1 week 1 day** - - **k8s-certs-renew.timer** k8s-certs-renew.service*
timer/unit 파일을 보면 OnCalendar 값이 그대로 반영되어 있습니다.
**systemctl status k8s-certs-renew.timer --no-pager**
*● k8s-certs-renew.timer - Timer to renew K8S control plane certificates
Triggers: ● k8s-certs-renew.service*
**cat /etc/systemd/system/k8s-certs-renew.timer**
*[Timer]
**OnCalendar=Mon *-*-1,2,3,4,5,6,7 03:00:00**
RandomizedDelaySec=10min
FixedRandomDelay=yes
Persistent=yes*
service는 oneshot으로 /usr/local/bin/k8s-certs-renew.sh를 실행합니다.
**systemctl status k8s-certs-renew.service**
*○ k8s-certs-renew.service - Renew K8S control plane certificates
Loaded: loaded (/etc/systemd/system/k8s-certs-renew.service; static)
Active: inactive (dead)
TriggeredBy: ● k8s-certs-renew.timer*
**cat /etc/systemd/system/k8s-certs-renew.service**
*[Service]
Type=oneshot
ExecStart=/usr/local/bin/k8s-certs-renew.sh*
스크립트는 다음 흐름으로 동작합니다.
- 현재 인증서 만료 확인 (kubeadm certs check-expiration)
- 다음 실행 시간과 buffer(days_buffer=7)를 기준으로 “갱신이 필요한 인증서가 있는지” 판단
- 필요 시 kubeadm certs renew all 수행
- control plane static pod를 crictl rmp -f로 제거 → kubelet이 자동 재생성
- /root/.kube/config 갱신, apiserver(6443) 재기동 대기 후 재확인
**cat /usr/local/bin/k8s-certs-renew.sh**
#!/bin/bash
echo "## Check Expiration before renewal ##"
/usr/local/bin/**kubeadm certs check-expiration**
days_buffer=7
calendar=Mon *-*-1,2,3,4,5,6,7 03:00:00
next_time=$(systemctl show k8s-certs-renew.timer -p NextElapseUSecRealtime --value)
...
/usr/local/bin/**kubeadm certs renew all**
echo "## Restarting control plane pods managed by kubeadm ##"
/usr/local/bin/**crictl pods --namespace kube-system --name 'kube-scheduler-*|kube-controller-manager-*|kube-apiserver-*|etcd-*' -q | /usr/bin/xargs /usr/local/bin/crictl rmp -f**
echo "## Updating /root/.kube/config ##"
**cp /etc/kubernetes/admin.conf /root/.kube/config**
echo "## Waiting for apiserver to be up again ##"
**until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/6443; do sleep 1; done**
echo "## Expiration after renewal ##"
/usr/local/bin/**kubeadm certs check-expiration**
- Kubespray는 “kubeadm 인증서 자동 갱신”을 단순히 옵션으로 켜는 것에서 끝나지 않고,
systemd timer/service + 실행 스크립트까지 제공해서 운영 자동화를 완성합니다. - control-plane 재기동도 “kubelet이 static pod를 다시 띄우는 모델”을 활용해 안전하게 처리합니다.
bootstrap_os 단계에서 Fact 캐시 생성 확인 (tags: always)
설치 로그를 보면 초반에 bootstrap_os : Gather facts Task가 항상(tags: always) 실행됩니다.
여기서 수집한 Fact는 Kubespray가 설정한 fact_caching = jsonfile 정책에 따라 /tmp 아래에 호스트별 파일로 캐시되어, 이후 Play에서 조건 분기나 네트워크/OS 판별에 반복 활용됩니다.
# more kubespray_install.log
TASK [bootstrap_os : Gather facts] *********************************************
ok: [k8s-ctr]
...
# fact 캐시 확인
tree /tmp
**more /tmp/k8s-ctr**
- Kubespray의 ansible.cfg에서 fact_caching_connection = /tmp로 지정되어 있었고(5장에서 확인), 그 결과 /tmp/<hostname> 형태로 fact 캐시가 생성됩니다.
- 이런 구조 덕분에 재실행 시 “매번 gather_facts를 풀로 돌리지 않고”, 필요한 값만 빠르게 꺼내 쓰는 흐름이 가능합니다.
Prepare for etcd install 단계에서 사용자/그룹이 생성되는 흐름
PLAY [Prepare for etcd install] 단계는 etcd 설치 전 “기본 OS 준비 + 계정/디렉터리/권한”을 깔아주는 구간인데, 로그에서 가장 눈에 띄는 작업 중 하나가 adduser Role의 사용자/그룹 생성입니다.
특히 이번 환경에서는 kube 계정과 인증서 그룹(kube-cert)이 system 계정으로 생성되며, 이후 생성되는 파일 소유권(kube_owner)과도 연결됩니다.
# more kubespray_install.log
PLAY [Prepare for etcd install] ************************************************
Saturday 24 January 2026 21:29:00 +0900 (0:00:00.903) 0:00:12.761 ******
TASK [adduser : User | Create User Group] **************************************
changed: [k8s-ctr] => {"changed": true, "gid": 988, "name": "kube-cert", "state": "present", "system": true}
TASK [adduser : User | Create User] ********************************************
changed: [k8s-ctr] => {"changed": true, "comment": "Kubernetes user", "create_home": false, "group": 988, "home": "/home/kube",
"name": "kube", "shell": "/sbin/nologin", "state": "present", "system": true, "uid": 990}
실제로 /etc/passwd, /etc/group를 보면 kube, etcd 계정이 system 사용자로 등록된 것을 확인할 수 있습니다.
# 유저 확인
**cat /etc/passwd | tail -n 3**
*vboxadd:x:991:1::/var/run/vboxadd:/bin/false
kube:x:990:988:Kubernetes user:/home/kube:/sbin/nologin
etcd:x:989:987:Etcd user:/home/etcd:/sbin/nologin*
# 그룹 확인
**cat /etc/group | tail -n 3**
*vboxdrmipc:x:989:
kube-cert:x:988:
etcd:x:987:*
또한 etcd 관련 TLS 인증서들이 etcd 사용자 소유로 배치된 것도 확인할 수 있습니다.
# uid etcd 파일 확인
**find / -user etcd 2>/dev/null**
/etc/ssl/etcd
/etc/ssl/etcd/ssl
/etc/ssl/etcd/ssl/admin-k8s-ctr-key.pem
/etc/ssl/etcd/ssl/admin-k8s-ctr.pem
/etc/ssl/etcd/ssl/ca-key.pem
/etc/ssl/etcd/ssl/ca.pem
/etc/ssl/etcd/ssl/member-k8s-ctr-key.pem
/etc/ssl/etcd/ssl/member-k8s-ctr.pem
/etc/ssl/etcd/ssl/node-k8s-ctr-key.pem
/etc/ssl/etcd/ssl/node-k8s-ctr.pem
- Kubespray는 “설치에 필요한 사용자/그룹을 먼저 생성”한 뒤, 이후 디렉터리/인증서/바이너리의 소유권 정책을 일관되게 적용합니다.
- kube_owner: kube를 사용했기 때문에 Kubernetes 쪽 구성 경로는 kube 소유(6.3절), etcd 인증서는 etcd 소유로 분리되어 관리됩니다.
sysctl 관련 작업 흐름과 결과 (kubernetes/preinstall Role)
OS 커널 파라미터 튜닝은 kubernetes/preinstall Role에서 처리됩니다.
실제로 roles/kubernetes/preinstall/tasks/0080-system-configurations.yml에 ansible.posix.sysctl로 여러 값을 세팅하는 Task가 존재하며, 이 값들은 kubelet 보호 옵션(kubelet_protect_kernel_defaults)이 켜져 있을 때 적용됩니다.
**cat roles/kubernetes/preinstall/tasks/0080-system-configurations.yml**
...
- name: **Ensure kubelet expected parameters are set**
**ansible.posix.sysctl**:
sysctl_file: "{{ sysctl_file_path }}"
name: "{{ item.name }}"
value: "{{ item.value }}"
state: present
reload: true
ignoreerrors: "{{ sysctl_ignore_unknown_keys }}"
**with_items**:
- { name: **kernel.keys.root_maxbytes**, value: 25000000 }
- { name: kernel.keys.root_maxkeys, value: 1000000 }
- { name: **kernel.panic**, value: 10 }
- { name: **kernel.panic_on_oops**, value: 1 }
- { name: **vm.overcommit_memory**, value: 1 }
- { name: **vm.panic_on_oom**, value: 0 }
**when: kubelet_protect_kernel_defaults | bool**
적용 결과는 /etc/sysctl.conf와 /etc/sysctl.d/99-sysctl.conf(symlink)에서 확인됩니다.
# 설정값 확인
**grep "^[^#]" /etc/sysctl.conf**
net.ipv4.ip_forward=1
kernel.keys.root_maxbytes=25000000
kernel.keys.root_maxkeys=1000000
kernel.panic=10
kernel.panic_on_oops=1
vm.overcommit_memory=1
vm.panic_on_oom=0
net.ipv4.ip_local_reserved_ports=30000-32767
net.bridge.bridge-nf-call-iptables=1
net.bridge.bridge-nf-call-arptables=1
net.bridge.bridge-nf-call-ip6tables=1
**ls -l /etc/sysctl.d/**
*lrwxrwxrwx. 1 root root 14 May 18 2025 **99-sysctl.conf -> ../sysctl.conf**
-rw-r--r--. 1 root root 120 Jan 24 21:25 k8s.conf*
설치 로그에서도 sysctl 관련 Task가 실제로 changed로 적용되는 것을 확인할 수 있었습니다.
TASK [kubernetes/preinstall : **Enable ip forwarding**] ************************
changed: [k8s-ctr] => {"changed": true}
TASK [kubernetes/preinstall : **Ensure kubelet expected parameters are set**] ***
changed: [k8s-ctr] => (item={'name': 'kernel.keys.root_maxbytes', 'value': 25000000}) => {"changed": true, ...}
changed: [k8s-ctr] => (item={'name': 'kernel.keys.root_maxkeys', 'value': 1000000}) => {"changed": true, ...}
changed: [k8s-ctr] => (item={'name': 'kernel.panic', 'value': 10}) => {"changed": true, ...}
changed: [k8s-ctr] => (item={'name': 'kernel.panic_on_oops', 'value': 1}) => {"changed": true, ...}
changed: [k8s-ctr] => (item={'name': 'vm.overcommit_memory', 'value': 1}) => {"changed": true, ...}
changed: [k8s-ctr] => (item={'name': 'vm.panic_on_oom', 'value': 0}) => {"changed": true, ...}
- Kubespray의 OS 튜닝은 “임의의 쉘 스크립트”가 아니라 ansible.posix.sysctl 모듈로 선언적으로 관리됩니다.
- 결과적으로 sysctl 값은 /etc/sysctl.conf에 수렴하고, /etc/sysctl.d/99-sysctl.conf가 이를 가리키는 구조(심볼릭 링크)로 운영됩니다.
- ip_forward, bridge 관련 옵션, kubelet 보호 파라미터( panic/oops, overcommit ) 등이 함께 들어가면서 Kubernetes 실행에 필요한 커널 기본값이 맞춰집니다.
with_items는 “이전 앤서블 반복문 스타일
Kubespray 코드에서 with_items가 종종 보이는데, 이는 예전 Ansible 스타일의 반복문 구문입니다. 최신 Ansible에서는 loop:를 주로 쓰지만, 기존 코드/플레이북을 분석할 때는 with_ 계열도 알고 있으면 이해가 빨라집니다.
예시: with_items로 단순 리스트 반복
# my-ansible/old-style-loop.yml
---
- hosts: **localhost**
vars:
data:
- user0
- user1
- user2
tasks:
- name: "with_items"
ansible.builtin.debug:
msg: "{{ **item** }}"
**with_items**: "{{ **data** }}"
실행 결과
**ansible-playbook old-style-loop.yml**
*TASK [with_items] *************************************************************
ok: [localhost] => (item=user0) => {"msg": "user0"}
ok: [localhost] => (item=user1) => {"msg": "user1"}
ok: [localhost] => (item=user2) => {"msg": "user2"}*
- with_items는 반복 대상이 리스트일 때 loop와 거의 동일하게 동작하며, 반복 변수는 item으로 들어옵니다.
- Kubespray처럼 역사가 긴 프로젝트에서는 with_ 구문이 남아있을 수 있으므로, 플레이북 분석 시 알아두면 편합니다.
kubernetes/preinstall Role이 하는 일 (tags: preinstall)
kubernetes/preinstall은 Kubespray 설치 과정에서 가장 “운영 친화적인 자동화”를 담당하는 Role 중 하나입니다.
목적은 단순합니다.
- kubelet이 안정적으로 기동하고
- kubeadm init/join이 실패하지 않도록
- OS 상태를 Kubernetes 요구사항에 맞게 정리합니다.
실제로 preinstall은 swap, sysctl, DNS(resolv.conf), 네트워크 매니저(NetworkManager), NTP, dhclient hooks 등 “kubelet/kubeadm이 민감해하는 영역”을 집중적으로 다룹니다.
**tree roles/kubernetes/preinstall/tasks/**
*roles/kubernetes/preinstall/tasks/
├── 0010-swapoff.yml
├── 0020-set_facts.yml
├── 0040-verify-settings.yml
├── 0050-create_directories.yml
├── 0060-resolvconf.yml
├── 0061-systemd-resolved.yml
├── 0062-networkmanager-unmanaged-devices.yml
├── 0063-networkmanager-dns.yml
├── 0080-system-configurations.yml
├── 0081-ntp-configurations.yml
├── 0100-dhclient-hooks.yml
├── 0110-dhclient-hooks-undo.yml
└── main.yml*
**cat roles/kubernetes/preinstall/defaults/main.yml**
*supported_os_distributions:
- 'RedHat'
- 'CentOS'
- 'Fedora'
- 'Ubuntu'
- 'Debian'
...
- 'Rocky'
- 'Amazon'
...*
위 supported_os_distributions 목록에 Rocky가 포함되어 있고, 실습 환경(Rocky Linux 10)에서도 preinstall 단계가 정상 흐름으로 동작합니다.
preinstall의 실행 흐름: tasks/main.yml 기준으로 이해하기
roles/kubernetes/preinstall/tasks/main.yml은 “기능별 task 파일을 import”하는 구조로 되어 있습니다.
즉, preinstall은 단일 스크립트가 아니라 스왑/검증/디렉터리/DNS/커널튜닝/NTP 같은 서브 태스크 모음으로 구성됩니다.
# cat roles/kubernetes/preinstall/tasks/main.yml
---
- name: **Disable swap**
import_tasks: 0010-swapoff.yml
when:
- not dns_late
- kubelet_fail_swap_on
- name: **Set facts**
import_tasks: 0020-set_facts.yml
tags:
- resolvconf
- facts
- name: **Check settings**
import_tasks: 0040-verify-settings.yml
when:
- not dns_late
tags:
- asserts
- name: **Create directories**
import_tasks: 0050-create_directories.yml
when:
- not dns_late
- name: **Apply resolvconf settings**
import_tasks: 0060-resolvconf.yml
when:
- dns_mode != 'none'
- resolvconf_mode == 'host_resolvconf'
- systemd_resolved_enabled.rc != 0
- networkmanager_enabled.rc != 0
tags:
- bootstrap_os
- resolvconf
- name: **Apply system configurations**
import_tasks: 0080-system-configurations.yml
when:
- not dns_late
tags:
- bootstrap_os
- name: **Flush handlers**
meta: flush_handlers
...
- preinstall은 “배포 초반(dns_late=false)”에 먼저 실행되고, 마지막 Play에서 dns_late=true로 다시 호출되는 구간(앞 장의 Apply resolv.conf changes...)과 맞물려 DNS를 정리합니다.
- meta: flush_handlers가 포함되어 있어, DNS/네트워크 설정 변경이 있었다면 handler를 즉시 반영하도록 설계되어 있습니다.
NetworkManager / systemd-resolved 감지: Rocky Linux 계열 분기 확인
preinstall 단계에서는 “이 호스트가 어떤 DNS/네트워크 구성 요소를 쓰는지”를 먼저 체크하고, 그 결과에 따라 resolv.conf 적용 방식이 달라집니다.
실습 로그에서 확인한 내용은 다음과 같습니다.
- NetworkManager는 active (rc=0)
- systemd-resolved는 inactive / not found (rc=4)
# NetworkManager 상태 체크
TASK [kubernetes/preinstall : **NetworkManager** | Check if host has NetworkManager] ***
ok: [k8s-ctr] => {"changed": false, "cmd": ["systemctl", "is-active", "--quiet", "NetworkManager.service"], "rc": 0, ...}
**systemctl status NetworkManager.service --no-pager**
**journalctl -u NetworkManager.service --no-pager**
**nmcli device status**
**nmcli connection show**
# systemd-resolved 체크
TASK [kubernetes/preinstall : Check **systemd-resolved**] **************************
ok: [k8s-ctr] => {"changed": false, "cmd": ["systemctl", "is-active", "systemd-resolved"], "rc": 4, "stdout": "inactive", ...}
**systemctl status systemd-resolved**
*Unit systemd-resolved.service could not be found.*
- Rocky Linux 10 환경에서는 systemd-resolved가 기본 경로가 아니어서, Kubespray 입장에서는 NetworkManager 기반으로 DNS/resolv.conf를 다루는 쪽으로 분기됩니다.
- 이런 감지 로직이 있기 때문에, 동일한 Kubespray라도 OS별로 resolv.conf 처리 방식이 달라질 수 있습니다.
preinstall이 만드는 디렉터리와 소유권: kube_owner의 효과가 드러나는 지점
preinstall의 중요한 작업 중 하나는 “Kubernetes가 사용할 경로를 미리 생성”하는 것입니다.
그리고 이때 kube_owner 값이 실제 소유권으로 반영됩니다. (이번 환경은 kube_owner: kube)
로그에서 /etc/kubernetes, /etc/cni, /opt/cni 등이 owner: kube(uid=990)로 생성된 것이 그대로 보입니다.
# kube 유저 권한 디렉터리 생성
TASK [kubernetes/preinstall : **Create kubernetes directories**] *******************
changed: [k8s-ctr] => (item=/**etc/kubernetes**) => {... **"owner": "kube"**, "uid": 990}
changed: [k8s-ctr] => (item=**/etc/kubernetes/manifests**) => {... **"owner": "kube"**, "uid": 990}
changed: [k8s-ctr] => (item=/**usr/local/bin/kubernetes-scripts**) => {... **"owner": "kube"**, "uid": 990}
changed: [k8s-ctr] => (item**=/usr/libexec/kubernetes/kubelet-plugins/volume/exec**) => {... **"owner": "kube"**, "uid": 990}
# root 소유 디렉터리 생성
TASK [kubernetes/preinstall : **Create other directories of root owner**] **********
changed: [k8s-ctr] => (item=**/etc/kubernetes/ssl**) => {... **"owner": "root"**, "uid": 0}
# kubeadm 호환을 위한 pki 링크 생성
TASK [kubernetes/preinstall : **Create kubernetes kubeadm compat cert dir** (kubernetes/kubeadm issue 1498)] ***
changed: [k8s-ctr] => {"dest": "**/etc/kubernetes/pki**", "src": "/etc/kubernetes/ssl", "state": "link", ...}
# CNI 디렉터리 생성
TASK [kubernetes/preinstall : **Create cni directories**] **************************
changed: [k8s-ctr] => (item=**/etc/cni/net.d)** => {... **"owner": "kube"**, "uid": 990}
changed: [k8s-ctr] => (item=**/opt/cni/bin)** => {... **"owner": "kube"**, "uid": 990}
이 동작은 실제 코드(0050-create_directories.yml)에서도 동일하게 확인됩니다.
**cat roles/kubernetes/preinstall/tasks/0050-create_directories.yml**
---
- name: **Create kubernetes directories**
file:
path: "{{ item }}"
state: directory
owner: "{{ kube_owner }}"
mode: "0755"
when: ('k8s_cluster' in group_names)
become: true
**with_items**:
- "{{ kube_config_dir }}"
- "{{ kube_manifest_dir }}"
- "{{ kube_script_dir }}"
- "{{ kubelet_flexvolumes_plugins_dir }}"
- name: **Create other directories of root owner**
file:
path: "{{ item }}"
state: directory
owner: root
mode: "0755"
when: ('k8s_cluster' in group_names)
become: true
**with_items**:
- "{{ kube_cert_dir }}"
- "{{ bin_dir }}"
- name: Create kubernetes kubeadm compat cert dir (kubernetes/kubeadm issue 1498)
file:
src: "{{ kube_cert_dir }}"
dest: "{{ kube_cert_compat_dir }}"
state: link
mode: "0755"
when:
- ('k8s_cluster' in group_names)
- kube_cert_dir != kube_cert_compat_dir
- not kube_cert_compat_dir_check.stat.exists
- name: Create cni directories
file:
path: "{{ item }}"
state: directory
owner: "{{ kube_owner }}"
mode: "0755"
with_items:
- "/etc/cni/net.d"
- "/opt/cni/bin"
when:
- kube_network_plugin in ["calico", "flannel", "cilium", "kube-ovn", "kube-router", "macvlan"]
- ('k8s_cluster' in group_names)
- /etc/kubernetes, /etc/cni, /opt/cni가 “kube 소유로 생성”되는 건 kube_owner 옵션의 직접 결과입니다.
- /etc/kubernetes/pki 링크는 kubeadm이 기대하는 기본 경로(/etc/kubernetes/pki)와 Kubespray의 cert 경로(/etc/kubernetes/ssl) 간 차이를 메워주는 호환 처리입니다.
NetworkManager 재시작 Handler: DNS 반영을 “즉시” 보장
preinstall에는 handler가 존재하며, DNS 설정을 NetworkManager 방식으로 건드린 경우 적용을 위해 NetworkManager.service를 재시작합니다.
로그에서 RUNNING HANDLER가 찍히는 경우가 있는데, 이는 main.yml의 meta: flush_handlers로 인해 “뒤로 미루지 않고 즉시 적용”되는 패턴입니다.
**cat roles/kubernetes/preinstall/handlers/main.yml**
- name: **Preinstall | reload NetworkManager**
service:
name: **NetworkManager.service**
state: **restarted**
listen: Preinstall | update resolvconf for networkmanager
결국 아래 명령이 실행되는 것과 동일합니다.
**systemctl restart NetworkManager.service**
**journalctl -u NetworkManager.service --no-pager**
- DNS/네트워크 계열 설정은 “적용 타이밍”이 중요해서, preinstall은 handler + flush_handlers 조합으로 즉시 반영을 보장합니다.
- 이 덕분에 이후 단계(컨테이너 런타임 설치/이미지 다운로드/kubeadm init)가 “올바른 nameserver/search domain”을 기반으로 수행될 확률이 올라갑니다.
container-engine Role: 컨테이너 런타임 설치/검증 (tags: container-engine)
Kubespray의 container-engine Role은 Kubernetes 노드가 사용할 컨테이너 런타임(containerd/crio/docker) 과 실행 환경 구성요소들을 설치·검증하는 단계입니다.
Kubelet/kubeadm 이전에 반드시 준비되어야 하는 영역이라, 배포 흐름에서도 kubernetes/preinstall 바로 다음에 등장합니다.
실제 디렉터리 구조를 보면 container-engine은 “런타임 본체 + 주변 도구(runc/nerdctl/crictl) + 검증(validate) + 클린업”으로 구성된 것을 확인할 수 있습니다.
**tree roles/container-engine/ -L 2**
*roles/container-engine/
├── containerd
├── containerd-common
├── crictl
...
├── nerdctl
├── runc
├── skopeo
├── validate-container-engine
...*
- validate-container-engine: 기존 런타임/서비스 존재 여부 확인, 충돌 제거/중지
- runc: OCI runtime 설치
- containerd: 런타임 설치 + systemd 등록 + config 생성
- crictl/nerdctl: 운영/디버깅 도구 설치
- (옵션) 레지스트리 설정: /etc/containerd/certs.d/<registry>/hosts.toml
validate-container-engine: “기존 설치 흔적”을 먼저 확인하는 이유
Kubespray는 무작정 설치부터 하지 않고, 먼저 기존 kubelet/containerd 설치 흔적이 있는지 점검합니다.
특히 컨테이너 런타임은 기존 설정이 남아있으면 이후 kubelet/cri 연결에서 미묘하게 꼬일 수 있어서, 초반에 fact로 “이미 설치되어 있나?”를 명확히 판단합니다.
**cat roles/container-engine/validate-container-engine/tasks/main.yml**
- name: Ensure **kubelet systemd unit exists**
stat:
path: "/etc/systemd/system/kubelet.service"
register: kubelet_systemd_unit_exists
tags:
- facts
- name: Check if **containerd is installed**
find:
file_type: file
recurse: true
use_regex: true
patterns:
- containerd.service$
paths:
- /lib/systemd
- /etc/systemd
- /run/systemd
register: containerd_installed
tags:
- facts
실습 로그에서도 “kubelet systemd unit 없음”, “containerd.service 없음”으로 확인되면서 클린 설치 루트로 진행된 것을 볼 수 있었습니다.
TASK [container-engine/validate-container-engine : Ensure kubelet systemd unit exists] ***
ok: [k8s-ctr] => {"changed": false, "stat": {"exists": false}}
TASK [container-engine/validate-container-engine : Check if containerd is installed] ***
ok: [k8s-ctr] => {"changed": false, "matched": 0, "msg": "All paths examined", ...}
- validate 단계는 “설치 성공률”을 높이기 위한 안전장치다.
- 특히 동일 호스트에 재설치/재실행 시, 런타임 서비스/설정 파일이 남아있으면 장애 원인이 되므로 이 단계가 중요합니다.
runc 설치: download Role을 재사용하는 구조
runc Role을 보면, 파일 다운로드를 직접 구현하지 않고 download Role의 공용 태스크(download_file.yml) 를 include 해서 재사용합니다.
즉, Kubespray는 “다운로드/검증/캐시” 같은 반복 기능을 별도 Role(download)로 추상화해 두고, 여러 컴포넌트(runc, containerd, etcd, kubelet…)가 공통으로 쓰는 구조입니다.
**cat roles/container-engine/runc/tasks/main.yml**
- name: **Runc | Download runc binary**
include_tasks: "../../../download/tasks/download_file.yml"
vars:
download: "{{ download_defaults | combine(downloads.runc) }}"
...
- name: **Copy runc binary from download dir**
copy:
src: "{{ downloads.runc.dest }}"
dest: "{{ runc_bin_dir }}/runc"
mode: "0755"
remote_src: true
로그에서도 /tmp/releases에 내려받고, 최종적으로 /usr/local/bin/runc로 배치되는 흐름이 그대로 보입니다.
TASK [container-engine/runc : Download_file | Create dest directory on node] ***
changed: [k8s-ctr] => {"path": "/tmp/releases", ...}
TASK [container-engine/runc : Download_file | Download item] ***
changed: [k8s-ctr] => {"changed": true, ...}
TASK [container-engine/runc : Copy runc binary from download dir] ***
changed: [k8s-ctr] => {"dest": "/usr/local/bin/runc", "mode": "0755", ...}
containerd 설치: systemd unit + config.toml + registry 설정(/etc/containerd/certs.d)
설치 흐름 (tasks/main.yml)
containerd Role은 크게 아래 작업을 수행합니다.
- containerd 다운로드
- 바이너리 unpack (/usr/local/bin 사용)
- systemd unit 생성(/etc/systemd/system/containerd.service)
- config.toml 생성(/etc/containerd/config.toml)
- registry 설정(/etc/containerd/certs.d/*/hosts.toml)
- 핸들러로 재시작 + enable/start
**cat roles/container-engine/containerd/tasks/main.yml**
- name: Containerd | Download containerd
include_tasks: "../../../download/tasks/download_file.yml"
- name: Containerd | Unpack containerd archive
unarchive:
src: "{{ downloads.containerd.dest }}"
dest: "{{ containerd_bin_dir }}"
mode: "0755"
remote_src: true
extra_opts: [--strip-components=1]
notify: Restart containerd
- name: Containerd | Generate systemd service for containerd
template:
src: containerd.service.j2
dest: /etc/systemd/system/containerd.service
notify: Restart containerd
- name: Containerd | Copy containerd config file
template:
src: "{{ 'config.toml.j2' if containerd_version is version('2.0.0', '>=') else 'config-v1.toml.j2' }}"
dest: /etc/containerd/config.toml
notify: Restart containerd
systemd unit 확인
실제 생성된 unit file을 보면 ExecStart가 /usr/local/bin/containerd를 바라보고 있습니다.
즉 “OS 패키지 설치”가 아니라 바이너리 기반 설치로 런타임을 고정하는 방식입니다.
**cat /etc/systemd/system/containerd.service**
[Service]
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/containerd
...
이 unit 템플릿은 containerd.service.j2에서 변수(containerd_bin_dir) 기반으로 만들어집니다.
# roles/container-engine/containerd/templates/containerd.service.j2
ExecStart={{ containerd_bin_dir }}/containerd
LimitNOFILE={{ containerd_limit_open_file_num }}
...
config.toml 핵심 포인트: registry config_path 분리
실제 /etc/containerd/config.toml에는 registry 설정이 config_path = "/etc/containerd/certs.d"로 분리된 것을 확인할 수 있습니다.
**cat /etc/containerd/config.toml**
...
[plugins."io.containerd.cri.v1.images".registry]
config_path = "/etc/containerd/certs.d"
즉 최신 containerd에서는 과거처럼 config.toml 한 파일에 레지스트리 설정을 다 넣기보다,
/etc/containerd/certs.d/<registry>/hosts.toml 구조로 레지스트리별 설정을 분리 관리하는 패턴을 따릅니다.
Docker Hub hosts.toml 확인
**tree /etc/containerd/**
/etc/containerd/
├── certs.d
│ └── docker.io
│ └── hosts.toml
├── config.toml
└── cri-base.json
**cat /etc/containerd/certs.d/docker.io/hosts.toml**
server = "https://docker.io"
[host."https://registry-1.docker.io"]
capabilities = ["pull","resolve"]
skip_verify = false
override_path = false
- hosts.toml은 “docker.io 요청이 실제로는 registry-1.docker.io로 간다”는 매핑과 capabilities를 정의합니다.
- kubelet/crictl/ctr 모두 containerd를 통해 이미지를 pull하므로, 이 설정이 곧 클러스터 전체 이미지 pull 정책에 영향을 줍니다.
- 사내 미러/프록시 레지스트리가 있다면 동일 위치에 host 엔트리를 추가해 우선순위를 줄 수 있습니다.
containerd 핸들러: 설정 변경 후 즉시 재시작/대기
containerd Role은 config 생성, registry 설정 등 변경이 발생하면 handler로 재시작합니다.
로그에서도 RUNNING HANDLER로 restart/wait가 실행되는 것을 확인할 수 있습니다.
RUNNING HANDLER [container-engine/containerd : Containerd | restart containerd] ***
RUNNING HANDLER [container-engine/containerd : Containerd | wait for containerd]
TASK [container-engine/containerd : Containerd | Ensure containerd is started and enabled]
- notify → handler 패턴 덕분에 “필요할 때만 restart”가 발생하며,
- 즉시 반영이 필요한 경우를 위해 Role 내부에서도 Flush가 들어가도록 구성되어 있습니다.
파드에서 too many open files 발생 시: OCI Runtime Spec 기반 접근
운영 중 파드에서 too many open files가 발생하면 흔히 ulimit -n(RLIMIT_NOFILE)과 관련이 있습니다.
Kubespray + containerd 환경에서는 이 값이 단순히 /etc/security/limits.conf만으로 해결되지 않고, 아래 레이어가 함께 얽힙니다.
- 커널 전역 한계: fs.file-max, /proc/sys/fs/file-nr
- 프로세스 한계: RLIMIT_NOFILE (ulimit -n) / PAM limits
- systemd 서비스 한계: LimitNOFILE
- 런타임(핵심): containerd의 base_runtime_spec(OCI spec) → 파드 프로세스 rlimit에 영향
현재 Kubespray 기본값 확인: /etc/containerd/cri-base.json
Kubespray 설치 후 기본 cri-base.json에 rlimit이 포함되어 있고, 파드에서도 그대로 반영되는 것을 확인했습니다.
**cat /etc/containerd/cri-base.json | jq**
...
"rlimits": [
{
"type": "RLIMIT_NOFILE",
"hard": 65535,
"soft": 65535
}
],
...
파드를 띄워서 ulimit -a를 보면 nofiles=65535로 찍힙니다.
kubectl exec -it ubuntu -- sh -c 'ulimit -a'
...
nofiles 65535
...
해결 방향: container-engine Role만 재실행해 런타임 spec 반영하기
이런 문제는 kubelet/kubeadm 전체 재설치가 아니라, container-engine만 태그 실행으로 재적용하는 방식이 깔끔합니다.
- 런타임 spec 패치 변수는 containerd defaults에 존재합니다.
**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 }}"
예시로 “rlimits를 비워서(=런타임 기본을 따르게) 테스트”하려면 group_vars에 patch를 덮어쓸 수 있습니다.
cat << EOF >> inventory/mycluster/group_vars/all/containerd.yml
containerd_default_base_runtime_spec_patch:
process:
rlimits: []
EOF
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml \
--tags "container-engine" --limit k8s-ctr -e kube_version="1.33.3"**
적용 후 /etc/containerd/cri-base.json의 rlimits가 변경되었는지 확인합니다.
cat /etc/containerd/cri-base.json | jq | grep rlimits
"rlimits": [],
이 방식의 장점은 “cluster.yml 전체 재실행”이 아니라, 런타임 영역만 안전하게 재적용할 수 있다는 점입니다.
태그 기반 작업 범위 확인: --tags container-engine vs --tags containerd
Kubespray는 태그 설계가 잘 되어 있어서, 특정 영역만 “부분 실행”하기가 좋습니다.
실습에서도 --list-tasks로 범위를 먼저 확인하면 안전합니다.
--tags "container-engine": 런타임 전체(검증+runc+containerd+도구)
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml \
--tags "container-engine" --list-tasks**
출력에서 보듯 validate-container-engine → runc → crictl/nerdctl → containerd가 포함되고, 다른 런타임(docker/crio) 경로도 조건부로 나열됩니다.
--tags "containerd": containerd 설치 경로만 좁히기
roles/container-engine/meta/main.yaml을 보면 containerd Role이 tags: [container-engine, containerd]로 묶여 있어, --tags containerd만으로도 containerd 관련 작업만 타게 됩니다.
# cat roles/container-engine/meta/main.yaml (발췌)
- role: container-engine/containerd
when:
- container_manager == 'containerd'
tags:
- container-engine
- containerd
**ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml \
--tags "containerd" --list-tasks**
- 런타임을 전반적으로 손볼 때: --tags container-engine
- containerd 설정/서비스만 재적용할 때: --tags containerd
- 둘 다 “배포 전체를 흔들지 않고” 특정 레이어만 조정하는 데 유용합니다.
공통 다운로드 레이어: download Role (tags: download)
Kubespray는 바이너리 다운로드 / 이미지 Pull / 압축 해제를 각 Role이 따로 구현하지 않고, 공통으로 roles/download를 재사용합니다.
앞에서 봤던 runc, containerd, etcd, kubeadm/kubelet/kubectl, cni-plugins 모두 결국 이 download Role의 태스크들을 호출합니다.
**tree roles/download/**
roles/download/
├── meta
│ └── main.yml
├── tasks
│ ├── check_pull_required.yml
│ ├── download_container.yml
│ ├── download_file.yml
│ ├── extract_file.yml
│ ├── main.yml
│ ├── prep_download.yml
│ ├── prep_kubeadm_images.yml
│ └── set_container_facts.yml
└── templates
└── kubeadm-images.yaml.j2
구조적으로 보면 크게 2가지 축이 있습니다.
- 파일 기반 다운로드(download_file.yml)
- kubeadm/kubelet/kubectl/etcd/crictl/runc/containerd/cni-plugins …
- 컨테이너 이미지 다운로드(download_container.yml)
- nerdctl pull, tar cache 관리, 필요 여부 판단(check_pull_required)
kubeadm 바이너리 + “필요 이미지 목록” 준비: prep_kubeadm_images.yml
Kubeadm은 “클러스터 초기화” 도구이면서 동시에, 설치 시점에 필요한 컨트롤플레인 이미지 목록을 계산할 수 있습니다.
Kubespray는 이를 활용해 다음 순서로 동작합니다.
- kubeadm 바이너리 다운로드 + 시스템 경로로 복사
- kubeadm-images.yaml 생성 (CRI socket / repo / k8s 버전 / etcd 설정 / coredns 등)
- kubeadm config images list로 필요 이미지 목록 생성
- 결과를 kubeadm_images 딕셔너리로 만들어 “이후 download 단계에서 이미지 pull”에 사용
TASK [download : Download | Get kubeadm binary and list of required images] ****
included: /root/kubespray/roles/download/tasks/prep_kubeadm_images.yml for k8s-ctr
핵심 태스크는 아래 3개가 특히 중요합니다.
kubeadm 바이너리 배치
- name: Prep_kubeadm_images | Copy kubeadm binary from download dir to system path
copy:
src: "{{ downloads.kubeadm.dest }}"
dest: "{{ bin_dir }}/kubeadm"
mode: "0755"
remote_src: true
kubeadm-images.yaml 생성 (kubeadm-images.yaml.j2)
여기서 포인트는 “이미지 레포지토리/버전/CRI socket/etcd 타입” 같은 kubeadm 계산의 입력값이 정리된다는 점입니다.
# kubeadm-images.yaml.j2 (발췌)
apiVersion: kubeadm.k8s.io/{{ kubeadm_config_api_version }}
kind: InitConfiguration
nodeRegistration:
criSocket: {{ cri_socket }}
---
apiVersion: kubeadm.k8s.io/{{ kubeadm_config_api_version }}
kind: ClusterConfiguration
imageRepository: {{ kubeadm_image_repo }}
kubernetesVersion: v{{ kube_version }}
etcd:
{% if etcd_deployment_type == "kubeadm" %}
local:
imageRepository: "{{ etcd_image_repo | regex_replace("/etcd$","") }}"
imageTag: "{{ etcd_image_tag }}"
{% else %}
external:
endpoints:
...
{% endif %}
dns:
imageRepository: {{ coredns_image_repo | regex_replace('/coredns(?!/coredns).*$', '') }}
imageTag: {{ coredns_image_tag }}
필요 이미지 목록 산출 → dict로 변환
- name: Prep_kubeadm_images | Generate list of required images
shell: "set -o pipefail && {{ bin_dir }}/kubeadm config images list --config={{ kube_config_dir }}/kubeadm-images.yaml | grep -Ev 'coredns|pause'"
register: kubeadm_images_raw
- name: Prep_kubeadm_images | Parse list of images
set_fact:
kubeadm_image:
key: "kubeadm_{{ (item | regex_replace('^(?:.*\\/)*', '')).split(':')[0] }}"
value:
enabled: true
container: true
repo: "{{ item | regex_replace('^(.*):.*$', '\\1') }}"
tag: "{{ item | regex_replace('^.*:(.*)$', '\\1') }}"
...
실제 다운로드 실행: “바이너리/아카이브”는 download_file.yml
Download | Download files / images 태스크를 보면, 다운로드 대상이 한꺼번에 item(key/value) 형태로 흘러가며, 내부적으로 download_file.yml을 반복 include 하는 구조입니다.
로그에서도 etcd/cni/kubeadm/kubelet/kubectl/crictl/runc/containerd 등 다양한 아티팩트가 동일한 흐름으로 내려받아지는 것을 확인할 수 있습니다.
TASK [download : Download | Download files / images] ***************************
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'etcd', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'cni', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'kubeadm', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'kubelet', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'kubectl', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'crictl', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'runc', ...})
included: /root/kubespray/roles/download/tasks/download_file.yml for k8s-ctr => (item={'key': 'containerd', ...})
- dest: /tmp/releases/...에 “원본”을 모아두고
- 각 Role이 필요할 때 /usr/local/bin 등으로 복사/압축해제/설정 생성
컨테이너 이미지 중복 Pull 방지: check_pull_required.yml
이미지 다운로드는 “매번 pull”하면 시간이 오래 걸리고, 네트워크에 따라 설치가 느려집니다.
그래서 Kubespray는 노드에 이미 이미지가 존재하는지 먼저 확인한 뒤, 없을 때만 pull 하도록 설계했습니다.
노드 이미지 목록 조회 (nerdctl)
TASK [download : Check_pull_required | Generate a list of information about the images on a node] ***
ok: [k8s-ctr] => {"cmd": "/usr/local/bin/nerdctl -n k8s.io images --format '{{ .Repository }}:{{ .Tag }}' ..."}
없으면 pull_required=true
TASK [download : Check_pull_required | Set pull_required if the desired image is not yet loaded] ***
ok: [k8s-ctr] => {"ansible_facts": {"pull_required": true}, "changed": false}
필요할 때만 pull (download_container.yml)
TASK [download : Download_container | Download image if required] **************
changed: [k8s-ctr] => {"cmd": ["/usr/local/bin/nerdctl", "-n", "k8s.io", "pull", "--quiet", "docker.io/flannel/flannel:v0.27.3"], "delta": "0:00:06.586190"}
이 구조 덕분에 재실행/부분 실행 시에도 “이미 받아둔 이미지면 스킵”되어 속도가 크게 개선됩니다.
캐시 정리 + 플래그 초기화
이미지를 tar로 캐시했을 경우 용량 확보를 위해 삭제(없으면 그대로 ok), 다음 아이템 처리를 위해 내부 플래그 초기화까지 해줍니다.
TASK [download : Download_container | Remove container image from cache] *******
ok: [k8s-ctr] => {"path": "/tmp/releases/images/docker.io_flannel_flannel_v0.27.3.tar", "state": "absent"}
TASK [download : Set default values for flag variables] ************************
ok: [k8s-ctr] => {"ansible_facts": {"image_changed": false, "image_is_cached": false, "pull_required": false}}
Playbook 흐름에서 download 위치: “etcd 설치 전에 다운로드가 끼어 있음”
playbooks/cluster.yml을 보면 “Prepare for etcd install” play에서 다음 순서로 role이 실행됩니다.
- preinstall
- container-engine
- download
- 그리고 나서 Install etcd(install_etcd.yml import)
- name: Prepare for etcd install
hosts: k8s_cluster:etcd
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" }
- name: Install etcd
import_playbook: install_etcd.yml
즉 etcd role이 실행되기 전에:
- etcd 바이너리(또는 이미지) + 필요한 도구들이 이미 준비되어 있는 상태가 됩니다.
install_etcd.yml: “필요한 노드에만” etcd/인증서 배포
일부 CNI(예: flannel/canal/cilium 또는 calico-etcd)는 워커 노드에서도 etcd에 접근해야 하므로
Kubespray는 조건을 만족하는 노드를 동적으로 _kubespray_needs_etcd 그룹에 묶습니다.
- name: Add worker nodes to the etcd play if needed
hosts: kube_node
tasks:
- name: Check if nodes needs etcd client certs (depends on network_plugin)
group_by:
key: "_kubespray_needs_etcd"
when:
- kube_network_plugin in ["flannel", "canal", "cilium"] or ...
- etcd_deployment_type != "kubeadm"
tags: etcd
- name: Install etcd
hosts: etcd:kube_control_plane:_kubespray_needs_etcd
roles:
- { role: kubespray_defaults }
- role: etcd
tags: etcd
when: etcd_deployment_type != "kubeadm"
- etcd 서버 노드(etcd 그룹)에는 “서비스 설치/기동”이 들어가고
- worker 중 필요 노드에는 “etcd client 인증서 배포”까지 들어갈 수 있음
etcd Role 실행 결과: 인증서 → 서비스 → 건강 체크 → 백업 핸들러
인증서 디렉터리/스크립트 생성
로그를 보면 etcd 유저(uid 989)로 /etc/ssl/etcd/ssl이 만들어지고,
openssl.conf 및 인증서 생성 스크립트가 /usr/local/bin/etcd-scripts 하위에 배치됩니다.
TASK [etcd : Gen_certs | create etcd cert dir] ***
changed: [k8s-ctr] => {"owner": "etcd", "path": "/etc/ssl/etcd/ssl", "mode": "0700", ...}
TASK [etcd : Gen_certs | create etcd script dir (on k8s-ctr)] ***
changed: [k8s-ctr] => {"path": "/usr/local/bin/etcd-scripts", "mode": "0700", ...}
TASK [etcd : Gen_certs | write openssl config] ***
changed: [k8s-ctr] => {"dest": "/etc/ssl/etcd/openssl.conf", "mode": "0640", ...}
TASK [etcd : Gen_certs | copy certs generation script] ***
changed: [k8s-ctr] => {"dest": "/usr/local/bin/etcd-scripts/make-ssl-etcd.sh", "mode": "0700", ...}
etcd systemd 서비스 구성과 환경변수(/etc/etcd.env) 확인
kubeadm/kind 환경에서는 etcd를 static pod로 기동하는 경우가 많지만, Kubespray 기본 설정은 etcd를 systemd 서비스로 기동합니다.
따라서 실제 동작은 /etc/systemd/system/etcd.service의 ExecStart=/usr/local/bin/etcd 와 /etc/etcd.env 로 주입되는 환경변수를 보면 가장 명확합니다.
# 'kubedam / kind' k8s 에서는 etcd를 파드로 기동했지만, kubespary 기본설정은 etcd 를 systemd 로 기동.
**systemctl status etcd.service --no-pager**
*● etcd.service - etcd
Loaded: loaded (/etc/systemd/system/etcd.service; enabled; preset: disabled)
Active: active (running) since Sun 2026-01-25 13:46:35 KST; 2h 19min ago*
**cat /etc/systemd/system/etcd.service**
*[Unit]
Description=etcd
After=network.target
[Service]
Type=notify
User=root
EnvironmentFile=/etc/etcd.env
ExecStart=/usr/local/bin/etcd
NotifyAccess=all
Restart=always
RestartSec=10s
LimitNOFILE=40000
[Install]
WantedBy=multi-user.target*
**cat /etc/etcd.env**
*# Environment file for etcd 3.5.25
ETCD_DATA_DIR=/var/lib/etcd
ETCD_ADVERTISE_CLIENT_URLS=https://192.168.10.10:2379
ETCD_INITIAL_ADVERTISE_PEER_URLS=https://192.168.10.10:2380
ETCD_INITIAL_CLUSTER_STATE=existing
ETCD_METRICS=basic
ETCD_LISTEN_CLIENT_URLS=https://192.168.10.10:2379,https://127.0.0.1:2379
ETCD_ELECTION_TIMEOUT=5000
ETCD_HEARTBEAT_INTERVAL=250
ETCD_INITIAL_CLUSTER_TOKEN=k8s_etcd
ETCD_LISTEN_PEER_URLS=https://192.168.10.10:2380
ETCD_NAME=etcd1
ETCD_PROXY=off
ETCD_INITIAL_CLUSTER=etcd1=https://192.168.10.10:2380
ETCD_AUTO_COMPACTION_RETENTION=8
ETCD_SNAPSHOT_COUNT=100000
ETCD_QUOTA_BACKEND_BYTES=2147483648
ETCD_MAX_REQUEST_BYTES=1572864
ETCD_LOG_LEVEL=info
ETCD_MAX_SNAPSHOTS=5
ETCD_MAX_WALS=5
# Flannel need etcd v2 API
ETCD_ENABLE_V2=true
...*
etcd Role의 백업 핸들러 동작과 산출물 확인(/var/backups)
etcd Role은 설치 이후 handler로 v2 backup / v3 snapshot을 수행하며, 결과물은 /var/backups/etcd-<timestamp>/ 형태로 남습니다.
실습 로그와 실제 디렉터리 트리를 함께 보면 “설치 직후 백업이 자동으로 수행됐는지”를 바로 확인할 수 있습니다.
# etcd 설치 후 핸들러에 의해 백업 수행 결과 데이터 확인
RUNNING HANDLER [etcd : **Backup etcd v2 data**] ***********************************
hanged: [k8s-ctr] => {"attempts": 1, "changed": true, "cmd": ["/usr/local/bin/etcdctl", "backup", "--data-dir", "/v
ar/lib/etcd", "--backup-dir", "**/var/backups/etcd-2026-01-25_13:46:27**"]
...
RUNNING HANDLER [etcd : **Backup etcd v3 data]** ***********************************
changed: [k8s-ctr] => {"attempts": 1, "changed": true, "cmd": ["/usr/local/bin/etcdctl", "snapshot", "save", "**/var/b
ackups/etcd-2026-01-25_13:46:27/snapshot.db**"],
**tree /var/backups/**
*/var/backups/
└── **etcd-2026-01-25_13:46:27 #* Backup etcd v2 data**
*├── member
│ ├── snap
│ │ └── db
│ └── wal
│ └── 0000000000000000-0000000000000000.wal
└── **snapshot.db #* Backup etcd v3 data**
etcd metrics 엔드포인트 확인 + kubeadm static pod와 비교 + Kubespray 설정 예시
기본 systemd 기반 etcd는 client/peer 포트(2379/2380)는 열려 있어도, metrics 포트(2381)는 기본에서 열려 있지 않은 것을 확인했습니다.
kubeadm static pod의 경우 --listen-metrics-urls=http://127.0.0.1:2381 같은 형태로 명시되는 반면, Kubespray에서는 etcd 변수로 metrics 노출을 켜는 방식입니다.
# 메트릭 노출하는 엔포인트는?
**ss -tnlp | grep etcd**
*LISTEN 0 4096 127.0.0.1:2379 0.0.0.0:* users:(("etcd",pid=20604,fd=7))
LISTEN 0 4096 192.168.10.10:**2379** 0.0.0.0:* users:(("etcd",pid=20604,fd=8))
LISTEN 0 4096 192.168.10.10:**2380** 0.0.0.0:* users:(("etcd",pid=20604,fd=6))*
**etcdctl.sh member list -w table**
*+------------------+---------+-------+----------------------------+----------------------------+------------+
| ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS | IS LEARNER |
+------------------+---------+-------+----------------------------+----------------------------+------------+
| a997582217e26c7f | started | etcd1 | https://192.168.10.10:**2380** | https://192.168.10.10:**2379** | false |
+------------------+---------+-------+----------------------------+----------------------------+------------+*
**curl 127.0.0.1:2381/metrics**
*curl: (7) Failed to connect to 127.0.0.1 port 2381 after 0 ms: Could not connect to server*
**# (참고) kubeadm 통해 설치한 etcd static pod yaml** : etcd client 는 https://192.168.10.100:2379 호출, metrics 은 http://127.0.0.1:2381 확인
tree /var/lib/etcd/
**cat /etc/kubernetes/manifests/etcd.yaml**
*- --advertise-client-urls=https://192.168.10.100:2379
- --listen-**client**-urls=https://127.0.0.1:2379,**https://192.168.10.100:2379**
- --listen-**metrics**-urls=**http://127.0.0.1:2381***
*volumeMounts:
- mountPath: /var/lib/etcd
name: etcd-data
- mountPath: /etc/kubernetes/pki/etcd
name: etcd-certs
...*
# kubespary 에 설정 방법(예시)
**cat inventory/mycluster/group_vars/all/etcd.yml**
*etcd_metrics_enabled: true
etcd_metrics_listen_address: "0.0.0.0:2381"*
etcdctl.sh 스크립트와 인증서 배치 확인
etcd는 TLS가 기본이기 때문에, 매번 --cacert/--cert/--key를 직접 주는 방식은 번거롭습니다.
Kubespray는 이를 위해 etcdctl.sh를 설치해 “TLS 옵션을 고정한 래퍼” 형태로 제공하며, 인증서 파일들은 /etc/ssl/etcd/ssl에 배치됩니다.
특히 openssl.conf의 SAN 목록을 보면 localhost/호스트명/LB 도메인/서비스 DNS/IP까지 포함되어 있어, “어떤 이름/IP로 접근해도 인증서 검증이 가능한지”를 확인할 수 있습니다.
**cat /usr/local/bin/etcdctl.sh**
*#!/bin/bash
# Ansible managed
# example invocation: etcdctl.sh get --keys-only --from-key ""
etcdctl \
--cacert /etc/ssl/etcd/ssl/ca.pem \
--cert /etc/ssl/etcd/ssl/admin-k8s-ctr.pem \
--key /etc/ssl/etcd/ssl/admin-k8s-ctr-key.pem "$@"*
#
**tree /etc/ssl/etcd**
*/etc/ssl/etcd
├── **openssl.conf**
└── ssl
├── **admin-k8s-ctr-key.pem**
├── **admin-k8s-ctr.pem**
├── ca-key.pem
├── **ca.pem**
├── member-k8s-ctr-key.pem
├── member-k8s-ctr.pem
├── node-k8s-ctr-key.pem
└── node-k8s-ctr.pem*
#
**cat /etc/ssl/etcd/openssl.conf**
*[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[ ssl_client ]
extendedKeyUsage = clientAuth, serverAuth
basicConstraints = CA:FALSE
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid,issuer
subjectAltName = @alt_names
[ v3_ca ]
basicConstraints = CA:TRUE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
authorityKeyIdentifier=keyid:always,issuer
[alt_names]
DNS.1 = **localhost**
DNS.2 = **k8s-ctr**
DNS.3 = **lb-apiserver.kubernetes.local**
DNS.4 = etcd.kube-system.svc.cluster.local
DNS.5 = etcd.kube-system.svc
DNS.6 = etcd.kube-system
DNS.7 = **etcd**
IP.1 = **192.168.10.10**
IP.2 = **127.0.0.1**
IP.3 = ::1*
# 스크립트 실행
**etcdctl.sh -h**
**etcdctl.sh get --keys-only --from-key ""**
...
**etcdctl.sh endpoint status -w table**
*+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| ENDPOINT | ID | VERSION | DB SIZE | **IS LEADER** | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 127.0.0.1:2379 | a997582217e26c7f | 3.5.25 | 3.1 MB | **true** | false | 3 | 6545 | 6545 | |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+*
**etcdctl.sh member list -w table**
*+------------------+---------+-------+----------------------------+----------------------------+------------+
| ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS | IS LEARNER |
+------------------+---------+-------+----------------------------+----------------------------+------------+
| a997582217e26c7f | started | etcd1 | https://192.168.10.10:2380 | https://192.168.10.10:2379 | false |
+------------------+---------+-------+----------------------------+----------------------------+------------+*
PLAY [Install Kubernetes nodes] (tags: node): kubelet 설치 + 커널 요구사항 반영
kubernetes/node Role은 노드를 Kubernetes Node로 만들기 위해 (1) facts 정규화 → (2) kubelet 설치 → (3) kubelet config/systemd 구성 → (4) 커널 모듈/sysctl 반영을 한 흐름으로 수행합니다.
특히 br_netfilter, nodePort reserved range, kubelet config(failSwapOn, maxPods, maxParallelImagePulls 등)처럼 kubelet이 민감한 영역을 여기서 처리합니다.
- name: **Install Kubernetes nodes**
hosts: k8s_cluster
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { **role: kubernetes/node**, **tags: node** }
# role 확인
**roles/kubernetes/node**
├─ facts → 노드 역할/환경 판단
├─ install → kubelet 설치
├─ kubelet → 설정 + systemd
├─ kubeconfig → API Server 인증
└─ loadbalancer → (선택) 로컬 API 프록시
**tree roles/kubernetes/node**
*roles/kubernetes/node
├── **defaults**
│ └── main.yml # kubelet 기본 옵션, max-pods 계산 로직, eviction threshold, 로그 설정
├── **handlers**
│ └── main.yml # kubelet 재시작 담당
├── **tasks**
│ ├── **facts.yml** # 노드 환경 정보 수집 & 변수 정규화 - is_kube_control_plane vs is_kube_node 조건 분기
│ ├── **install.yml** # kubelet 바이너리 설치 - Kubernetes repo 설정, kubelet 패키지 설치, 버전 고정
│ ├── kubelet.yml # kubelet 설정 & systemd 서비스 구성
│ ├── **loadbalancer** # 조건부 실행(control-plane이 여러 대, loadbalancer_apiserver_localhost: true), kubelet은 항상 localhost:6443만 바라봄
│ │ ├── haproxy.yml # 마스터 노드가 여러 대일 경우(High Availability), 워커 노드가 API 서버에 접속할 수 있도록 로컬 로드밸런서를 구성합니다.
│ │ ├── kube-vip.yml
│ │ └── nginx-proxy.yml
│ └── **main.yml***
├── **templates**
│ ├── http-proxy.conf.j2
│ ├── kubelet-config.v1beta1.yaml.j2 # kubelet 내부 설정
│ ├── kubelet.env.v1beta1.j2 # kubelet CLI 옵션
│ ├── kubelet.service.j2 # systemd unit
│ ├── loadbalancer
│ │ ├── haproxy.cfg.j2
│ │ └── nginx.conf.j2
│ ├── manifests
│ │ ├── haproxy.manifest.j2
│ │ ├── kube-vip.manifest.j2
│ │ └── nginx-proxy.manifest.j2
│ └── node-kubeconfig.yaml.j2
└── **vars # OS별 차이 처리** - Ubuntu / Fedora / Rocky 계열 차이 : 패키지 이름, systemd 경로
├── fedora.yml
├── ubuntu-18.yml
├── ubuntu-20.yml
├── ubuntu-22.yml
└── ubuntu-24.yml*
tasks/main.yml을 보면, kubelet 설치뿐 아니라 nodePort 예약(sysctl), br_netfilter 적재, bridge-nf-call 설정 등 “kube-proxy/네트워크” 요구사항까지 포함되어 있습니다.
**cat roles/kubernetes/node/tasks/main.yml**
---
- name: **Fetch facts**
import_tasks: facts.yml
tags:
- facts
- kubelet
- name: **Ensure /var/lib/cni exists**
file:
path: /var/lib/cni
state: directory
mode: "0755"
- name: **Install kubelet binary**
import_tasks: install.yml
tags:
- kubelet
- name: **Install kube-vip**
import_tasks: loadbalancer/kube-vip.yml
when:
- ('kube_control_plane' in group_names)
- kube_vip_enabled
tags:
- kube-vip
- name: **Install nginx-proxy**
import_tasks: loadbalancer/nginx-proxy.yml
when:
- ('kube_control_plane' not in group_names) or (kube_apiserver_bind_address != '::')
- loadbalancer_apiserver_localhost
- loadbalancer_apiserver_type == 'nginx'
tags:
- nginx
- name: **Install haproxy**
import_tasks: loadbalancer/haproxy.yml
when:
- ('kube_control_plane' not in group_names) or (kube_apiserver_bind_address != '::')
- loadbalancer_apiserver_localhost
- loadbalancer_apiserver_type == 'haproxy'
tags:
- haproxy
- name: **Ensure nodePort range is reserved**
ansible.posix.sysctl:
name: net.ipv4.ip_local_reserved_ports
value: "{{ kube_apiserver_node_port_range }}"
sysctl_set: true
sysctl_file: "{{ sysctl_file_path }}"
state: present
reload: true
ignoreerrors: "{{ sysctl_ignore_unknown_keys }}"
when: kube_apiserver_node_port_range is defined
tags:
- kube-proxy
- name: **Verify if br_netfilter module exists**
command: "modinfo br_netfilter"
environment:
PATH: "{{ ansible_env.PATH }}:/sbin" # Make sure we can workaround RH's conservative path management
register: modinfo_br_netfilter
failed_when: modinfo_br_netfilter.rc not in [0, 1]
changed_when: false
check_mode: false
- name: **Verify br_netfilter module path exists**
file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /etc/modules-load.d
- /etc/modprobe.d
- name**: Enable br_netfilter module**
community.general.modprobe:
name: br_netfilter
state: present
when: modinfo_br_netfilter.rc == 0
- name: **Persist br_netfilter module**
copy:
dest: /etc/modules-load.d/kubespray-br_netfilter.conf
content: br_netfilter
mode: "0644"
when: modinfo_br_netfilter.rc == 0
# kube-proxy needs net.bridge.bridge-nf-call-iptables enabled when found if br_netfilter is not a module
- name: **Check if bridge-nf-call-iptables key exists**
command: "sysctl net.bridge.bridge-nf-call-iptables"
failed_when: false
changed_when: false
check_mode: false
register: sysctl_bridge_nf_call_iptables
- name: **Enable bridge-nf-call tables**
ansible.posix.sysctl:
name: "{{ item }}"
state: present
sysctl_file: "{{ sysctl_file_path }}"
value: "1"
reload: true
ignoreerrors: "{{ sysctl_ignore_unknown_keys }}"
when: sysctl_bridge_nf_call_iptables.rc == 0
with_items:
- net.bridge.bridge-nf-call-iptables
- net.bridge.bridge-nf-call-arptables
- net.bridge.bridge-nf-call-ip6tables
- name: **Modprobe Kernel Module for IPVS**
community.general.modprobe:
name: "{{ item }}"
state: present
persistent: present
loop: "{{ kube_proxy_ipvs_modules }}"
when: kube_proxy_mode == 'ipvs'
tags:
- kube-proxy
- name: **Modprobe conntrack module**
community.general.modprobe:
name: "{{ item }}"
state: present
persistent: present
register: modprobe_conntrack_module
ignore_errors: true # noqa ignore-errors
loop:
- nf_conntrack
- nf_conntrack_ipv4
when:
- kube_proxy_mode == 'ipvs'
- modprobe_conntrack_module is not defined or modprobe_conntrack_module is ansible.builtin.failed # loop until first success
tags:
- kube-proxy
- name: **Modprobe Kernel Module for nftables**
community.general.modprobe:
name: "nf_tables"
state: present
persistent: present
when: kube_proxy_mode == 'nftables'
tags:
- kube-proxy
- name: **Install kubelet**
import_tasks: kubelet.yml
tags:
- kubelet
- kubeadm
kubelet 설정은 kubelet.yml에서 /etc/kubernetes/kubelet.env, /etc/kubernetes/kubelet-config.yaml, /etc/systemd/system/kubelet.service를 생성하고 handler로 즉시 반영(flush_handlers)하는 흐름입니다.
**cat roles/kubernetes/node/tasks/kubelet.yml**
---
- name: **Set kubelet api version to v1beta1**
set_fact:
kubeletConfig_api_version: v1beta1
tags:
- kubelet
- kubeadm
- name: **Write kubelet environment config file (kubeadm)**
template:
src: "kubelet.env.{{ kubeletConfig_api_version }}.j2"
dest: "{{ kube_config_dir }}/kubelet.env"
setype: "{{ (preinstall_selinux_state != 'disabled') | ternary('etc_t', omit) }}"
backup: true
mode: "0600"
notify: Node | restart kubelet
tags:
- kubelet
- kubeadm
- name: **Write kubelet config file**
template:
src: "kubelet-config.{{ kubeletConfig_api_version }}.yaml.j2"
dest: "{{ kube_config_dir }}/kubelet-config.yaml"
mode: "0600"
notify: Kubelet | restart kubelet
tags:
- kubelet
- kubeadm
- name: **Write kubelet systemd init file**
template:
src: "kubelet.service.j2"
dest: "/etc/systemd/system/kubelet.service"
backup: true
mode: "0600"
validate: "sh -c '[ -f /usr/bin/systemd/system/factory-reset.target ] || exit 0 && systemd-analyze verify %s:kubelet.service'"
# FIXME: check that systemd version >= 250 (factory-reset.target was introduced in that release)
# Remove once we drop support for systemd < 250
notify: Node | restart kubelet
tags:
- kubelet
- kubeadm
- name: **Flush_handlers and reload-systemd**
meta: flush_handlers
- name: **Enable kubelet**
service:
name: kubelet
enabled: true
state: started
tags:
- kubelet
notify: Kubelet | restart kubelet
node Role 실행 로그로 본 “다운로드 아티팩트 → 설치 경로 반영” 흐름
설치 로그를 보면 kubelet 바이너리가 /tmp/releases에서 /usr/local/bin/kubelet로 복사되고, kubelet config/systemd unit이 작성된 뒤 systemd reload + kubelet restart가 수행됩니다.
즉 6.1~6.2에서 확인했던 “download → bin_dir 배치” 흐름이 node Role에서도 그대로 재현됩니다.
more kubespray_install.log
PLAY [Install Kubernetes nodes]
# 사전 필요 사항 체크 및 설정
TASK [kubernetes/node : **Set kubelet_cgroup_driver_detected fact for containerd**]
TASK [kubernetes/node : **Set kubelet_cgroup_driver**]
ok: [k8s-ctr] => {"ansible_facts": {"kubelet_cgroup_driver": "systemd"}, "changed": false}
TASK [kubernetes/node : **Ensure /var/lib/cni exists**]
TASK [kubernetes/node : Install | **Copy kubelet binary from download dir**] *******
changed: [k8s-ctr] => {"changed": true, "checksum": "16917b26505181c309639e93f67be58957fdda68", "dest": "**/usr/local/
bin/kubelet**", "gid": 0, "group": "root", "md5sum": "33225fc99134693a4fe6b34a8279a3ef", "mode": "0755", "owner": "root", "s
econtext": "system_u:object_r:kubelet_exec_t:s0", "size": 78184740, "src": "**/tmp/releases/kubelet-1.33.3-arm64**", "state":
"file", "uid": 0}
TASK [kubernetes/node : **Ensure nodePort range is reserved**] *********************
changed: [k8s-ctr] => {"changed": true}
TASK [kubernetes/node : **Verify if br_netfilter module exists**]
...
TASK [kubernetes/node : **Enable bridge-nf-call tables**]
# kubelet 설정 파일 작성 및 서비스 재시작 적용
TASK [kubernetes/node : **Write kubelet environment config file (kubeadm)**] *******
changed: [k8s-ctr] => {"changed": true, "checksum": "9fa87476de37606e577797e0ee3921754a55c1f5", "dest": "**/etc/kubern
etes/kubelet.env**", "gid": 0, "group": "root",...
TASK [kubernetes/node : Write kubelet config file] *****************************
changed: [k8s-ctr] => {"changed": true, "checksum": "7635cec9f64773529ce982982e34ba46b52cf0f4", "dest": "**/etc/kubern
etes/kubelet-config.yaml**", "gid": 0, "group": "root",...
TASK [kubernetes/node : Write kubelet systemd init file] ***********************
changed: [k8s-ctr] => {"changed": true, "checksum": "7c6e996e6a33a8e05266c72a072d76f9aeff796d", "dest": "**/etc/system
d/system/kubelet.service**", "gid": 0, "group": "root",...
**RUNNING HANDLER** [kubernetes/node : **Kubelet | reload systemd]** *******************
ok: [k8s-ctr] => {"changed": false, "name": null, "status": {}}
Sunday 25 January 2026 13:46:39 +0900 (0:00:00.413) 0:03:42.933 ********
RUNNING HANDLER [kubernetes/node : **Kubelet | restart kubelet**] ******************
TASK [kubernetes/node : **Enable kubelet**] ****************************************
nodePort 예약(sysctl)과 kubelet 설정 파일 생성 결과 확인
node Role은 kube_apiserver_node_port_range 값이 정의돼 있으면 net.ipv4.ip_local_reserved_ports에 반영합니다.
또한 /etc/kubernetes 아래에 kubelet 관련 파일과 static pod manifests 경로가 생성되어, 이후 control-plane 단계의 static pod 모델과 결합됩니다.
# Service NodePort 시, 사용할 포트 범위
- name: Ensure nodePort range is reserved
ansible.posix.sysctl:
name: net.ipv4.ip_local_reserved_ports
value: "{{ kube_apiserver_node_port_range }}"
sysctl_set: true
sysctl_file: "{{ sysctl_file_path }}"
state: present
reload: true
ignoreerrors: "{{ sysctl_ignore_unknown_keys }}"
when: kube_apiserver_node_port_range is defined
tags:
- kube-proxy
**cat sysctl-1.txt | grep net.ipv4.ip_local_reserved_ports**
net.ipv4.ip_local_reserved_ports =
**cat sysctl-2.txt | grep net.ipv4.ip_local_reserved_ports**
net.ipv4.ip_local_reserved_ports = 30000-32767
**sysctl net.ipv4.ip_local_reserved_ports**
net.ipv4.ip_local_reserved_ports = 30000-32767
**tree /etc/kubernetes/**
*/etc/kubernetes/
...
├── admin.conf
├── admin.conf.23260.2026-01-25@13:46:52~
├── cni-flannel-rbac.yml
├── cni-flannel.yml
├── controller-manager.conf
├── controller-manager.conf.23284.2026-01-25@13:46:52~
├── k8s-cluster-critical-pc.yml
├── ****kubeadm-config.yaml
├── kubeadm-images.yaml
├── **kubelet.conf**
├── kubelet.conf.23308.2026-01-25@13:46:52~
├── kubelet-config.yaml
├── **kubelet.env**
├── kubescheduler-config.yaml
├── manifests
│ ├── kube-apiserver.yaml
│ ├── kube-controller-manager.yaml
│ └── kube-scheduler.yaml
...*
#
**cat /etc/kubernetes/kubelet-config.yaml**
*apiVersion: kubelet.config.k8s.io/v1beta1
kind: **KubeletConfiguration**
nodeStatusUpdateFrequency: "10s"
**failSwapOn: True # 노드에 스왑(Swap) 메모리가 활성화되어 있을 경우 kubelet이 실행되지 않도록 강제하는 설정.**
authentication:
anonymous:
enabled: false
webhook:
enabled: True
x509:
clientCAFile: /etc/kubernetes/ssl/ca.crt
authorization:
mode: Webhook
**staticPodPath: "/etc/kubernetes/manifests"**
**cgroupDriver: systemd**
containerLogMaxFiles: 5
**containerLogMaxSize: 10Mi**
containerRuntimeEndpoint : unix:///var/run/containerd/containerd.sock
**maxPods: 110 #*** 노드 별 최대 파드 배치 갯수
*podPidsLimit: -1
address: "192.168.10.10"
readOnlyPort: 0
healthzPort: 10248
healthzBindAddress: "127.0.0.1"
kubeletCgroups: /system.slice/kubelet.service
clusterDomain: cluster.local
**protectKernelDefaults: true** # kubelet은 시스템의 sysctl 값들이 필수 요구사항을 충족하는지 확인.
rotateCertificates: true
clusterDNS:
- 10.233.0.3
kubeReserved:
cpu: "100m"
memory: "256Mi"
ephemeral-storage: "500Mi"
pid: "1000"
systemReserved:
cpu: "500m"
memory: "512Mi"
ephemeral-storage: "500Mi"
pid: "1000"
resolvConf: "/etc/resolv.conf"
eventRecordQPS: 50
shutdownGracePeriod: 60s
shutdownGracePeriodCriticalPods: 20s
**maxParallelImagePulls: 1 # 하나의 노드에서 동시에 진행할 수 있는 이미지 다운로드(Pull)의 최대 개수를 제한!***
# static pods yaml 경로
**tree /etc/kubernetes/manifests/**
*/etc/kubernetes/manifests/
├── kube-apiserver.yaml
├── kube-controller-manager.yaml
└── kube-scheduler.yaml*
# 노드 별 최대 파드 배치 갯수 확인
kubectl describe node
**kubectl describe node | grep pods**
# ***maxParallelImagePulls: 10 등 적절히 수정 후 kubelet restart 후 파드들 기동하면서 테스트 해보자!***
PLAY [Install the control plane] (tags: control-plane / client / cluster-roles): kubeadm init + kubectl 접근 + RBAC 적용
노드 준비(kubelet + 커널 요구사항)가 끝나면 control plane 단계에서 kubeadm init, kubectl 접근 환경 구성, 기본 RBAC 적용이 수행됩니다.
Play 정의를 보면 kubernetes/control-plane, kubernetes/client, kubernetes-apps/cluster_roles의 역할이 분리되어 있어 “control plane 구성 / 접근 환경 / RBAC”가 각각 책임을 갖도록 설계되어 있습니다.
- name: **Install the control plane**
hosts: kube_control_plane
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: **kubernetes/control-plane,** tags: control-plane } # kubeadm init(HA)
- { role: **kubernetes/client**, tags: client } # kubectl 접근 환경, admin kubeconfig 생성
- { role: **kubernetes-apps/cluster_roles**, tags: cluster-roles } # 클러스터 기본 RBAC 세팅
앤서블 오류 처리 문법: block / rescue / always
Ansible은 작업을 논리적으로 묶는 block을 제공하며, block 내부 작업 실패 시 처리 흐름을 rescue, 그리고 성공/실패와 무관하게 항상 수행할 작업을 always로 정의할 수 있습니다.
즉 “정상 흐름 + 예외 처리 + 후처리”를 플레이북 문법으로 일관되게 표현할 수 있다는 점이 핵심입니다.
- block: 기본 실행 작업(정상 흐름)
- rescue: block 실패 시 실행할 예외 처리
- always: 성공/실패 상관없이 항상 실행되는 후처리(정리/로그/알림 등)
예제 플레이북 작성: 디렉터리 존재 여부 검사 → 실패 시 생성 → 항상 로그 파일 생성
아래 예제는 /var/log/daily_log 디렉터리를 찾는 과정에서, result.msg에 "Not all paths"가 포함되면 의도적으로 실패 처리(failed_when)하도록 구성했습니다.
실패하면 rescue에서 디렉터리를 생성하고, always에서 항상 로그 파일을 touch로 만듭니다.
# ~/my-ansible/block-example.yml
---
- hosts: **tnode2**
**vars**:
**logdir**: /var/log/daily_log
**logfile**: todays.log
**tasks**:
- name: Configure Log Env
**block**:
- name: Find Directory
ansible.builtin.**find**:
paths: "{{ logdir }}"
register: result
**failed_when**: "'Not all paths' in result.msg"
**rescue**:
- name: Make Directory when Not found Directory
ansible.builtin.**file**:
path: "{{ logdir }}"
state: directory
mode: '0755'
**always**:
- name: Create File
ansible.builtin.**file**:
path: "{{ logdir }}/{{ logfile }}"
state: touch
mode: '0644'
1차 실행 결과: Find 실패 → rescue 수행 → always 수행(rescued=1)
첫 실행에서는 /var/log/daily_log 경로가 디렉터리가 아니라는 경고/오류 조건이 발생했고, failed_when 조건 때문에 block이 실패로 처리됩니다.
그 결과 rescue가 실행되어 디렉터리를 만들고, always가 실행되어 로그 파일까지 생성됩니다. 마지막 recap에서 rescued=1로 확인됩니다.
ansible-playbook **block-example.yml
...
*TASK** [Find Directory] ***********************************************************************************************************************************************************************
[WARNING]: **Skipped** '/var/log/daily_log' path due to this access issue: '/var/log/daily_log' is not a directory
fatal: [tnode2-ubuntu.local]: FAILED! => {"changed": false, "examined": 0, "failed_when_result": true, "files": [], "matched": 0, "msg": "Not all paths examined, check warnings for details", "skipped_paths": {"/var/log/daily_log": "'/var/log/daily_log' is not a directory"}}
**TASK** [Make Directory when Not found Directory] **********************************************************************************************************************************************
**changed**: [tnode2-ubuntu.local]
**TASK** [Create File] **************************************************************************************************************************************************************************
**changed**: [tnode2-ubuntu.local]
PLAY RECAP **********************************************************************************************************************************************************************************
tnode2-ubuntu.local : ok=2 **changed=2** unreachable=0 failed=0 skipped=0 **rescued=1** ignored=0*
# tnode2에서 확인 : 디렉터리와 로그 파일 생성 확인
ansible -m shell -a "**ls -l /var/log/daily_log/**" **tnode2**
*-rw-r--r-- 1 root root 0 Jan 3 **15:00** todays.log*
2차 실행 결과: Find 성공 → rescue 미실행 → always만 수행(rescued=0)
ansible-playbook **block-example.yml
...**
*TASK [Find Directory] ***********************************************************************************************************************************************************************
ok: [tnode2-ubuntu.local]
TASK [Create File] **************************************************************************************************************************************************************************
changed: [tnode2-ubuntu.local]
PLAY RECAP **********************************************************************************************************************************************************************************
tnode2-ubuntu.local : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0*
****# tnode2에서 확인 : 디렉터리와 로그 파일 생성 확인
ansible -m shell -a "**ls -l /var/log/daily_log/**" **tnode2**
*-rw-r--r-- 1 root root 0 Jan 3 **15:05** todays.log*
한 번 디렉터리가 만들어진 뒤 다시 실행하면, block의 Find가 정상 수행되므로 rescue는 더 이상 동작하지 않습니다.
always는 매번 실행되기 때문에 파일 생성(touch)이 다시 수행되고 시간만 갱신됩니다.
#
ansible-playbook **block-example.yml
...**
*TASK [Find Directory] ***********************************************************************************************************************************************************************
ok: [tnode2-ubuntu.local]
TASK [Create File] **************************************************************************************************************************************************************************
changed: [tnode2-ubuntu.local]
PLAY RECAP **********************************************************************************************************************************************************************************
tnode2-ubuntu.local : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0*
****# tnode2에서 확인 : 디렉터리와 로그 파일 생성 확인
ansible -m shell -a "**ls -l /var/log/daily_log/**" **tnode2**
*-rw-r--r-- 1 root root 0 Jan 3 **15:05** todays.log*
Kubespray 로그에서의 “실패 제어(무시/예외 처리) 흐름” 확인
앞의 block 예제는 “실패 시 대체 경로(rescue)로 복구”였다면, Kubespray 로그에서는 특정 단계가 실패하더라도 전체 설치를 중단하지 않게 처리(예: ...ignoring)되는 흐름을 볼 수 있습니다.
즉, 설치 과정에서 “치명적이지 않은 실패를 허용”하고 다음 단계로 진행하는 설계 포인트를 로그로 확인합니다.
**more kubespray_install.log**
...
PLAY [Install the control plane]
TASK [kubernetes/control-plane : Pre-upgrade | Delete control plane manifests if etcd secrets changed]
TASK [kubernetes/control-plane : **Create kube-scheduler config**] # /etc/kubernetes/kubescheduler-config.yaml
TASK [kubernetes/control-plane : Install | **Copy kubectl binary from download dir**]
TASK [kubernetes/control-plane : Install kubectl bash completion]
TASK [kubernetes/control-plane : Set kubectl bash completion file permissions]
TASK [kubernetes/control-plane : Check which kube-control nodes are already members of the cluster]
fatal: [k8s-ctr]: FAILED! => ...ignoring
TASK [kubernetes/control-plane : Kubeadm | Check if kubeadm has already run]
TASK [kubernetes/control-plane : **Kubeadm | aggregate all SANs]**
ok: [k8s-ctr] => {"ansible_facts": {"apiserver_sans": ["kubernetes", "kubernetes.default", "kubernetes.default.svc",
"kubernetes.default.svc.cluster.local", "10.233.0.1", "localhost", "127.0.0.1", "::1", "**k8s-ctr**", "lb-apiserver.kubernete
s.local", "**192.168.10.10**", "10.0.2.15", "fd17:625c:f037:2:a00:27ff:fe90:eaeb"]}, "changed": false}
TASK [kubernetes/control-plane : Kubeadm | **Create kubeadm config**] # /etc/kubernetes/kubeadm-config.yaml
TASK [kubernetes/control-plane : Kubeadm | **Initialize first control plane node (1st try)**]
changed: [k8s-ctr] => {"changed": true, "cmd": ["timeout", "-k", "300s", "300s", "/usr/local/bin/**kubead**m", "**init**", "
-**-config=/etc/kubernetes/kubeadm-config.yaml**", "--ignore-preflight-errors=", **"--skip-phases=addon/coredns**", "**--upload-cert
s**"]
TASK [kubernetes/control-plane : **Create kubeadm token for joining nodes with 24h expiration** (default)]
ok: [k8s-ctr] => {"attempts": 1, "changed": false, "cmd": ["/usr/local/bin/kubeadm", "**--kubeconfi**g", "**/etc/kubernete
s/admin.conf**", "token", "create"]
TASK [kubernetes/control-plane : **Set kubeadm_toke**n]
TASK [kubernetes/control-plane : K**ubeadm | Join other control plane nodes**]
included: /root/kubespray/roles/kubernetes/control-plane/tasks/**kubeadm-secondary.yml** for k8s-ctr
TASK [kubernetes/control-plane : **Set kubeadm_discovery_address**]
ok: [k8s-ctr] => {"ansible_facts": {"kubeadm_discovery_address": "**192.168.10.10:6443**"}, "changed": false}
TASK [kubernetes/control-plane : **Upload certificates** so they are fresh and not expired]
TASK [kubernetes/control-plane : **Wait for k8s apiserver]**
TASK [kubernetes/control-plane : Kubeadm | **Remove taint for control plane node with node role**]
"cmd": ["/usr/local/bin**/kubectl"**, "--kubeconfig", "/etc/kubernetes/admin.conf", **"taint", "node", "k8s-ctr"**, "**n**
**ode-role.kubernetes.io/control-plane:NoSchedule-**"],
TASK [kubernetes/control-plane : **Update server field in component kubeconfigs**]
TASK [kubernetes/control-plane : **Include kubelet client cert rotation fixes**] ***
included: /root/kubespray/roles/kubernetes/control-plane/tasks/kubelet-fix-client-cert-rotation.yml for k8s-ctr
TASK [kubernetes/control-plane : **Fixup kubelet client cert rotation** 1/2]
TASK [kubernetes/control-plane : Fixup kubelet client cert rotation 2/2]
TASK [kubernetes/control-plane : **Install script to renew K8S control plane certificates**]
"dest": "**/usr/local/bin/k8s-certs-renew.sh**",
TASK [kubernetes/control-plane : **Renew K8S control plane certificates monthly** 1/2]
"dest": "**/etc/systemd/system/k8s-certs-renew.service**"
TASK [kubernetes/control-plane : Renew K8S control plane certificates monthly 2/2]
changed: [k8s-ctr] => {"changed": true, "enabled": true, "name": "**k8s-certs-renew.timer"**, "state": "**started**", "statu
s"
kubeadm-config.yaml과 static pod(control-plane) 구성 확인
kubeadm-config.yaml에는 InitConfiguration/ClusterConfiguration뿐 아니라, apiserver/controller-manager/scheduler의 bind-address, SAN 목록, kube-proxy metricsBindAddress 등 “클러스터 기동 시점의 핵심 파라미터”가 묶여 있습니다.
또한 /etc/kubernetes/manifests에 static pod yaml이 배치되고, kube-system 네임스페이스에서 control-plane 파드가 실제로 Running 상태임을 확인할 수 있습니다.
# TASK [kubernetes/control-plane : Kubeadm | **Create kubeadm config**]
**cat /etc/kubernetes/kubeadm-config.yaml**
*apiVersion: kubeadm.k8s.io/v1beta4
kind: **InitConfiguration**
localAPIEndpoint:
advertiseAddress: "192.168.10.10"
bindPort: 6443
...
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: **ClusterConfiguration**
...
networking:
dnsDomain: cluster.local
serviceSubnet: "10.233.0.0/18"
podSubnet: "10.233.64.0/18"
kubernetesVersion: v1.33.3
controlPlaneEndpoint: "192.168.10.10:6443"
certificatesDir: /etc/kubernetes/ssl
**apiServer:**
extraArgs:
...
- name: bind-address
value: **"::"**
...
**certSANs:**
- "kubernetes"
- "kubernetes.default"
- "kubernetes.default.svc"
- "kubernetes.default.svc.cluster.local"
- "10.233.0.1"
- "localhost"
- "127.0.0.1"
- "::1"
- "k8s-ctr"
- "lb-apiserver.kubernetes.local"
- "192.168.10.10"
- "10.0.2.15"
- "fd17:625c:f037:2:a00:27ff:fe90:eaeb"
**controllerManager:**
extraArgs:
...
- name: bind-address
value: "**::"**
scheduler**:**
extraArgs:
- name: bind-address
value: **"::"**
...
---
apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: **KubeProxyConfiguration**
bindAddress: "0.0.0.0"
...
**metricsBindAddress: "127.0.0.1:10249"***
# static pod 확인
**tree /etc/kubernetes/manifests/**
**kubectl get pod -n kube-system -l tier=control-plane**
*NAME READY STATUS RESTARTS AGE
kube-apiserver-k8s-ctr 1/1 Running 1 3h56m
kube-controller-manager-k8s-ctr 1/1 Running 2 3h56m
kube-scheduler-k8s-ctr 1/1 Running 1 3h56m*
# ipv6 tcp 연결(ESTAB) 정보 확인
**ss -tnp | grep 'ffff'**
*ESTAB 0 0 [::ffff:127.0.0.1]:6443 [::ffff:127.0.0.1]:49146 users:(("kube-apiserver",pid=31847,fd=106))
ESTAB 0 0 [::ffff:127.0.0.1]:6443 [::ffff:127.0.0.1]:48952 users:(("kube-apiserver",pid=31847,fd=78))*
...
**kubectl describe pod -n kube-system kube-apiserver-k8s-ctr | grep bind-address**
*--bind-address=::*
**kubectl describe pod -n kube-system kube-controller-manager-k8s-ctr |grep bind-address**
*--bind-address=::*
**kubectl describe pod -n kube-system kube-scheduler-k8s-ctr |grep bind-address**
*--bind-address=::*
인증서 구성과 만료 기간 확인(kubeadm certs / /etc/kubernetes/ssl)
kubeadm certs check-expiration 출력은 control-plane 인증서(1년)와 CA(10년)의 기본 만료 정책을 한 눈에 보여줍니다.
그리고 실제 /etc/kubernetes/ssl에 생성된 인증서를 직접 openssl로 보면, apiserver 인증서 SAN에 IPv4/IPv6/호스트명 등이 포함됐는지까지 검증할 수 있습니다.
# 인증서 정보 확인 : etcd ca 안보임!
**kubeadm certs check-expiration**
*CERTIFICATE EXPIRES RESIDUAL TIME CERTIFICATE AUTHORITY EXTERNALLY MANAGED
admin.conf Jan 25, 2027 04:46 UTC 364d ca no
apiserver Jan 25, 2027 04:46 UTC 364d ca no
apiserver-kubelet-client Jan 25, 2027 04:46 UTC 364d ca no
controller-manager.conf Jan 25, 2027 04:46 UTC 364d ca no
front-proxy-client Jan 25, 2027 04:46 UTC 364d front-proxy-ca no
scheduler.conf Jan 25, 2027 04:46 UTC 364d ca no
super-admin.conf Jan 25, 2027 04:46 UTC 364d ca no
CERTIFICATE AUTHORITY EXPIRES RESIDUAL TIME EXTERNALLY MANAGED
ca Jan 23, 2036 04:46 UTC 9y no
front-proxy-ca Jan 23, 2036 04:46 UTC 9y no*
# 인증서 디렉터리 확인
**tree /etc/kubernetes/ssl/**
*/etc/kubernetes/ssl/
├── apiserver.crt
├── apiserver.key
├── apiserver-kubelet-client.crt
├── apiserver-kubelet-client.key
├── ca.crt
├── ca.key
├── front-proxy-ca.crt
├── front-proxy-ca.key
├── front-proxy-client.crt
├── front-proxy-client.key
├── sa.key
└── sa.pub*
# kubernetes 의 CA 인증서 (10년)
**cat /etc/kubernetes/ssl/ca.crt | openssl x509 -text -noout**
*Issuer: **CN=kubernetes**
Validity
Not Before: Jan 25 04:41:43 2026 GMT
Not After : Jan 23 04:46:43 2036 GMT
Subject: **CN=kubernetes**
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature, Key Encipherment, Certificate Sign
X509v3 Basic Constraints: critical
**CA:TRUE**
X509v3 Subject Alternative Name:
**DNS:kubernetes***
# apiserver 의 웹 서버용 인증서 (1년) : san 에 ipv4, ipv6 주소 포함 확인
**cat /etc/kubernetes/ssl/apiserver.crt | openssl x509 -text -noout**
*Issuer: CN=kubernetes
Validity
Not Before: Jan 25 04:41:43 2026 GMT
Not After : Jan 25 04:46:43 **2027** GMT
Subject: **CN=kube-apiserver**
X509v3 Extended Key Usage:
**TLS Web Server** Authentication
X509v3 **Subject Alternative Nam**e:
DNS:**k8s-ctr,** 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.10**, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, IP Address:10.0.2.15, IP Address:**FD17:625C:F037:2:A00:27FF:FE90:EAEB***
# apiserver 의 웹 클라이언트 인증서 (1년)
**cat /etc/kubernetes/ssl/apiserver-kubelet-client.crt | openssl x509 -text -noout**
*Issuer: CN=kubernetes
Validity
Not Before: Jan 25 04:41:43 2026 GMT
Not After : Jan 25 04:46:43 2027 GMT
Subject: **O=kubeadm:cluster-admins**, **CN=kube-apiserver-kubelet-client**
X509v3 Extended Key Usage:
**TLS Web Client** Authentication*
# 나머지 인증서 들도 각자 확인해보자.
네트워크 플러그인 설치: role: network_plugin (tags: network)
control-plane이 올라온 뒤에는 kubeadm 단계와 함께 CNI 설치가 진행되며, 이 과정은 role: network_plugin에서 담당합니다.
role 구조를 보면, 공통 CNI 바이너리 설치(network_plugin/cni)와 실제 네트워크 플러그인(예: flannel)의 매니페스트 적용이 분리되어 있습니다.
- name: Invoke kubeadm and install a CNI
hosts: k8s_cluster
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: kubernetes/kubeadm, tags: kubeadm}
- { role: kubernetes/node-label, tags: node-label }
- { role: kubernetes/node-taint, tags: node-taint }
- { role: kubernetes-apps/common_crds }
- { **role: network_plugin, tags: network** }
**tree roles/network_plugin/ -L 1**
*roles/network_plugin/
├── calico
├── calico_defaults
├── cilium
├── **cni**
├── custom_cni
├── **flannel**
├── kube-ovn
├── kube-router
├── macvlan
├── meta
├── multus
└── ovn4nfv*
...
CNI 바이너리 설치: /opt/cni/bin 구성 확인(network_plugin/cni)
CNI 단계에서는 /opt/cni/bin 디렉터리를 보장한 뒤, 다운로드된 cni-plugins 아카이브를 풀어 플러그인 바이너리들을 배치합니다.
결과적으로 /opt/cni/bin 아래에 bridge/loopback/host-local/portmap 등 표준 CNI 플러그인이 위치하는 것을 확인합니다.
TASK [network_plugin/cni : CNI | **make sure /opt/cni/bin exists**] ****************
ok: [k8s-ctr] => {"changed": false, "gid": 0, "group": "root", "mode": "0755", "owner": "kube", "path": "/opt/cni/bi
n", "secontext": "unconfined_u:object_r:usr_t:s0", "size": 6, "state": "directory", "uid": 990}
TASK [network_plugin/cni : CNI | **Copy cni plugins**] *****************************
changed: [k8s-ctr] => {"changed": true, "dest": "/opt/cni/bin", "extract_results": {"cmd": ["/usr/bin/gtar", "--extr
act", "-C", "/opt/cni/bin", "-z", "--owner=kube", "-f", "/tmp/releases/cni-plugins-linux-arm64-1.8.0.tgz"], ...
...
**cat roles/network_plugin/cni/defaults/main.yml**
---
cni_bin_owner: "{{ kube_owner }}"
**cat roles/network_plugin/cni/tasks/main.yml**
---
- name: CNI | make sure /opt/cni/bin exists
file:
path: /opt/cni/bin
state: directory
mode: "0755"
owner: "{{ cni_bin_owner }}"
recurse: true
- name: CNI | Copy cni plugins
unarchive:
src: "{{ downloads.cni.dest }}"
dest: "/opt/cni/bin"
mode: "0755"
owner: "{{ cni_bin_owner }}"
remote_src: true
**tree -ug /opt/cni/bin**
[kube root ] /opt/cni/bin
├── [kube root ] bandwidth
├── [kube root ] bridge
...
└── [kube root ] vrf
Flannel 설치: 매니페스트 생성/적용 + subnet.env 생성 확인(network_plugin/flannel)
Flannel 단계에서는 템플릿(cni-flannel*.yml.j2)을 기반으로 /etc/kubernetes에 매니페스트를 생성하고, kube 모듈로 리소스를 적용합니다.
이후 flannel이 정상 기동되면 /run/flannel/subnet.env 파일이 생성되며, 여기서 실제로 할당된 네트워크/서브넷/MTU 값까지 확인할 수 있습니다.
TASK [network_plugin/flannel : **Flannel | Create Flannel manifests**] *************
changed: [k8s-ctr] => (item={'name': 'flannel', 'file': 'cni-flannel-rbac.yml', 'type': 'sa'}) => {"dest": "**/etc/kubernetes/cni-flannel-rbac.yml**", ...}
changed: [k8s-ctr] => (item={'name': 'kube-flannel', 'file': 'cni-flannel.yml', 'type': 'ds'}) => {"dest": "/**etc/kubernetes/cni-flannel.yml**", ...}
TASK [network_plugin/flannel : **Flannel | Start Resources**] **********************
ok: [k8s-ctr] => (item={'diff': [], 'dest': '**/etc/kubernetes/cni-flannel-rbac.yml'**, ...})
ok: [k8s-ctr] => (item={'diff': [], 'dest': '**/etc/kubernetes/cni-flannel.yml**', ...})
TASK [network_plugin/flannel : Flannel | **Wait for flannel subnet.env** file presence] ***
ok: [k8s-ctr] => {"path": "**/run/flannel/subnet.env**", "state": "file", ...}
# 결과 파일 확인
**cat /etc/kubernetes/cni-flannel.yml | grep enp | uniq**
*command: [ "/opt/bin/flanneld", "--ip-masq", "--kube-subnet-mgr", "**--iface=enp0s9**" ]*
**cat /run/flannel/subnet.env**
*FLANNEL_NETWORK=10.233.64.0/18
FLANNEL_SUBNET=10.233.64.1/24
FLANNEL_MTU=1450
FLANNEL_IPMASQ=true*
# role file 확인
**cat roles/network_plugin/flannel/defaults/main.yml**
flannel_backend_type: "vxlan"
flannel_vxlan_vni: 1
flannel_vxlan_port: 8472
flannel_vxlan_direct_routing: false
**cat roles/network_plugin/flannel/tasks/main.yml**
---
- name: **Flannel | Stop if kernel version is too low for Flannel Wireguard encryption**
assert:
that: ansible_kernel.split('-')[0] is version('5.6.0', '>=')
when:
- kube_network_plugin == 'flannel'
- flannel_backend_type == 'wireguard'
- not ignore_assert_errors
- name: **Flannel | Create Flannel manifests**
template:
src: "{{ item.file }}.j2"
dest: "{{ kube_config_dir }}/{{ item.file }}"
mode: "0644"
with_items:
- {name: flannel, file: cni-flannel-rbac.yml, type: sa}
- {name: kube-flannel, file: cni-flannel.yml, type: ds}
register: flannel_node_manifests
when:
- inventory_hostname == groups['kube_control_plane'][0]
- name: **Flannel | Start Resources**
kube:
name: "{{ item.item.name }}"
namespace: "kube-system"
kubectl: "{{ bin_dir }}/kubectl"
resource: "{{ item.item.type }}"
filename: "{{ kube_config_dir }}/{{ item.item.file }}"
state: "latest"
with_items: "{{ flannel_node_manifests.results }}"
when: inventory_hostname == groups['kube_control_plane'][0] and not item is skipped
- name: **Flannel | Wait for flannel subnet.env file presence**
wait_for:
path: /run/flannel/subnet.env
delay: 5
timeout: 600
Kubernetes 애드온 설치: PLAY [Install Kubernetes apps] (tags: apps)
네트워크까지 준비되면 Kubespray는 control-plane 노드에서 애드온을 설치합니다.
이번 로그/결과에서는 CoreDNS + DNS-AutoScaler, Helm, Metrics-server가 적용된 흐름을 확인할 수 있습니다.
- name: Install Kubernetes apps
hosts: **kube_control_plane**
gather_facts: false
any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
environment: "{{ proxy_disable_env }}"
roles:
- { role: kubespray_defaults }
- { role: kubernetes-apps/external_cloud_controller, tags: external-cloud-controller } # skip
- { role: kubernetes-apps/policy_controller, tags: policy-controller } # skip
- { role: kubernetes-apps/ingress_controller, tags: ingress-controller } # skip
- { **role: kubernetes-apps/external_provisioner, tags: external-provisioner** }
- { **role: kubernetes-apps, tags: apps** }
...
CoreDNS & DNS-AutoScaler 적용 확인(kube-system)
CoreDNS는 kube-system에서 DNS 서비스를 제공하며, DNS-AutoScaler는 노드 수/코어 수 기반으로 CoreDNS replica 산정을 돕는 컨트롤러입니다.
설치 로그에서 템플릿들이 순서대로 apply되고, 실제 deployment가 Running 상태인지까지 확인합니다.
TASK [kubernetes-apps/ansible : Kubernetes Apps | **CoreDNS**] *********************
changed: [k8s-ctr] => (item=**coredns-clusterrole.yml.j2**) => {"cmd": ["/usr/local/bin/**kubectl**", "--kubeconfig", "/etc/kubernetes/admin.conf", "apply", "-f", "-", "-n", "kube-system"], ...}
...
changed: [k8s-ctr] => (item=**coredns-deployment.yml.j2** ) ...
...
changed: [k8s-ctr] => (item=**dns-autoscaler.yml.j2** )...
**kubectl get deployment -n kube-system coredns dns-autoscaler -o wide**
*NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
coredns 1/1 1 1 4h51m coredns registry.k8s.io/coredns/coredns:v1.12.0 k8s-app=kube-dns
dns-autoscaler 1/1 1 1 4h51m autoscaler registry.k8s.io/cpa/cluster-proportional-autoscaler:v1.8.8 k8s-app=dns-autoscaler*
**kubectl describe cm -n kube-system coredns**
*.:53 {
...
prometheus :9153
...
}*
**kubectl describe cm -n kube-system dns-autoscaler**
{"coresPerReplica":256,"min":1,"nodesPerReplica":16,"preventSinglePointFailure":false}
Helm 설치 확인: 다운로드 → 압축해제 → 바이너리 배치
Helm도 Kubespray의 download 흐름을 그대로 따라가며, 최종적으로 /usr/local/bin/helm에 설치되고 bash completion까지 설정됩니다.
TASK [kubernetes-apps/helm : **Helm | Download helm**] *****************************
included: /root/kubespray/roles/kubernetes-apps/helm/tasks/../../../download/tasks/download_file.yml for k8s-ctr
...
TASK [kubernetes-apps/helm : Helm | **Copy helm binary from download dir**] ********
changed: [k8s-ctr] => {"dest": "/usr/local/bin/helm", ...}
**which helm**
/usr/local/bin/helm
**helm version**
version.BuildInfo{Version:"v3.18.4", ...}
Metrics-server 설치 확인: addon 디렉터리 구성 + 매니페스트 적용
Metrics-server는 /etc/kubernetes/addons/metrics_server에 매니페스트를 생성하고 적용하며, kube-system에서 파드 Running을 확인할 수 있습니다. 이후 kubectl top 계열 명령의 기반이 됩니다.
TASK [kubernetes-apps/metrics_server : **Metrics Server | Create addon dir]** ******
changed: [k8s-ctr] => {"path": "/etc/kubernetes/addons/metrics_server", ...}
TASK [kubernetes-apps/metrics_server : **Metrics Server | Templates list]** ********
ok: [k8s-ctr] => {"ansible_facts": {"metrics_server_templates": [...]}}
TASK [kubernetes-apps/metrics_server : **Metrics Server | Create manifests**] ******
TASK [kubernetes-apps/metrics_server : **Metrics Server | Apply manifests**] *******
...
**tree /etc/kubernetes/addons/**
*/etc/kubernetes/addons/
└── **metrics_server**
├── auth-delegator.yaml
├── auth-reader.yaml
├── metrics-apiservice.yaml
├── metrics-server-deployment.yaml
├── metrics-server-sa.yaml
├── metrics-server-service.yaml
├── resource-reader-clusterrolebinding.yaml
└── resource-reader.yaml*
**kubectl get pod -n kube-system -l app.kubernetes.io/name=metrics-server**
*NAME READY STATUS RESTARTS AGE
metrics-server-7cd7f9897-kg5gd 1/1 Running 0 5h*
**kubectl top pod -A**
...
마무리하며
이번 실습에서는 Kubespray를 활용해 Kubernetes 클러스터를 직접 배포해보고 설치 과정이 어떤 순서로 진행되고 어떤 결과물이 남는지를 로그와 코드(Playbook/Role/Task) 기준으로 끝까지 따라가며 확인해 보았습니다. 단일 노드 환경이었지만, preinstall → container-engine → download → etcd → node → control-plane → CNI → addons → DNS 마무리까지 이어지는 흐름을 직접 관찰하면서 Kubespray가 담당하는 역할의 범위가 생각보다 넓다는 점을 체감할 수 있었습니다.
특히 인벤토리와 group_vars의 옵션을 조금만 바꿔도 실제로 생성되는 파일, 디렉터리 소유권, systemd 유닛, 다운로드 아티팩트 경로, 그리고 클러스터 구성 방식이 달라지는 것을 확인할 수 있었습니다. 예를 들어 kube_owner, local_release_dir, kube_network_plugin, kube_proxy_mode, auto_renew_certificates 같은 설정은 단순한 변수처럼 보이지만, 결과적으로 운영 시 확인해야 하는 경로와 동작 방식 자체를 결정하는 스위치라는 점이 인상적이었습니다.
또한 설치 로그를 기준으로 역할을 쪼개서 보면, Kubespray는 kubeadm을 “대신 실행해주는 도구”라기보다 배포 전 검증과 OS 부트스트랩, 런타임 구성, 다운로드/캐시 정책, 인증서/etcd 구성, 네트워크/애드온 적용까지 포함한 라이프사이클 자동화 프레임워크에 가깝다는 것도 확인할 수 있었습니다. 덕분에 문제가 생겼을 때도 “어디 레이어에서 꼬였는지”를 추적하는 기준이 생기고, --list-tasks, --tags, --limit 같은 기능을 통해 전체 재설치가 아니라 필요한 영역만 안전하게 재적용할 수 있는 운영 방식을 떠올릴 수 있었습니다.
결국 이번 실습을 통해 Kubernetes 클러스터를 “한 번에 설치한다”기보다는, Kubespray가 제공하는 구조 안에서 OS/런타임/etcd/kubeadm/CNI/애드온을 단계적으로 조립하고 검증하는 과정으로 바라보게 되었습니다. 이후 HA 구성이나 폐쇄망(Air-Gap), Cilium/NFD 같은 고급 옵션을 적용하더라도, 내부 동작을 더 명확히 이해한 상태에서 설정을 선택하고 장애를 해석할 수 있을 것이라 생각합니다.