들어가며
EC2 부팅 후 실행되는 셸 스크립트 4개(init_v2.sh, master_v2.sh, worker_v2.sh, final_v2.sh)와 Flannel 매니페스트(kube-flannel.yml)의 동작을 설명합니다.
스크립트 실행 순서
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| EC2 부팅 (UserData)
│
├── [모든 노드] init_v2.sh OS 준비, containerd, K8s 패키지
│
├── [Master만] master_v2.sh
│ ├─ kubeadm init
│ ├─ 편의 도구 설치 (helm, kubectx, kube-ps1)
│ ├─ admin.conf → S3 업로드
│ └─ final_v2.sh 호출
│ ├─ Flannel CNI 적용 (kube-flannel.yml)
│ ├─ Metrics Server
│ ├─ NFS Provisioner (EFS 기반)
│ ├─ AWS EBS CSI Driver
│ └─ StorageClass: ebs-gp3 (기본), ebs-gp3-wal
│
└── [Worker만] worker_v2.sh
├─ API 서버 대기
├─ kubeadm join
└─ admin.conf ← S3 다운로드
|
init_v2.sh — 공통 노드 초기화
모든 노드(Master + Worker)에서 동일하게 실행됩니다.
커널 모듈 + sysctl
1
2
3
4
5
6
7
8
9
10
11
12
13
| cat <<EOF > /etc/modules-load.d/containerd.conf
overlay
br_netfilter
EOF
modprobe overlay
modprobe br_netfilter
cat <<EOF > /etc/sysctl.d/99-kubernetes-cri.conf
net.bridge.bridge-nf-call-iptables = 1
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sysctl -p /etc/sysctl.d/99-kubernetes-cri.conf
|
overlay: containerd가 레이어드 파일시스템(OverlayFS)을 사용하는 데 필요합니다.br_netfilter: 브리지를 통과하는 트래픽에 iptables/ip6tables 규칙이 적용되게 합니다. 이 모듈이 없으면 K8s 네트워크 정책과 서비스 라우팅이 작동하지 않습니다.net.ipv4.ip_forward: 노드가 파드 트래픽을 다른 노드로 포워딩하는 데 필요합니다.
containerd + SystemdCgroup
1
2
3
| containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
systemctl restart containerd
|
SystemdCgroup = true는 containerd가 systemd cgroup 드라이버를 사용하게 합니다. kubelet도 --cgroup-driver=systemd로 설정하므로, 둘이 일치해야 합니다. 불일치 시 kubelet이 시작되지 않거나 노드가 NotReady 상태로 남습니다.
K8s 패키지 버전 자동 해석
1
2
3
4
5
6
7
8
9
10
11
12
13
| KUBE_MINOR="${KUBERNETES_VERSION%.*}" # 1.28.0 → 1.28
# pkgs.k8s.io 레포 등록
curl -fsSL "https://pkgs.k8s.io/core:/stable:/v${KUBE_MINOR}/deb/Release.key" \
| gpg --dearmor -o /usr/share/keyrings/kubernetes-archive-keyring.gpg --yes
echo "deb [signed-by=...] https://pkgs.k8s.io/core:/stable:/v${KUBE_MINOR}/deb/ /" \
| tee /etc/apt/sources.list.d/kubernetes.list
# 정확한 패키지 버전 탐색 (예: 1.28.0 → 1.28.0-1.1)
KUBE_PKG_VER=$(apt-cache madison kubelet | awk '{print $3}' | grep "^${KUBERNETES_VERSION}-" | head -1)
apt-get install -y kubelet="$KUBE_PKG_VER" kubeadm="$KUBE_PKG_VER" kubectl="$KUBE_PKG_VER"
apt-mark hold kubelet kubeadm kubectl
|
AllowedValues에 1.28.0처럼 마이너 버전까지만 지정하고, 실제 apt 패키지 버전(1.28.0-1.1 등 suffix 포함)을 자동으로 해석합니다. apt-mark hold로 자동 업그레이드를 차단합니다. 버전 충돌로 인한 클러스터 불안정을 방지하기 위해서입니다.
master_v2.sh — 컨트롤플레인 구성
kubeadm init
1
2
3
4
5
6
| kubeadm init \
--token 123456.1234567890123456 \
--token-ttl 0 \
--pod-network-cidr=172.16.0.0/16 \
--apiserver-advertise-address=192.168.10.10 \
--service-cidr 10.200.1.0/24
|
| 파라미터 | 값 | 이유 |
|---|
--token | 123456.1234567890123456 | 워커가 미리 알고 join할 수 있도록 고정 |
--token-ttl 0 | 만료 없음 | 워커가 Master보다 느리게 뜨는 경우 토큰 만료 방지 |
--pod-network-cidr | 172.16.0.0/16 | Flannel 기본값과 일치시킴 |
--apiserver-advertise-address | 192.168.10.10 | Master ENI 고정 IP (워커가 이 주소로 join) |
--service-cidr | 10.200.1.0/24 | SecurityGroup에 열어둔 범위와 일치 |
토큰을 고정값으로 쓰는 것은 테스트 환경 전용입니다. 프로덕션에서는 kubeadm token create로 단기 토큰을 발급하거나 --discovery-token-ca-cert-hash를 사용하십시오.
admin.conf → S3 업로드
1
2
3
| aws s3 cp /etc/kubernetes/admin.conf \
"s3://my-k8s-scripts/runtime/${STACK_NAME}/admin.conf" \
--sse AES256
|
kubeadm init 완료 후 생성된 admin.conf를 S3에 업로드합니다. 워커가 이것을 받아 kubectl 명령을 실행할 수 있게 됩니다.
기존 방식인 sshpass를 이용한 scp는 root SSH 패스워드 설정이 필요하고 보안 이슈가 있습니다. S3 경유 방식은 IAM Role 권한만으로 안전하게 파일을 전달합니다. --sse AES256으로 S3 서버 사이드 암호화를 적용합니다.
편의 도구 설치
1
2
3
4
5
6
7
8
9
10
| # kubectx / kubens — 컨텍스트와 네임스페이스 전환 도구
git clone https://github.com/ahmetb/kubectx /opt/kubectx
ln -s /opt/kubectx/kubens /usr/local/bin/kubens
ln -s /opt/kubectx/kubectx /usr/local/bin/kubectx
# kube-ps1 — 프롬프트에 현재 컨텍스트/네임스페이스 표시
git clone https://github.com/jonmosco/kube-ps1.git /root/kube-ps1
# Helm 3
curl -s https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
|
SSH 접속 후 kubectx HomeLab으로 컨텍스트를 전환할 수 있습니다(final_v2.sh에서 rename 처리).
worker_v2.sh — 워커 노드 조인
API 서버 대기
1
2
3
4
5
6
7
8
| for i in $(seq 1 60); do
if nc -z -w 2 192.168.10.10 6443 2>/dev/null; then
echo "API server reachable"
break
fi
echo "Waiting for master API server... ($i/60)"
sleep 5
done
|
Master(W1/W2/W3)와 Worker가 CloudFormation에서 병렬로 기동됩니다. W1EC2에 DependsOn: MEC2가 걸려 있어 Master EC2 생성 후 Worker가 시작되지만, cfn-signal 완료까지 기다리지는 않습니다. 따라서 Worker가 뜰 때 kubeadm init이 아직 진행 중일 수 있어 최대 5분(60 × 5초) 대기합니다.
kubeadm join
1
2
3
4
| kubeadm join \
--token 123456.1234567890123456 \
--discovery-token-unsafe-skip-ca-verification \
192.168.10.10:6443
|
--discovery-token-unsafe-skip-ca-verification은 CA 인증서 해시 검증을 건너뜁니다. 이 옵션을 사용하려면 네트워크 레벨 신뢰(VPC 내부 통신)가 전제됩니다. 테스트 환경에서는 허용 가능한 트레이드오프입니다.
admin.conf ← S3 다운로드
1
2
3
4
5
6
7
8
9
10
11
12
| S3_ADMIN="s3://my-k8s-scripts/runtime/${STACK_NAME}/admin.conf"
for i in $(seq 1 60); do
if aws s3 ls "$S3_ADMIN" >/dev/null 2>&1; then
break
fi
echo "Waiting for $S3_ADMIN ... ($i/60)"
sleep 5
done
mkdir -p /root/.kube
aws s3 cp "$S3_ADMIN" /root/.kube/config
chmod 600 /root/.kube/config
|
Master의 master_v2.sh가 S3에 admin.conf를 올리는 시점이 워커 입장에서 불확실합니다. S3 오브젝트가 존재할 때까지 최대 5분을 폴링합니다.
final_v2.sh — 클러스터 완성
Master에서 master_v2.sh 내부에서 호출됩니다. K8s 기본 클러스터 위에 필요한 애드온을 설치합니다.
Flannel CNI
1
2
3
| aws s3 cp s3://my-k8s-scripts/kube-flannel.yml /root/kube-flannel.yml
sed -i 's/\r$//' /root/kube-flannel.yml
kubectl apply -f /root/kube-flannel.yml
|
S3에 미리 올려둔 매니페스트를 사용합니다. kubectl apply -f에 공식 URL을 직접 쓰지 않는 이유는 두 가지입니다.
- 버전 고정:
ghcr.io/flannel-io/flannel:v0.26.6 — 매번 배포할 때마다 다른 버전이 적용되는 것을 방지합니다. - 네트워크 안정성: EC2 부팅 직후
ghcr.io 접근이 일시적으로 실패하는 경우가 있습니다. S3는 IAM 기반이므로 훨씬 안정적입니다.
sed -i 's/\r$//'는 Windows에서 편집한 파일의 CRLF 줄바꿈을 제거합니다. 없으면 bash가 명령을 인식하지 못하는 경우가 있습니다.
kube-flannel.yml — 주요 설정
1
2
3
4
5
6
7
8
| net-conf.json: |
{
"Network": "172.16.0.0/16",
"EnableNFTables": false,
"Backend": {
"Type": "vxlan"
}
}
|
Network: 172.16.0.0/16: kubeadm init --pod-network-cidr과 일치해야 합니다.EnableNFTables: false: Ubuntu 22.04는 기본적으로 nftables를 사용하지만, K8s 1.28은 iptables 기반 규칙을 가정합니다. nftables를 사용하면 서비스 라우팅이 오작동할 수 있으므로 비활성화합니다.Type: vxlan: 노드 간 트래픽을 VXLAN 터널로 캡슐화합니다. AWS VPC 환경에서 안정적으로 동작합니다.
Metrics Server
1
| kubectl apply -f https://raw.githubusercontent.com/gasida/DOIK/main/vanilla/metrics-server.yaml
|
kubectl top nodes/pods와 HPA(Horizontal Pod Autoscaler)에 필요합니다.
Local-path StorageClass (비기본값으로 강등)
1
2
3
4
5
| kubectl apply -f https://raw.githubusercontent.com/gasida/DOIK/main/vanilla/local-path-storage.yaml
# 기본 StorageClass에서 제거
kubectl patch storageclass local-path \
-p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
|
Local-path는 노드 로컬 스토리지를 사용합니다. CloudNativePG는 데이터와 WAL이 노드 장애 후에도 살아있어야 하므로 EBS에 올려야 합니다. Local-path를 기본값으로 두면 실수로 CloudNativePG PVC가 로컬 스토리지에 생성될 수 있어 기본값을 해제합니다.
NFS Provisioner (EFS 기반)
1
2
3
4
5
6
| helm upgrade --install nfs-provisioner -n kube-system \
nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
--set nfs.server="$EFS_DNS" \
--set nfs.path=/ \
--set nodeSelector."kubernetes\.io/hostname"=k8s-m \
--values /dev/stdin # tolerations: master NoSchedule 허용
|
EFS를 NFS 백엔드로 사용하는 StorageClass를 생성합니다. CloudNativePG 외 부하 테스트 도구나 공유 설정 파일 보관용으로 활용할 수 있습니다. nodeSelector로 Master 노드에만 프로비저너 파드를 고정합니다. Master에는 기본적으로 control-plane taint가 있으므로 tolerations도 함께 설정합니다.
AWS EBS CSI Driver
1
2
3
4
5
| helm upgrade --install aws-ebs-csi-driver aws-ebs-csi-driver/aws-ebs-csi-driver \
-n kube-system \
--set controller.tolerations[0].key=node-role.kubernetes.io/master \
--set controller.tolerations[0].effect=NoSchedule \
--set controller.tolerations[0].operator=Exists
|
CloudNativePG의 PVC를 gp3 EBS 볼륨으로 프로비저닝하는 드라이버입니다. Controller 파드가 어느 노드에든 스케줄될 수 있도록 Master taint에 대한 toleration을 추가합니다. final_v2.sh 실행 시점에 Master 언테인트(TASK 6)가 아직 완료되지 않았을 수 있기 때문입니다.
StorageClass: ebs-gp3 (기본값)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ebs-gp3
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "6000"
throughput: "250"
fsType: ext4
encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
|
| 설정 | 이유 |
|---|
iops: 6000 | gp3 기본 3000 IOPS의 2배 — CloudNativePG의 WAL 쓰기 부하 대응 |
throughput: 250 | gp3 기본 125 MB/s의 2배 |
encrypted: true | 데이터 보안 |
WaitForFirstConsumer | PVC가 먼저 생성되면 볼륨 AZ가 파드보다 먼저 결정돼 스케줄링 실패가 발생할 수 있음. 파드 스케줄 후 같은 AZ에 볼륨을 생성 |
allowVolumeExpansion: true | 볼륨 사이즈를 나중에 늘릴 수 있음 |
WAL 전용 StorageClass(ebs-gp3-wal)도 같은 파라미터로 생성됩니다. CloudNativePG는 데이터와 WAL을 별도 볼륨에 분리해 IOPS 경합을 줄일 수 있습니다.
Master 언테인트 + 레이블
1
2
| kubectl taint node k8s-m node-role.kubernetes.io/control-plane- 2>/dev/null || true
kubectl label nodes k8s-m node-role.kubernetes.io/master= --overwrite 2>/dev/null || true
|
기본적으로 Master 노드에는 control-plane: NoSchedule taint가 있어 일반 파드가 스케줄되지 않습니다. 테스트 환경에서는 노드 수가 적으므로 언테인트해 Master에도 파드가 배치될 수 있게 합니다.
트러블슈팅
SSH 접속
1
2
3
| MASTER=$(aws cloudformation describe-stacks --stack-name bjh-test-k8s \
--query "Stacks[0].Outputs[?OutputKey=='MasterNodeIP'].OutputValue" --output text)
ssh -i <YOUR-KEY-PAIR>.pem ubuntu@$MASTER # 작성자 예시: bjh-test-bastion.pem
|
접속 후 sudo su -로 root 전환합니다(.bashrc에 자동 적용됨).
로그 파일 위치
| 파일 | 내용 |
|---|
/var/log/cloud-init-output.log | UserData 전체 stdout |
/var/log/userdata.log | set -x 출력 포함 상세 실행 트레이스 |
/root/init.log | init_v2.sh 실행 결과 |
/root/master.log | master_v2.sh 실행 결과 |
/root/final.log | final_v2.sh 실행 결과 |
/root/worker.log | worker_v2.sh 실행 결과 |
1
2
3
4
5
6
7
8
9
10
11
12
| # 가장 최근 100줄 확인
tail -n 100 /root/init.log
tail -n 100 /root/master.log
tail -n 100 /root/final.log
# kubelet 상태
journalctl -u kubelet --no-pager | tail -100
# 파드 상태
kubectl get nodes -o wide
kubectl get pod -A
kubectl get pod -A -o wide | grep -v Running # 비정상 파드만
|
S3 실패 로그 조회 (EC2 접속 전)
on_err 트랩이 실패 원인 파일을 S3에 업로드합니다.
1
2
3
| aws s3 ls s3://my-k8s-scripts/runtime/bjh-test-k8s/
aws s3 cp s3://my-k8s-scripts/runtime/bjh-test-k8s/MEC2-fail-reason.txt -
aws s3 cp s3://my-k8s-scripts/runtime/bjh-test-k8s/MEC2-userdata.log -
|
cfn-signal 타임아웃 발생 시
CreationPolicy Timeout PT25M 안에 cfn-signal이 전송되지 않으면 CFN이 ROLLBACK합니다. 타임아웃 원인은 주로 다음과 같습니다.
1
2
3
4
5
6
7
| # EC2 콘솔 시리얼 출력 — SSH 접속 전에도 확인 가능
aws ec2 describe-instances \
--filters "Name=tag:aws:cloudformation:stack-name,Values=bjh-test-k8s" \
"Name=tag:Name,Values=bjh-test-k8s-Master" \
--query 'Reservations[0].Instances[0].InstanceId' --output text \
| xargs -I {} aws ec2 get-console-output --instance-id {} --query Output --output text \
| tail -100
|
자주 발생하는 원인:
| 원인 | 확인 방법 | 조치 |
|---|
| S3 스크립트 미업로드 | aws s3 ls s3://my-k8s-scripts/ | 5개 파일 업로드 후 재배포 |
| IAM 권한 부족 | UserData 로그의 AccessDenied 메시지 | IAM 정책 확인 |
init_v2.sh 실패 | /root/init.log | 패키지 다운로드 실패 → 재배포 |
| EFS 마운트 실패 | UserData 로그의 mount 라인 | EFS MountTarget 상태 확인 |
| kubeadm 실패 | /root/master.log | K8s 버전 호환성 확인 |
Worker 로그 확인
1
2
3
4
5
6
| W1=$(aws cloudformation describe-stacks --stack-name bjh-test-k8s \
--query "Stacks[0].Outputs[?OutputKey=='WorkerNode1IP'].OutputValue" --output text)
ssh -i <YOUR-KEY-PAIR>.pem ubuntu@$W1 # 작성자 예시: bjh-test-bastion.pem
sudo su -
tail -n 100 /root/init.log /root/worker.log
journalctl -u kubelet --no-pager | tail -100
|
스택 업데이트 / 삭제
EBS Throughput 수동 변경
CFN EC2::Instance 블록 디바이스는 Throughput 속성을 지원하지 않습니다. 기본값(125 MB/s)에서 올리려면 인스턴스 기동 후 별도로 적용합니다.
1
2
3
4
5
6
7
8
9
| for ID in $(aws ec2 describe-instances \
--filters "Name=tag:aws:cloudformation:stack-name,Values=bjh-test-k8s" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId' --output text); do
VOL=$(aws ec2 describe-instances --instance-ids $ID \
--query 'Reservations[0].Instances[0].BlockDeviceMappings[?DeviceName==`/dev/sda1`].Ebs.VolumeId' \
--output text)
aws ec2 modify-volume --volume-id $VOL --throughput 500
done
|
EBS CSI Driver 설치 확인
1
2
3
4
| kubectl get csidrivers # ebs.csi.aws.com 존재 확인
kubectl -n kube-system get pod \
-l app.kubernetes.io/name=aws-ebs-csi-driver
kubectl get sc # ebs-gp3가 기본값(*)이어야 함
|
스토리지 클래스 확인
1
2
3
4
5
6
| kubectl get sc
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION
# ebs-gp3 (default) ebs.csi.aws.com Delete WaitForFirstConsumer true
# ebs-gp3-wal ebs.csi.aws.com Delete WaitForFirstConsumer true
# local-path rancher.io/local-path Delete WaitForFirstConsumer false
# nfs-client cluster.local/nfs-... Delete Immediate true
|
마치며
클러스터가 정상 동작하여 저는 해당 환경에서 CloudNativePG Operator 등의 테스트에 활용하고 있습니다.
1
2
3
4
5
6
7
8
| # CNPG Operator 설치 (예시)
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm upgrade --install cnpg -n cnpg-system --create-namespace \
cnpg/cloudnative-pg
# 설치 확인
kubectl -n cnpg-system get pod
kubectl get crd | grep postgresql
|
테스트가 끝나면 스택을 삭제합니다.
1
2
3
4
| 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"
|