# kubespray로 노드를 넣고 빼기 - --limit이 위험해지는 지점
kubespray로 만든 클러스터에 노드를 하나 더 붙이는 작업은 명령 한 줄처럼 보입니다. 그런데 어떤 플레이북을 어떤 옵션으로 돌리느냐에 따라, 노드가 붙는 대신 기존 클러스터의 설정이 망가지기도 합니다.
원인은 kubespray가 Ansible 위에 서 있다는 사실에 있습니다. Ansible의 실행 대상 제한(--limit)과 팩트 수집 방식이 클러스터 전역 설정을 만드는 템플릿과 만나면, 제외한 노드의 정보가 빠진 설정 파일이 생성됩니다. 이 글에서는 플레이북들의 역할 분담, --limit이 안전한 조건과 아닌 조건, 그리고 노드를 뺄 때 반드시 거쳐야 하는 정리 과정을 다룹니다.
# 1. 플레이북이 나뉘어 있는 이유
노드를 추가할 때 쓰는 플레이북이 둘입니다.
For worker nodes, use
scale.yml. ... You can use--limit=NODE_NAMEto limit Kubespray to avoid disturbing other nodes in the cluster.
[For control plane nodes] You can NOT use
scale.ymlfor that - instead runcluster.yml.
경계가 워커냐 컨트롤 플레인이냐로 그어져 있습니다. 이유는 두 작업이 건드리는 범위가 다르기 때문입니다.
워커를 추가하는 것은 대체로 새 노드 안에서 끝나는 작업입니다. 런타임을 깔고, kubelet을 설정하고, 조인 토큰으로 붙입니다. 기존 노드가 알아야 할 것이 거의 없습니다.
컨트롤 플레인을 추가하는 것은 클러스터 전역 상태를 바꾸는 작업입니다. API 서버 인증서에 새 노드의 이름과 주소가 들어가야 하고, etcd 멤버 목록이 갱신되어야 하고, 로드 밸런서 설정과 kubeconfig의 엔드포인트가 달라질 수 있습니다. 이 변경들은 기존 노드에도 적용되어야 합니다. 그래서 전체 수렴을 도는 cluster.yml이 필요합니다.
정리하면 이렇습니다.
| 작업 | 플레이북 | 영향 범위 |
|---|---|---|
| 워커 추가 | scale.yml | 신규 노드 위주 |
| 컨트롤 플레인 추가 | cluster.yml | 클러스터 전체 |
| etcd 노드 추가 | cluster.yml | 클러스터 전체 |
| 노드 제거 | remove-node.yml | 대상 + 전역 멤버십 |
# 2. --limit을 쓰기 전에 팩트를 채워야 한다
--limit은 매력적인 옵션입니다. 노드 하나만 건드리니 안전해 보입니다. 문서는 여기에 조건을 답니다.
Before applying limits, run playbook
facts.ymlwithout the limit to refresh facts cache for all nodes.
제한 없이 facts.yml을 먼저 돌리라는 것인데, 이 한 줄이 왜 필요한지가 핵심입니다.
Ansible의 팩트는 호스트별로 수집되고, 템플릿은 다른 호스트의 팩트를 참조할 수 있습니다. kubespray의 설정 템플릿이 정확히 그렇게 되어 있습니다. etcd 엔드포인트 목록, API 서버 인증서의 SAN 목록, 로드 밸런서 백엔드 목록은 전부 "다른 노드들의 주소"를 모아 만듭니다.
--limit을 걸면 제외된 호스트의 팩트가 이번 실행에서 수집되지 않습니다. 캐시에도 없으면 템플릿이 참조할 값이 비어 있게 되고, 결과는 일부 노드가 빠진 설정 파일입니다. etcd 엔드포인트가 하나만 적힌 설정이 생성되거나, 인증서 SAN에서 특정 노드가 사라지는 식입니다.
증상이 즉시 나타나지 않는다는 점이 고약합니다. 기존 연결은 유지되고, 다음 재시작이나 인증서 갱신 시점에 드러납니다. 원인 작업과 증상 사이가 며칠 벌어집니다.
# 1) 제한 없이 팩트를 먼저 채운다 - 이 단계를 건너뛰지 않는다
ansible-playbook -i inventory/mycluster/hosts.yaml facts.yml
# 2) 그 뒤에야 대상을 좁힌다
ansible-playbook -i inventory/mycluster/hosts.yaml scale.yml --limit=node7
규칙을 하나 정하면, --limit은 항상 facts.yml 실행과 짝으로 씁니다. 짝을 못 맞출 상황이면 --limit 없이 도는 편이 낫습니다. 느릴 뿐 틀리지는 않습니다.
# 3. 인벤토리의 순서가 의미를 가진다
컨트롤 플레인을 추가할 때 제약이 하나 더 있습니다.
Adding control plane nodes in the first position is not supported and will cause the playbook to fail.
인벤토리 그룹의 첫 항목은 특별합니다. kubespray는 첫 컨트롤 플레인 노드를 부트스트랩 기준점으로 삼아 다른 노드를 조인시킵니다. 그 자리에 새 노드를 끼워 넣으면 "아직 클러스터에 없는 노드"를 기준으로 삼으려다 실패합니다.
새 컨트롤 플레인은 항상 그룹의 끝에 추가합니다.
# inventory/mycluster/hosts.yaml
kube_control_plane:
hosts:
cp1: {} # 기존 - 이 자리를 건드리지 않는다
cp2: {}
cp3: {} # 새 노드는 여기에 붙인다
이 제약은 노드를 뺄 때도 영향을 줍니다. 첫 번째 컨트롤 플레인을 제거하려면 순서상 다른 노드가 그 자리로 올라오게 되는데, 이는 부트스트랩 기준이 바뀌는 것과 같습니다. 첫 노드를 빼는 작업은 다른 노드를 빼는 것과 난이도가 다릅니다. 가능하면 먼저 다른 노드를 추가해 여유를 만들고, 순서를 정리한 뒤에 진행합니다.
# 4. etcd는 홀수를 유지해야 한다
you need to make sure there are always an odd number of etcd nodes in the cluster
etcd는 과반수로 쿼럼을 판단합니다. 짝수는 가용성을 높이지 않으면서 장애 확률만 올립니다.
| 멤버 수 | 견딜 수 있는 장애 | 평가 |
|---|---|---|
| 3 | 1 | 표준 |
| 4 | 1 | 3과 같은데 장애 확률만 증가 |
| 5 | 2 | 큰 클러스터 |
| 6 | 2 | 5와 같음 |
4대는 내구성이 3대와 같으면서 고장 날 수 있는 기계가 하나 더 있는 상태입니다. 그래서 etcd 노드 증설은 하나씩이 아니라 둘씩, 혹은 추가와 제거를 짝으로 묶어야 합니다.
여기서 실무적인 순서 문제가 생깁니다. 3대를 5대로 만들려면 중간에 4대인 순간을 지나갑니다. 그 구간이 짧아야 하고, 한 번의 플레이북 실행으로 두 노드를 함께 넣는 편이 낫습니다. 이때도 etcd 멤버 추가(member add) 자체는 한 대씩 순차로 이뤄지므로, 앞 노드의 조인이 끝나기 전에 다음 노드가 실패하지 않도록 -e etcd_retries=10을 붙이는 것이 좋습니다. 노드 교체(한 대를 빼고 한 대를 넣기)라면 먼저 넣고 나중에 빼는 순서를 택해야 3 → 4 → 3이 되어 쿼럼이 유지됩니다. 반대로 하면 3 → 2가 되어 한 대만 더 죽어도 클러스터가 멈춥니다.
# 5. 노드를 뺄 때 kubectl delete node로 끝내면 안 된다
노드를 빼는 것은 넣는 것보다 정리할 것이 많습니다. remove-node.yml이 하는 일이 그 목록입니다.
ansible-playbook -i inventory/mycluster/hosts.yaml remove-node.yml -e node=node7
kubectl delete node만 하면 Kubernetes 오브젝트만 사라집니다. 남는 것들이 있습니다.
- etcd 멤버십. etcd 노드였다면 멤버 목록에 남아 쿼럼 계산에 계속 포함됩니다. 3대 중 1대를 이렇게 빼면 "3대 중 2대 생존"이 아니라 "여전히 3대 구성인데 1대가 영구 장애"인 상태가 됩니다.
- 노드 자체의 상태. 런타임, kubelet, CNI 설정, iptables 규칙이 남습니다. 그 머신을 다시 클러스터에 넣으면 예전 상태와 충돌합니다.
- 로드 밸런서·인증서. 컨트롤 플레인이었다면 전역 설정에 흔적이 남습니다.
노드에 접속할 수 없는 상황(이미 죽었거나 회수된 인스턴스)에서는 두 변수가 필요합니다.
add
reset_nodes=falseandallow_ungraceful_removal=trueto your command
ansible-playbook -i inventory/mycluster/hosts.yaml remove-node.yml \
-e node=node7 -e reset_nodes=false -e allow_ungraceful_removal=true
reset_nodes=false는 그 노드에 접속해 정리하는 단계를 건너뛴다는 뜻입니다. 죽은 노드에 SSH를 시도하다 플레이북이 멈추는 것을 막습니다. allow_ungraceful_removal=true는 drain이 끝나지 않아도 진행하게 합니다.
문서가 짚는 대로 이 조합은 컨트롤 플레인이나 etcd 노드를 뺄 때도 동일하게 적용됩니다. 다만 대가가 명확합니다. 그 노드는 정리되지 않은 채 남습니다. 나중에 그 머신을 재사용한다면 수동으로 초기화해야 하고, 그러지 않으면 다음 조인에서 문제가 생깁니다.
# 6. 이 작업들이 무중단이 아니라는 점
cluster.yml을 기존 클러스터에 돌리는 것은 멱등적이라고 설명되지만, 멱등적이라는 말이 무중단이라는 뜻은 아닙니다.
Ansible 역할은 설정 파일을 템플릿으로 생성하고, 내용이 바뀌면 핸들러로 서비스를 다시 시작합니다. 즉 템플릿 결과가 이전과 한 글자라도 다르면 해당 컴포넌트가 재시작합니다. 버전이 그대로여도 인벤토리 변수 하나가 바뀌었거나 노드가 늘어 목록이 달라졌다면 재시작 대상이 됩니다.
컨트롤 플레인에서는 그 영향이 큽니다. API 서버가 순차적으로 재시작하는 동안 요청이 실패할 수 있고, etcd가 재시작하면 잠깐 쿼럼이 흔들립니다. 그래서 다음이 실무 규칙이 됩니다.
- 노드 추가 작업은 변경 창을 잡고 합니다. "노드 하나 붙이는 것"으로 취급하지 않습니다.
- 실행 전에 무엇이 바뀔지 확인합니다.
- 컨트롤 플레인 작업 중에는 배포 파이프라인을 멈춥니다. API 서버가 흔들리는 동안 진행되는 배포는 예측하기 어렵습니다.
# 무엇이 바뀔지 미리 본다 - 변경이 없으면 changed=0 이어야 한다
ansible-playbook -i inventory/mycluster/hosts.yaml cluster.yml --check --diff
--check가 완벽하지는 않습니다. 앞 단계의 결과에 의존하는 태스크는 검사 모드에서 건너뛰므로 실제와 다를 수 있습니다. 그래도 의도하지 않은 변경을 발견하는 데는 충분합니다. changed가 예상보다 많이 잡히면 그 이유를 먼저 확인해야 합니다.
# 7. 직접 확인하는 방법
작업 전후로 볼 것들입니다.
# 노드 목록과 역할
kubectl get nodes -o wide --show-labels | cut -c1-160
# etcd 멤버 - kubectl에는 안 보이는 상태다
kubectl -n kube-system exec etcd-cp1 -- etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/ssl/etcd/ssl/ca.pem \
--cert=/etc/ssl/etcd/ssl/member-cp1.pem \
--key=/etc/ssl/etcd/ssl/member-cp1-key.pem \
member list -w table
# 쿼럼 상태 - 제거 작업 뒤 반드시 확인
kubectl -n kube-system exec etcd-cp1 -- etcdctl ... endpoint health --cluster
인증서에 새 노드가 반영됐는지도 봐야 합니다. 2절의 팩트 문제가 있었다면 여기서 드러납니다.
# API 서버 인증서의 SAN에 모든 컨트롤 플레인이 들어 있는가
openssl x509 -in /etc/kubernetes/ssl/apiserver.crt -noout -text \
| grep -A2 'Subject Alternative Name'
노드를 뺀 뒤에는 잔여물을 확인합니다.
# Kubernetes에서는 사라졌는데 etcd 멤버에 남아 있지 않은지
diff <(kubectl get nodes -o name | sed 's|node/||' | sort) \
<(etcdctl ... member list -w simple | cut -d, -f3 | tr -d ' ' | sort)
# 8. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
--limit 실행 후 설정에 노드가 빠짐 | 팩트 캐시 미갱신 | facts.yml을 제한 없이 선행 |
| 컨트롤 플레인 추가가 실패 | 인벤토리 첫 위치에 추가 | 그룹 끝에 배치 |
scale.yml로 컨트롤 플레인이 안 붙음 | 지원 대상이 아님 | cluster.yml 사용 |
| 노드를 뺐는데 쿼럼이 이상함 | etcd 멤버가 남음 | remove-node.yml로 정리 |
| 죽은 노드 제거가 멈춤 | 접속 시도 후 대기 | reset_nodes=false 추가 |
| drain이 끝나지 않음 | 옮길 수 없는 파드 | allow_ungraceful_removal=true 검토 |
| 재사용한 머신이 조인 실패 | 이전 상태가 남음 | 수동 초기화 후 재시도 |
| 노드 추가 중 API가 불안정 | 설정 변경으로 컴포넌트 재시작 | 변경 창 확보, 배포 중단 |
| 며칠 뒤 인증서 문제 발생 | 2절 문제의 지연 발현 | SAN 확인 후 전체 실행으로 복구 |
# 9. 마무리
- 플레이북 경계는 영향 범위로 그어져 있습니다. 워커는 신규 노드에서 끝나므로
scale.yml, 컨트롤 플레인은 전역 상태를 바꾸므로cluster.yml입니다. --limit은 빠르지만, 템플릿이 다른 노드의 팩트를 참조하기 때문에 팩트 캐시를 먼저 채우지 않으면 일부 노드가 빠진 설정이 생성됩니다. 증상은 며칠 뒤에 나타납니다.- 인벤토리 첫 컨트롤 플레인은 부트스트랩 기준입니다. 새 노드는 항상 끝에 붙입니다.
- etcd는 홀수를 유지합니다. 교체는 넣고 나서 빼는 순서여야 쿼럼이 안 흔들립니다.
- 노드 제거는
kubectl delete node로 끝나지 않습니다. etcd 멤버십과 노드 자체의 상태가 남습니다. - 멱등적이라는 말은 무중단이라는 뜻이 아닙니다. 설정 템플릿 결과가 바뀌면 컴포넌트가 재시작합니다.
--check --diff로 무엇이 바뀔지 먼저 봅니다.