포스트

EC2 위의 K8s 배포 자동화 — Part 3: 부트스트랩 스크립트 부연설명

EC2 위의 K8s 배포 자동화 — Part 3: 부트스트랩 스크립트 부연설명

들어가며

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

AllowedValues1.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
파라미터이유
--token123456.1234567890123456워커가 미리 알고 join할 수 있도록 고정
--token-ttl 0만료 없음워커가 Master보다 느리게 뜨는 경우 토큰 만료 방지
--pod-network-cidr172.16.0.0/16Flannel 기본값과 일치시킴
--apiserver-advertise-address192.168.10.10Master ENI 고정 IP (워커가 이 주소로 join)
--service-cidr10.200.1.0/24SecurityGroup에 열어둔 범위와 일치

토큰을 고정값으로 쓰는 것은 테스트 환경 전용입니다. 프로덕션에서는 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에서 병렬로 기동됩니다. W1EC2DependsOn: 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을 직접 쓰지 않는 이유는 두 가지입니다.

  1. 버전 고정: ghcr.io/flannel-io/flannel:v0.26.6 — 매번 배포할 때마다 다른 버전이 적용되는 것을 방지합니다.
  2. 네트워크 안정성: 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: 6000gp3 기본 3000 IOPS의 2배 — CloudNativePG의 WAL 쓰기 부하 대응
throughput: 250gp3 기본 125 MB/s의 2배
encrypted: true데이터 보안
WaitForFirstConsumerPVC가 먼저 생성되면 볼륨 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.logUserData 전체 stdout
/var/log/userdata.logset -x 출력 포함 상세 실행 트레이스
/root/init.loginit_v2.sh 실행 결과
/root/master.logmaster_v2.sh 실행 결과
/root/final.logfinal_v2.sh 실행 결과
/root/worker.logworker_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.logK8s 버전 호환성 확인

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"
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.