Terraform과 Ansible로 클러스터를 코드에서 다시 세울 수 있게 만드는 절차
클라우드 리소스는 Terraform, 노드 프로비저닝은 Ansible로 코드화하고, 실제로 라이브 클러스터를 파기한 뒤 코드만으로 Day-0 재구축까지 검증한 절차와 그 과정에서 만난 문제들.
목표
시리즈 1~2에서 손으로 실행한 절차(VM 준비, kubeadm init/join, CNI/스토리지 설치, 애플리케이션 배포)를 코드로 옮긴다. 클라우드 리소스는 Terraform, 노드의 OS/쿠버네티스 레벨 준비는 Ansible로 나눈다. 코드화가 끝났다고 선언하기 전에, 라이브 클러스터를 실제로 통째로 파기하고 코드만으로 다시 세울 수 있는지까지 검증한다.
개념: IaC(Infrastructure as Code)란 무엇인가
콘솔에 로그인해 클릭으로 서버를 만들거나, SSH로 접속해 손으로 패키지를 설치하면 "지금 이 서버가 왜 이런 상태인지"는 그 작업을 한 사람의 기억에만 남는다. 시간이 지나거나 담당자가 바뀌면 같은 환경을 다시 만들 방법이 없다 - IaC는 인프라의 상태를 코드로 선언해두고, 그 코드를 실행하면 실제 인프라가 그 상태가 되도록 만드는 방식이다. 코드 자체가 곧 "지금 인프라가 어떤 모습이어야 하는지"에 대한 살아있는 문서가 된다.
IaC 도구는 크게 두 방식으로 나뉜다.
- 선언적(declarative): "최종적으로 이런 모습이어야 한다"만 적으면, 도구가 현재 상태와 비교해서 차이만 계산해 적용한다. Terraform이 이 방식이다.
- 절차적(procedural): "이 순서대로 이 작업들을 해라"를 적어두면 그대로 실행한다. Ansible이 이 방식이다.
두 방식 모두에서 **멱등성(idempotency)**이 핵심이다 - 같은 코드를 몇 번을 실행해도 결과가 항상 같아야 한다. "이미 존재하면 건너뛰고, 없으면 만든다"는 판단을 도구가 대신 해주기 때문에, 사람이 "이거 이미 실행했었나?"를 기억할 필요가 없어진다.
역할 분담: 왜 두 도구를 같이 쓰는가
| Terraform | Ansible | |
|---|---|---|
| 대상 | AWS 리소스 (보안그룹, EC2 인스턴스 등) | 이미 떠 있는 노드의 OS/쿠버네티스 상태 |
| 모델 | 선언적, 상태 파일(state)로 리소스 존재 여부를 추적 | 절차적, 멱등성 있는 태스크를 순서대로 실행 |
| 적합한 이유 | 클라우드 리소스는 "생성/삭제"가 명확한 단위 | 노드는 이미 물리적으로 존재 - "생성"이 아니라 "구성" 대상 |
실제 구축 절차
1. Terraform - AWS 리소스 코드화
가장 먼저 결정해야 했던 건 "무엇을 resource로 선언하고, 무엇을 data로만 참조할 것인가"였다. VPC·서브넷·SSH 키페어(k8s-lab)는 계정에 이미 존재하는 리소스다 - 이걸 Terraform resource로 선언하면, terraform destroy나 설정 변경 한 번에 실수로 그 리소스 자체가 재생성/파기될 위험이 생긴다. 특히 키페어가 무효화되면 기존에 떠 있는 인스턴스에 대한 SSH 접근 자체가 통째로 불가능해진다 - 그래서 이런 계정 레벨 리소스는 전부 data 소스로만 참조하고, resource로는 이번에 새로 만들고 지울 대상(보안그룹, 인스턴스)만 선언했다.
# terraform/main.tf
# 이 랩은 기본 VPC를 그대로 쓴다. Terraform이 VPC/서브넷/키페어까지 만들지 않고
# "이미 있는 것"으로 참조만 하는 이유:
# - 기본 VPC는 AWS 계정에 이미 존재 (destroy 대상이 아님 - 실수로 계정 전체
# 네트워크를 건드리지 않게 분리)
# - 키페어(k8s-lab.pem)는 로컬에만 있는 개인키와 짝이 맞아야 하므로, Terraform이
# 새로 만들면 기존 .pem 파일이 무용지물이 됨 → 반드시 기존 키페어를 "참조"만 해야 함.
data "aws_vpc" "default" {
default = true
}
# 인스턴스 3대가 기존에 놓였던 두 AZ(2c: CP, 2b: 워커 2대)에 맞춰 서브넷을 고른다.
data "aws_subnet" "az_2c" {
filter {
name = "availability-zone"
values = ["ap-northeast-2c"]
}
vpc_id = data.aws_vpc.default.id
}
data "aws_key_pair" "k8s_lab" {
key_name = var.key_pair_name # 기본값 "k8s-lab" - Terraform이 새로 만들지 않고 참조만
}
# Ubuntu 22.04 최신 AMI를 SSM 파라미터로 조회 (수동 조회 절차와 동일한 소스)
data "aws_ssm_parameter" "ubuntu_2204_ami" {
name = "/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id"
}
# terraform/security_group.tf - 그날그날 하나씩 열어온 인바운드 규칙을 그대로 코드화
# (실제 계정의 sg-xxxxxxxxxxxxxxxxx 규칙을 조회해 옮김)
resource "aws_security_group" "k8s_lab" {
name = "k8s-lab-sg"
description = "k8s lab"
vpc_id = data.aws_vpc.default.id
}
# 노드 간 통신: 전체 프로토콜, 자기 자신(같은 SG) 참조 → 쿠버네티스 노드 간 모든 통신 허용
resource "aws_vpc_security_group_ingress_rule" "node_to_node" {
security_group_id = aws_security_group.k8s_lab.id
ip_protocol = "-1"
referenced_security_group_id = aws_security_group.k8s_lab.id
description = "node-to-node all traffic"
}
# GitHub 웹훅 수신 (Argo Events) - 발신 IP가 고정이 아니라 전체 개방 필요,
# 대신 HMAC 서명 검증으로 위조 요청 차단
resource "aws_vpc_security_group_ingress_rule" "github_webhook" {
security_group_id = aws_security_group.k8s_lab.id
ip_protocol = "tcp"
from_port = var.webhook_nodeport
to_port = var.webhook_nodeport
cidr_ipv4 = "0.0.0.0/0"
description = "Argo Events GitHub webhook"
}
# Jenkins UI - 빌드 실행 권한이 있어 접속자 IP만 허용 (ArgoCD/Hubble과 다른 보안 수준)
resource "aws_vpc_security_group_ingress_rule" "jenkins" {
security_group_id = aws_security_group.k8s_lab.id
ip_protocol = "tcp"
from_port = 30880
to_port = 30880
cidr_ipv4 = var.my_ip_cidr
description = "Jenkins UI (restricted to admin IP)"
}
# ingress-nginx/ArgoCD/Hubble UI 등 나머지 규칙도 같은 패턴으로 이어진다
# terraform/instances.tf
resource "aws_instance" "k8s_cp" {
ami = data.aws_ssm_parameter.ubuntu_2204_ami.value
instance_type = var.cp_instance_type # t3.medium
subnet_id = data.aws_subnet.az_2c.id
key_name = data.aws_key_pair.k8s_lab.key_name
vpc_security_group_ids = [aws_security_group.k8s_lab.id]
# 스팟(persistent) - 중단되면 stop, 종료 아님 (저녁마다 정지하는 운영과 호환)
instance_market_options {
market_type = "spot"
spot_options {
spot_instance_type = "persistent"
instance_interruption_behavior = "stop"
}
}
root_block_device {
volume_size = var.cp_root_volume_size # 30
volume_type = "gp3"
}
}
보안그룹 description에 한글을 넣었더니 apply가 거부됐다
security_group.tf의 인바운드 규칙 description에 한글 설명을 그대로 넣었더니 terraform apply가 InvalidParameterValue로 실패했다. AWS 보안그룹 규칙의 description 필드는 ASCII 문자만 허용한다 - 한글(비ASCII)이 하나라도 섞이면 API 레벨에서 거부된다. 전부 영문 설명으로 바꿔서 해결했다. 콘솔에서 클릭으로 만들 때는 잘 몰랐는데, 코드로 선언하고 나서야 마주친 제약이다.
variables.tf에서 한 가지 눈에 띄는 설계 포인트 - Jenkins/Argo Workflows처럼 민감한 UI에 접속을 허용할 my_ip_cidr 변수는 기본값을 아예 지정하지 않았다. 반드시 -var나 terraform.tfvars로 명시적으로 넘겨야만 apply가 되게 만들어서, "깜빡하고 0.0.0.0/0으로 열어버리는" 실수를 원천 차단한 것이다.
state 파일이 하는 역할은 이렇다 - Terraform은 .tf 코드만 보고는 "이 리소스가 실제로 이미 있는지"를 알 수 없다. terraform.tfstate에 "내가 마지막으로 만든 리소스가 무엇이었는지"를 기록해두고, 다음 실행 때 이 state와 코드를 비교해 차이만 적용한다. 이번 랩은 혼자 쓰는 학습 환경이라 로컬 state로 진행했지만, 여러 사람/여러 세션이 같은 인프라를 다룬다면 원격 백엔드(S3+DynamoDB 락)로 옮겨야 두 사람이 동시에 apply해서 state가 꼬이는 걸 막을 수 있다.
2. Ansible - 노드 프로비저닝
시리즈 1에서 손으로 했던 사전 준비(스왑 비활성화, 커널 모듈, containerd, kubeadm)와 CNI(Cilium)·스토리지(Longhorn) 설치를 플레이북으로 옮겼다.
로컬에 Ansible을 못 돌려서, 컨트롤플레인 노드를 Ansible 컨트롤 노드로 썼다
작업 환경이 Windows이고 WSL/Docker가 없어서, 로컬 PC에서 ansible-playbook을 직접 실행할 방법이 없었다. 그래서 발상을 바꿔서 컨트롤플레인 노드 자체를 Ansible 컨트롤 노드로 부트스트랩하는 bootstrap.sh를 만들었다:
- CP 노드에 전용 오케스트레이션용 SSH 키를 새로 생성한다 (원본
k8s-lab.pem은 로컬 밖으로 절대 나가지 않는다). - 그 공개키를 워커 노드들에 등록한다.
- 플레이북을 CP 노드로 전송하고, CP 노드 안에서
ansible-playbook을 실행해 워커들을 구성한다.
즉 "로컬 → 모든 노드"가 아니라 "로컬 → CP 노드 → (CP가 스스로와 워커들을) 구성"하는 한 단계 간접 구조다. 로컬 환경 제약 때문에 나온 방식이지만, 결과적으로 원본 키를 로컬에만 묶어두는 최소권한 원칙과도 맞아떨어졌다.
bootstrap.sh의 핵심은 재시도와 회피 로직이다 - sshd의 MaxStartups(동시 미인증 연결 제한)로 추정되는 간헐적 실패에 대응하는 재시도 함수, 그리고 CP를 경유하는 ProxyJump 대신 워커 공인 IP로 직접 접속하는 우회 방식이다.
# ansible/bootstrap.sh
# sshd의 MaxStartups(동시 미인증 연결 제한)에 걸려 간헐적으로 "Permission denied"가
# 나는 현상이 있어(연결 시도가 몰릴 때 관측됨), 위험한 호출들은 짧게 재시도한다.
ssh_retry() {
local tries=15
local i=1
while [ $i -le $tries ]; do
if ssh $SSH_OPTS "$@"; then
return 0
fi
echo " (ssh 실패, ${i}/${tries}차 재시도 대기 중...)" >&2
sleep 5
i=$((i + 1))
done
return 1
}
for ip in $WORKER_PUBLIC_IPS; do
# ProxyJump으로 CP를 경유하면 원인 미상의 간헐적 publickey 실패가 있어(재현 어려움),
# 워커도 공인 IP가 있으므로 로컬에서 직접 접속해 등록한다.
cat /tmp/ansible_orchestrator.pub | ssh $SSH_OPTS ubuntu@"$ip" \
'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
done
# ansible/playbook.yml
- name: 공통 노드 준비 (모든 노드)
hosts: all
become: true
tasks:
- name: swap 비활성화
command: swapoff -a
when: ansible_swaptotal_mb > 0
- name: 커널 모듈 로드 (overlay, br_netfilter)
modprobe:
name: "{{ item }}"
state: present
loop: [overlay, br_netfilter]
- name: sysctl 파라미터 설정 (브릿지 트래픽이 iptables를 타도록)
copy:
dest: /etc/sysctl.d/k8s.conf
content: |
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
mode: "0644"
- name: sysctl 즉시 적용 (핸들러는 파일이 changed일 때만 실행되어, 파일만
쓰이고 실제 커널 파라미터는 미적용인 채로 남는 경우가 있어 항상 실행)
command: sysctl --system
changed_when: false
- name: containerd 기본 설정 생성 여부 확인
stat:
path: /etc/containerd/config.toml
register: containerd_config
- name: /etc/containerd 디렉터리 생성
file:
path: /etc/containerd
state: directory
mode: "0755"
when: not containerd_config.stat.exists
- name: 컨트롤플레인 초기화
hosts: control_plane
become: true
tasks:
- name: kubeadm init (Cilium이 kube-proxy를 대체하므로 addon/kube-proxy 스킵)
command: >
kubeadm init
--skip-phases=addon/kube-proxy
--control-plane-endpoint={{ control_plane_endpoint }}:6443
--upload-certs
when: not kubeadm_done.stat.exists
- name: Cilium 설치 (kube-proxy 대체 모드 + Hubble)
command: >
helm install cilium cilium/cilium --version 1.16.0
--namespace kube-system
--set kubeProxyReplacement=true
--set k8sServiceHost={{ control_plane_endpoint }}
--set k8sServicePort=6443
--set hubble.enabled=true
--set hubble.relay.enabled=true
--set hubble.ui.enabled=true
when: "'STATUS: deployed' not in cilium_status.stdout"
Ansible 플레이북에서 실제로 만난 버그 2가지
1) /etc/containerd 디렉터리가 없어서 다음 태스크가 실패: containerd config default는 기본 설정을 표준출력에 찍기만 할 뿐 /etc/containerd 디렉터리를 만들지는 않는다. 그런데 그 다음 태스크가 바로 이 디렉터리 안에 설정 파일을 쓰려고 하면 Destination directory /etc/containerd does not exist로 실패한다. 위 플레이북처럼 디렉터리 생성 태스크를 명시적으로 앞에 추가해서 해결했다.
2) sysctl 설정이 파일에만 쓰이고 실제로 적용은 안 됨: net.ipv4.ip_forward 등의 sysctl 값을 파일로 쓰는 태스크에 notify 핸들러(sysctl --system)로만 반영되게 짰는데, Ansible 핸들러는 그 태스크가 changed일 때만 실행된다. 1차 실행은 태스크 자체가 중간에 실패해서 핸들러가 flush되기 전에 플레이가 중단됐고, 2차 실행은 파일이 이미 존재해서 태스크가 changed가 아닌 ok로 판정돼 핸들러가 아예 호출되지 않았다. 결과적으로 kubeadm init이 ip_forward preflight 체크에서 계속 실패했다. 위 플레이북처럼 핸들러 의존을 없애고 매 실행마다 무조건 sysctl --system을 실행하도록 바꿔서 해결했다 - 멱등성을 챙기려다 오히려 "적용 자체를 건너뛰는" 흔한 함정이었다.
3. Day-0 재구축 검증 (실제로 파기하고 다시 세워봤다)
코드화가 끝났다고 선언하는 대신, 라이브 클러스터(Harbor·ArgoCD·Jenkins·CNPG·Argo Workflows/Events·애플리케이션까지 전부 떠 있는 상태)를 실제로 통째로 파기하고 코드만으로 재현되는지 검증했다.
영속 스팟 인스턴스는 terminate만으로 안 끝난다
EC2 인스턴스를 영속(persistent) 스팟 요청으로 띄워뒀던 게 여기서 발목을 잡았다. terminate-instances로 인스턴스를 종료했는데, 스팟 요청 자체가 살아있어서 종료 직후 대체 인스턴스가 자동으로 다시 떠버렸다. 인스턴스와 스팟 요청은 별개 객체라서, 스팟 요청을 cancel-spot-instance-requests로 먼저 취소한 다음 그렇게 새로 뜬 대체 인스턴스까지 마저 종료해야 완전히 정리된다. 실제 운영에서도 놓치기 쉬운 함정이다 - "인스턴스를 지웠는데 왜 또 떠 있지"가 스팟 요청 때문일 수 있다는 걸 이번에 직접 겪었다.
파기부터 재구축까지 전체 소요 시간을 실측했다 - 파기 시작부터 전 노드 Ready + 전 파드 Running 확인까지 약 24분이 걸렸다. kubectl get storageclass로 Longhorn StorageClass가 정상 등록된 것까지 확인했다.
재구축 중 사람이 직접 개입해야 했던 지점 7가지 (전부 기록)
Day-0 재구축이 "완전 자동"이라고 주장하려면, 사람이 손댄 부분을 숨기지 않고 전부 남겨야 한다고 판단해서 하나도 빼지 않고 기록했다.
- 영속 스팟 인스턴스 자동 재기동 - 위에서 설명한 것과 동일.
terminate-instances만으로 안 끝나서 스팟 요청 취소 + 대체 인스턴스 재종료가 추가로 필요했다. - 보안그룹 규칙 description의 ASCII 제한 - 한글 설명을 영문으로 전부 수정.
- 로컬 환경의
terraform/python3PATH 문제 - Git Bash에서bootstrap.sh실행 시terraform바이너리 경로를 못 찾았고,python3미설치로 JSON 파싱이 실패했다.TERRAFORM_BIN환경변수와 python3 없이도 동작하는 파싱 방식으로 스크립트를 고쳐서 대응했다. - 스크립트 컨텍스트에서만 재현되는 간헐적 SSH
Permission denied- 완전히 동일한 명령을 단독으로 실행하면 성공하는데,bootstrap.sh의 특정 단계(오케스트레이션 키 생성 직후) 안에서만 실패했다. 근본 원인은 확정하지 못했다(sshdMaxStartups로 인한 연결 몰림으로 추정). 재시도 로직과cloud-init status --wait대기를 추가했지만, 최종적으로는 해당 구간만 스크립트 대신 개별 명령으로 나눠 실행해서 우회했다. ProxyJump(CP 경유 워커 접속) 방식의 안정적 실패 - 워커도 공인 IP가 있어서 애초에 CP를 경유할 필요가 없었는데, ProxyJump로 등록하면 매번 실패했다. 로컬에서 워커 공인 IP로 직접 접속해 키를 등록하는 방식으로 설계를 바꿨다.- Ansible 버그 -
/etc/containerd디렉터리 부재 (위 아코디언 참고) - Ansible 버그 - sysctl 핸들러 의존성 (위 아코디언 참고)
결론적으로 사람이 개입해야 했던 지점은 크게 두 갈래였다 - 로컬 실행 환경(Windows/Git Bash)의 툴체인 문제와 AWS의 영속 스팟 인스턴스라는 계정 차원의 함정. 순수 Ansible 플레이북 버그 2개는 이번 실행으로 수정해서 플레이북 자체에 반영했으니, 다음 재실행부터는 그 두 개입도 필요 없다.
이번 IaC화의 범위는 "VM 준비 → kubeadm init/join → CNI(Cilium)/스토리지(Longhorn) 설치"까지다. Harbor·ArgoCD·Jenkins·CNPG·Argo Workflows/Events와 애플리케이션 자체는 범위 밖으로 남겨뒀다 - 전부 한 번에 코드화하려다 검증 자체가 흐려지는 것보다, 가장 자주 파기·재구축하게 되는 클러스터 하단부터 확실히 코드화하는 쪽을 택했다.
검증 방법
terraform plan # apply 없이 실행해도 diff가 없어야("No changes") IaC가 실제 상태를 정확히 반영하는 것
ansible-playbook -i inventory.ini playbook.yml --check # dry-run으로 멱등성 확인 - 두 번째 실행부터는 변경사항이 없어야 함
체크리스트
- Terraform으로 AWS 리소스 코드화 (계정 레벨 리소스는
data로만 참조) - Ansible로 노드 프로비저닝 자동화 (kubeadm + CNI/스토리지까지)
- Day-0 재구축 실험: 라이브 클러스터 전체 파괴 후 코드만으로 재현 (~24분)
- 재구축 중 사람이 개입해야 했던 지점 목록화 (7가지, 그중 2가지는 플레이북 수정으로 해소)
- Harbor/ArgoCD/Jenkins/CNPG/Argo Workflows·Events 및 앱 자체의 IaC화 (범위 밖, 후속 과제)