
Kubespray로 구축한 HA(High Availability) 클러스터에서 각 컴포넌트가 Kubernetes API Server에 접근하는 방식은 노드 타입에 따라 다르다.
이러한 구조는 HA 환경에서 안정성과 성능을 동시에 확보하기 위한 설계다.
Worker 노드는 자체적으로 nginx static pod를 실행하여 API Server에 대한 Client-Side 로드밸런싱을 수행한다.
Worker Node (k8s-node4, k8s-node5)
├── nginx-proxy (Static Pod)
│ ├── Listen: 127.0.0.1:6443
│ ├── Health Check: :8081/healthz
│ └── Upstream (least_conn)
│ ├── 192.168.10.11:6443 (k8s-node1)
│ ├── 192.168.10.12:6443 (k8s-node2)
│ └── 192.168.10.13:6443 (k8s-node3)
├── kubelet → https://localhost:6443
└── kube-proxy → https://127.0.0.1:6443

# Worker 노드의 컨테이너 목록 확인
ssh k8s-node4 crictl ps
출력 예시:
CONTAINER IMAGE CREATED STATE NAME POD
3c09f930b22b0 5a91d90f47ddf 15 minutes ago Running nginx-proxy nginx-proxy-k8s-node4
핵심 포인트:
ssh k8s-node4 cat /etc/nginx/nginx.conf
주요 설정:
error_log stderr notice;
worker_processes 2;
worker_rlimit_nofile 130048;
worker_shutdown_timeout 10s;
events {
multi_accept on;
use epoll;
worker_connections 16384;
}
stream {
upstream kube_apiserver {
least_conn; # 로드밸런싱 알고리즘
server 192.168.10.11:6443;
server 192.168.10.12:6443;
server 192.168.10.13:6443;
}
server {
listen 127.0.0.1:6443;
proxy_pass kube_apiserver;
proxy_timeout 10m;
proxy_connect_timeout 1s;
}
}
http {
server {
listen 8081;
location /healthz {
access_log off;
return 200;
}
}
}
worker_rlimit_nofile: 130048
upstream kube_apiserver
least_conn: 최소 연결 수 알고리즘 사용왜 RoundRobin이 아닌 least_conn인가?
listen 127.0.0.1:6443
healthz 엔드포인트
# nginx 헬스체크
ssh k8s-node4 curl -s localhost:8081/healthz -I
출력:
HTTP/1.1 200 OK
Server: nginx
# nginx를 통한 API Server 접근 테스트
ssh k8s-node4 curl -sk https://127.0.0.1:6443/version | grep gitVersion
출력:
"gitVersion": "v1.32.9",
# nginx 리스닝 포트 확인
ssh k8s-node4 ss -tnlp | grep nginx
출력:
LISTEN 0 511 0.0.0.0:8081 0.0.0.0:* users:(("nginx",pid=15043,fd=6))
LISTEN 0 511 127.0.0.1:6443 0.0.0.0:* users:(("nginx",pid=15043,fd=5))
의미:
ssh k8s-node4 cat /etc/kubernetes/kubelet.conf | grep server
출력:
server: https://localhost:6443
의미:
kubectl get cm -n kube-system kube-proxy -o yaml | grep 'kubeconfig.conf:' -A18
출력:
kubeconfig.conf: |-
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
server: https://127.0.0.1:6443
name: default
의미:
# nginx.conf 생성 Task 확인
tree roles/kubernetes/node/tasks/loadbalancer
cat roles/kubernetes/node/tasks/loadbalancer/nginx-proxy.yml
주요 Task:
- name: Nginx-proxy | Write nginx-proxy configuration
template:
src: "loadbalancer/nginx.conf.j2"
dest: "{{ nginx_config_dir }}/nginx.conf"
owner: root
mode: "0755"
backup: true
cat roles/kubernetes/node/templates/loadbalancer/nginx.conf.j2
핵심 부분:
stream {
upstream kube_apiserver {
least_conn;
{% for host in groups['kube_control_plane'] -%}
server {{ hostvars[host]['main_access_ip'] | ansible.utils.ipwrap }}:{{ kube_apiserver_port }};
{% endfor -%}
}
server {
listen 127.0.0.1:{{ loadbalancer_apiserver_port|default(kube_apiserver_port) }};
{% if ipv6_stack -%}
listen [::1]:{{ loadbalancer_apiserver_port|default(kube_apiserver_port) }};
{% endif -%}
proxy_pass kube_apiserver;
proxy_timeout 10m;
proxy_connect_timeout 1s;
}
}
템플릿 동작 원리:
1. groups['kube_control_plane']: Ansible inventory의 Control Plane 그룹
2. hostvars[host]['main_access_ip']: 각 Control Plane 노드의 IP 추출
3. kube_apiserver_port: 기본값 6443
4. Jinja2 for 루프로 모든 Control Plane을 upstream에 추가
cat roles/kubernetes/node/templates/manifests/nginx-proxy.manifest.j2
주요 내용:
apiVersion: v1
kind: Pod
metadata:
name: {{ loadbalancer_apiserver_pod_name }}
namespace: kube-system
labels:
addonmanager.kubernetes.io/mode: Reconcile
k8s-app: kube-nginx
annotations:
nginx-cfg-checksum: "{{ nginx_stat.stat.checksum }}"
spec:
containers:
- name: nginx-proxy
image: {{ nginx_image_repo }}:{{ nginx_image_tag }}
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx
volumes:
- name: nginx-config
hostPath:
path: {{ nginx_config_dir }}
특징:
/etc/kubernetes/manifests/에 위치Control Plane 노드는 자신의 로컬 API Server에 직접 연결한다.
Control Plane Node (k8s-node1, k8s-node2, k8s-node3)
├── kube-apiserver
│ ├── --bind-address=::
│ ├── --advertise-address=192.168.10.11
│ └── Listen: *:6443 (IPv4/IPv6)
├── kubelet → https://127.0.0.1:6443
├── kube-proxy → https://127.0.0.1:6443
├── kube-controller-manager → https://127.0.0.1:6443
└── kube-scheduler → https://127.0.0.1:6443
kubectl describe pod -n kube-system kube-apiserver-k8s-node1 | grep -E 'address|secure-port'
출력:
Annotations: kubeadm.kubernetes.io/kube-apiserver.advertise-address.endpoint: 192.168.10.11:6443
--advertise-address=192.168.10.11
--secure-port=6443
--bind-address=::
주요 설정 항목:
--bind-address=::
::는 IPv6의 0.0.0.0)--advertise-address=192.168.10.11
--secure-port=6443
ssh k8s-node1 ss -tnlp | grep 6443
출력:
LISTEN 0 4096 *:6443 *:* users:(("kube-apiserver",pid=26124,fd=3))
의미:
*:6443: 모든 인터페이스에서 리스닝# 로컬호스트로 접근
ssh k8s-node1 curl -sk https://127.0.0.1:6443/version | grep gitVersion
# 출력: "gitVersion": "v1.32.9",
# 실제 IP로 접근
ssh k8s-node1 curl -sk https://192.168.10.11:6443/version | grep gitVersion
# 출력: "gitVersion": "v1.32.9",
# NAT IP로 접근 (VirtualBox NAT)
ssh k8s-node1 curl -sk https://10.0.2.15:6443/version | grep gitVersion
# 출력: "gitVersion": "v1.32.9",
결론:
ssh k8s-node1 cat /etc/kubernetes/admin.conf | grep server
출력:
server: https://127.0.0.1:6443
ssh k8s-node1 cat /etc/kubernetes/super-admin.conf | grep server
출력:
server: https://192.168.10.11:6443
중요: super-admin.conf만 실제 IP 주소를 사용한다!
이유:
ssh k8s-node1 cat /etc/kubernetes/kubelet.conf | grep server
출력:
server: https://127.0.0.1:6443
kubectl get cm -n kube-system kube-proxy -o yaml | grep server
출력:
server: https://127.0.0.1:6443
ssh k8s-node1 cat /etc/kubernetes/controller-manager.conf | grep server
출력:
server: https://127.0.0.1:6443
ssh k8s-node1 cat /etc/kubernetes/scheduler.conf | grep server
출력:
server: https://127.0.0.1:6443
Worker 노드는 어느 Control Plane이 살아있는지 알 수 없다.
문제점:
해결책:
Control Plane은 자신의 로컬 API Server가 항상 있다.
이유:
kubectl logs -n kube-system nginx-proxy-k8s-node4
로그:
2026/01/28 04:02:40 [alert] 20#20: setrlimit(RLIMIT_NOFILE, 130048) failed (1: Operation not permitted)
2026/01/28 04:02:40 [alert] 21#21: setrlimit(RLIMIT_NOFILE, 130048) failed (1: Operation not permitted)
원인:
worker_rlimit_nofile 130048 설정RLIMIT_NOFILE이 65535로 제한ssh k8s-node4 cat /etc/containerd/config.toml | grep base_runtime_spec
출력:
base_runtime_spec = "/etc/containerd/cri-base.json"
ssh k8s-node4 cat /etc/containerd/cri-base.json | jq | grep rlimits -A 6
출력:
"rlimits": [
{
"type": "RLIMIT_NOFILE",
"hard": 65535,
"soft": 65535
}
]
# containerd 기본 변수 확인
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 }}"
group_vars에 Override 설정 추가:
cat << EOF >> inventory/mycluster/group_vars/all/containerd.yml
containerd_default_base_runtime_spec_patch:
process:
rlimits: [] # rlimits 제한 제거
EOF
# containerd 관련 Task 목록 확인
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml \
--tags "containerd" --list-tasks
주요 Task:
container-engine/containerd : Containerd | Generate default base_runtime_spec
container-engine/containerd : Containerd | Store generated default base_runtime_spec
container-engine/containerd : Containerd | Write base_runtime_specs
container-engine/containerd : Containerd | Copy containerd config file
# k8s-node4에만 containerd 재설정
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml \
--tags "containerd" \
--limit k8s-node4 \
-e kube_version="1.32.9"
옵션 설명:
--tags "containerd": containerd 관련 Task만 실행--limit k8s-node4: k8s-node4에만 적용-e kube_version="1.32.9": Kubernetes 버전 명시ssh k8s-node4 cat /etc/containerd/cri-base.json | jq | grep rlimits
출력:
"rlimits": [],
의미:
# nginx-proxy Pod 강제 재시작
ssh k8s-node4 crictl pods --namespace kube-system --name 'nginx-proxy-*' -q | xargs crictl rmp -f
kubectl logs -n kube-system nginx-proxy-k8s-node4
정상 로그:
2026/01/28 17:50:00 [notice] 1#1: start worker processes
2026/01/28 17:50:00 [notice] 1#1: start worker process 20
2026/01/28 17:50:00 [notice] 1#1: start worker process 21
alert 메시지가 사라짐!
Kubespray는 다음 3가지 Client-Side LoadBalancer를 지원한다:
장점:
단점:
장점:
단점:
장점:
단점:
선택 가이드:
Ansible Task에 붙이는 레이블이다. 특정 Tag가 붙은 Task만 선택적으로 실행할 수 있다.
# cluster.yml의 모든 Task와 Tag 확인
ansible-playbook -i inventory/mycluster/inventory.ini cluster.yml --list-tasks
# 특정 Tag의 Task만 확인
ansible-playbook -i inventory/mycluster/inventory.ini cluster.yml \
--tags "containerd" --list-tasks
| Tag | 설명 | 사용 예시 |
|---|---|---|
containerd | containerd 엔진 설정 | 컨테이너 런타임 재설정 |
download | 바이너리 다운로드 | 오프라인 설치 준비 |
etcd | etcd 클러스터 설정 | etcd 재설정 |
k8s-cluster | Kubernetes 클러스터 전체 | 전체 재배포 |
network | 네트워크 플러그인 | CNI 변경 |
apps | 애드온 앱 설치 | Metrics Server 등 |
# containerd만 재설정
ansible-playbook -i inventory/mycluster/inventory.ini cluster.yml \
--tags "containerd"
# download Task만 실행 (바이너리 다운로드만)
ansible-playbook -i inventory/mycluster/inventory.ini cluster.yml \
--tags "download"
# 여러 Tag 동시 사용
ansible-playbook -i inventory/mycluster/inventory.ini cluster.yml \
--tags "containerd,network"
# 특정 Tag 제외
ansible-playbook -i inventory/mycluster/inventory.ini cluster.yml \
--skip-tags "download"
# playbooks/ 디렉터리에서 tags 검색
grep -Rni "tags" playbooks/ -A2 -B1
# roles/ 디렉터리에서 tags 검색 (YAML 파일만)
grep -Rni "tags" roles/ --include="*.yml" -A2 -B1
Cilium과 같은 DaemonSet으로 배포된 Pod들도 API Server에 접근해야 한다.
Cilium Helm values 설정 예시:
k8sServiceHost: 127.0.0.1
k8sServicePort: 6443
Worker 노드에서 실행되는 Cilium Pod:
127.0.0.1:6443으로 API Server에 연결Control Plane 노드에서 실행되는 Cilium Pod:
127.0.0.1:6443으로 API Server에 연결결론:
127.0.0.1:6443) 사용Worker 노드 → API Server
Control Plane 노드 → API Server
nginx 설정의 핵심
worker_rlimit_nofile: 대량 연결 처리least_conn: 불균형 방지healthz: nginx 자체 헬스체크containerd rlimits 제한 제거
--tags "containerd"로 부분 적용--limit <node>로 특정 노드만 적용API Server 바인딩
--bind-address=::: IPv4/IPv6 모두 지원--advertise-address: 클러스터 내부 광고 IP안정성:
성능:
단순성: