포스트

EC2 위의 K8s 배포 자동화 — Part 2: CloudFormation 템플릿

EC2 위의 K8s 배포 자동화 — Part 2: CloudFormation 템플릿

들어가며

bjh-k8s_modified_v2.yaml 한 파일로 어떻게 VPC부터 K8s Ready 상태까지 자동으로 올라오는지를 분석합니다. 각 설계 결정의 이유와 함께 전체 CloudFormation 명령어 레퍼런스를 정리합니다.


템플릿 전체 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
bjh-k8s_modified_v2.yaml
  ├── Parameters    사용자 입력 (KeyName, WorkerCount, InstanceType, EBS 설정 등)
  ├── Conditions    CreateWorker2 / CreateWorker3  (WorkerCount 기반 조건 분기)
  ├── Resources
  │    ├── EC2Role + EC2InstanceProfile   IAM
  │    ├── MyVPC + Subnet × 4 + IGW      네트워크
  │    ├── EC2SG + EFSSG                 Security Group
  │    ├── ElasticFileSystem + MountTarget × 2   EFS
  │    ├── MEC2  (Master)  + Instance1ENI1
  │    ├── W1EC2 (Worker1) + Instance2ENI1   [항상]
  │    ├── W2EC2 (Worker2) + Instance3ENI1   [Condition: CreateWorker2]
  │    └── W3EC2 (Worker3) + Instance4ENI1   [Condition: CreateWorker3]
  └── Outputs       MasterNodeIP, WorkerNodeIP × N, EfsDnsName, S3RuntimePrefix

파라미터 치트시트

파라미터기본값비고
KeyName(필수)등록된 EC2 KeyPair 이름
SgIngressCidr0.0.0.0/0본인 공인 IP/32 권장
WorkerCount21 / 2 / 3
MasterNodeInstanceTypet3.larget2/t3/m5/m6i 시리즈
WorkerNodeInstanceTypem5.xlarget2/t3/m5/m6i 시리즈
Ec2EbsVolumeSize200GiB (gp3)
Ec2EbsVolumeIops6000gp3: 3000 ~ 16000
KubernetesVersion1.28.01.23.x ~ 1.30.0
OwnerTagbjhEC2 태그 owner
ExpiryDate2026-12-31태그 expiry-date (YYYY-MM-DD)
TargetRegionus-east-1다른 Region 사용 시 AZ도 변경
AvailabilityZone1/2us-east-1a/bRegion 변경 시 동기화 필수

아래 파라미터를 변경하면 인스턴스가 재생성됩니다 (서비스 중단): MasterNodeInstanceType · WorkerNodeInstanceType · KubernetesVersion · LatestAmiId (Ubuntu AMI 변경)
EBS 사이즈/IOPS 변경은 보통 무중단이지만 인스턴스 재시작이 필요할 수 있습니다.


IAM Role 설계

EC2 인스턴스는 하나의 IAM Role(EC2Role)을 공유합니다. 이 Role에는 세 가지 권한이 담겨 있습니다.

1. AmazonEBSCSIDriverPolicy (관리형 정책)

1
2
ManagedPolicyArns:
  - arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy

final_v2.sh에서 설치하는 AWS EBS CSI Driver가 볼륨 생성(ec2:CreateVolume), 연결(ec2:AttachVolume), 스냅샷 등의 API를 호출하는 데 필요합니다.

2. S3ScriptsAccess (인라인 정책)

1
2
3
4
5
6
7
8
- Effect: Allow
  Action: s3:GetObject
  Resource: "arn:aws:s3:::my-k8s-scripts/*"
- Effect: Allow
  Action: [s3:PutObject, s3:GetObject, s3:ListBucket]
  Resource:
    - "arn:aws:s3:::my-k8s-scripts"
    - "arn:aws:s3:::my-k8s-scripts/runtime/*"
  • GetObject (/*): 모든 노드가 init_v2.sh, master_v2.sh 등 스크립트를 다운로드할 때 사용합니다.
  • PutObject/GetObject/ListBucket (runtime/*): Master가 kubeadm init 후 생성된 admin.conf를 S3에 업로드하고, 워커가 그것을 받아가는 데 사용합니다. sshpass나 root SSH 패스워드 없이 IAM 기반으로 파일을 전달하는 구조입니다.

3. EFSDescribe (인라인 정책)

1
2
3
- Effect: Allow
  Action: [elasticfilesystem:DescribeMountTargets, elasticfilesystem:DescribeFileSystems]
  Resource: "*"

Master의 UserData에서 EFS 마운트 타깃 상태(LifeCycleState: available)를 폴링할 때 사용합니다.


네트워크 설계

Public Subnet Only — NAT Gateway 없음

1
2
PublicSubnet1:
  MapPublicIpOnLaunch: true  # 각 EC2에 공인 IP 자동 부여

모든 노드가 PublicSubnet에 위치하고 각자 공인 IP를 가집니다. 인터넷 아웃바운드 트래픽이 Internet Gateway를 직접 통과하므로 NAT Gateway 비용($0.045/h)이 발생하지 않습니다. 대신 EC2가 인터넷에 직접 노출되므로, SgIngressCidr를 본인 IP/32로 제한하는 것이 중요합니다.

고정 Private IP

1
2
3
4
5
6
7
8
Instance1ENI1:
  PrivateIpAddress: 192.168.10.10   # Master
Instance2ENI1:
  PrivateIpAddress: 192.168.10.101  # Worker1
Instance3ENI1:
  PrivateIpAddress: 192.168.10.102  # Worker2
Instance4ENI1:
  PrivateIpAddress: 192.168.20.103  # Worker3 (AZ2)

master_v2.shkubeadm init --apiserver-advertise-address=192.168.10.10worker_v2.shkubeadm join 192.168.10.10:6443이 이 값에 의존합니다.

IP를 동적으로 받으면 스크립트를 수정해야 하므로 ENI에 고정합니다.

SourceDestCheck: false

1
SourceDestCheck: false

K8s는 파드 트래픽을 노드 IP가 아닌 파드 IP로 라우팅합니다. AWS 기본값인 SourceDestCheck: true가 켜져 있으면 출발지/목적지 IP 검증에 걸려 파드 간 통신이 차단됩니다.

Security Group

1
2
3
4
5
6
7
8
9
SecurityGroupIngress:
  - IpProtocol: '-1'
    CidrIp: !Ref SgIngressCidr    # 본인 PC → SSH/kubectl
  - IpProtocol: '-1'
    CidrIp: !Ref VpcBlock         # 노드 간 (192.168.0.0/16)
  - IpProtocol: '-1'
    CidrIp: 172.16.0.0/16         # Flannel Pod CIDR
  - IpProtocol: '-1'
    CidrIp: 10.200.10.0/24        # K8s Service CIDR

노드 간 통신, Pod 네트워크(Flannel VXLAN), Service 네트워크를 모두 허용합니다. -1 프로토콜은 all traffic을 의미합니다.


EFS 구성

1
2
3
4
5
6
7
8
ElasticFileSystem:
  Type: AWS::EFS::FileSystem

ElasticFileSystemMountTarget0:
  SubnetId: !Ref PublicSubnet1   # AZ1

ElasticFileSystemMountTarget1:
  SubnetId: !Ref PublicSubnet2   # AZ2

NFS 공유 스토리지로 사용합니다. final_v2.sh에서 설치하는 nfs-subdir-external-provisioner가 이 EFS를 백엔드로 사용해 PVC를 프로비저닝합니다.

1
2
3
4
MEC2:
  DependsOn:
    - ElasticFileSystemMountTarget0
    - ElasticFileSystemMountTarget1

DependsOn으로 마운트 타깃 2개가 모두 생성된 후 Master EC2가 기동되도록 순서를 강제합니다. 이것만으로는 available 상태까지 보장되지 않으므로, UserData에서 추가로 폴링합니다.

1
2
3
4
5
6
# UserData 내부 — EFS MountTarget이 available이 될 때까지 최대 5분 대기
for i in $(seq 1 30); do
  STATE=$(aws efs describe-mount-targets ... --query 'MountTargets[0].LifeCycleState' --output text)
  if [ "$STATE" = "available" ]; then break; fi
  sleep 10
done

EC2 UserData 흐름 (Master 기준 상세)

UserData는 EC2가 처음 부팅될 때 root 권한으로 실행됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 1. cfn-bootstrap 설치 — cfn-signal을 사용하기 위한 전제
apt-get install -y awscli python3-pip
pip3 install ... aws-cfn-bootstrap-py3-latest.tar.gz

# 2. 실패 트랩 설치
on_err() {
  # S3에 실패 원인 파일 + 로그 업로드
  aws s3 cp /var/log/userdata.log s3://my-k8s-scripts/runtime/<stack>/MEC2-userdata.log
  # cfn-signal로 즉시 실패 신호 — 25분 타임아웃 기다리지 않고 바로 ROLLBACK
  cfn-signal --success false --reason "line=$1 rc=$rc" --stack ... --resource MEC2 ...
}
trap 'on_err $LINENO "$BASH_COMMAND"' ERR
set -eE   # 에러 즉시 종료 + trap 전파

# 3. EFS DNS를 파일로 기록 (master.sh보다 먼저)
echo "$EFS_DNS" > /root/efs.txt

# 4. EFS 마운트
mount -t nfs4 $EFS_DNS:/ /nfs4-share

# 5. S3에서 스크립트 다운로드 + 실행
aws s3 cp s3://my-k8s-scripts/init_v2.sh    /root/init.sh
aws s3 cp s3://my-k8s-scripts/master_v2.sh  /root/master.sh
/root/init.sh   > /root/init.log   2>&1
/root/master.sh > /root/master.log 2>&1

# 6. 성공 신호
cfn-signal --success true --stack ... --resource MEC2 ...

cfn-signal과 CreationPolicy

1
2
3
4
MEC2:
  CreationPolicy:
    ResourceSignal:
      Timeout: PT25M

CloudFormation은 MEC2 리소스가 생성된 후 최대 25분 동안 cfn-signal을 기다립니다. 이 신호가 없으면 타임아웃 후 ROLLBACK합니다. 신호가 --success false이면 즉시 ROLLBACK합니다.

CREATE_COMPLETE 상태는 cfn-signal을 받은 시점이므로, K8s가 실제로 Ready인 상태를 의미합니다. EC2만 올라온 상태가 아닙니다.

WorkerCount 조건 분기

1
2
3
4
5
Conditions:
  CreateWorker2: !Or
    - !Equals [ !Ref WorkerCount, "2" ]
    - !Equals [ !Ref WorkerCount, "3" ]
  CreateWorker3: !Equals [ !Ref WorkerCount, "3" ]

WorkerCount=1이면 W1만, =2이면 W1+W2, =3이면 W1+W2+W3이 생성됩니다. Worker3는 PublicSubnet2(AZ2)에 배치되어 AZ 분산 테스트가 가능합니다.


CFN 명령어 전체 레퍼런스

템플릿 검증 (deploy 전 필수)

1
2
3
4
5
6
7
# CFN 서버 사이드 문법 검증
aws cloudformation validate-template \
  --template-body file://bjh-k8s_modified_v2.yaml \
  --query '{Params:Parameters[].ParameterKey,Caps:Capabilities}' --output table

# (선택) 로컬 lint — pip install cfn-lint
cfn-lint bjh-k8s_modified_v2.yaml

배포

기본값 (master 1 + worker 2):

1
2
3
4
5
6
7
8
aws cloudformation deploy \
  --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
      # 작성자 예시: bjh-test-bastion
      KeyName=<YOUR-KEY-PAIR> \
      SgIngressCidr=$(curl -s https://checkip.amazonaws.com)/32

HA 테스트 (worker 3대):

1
2
3
4
5
6
7
aws cloudformation deploy --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s --capabilities CAPABILITY_IAM \
  --parameter-overrides \
      # 작성자 예시: bjh-test-bastion
      KeyName=<YOUR-KEY-PAIR> \
      SgIngressCidr=$(curl -s https://checkip.amazonaws.com)/32 \
      WorkerCount=3

성능 본격 (옵션 C):

1
2
3
4
5
6
7
8
9
10
aws cloudformation deploy --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s --capabilities CAPABILITY_IAM \
  --parameter-overrides \
      # 작성자 예시: bjh-test-bastion
      KeyName=<YOUR-KEY-PAIR> \
      SgIngressCidr=$(curl -s https://checkip.amazonaws.com)/32 \
      MasterNodeInstanceType=m5.large \
      WorkerNodeInstanceType=m5.2xlarge \
      Ec2EbsVolumeIops=12000 \
      WorkerCount=3

배포 전 Change-Set 미리보기 (비용 없음)

1
2
3
4
5
6
7
8
9
10
aws cloudformation deploy \
  --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s \
  --capabilities CAPABILITY_IAM \
  # 작성자 예시: KeyName=bjh-test-bastion
  --parameter-overrides KeyName=<YOUR-KEY-PAIR> SgIngressCidr=$(curl -s https://checkip.amazonaws.com)/32 \
  --no-execute-changeset

# 출력된 change-set 이름으로 상세 조회
aws cloudformation describe-change-set --stack-name bjh-test-k8s --change-set-name <이름>

배포 상태 모니터링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# (a) 한 줄 상태
aws cloudformation describe-stacks --stack-name bjh-test-k8s \
  --query "Stacks[0].StackStatus" --output text

# (b) 이벤트 타임라인 (최근 50개)
aws cloudformation describe-stack-events --stack-name bjh-test-k8s \
  --query 'StackEvents[*].[Timestamp,LogicalResourceId,ResourceStatus,ResourceStatusReason]' \
  --output table | head -60

# (c) 10초 간격 실시간 watch (Ctrl+C 종료)
while :; do
  clear
  aws cloudformation describe-stacks --stack-name bjh-test-k8s \
    --query "Stacks[0].StackStatus" --output text
  aws cloudformation describe-stack-events --stack-name bjh-test-k8s \
    --query 'StackEvents[0:10].[Timestamp,LogicalResourceId,ResourceStatus]' --output table
  sleep 10
done

# (d) 리소스별 진행 상태
aws cloudformation describe-stack-resources --stack-name bjh-test-k8s \
  --query 'StackResources[*].[LogicalResourceId,ResourceType,ResourceStatus]' \
  --output table

# (e) 완료 대기 (블로킹, 최대 ~25분)
aws cloudformation wait stack-create-complete --stack-name bjh-test-k8s && echo "DONE"

Output 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 모든 Output 한눈에
aws cloudformation describe-stacks --stack-name bjh-test-k8s \
  --query 'Stacks[0].Outputs' --output table

# Master 공인 IP
aws cloudformation describe-stacks --stack-name bjh-test-k8s \
  --query "Stacks[0].Outputs[?OutputKey=='MasterNodeIP'].OutputValue" --output text

# Master Private IP (kubeadm advertise — 보통 192.168.10.10)
aws cloudformation describe-stacks --stack-name bjh-test-k8s \
  --query "Stacks[0].Outputs[?OutputKey=='MasterNodePrivateIP'].OutputValue" --output text

# Worker 공인 IP (있는 만큼만 출력)
for k in WorkerNode1IP WorkerNode2IP WorkerNode3IP; do
  ip=$(aws cloudformation describe-stacks --stack-name bjh-test-k8s \
    --query "Stacks[0].Outputs[?OutputKey=='$k'].OutputValue" --output text)
  [ -n "$ip" ] && echo "$k = $ip"
done

# EFS DNS
aws cloudformation describe-stacks --stack-name bjh-test-k8s \
  --query "Stacks[0].Outputs[?OutputKey=='EfsDnsName'].OutputValue" --output text

# admin.conf S3 위치
aws cloudformation describe-stacks --stack-name bjh-test-k8s \
  --query "Stacks[0].Outputs[?OutputKey=='S3RuntimePrefix'].OutputValue" --output text

스택 업데이트

같은 --stack-name으로 deploy를 재실행하면 차이만큼만 업데이트됩니다. InstanceType 변경 등 인스턴스 재생성이 필요한 경우 admin.conf 재업로드가 필요합니다.

1
2
3
4
5
6
7
8
# 워커만 인스턴스 타입 업그레이드
aws cloudformation deploy --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s --capabilities CAPABILITY_IAM \
  --parameter-overrides \
      # 작성자 예시: bjh-test-bastion
      KeyName=<YOUR-KEY-PAIR> \
      SgIngressCidr=$(curl -s https://checkip.amazonaws.com)/32 \
      WorkerNodeInstanceType=m5.2xlarge

스택 삭제

1
2
3
4
5
6
7
8
9
10
11
12
# 삭제 시작 (비동기)
aws cloudformation delete-stack --stack-name bjh-test-k8s

# 삭제 완료 대기 (최대 ~10분)
aws cloudformation wait stack-delete-complete --stack-name bjh-test-k8s && echo "DELETED"

# admin.conf S3 잔여물 수동 정리 (stack 삭제는 S3 객체를 삭제하지 않음)
aws s3 rm s3://my-k8s-scripts/runtime/bjh-test-k8s/ --recursive

# 확인
aws cloudformation describe-stacks --stack-name bjh-test-k8s 2>&1 \
  | grep -q "does not exist" && echo "Stack fully deleted"

ROLLBACK / 실패 처리

상태의미조치
ROLLBACK_IN_PROGRESS자동 롤백 중기다림 (wait stack-rollback-complete)
ROLLBACK_COMPLETE첫 생성 실패 후 롤백 완료update 불가delete-stack 후 재생성
UPDATE_ROLLBACK_FAILEDupdate 중 롤백도 실패continue-update-rollback 으로 강제 진행
CREATE_FAILED특정 자원 생성 실패이벤트 로그에서 원인 자원 확인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 실패 자원과 원인 확인
aws cloudformation describe-stack-events --stack-name bjh-test-k8s \
  --query "StackEvents[?ResourceStatus=='CREATE_FAILED'].[Timestamp,LogicalResourceId,ResourceStatusReason]" \
  --output table

# ROLLBACK_COMPLETE 후 재시도
aws cloudformation delete-stack --stack-name bjh-test-k8s
aws cloudformation wait stack-delete-complete --stack-name bjh-test-k8s
aws cloudformation deploy ...  # 수정 후 재배포

# 실패 자원을 디버깅용으로 남기고 싶을 때 (--on-failure DO_NOTHING)
aws cloudformation create-stack \
  --template-body file://bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s --capabilities CAPABILITY_IAM \
  --on-failure DO_NOTHING \
  # 작성자 예시: ParameterValue=bjh-test-bastion
  --parameters ParameterKey=KeyName,ParameterValue=<YOUR-KEY-PAIR> \
               ParameterKey=SgIngressCidr,ParameterValue=$(curl -s https://checkip.amazonaws.com)/32
# 디버깅 후 수동 delete-stack

PowerShell 등가 명령어 (Windows 사용자)

BashPowerShell
MASTER=$(aws ...)$MASTER = aws ...
\ 줄바꿈` (백틱) 또는 한 줄로
&&; if ($?) { ... } (PS 5.1) 또는 && (PS 7+)
head -50\| Select-Object -First 50
grep -q ...\| Select-String ...

PowerShell 5.1 watch 루프 예시:

1
2
3
4
5
6
7
8
while ($true) {
  Clear-Host
  aws cloudformation describe-stacks --stack-name bjh-test-k8s `
    --query "Stacks[0].StackStatus" --output text
  aws cloudformation describe-stack-events --stack-name bjh-test-k8s `
    --query "StackEvents[0:10].[Timestamp,LogicalResourceId,ResourceStatus]" --output table
  Start-Sleep -Seconds 10
}

자주 쓰는 명령어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 배포 + 완료 대기 + SSH 명령 출력 (한 번에)
aws cloudformation deploy --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s --capabilities CAPABILITY_IAM \
  --parameter-overrides \
      # 작성자 예시: bjh-test-bastion
      KeyName=<YOUR-KEY-PAIR> \
      SgIngressCidr=$(curl -s https://checkip.amazonaws.com)/32 \
  && aws cloudformation describe-stacks --stack-name bjh-test-k8s \
       --query "Stacks[0].Outputs[?OutputKey=='MasterNodeIP'].OutputValue" --output text \
  | xargs -I {} echo "ssh -i <YOUR-KEY-PAIR>.pem ubuntu@{}"  # 작성자 예시: bjh-test-bastion.pem

# 삭제 + 대기 + S3 정리 (한 번에)
aws cloudformation delete-stack --stack-name bjh-test-k8s \
  && aws cloudformation wait stack-delete-complete --stack-name bjh-test-k8s \
  && aws s3 rm s3://my-k8s-scripts/runtime/bjh-test-k8s/ --recursive \
  && echo "All cleaned up"

(선택) Region 변경

기본값은 us-east-1입니다. 다른 Region으로 배포하려면 TargetRegionAvailabilityZone1/2를 함께 변경합니다.

1
2
3
4
5
6
7
8
9
10
aws cloudformation deploy --template-file bjh-k8s_modified_v2.yaml \
  --stack-name bjh-test-k8s --capabilities CAPABILITY_IAM \
  --region ap-northeast-2 \
  --parameter-overrides \
      # 작성자 예시: bjh-test-bastion
      KeyName=<YOUR-KEY-PAIR> \
      SgIngressCidr=165.225.228.251/32 \
      TargetRegion=ap-northeast-2 \
      AvailabilityZone1=ap-northeast-2a \
      AvailabilityZone2=ap-northeast-2c

S3 버킷도 해당 Region에 있어야 IAM Role의 s3:GetObject가 정상 동작합니다. Cross-Region도 가능하지만 레이턴시가 증가합니다.


다음 편

Part 3에서는 init_v2.sh, master_v2.sh, worker_v2.sh, final_v2.sh 네 스크립트의 각 단계를 해설합니다. kubeadm init 파라미터 의미, admin.conf를 S3로 전달하는 이유, EBS CSI Driver와 StorageClass 구성, Flannel CNI 매니페스트를 오프라인으로 관리하는 이유까지 다룹니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.