AWS Cloud School 13기 82~86일차

Forever 김·2026년 4월 30일

AWS Cloud School

목록 보기
77/97

토이 프로젝트(k8s)— EC2 위에 Kubernetes로 3-tier 아키텍처 직접 구축하기

AWS Cloud School 13기 토이프로젝트 (82~86일차, 2026.04.27~04.30)


프로젝트 개요

브라우저 기반 갤러그 아케이드 게임을 AWS EC2 위에 직접 Kubernetes 클러스터를 구성해서 3-tier 아키텍처로 배포하는 프로젝트다.

EKS 같은 관리형 서비스를 쓰는 게 아니라, EC2에 kubeadm으로 K8s를 직접 설치하고 운영하는 경험을 목표로 했다.

기술 스택

분류기술
인프라AWS VPC, EC2, RDS (MySQL), ALB, ECR
컨테이너Docker, containerd
오케스트레이션Kubernetes v1.30 (kubeadm), Flannel CNI
React (Frontend), Node.js/Express + WebSocket (Backend)

최종 아키텍처

toy-vpc (10.10.0.0/16) | ap-northeast-2 | Self-managed Kubernetes on EC2 t3.medium

  • Public Subnets: toy-alb (ALB), NAT Gateway, Internet Gateway
  • Private Subnet: K8s Master + Worker1 (10.10.2.224) + Worker2 (10.10.2.195)
    • 각 Worker에 Nginx Ingress Controller, galaga-frontend, galaga-backend Pod 배포
  • DB Subnets: RDS MySQL 8.4
  • ECR: Frontend/Backend 이미지 저장소

트래픽 흐름

사용자
  → ALB:80  → Nginx Ingress Controller (NodePort 32390)
              ├── /api  → Backend Pod (Express REST, port 4000)
              └── /     → Frontend Pod (Nginx + React, port 80)
  → ALB:4000 → Backend Pod (NodePort 30400) ← WebSocket 전용
                └── RDS MySQL (galaga_db)

VPC 구성

toy-vpc 10.10.0.0/16 (ap-northeast-2)

서브넷CIDR용도
toy-vpc-pub-sub1~310.10.1/11/21.0/24Bastion, ALB
toy-vpc-pri-sub1~310.10.2/12/22.0/24K8s Master/Worker
toy-vpc-db-sub1~310.10.3/13/23.0/24RDS

EC2 인스턴스

이름역할타입서브넷
toy-bastionBastion Hostt3.micropub-sub1
toy-vpc-master1K8s Mastert3.mediumpri-sub1
toy-vpc-worker1K8s Workert3.mediumpri-sub1
toy-vpc-worker2K8s Workert3.mediumpri-sub2

Day 1 — 네트워크 설계 & 보안 그룹 구성

보안 그룹 설계

K8s 클러스터 운영에 필요한 포트를 정확히 열어주는 게 핵심이다.

sg-k8s-master

  • TCP 22 ← sg-bastion
  • TCP 6443 ← 0.0.0.0/0 (kubectl API)
  • TCP 2379-2380 ← sg-k8s-master (etcd)
  • TCP 10250-10252 ← master/worker SG

sg-k8s-worker

  • TCP 22 ← sg-bastion
  • TCP 10250 ← sg-k8s-master
  • TCP 30000-32767 ← 0.0.0.0/0 (NodePort)
  • 전체 ← sg-k8s-worker, sg-k8s-master (Pod 간 통신)

sg-lb

  • TCP 80, 443, 4000 ← 0.0.0.0/0

sg-db (toy-rdssg)

  • TCP 3306 ← sg-k8s-worker

Day 2 — K8s 클러스터 구성

모든 노드 공통 설치 스크립트

Bastion을 통해 SSH 접속 후 Master + Worker 전체에 실행.

# 1. swap 비활성화
swapoff -a
sed -i '/ swap / s/^/#/' /etc/fstab

# 2. 커널 모듈 설정
tee /etc/modules-load.d/containerd.conf <<EOF
overlay
br_netfilter
EOF

tee /etc/sysctl.d/kubernetes.conf <<EOF
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
net.ipv4.ip_forward = 1
EOF

modprobe overlay
modprobe br_netfilter
sysctl --system

# 3. containerd 설치
apt install -y curl gnupg2 software-properties-common apt-transport-https ca-certificates
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmour -o /etc/apt/trusted.gpg.d/docker.gpg
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
apt update -y && apt install -y containerd.io
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null 2>&1
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
systemctl restart containerd && systemctl enable containerd

# 4. kubeadm, kubelet, kubectl 설치 (v1.30)
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /" | sudo tee /etc/apt/sources.list.d/kubernetes.list
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
apt update -y && apt install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl

Master 노드 초기화

kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --upload-certs \
  --kubernetes-version=v1.30.3 \
  --ignore-preflight-errors=all

mkdir -p $HOME/.kube
sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

CNI 설치 (Flannel)

wget https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
kubectl apply -f kube-flannel.yml

Worker 노드 조인

kubeadm join 10.10.2.248:6443 --token <token> \
  --discovery-token-ca-cert-hash sha256:<hash>

💡 토큰 만료 시 재발급: kubeadm token create --print-join-command


Day 3 — 앱 배포

Docker 이미지 빌드 & ECR 푸시

# ECR 로그인
aws ecr get-login-password --region ap-northeast-2 | \
  docker login --username AWS --password-stdin \
  851957594139.dkr.ecr.ap-northeast-2.amazonaws.com

# Backend 빌드 & 푸시
docker build -t galaga-backend ./backend
docker tag galaga-backend:latest \
  851957594139.dkr.ecr.ap-northeast-2.amazonaws.com/toy/k8s/back:latest
docker push 851957594139.dkr.ecr.ap-northeast-2.amazonaws.com/toy/k8s/back:latest

# Frontend 빌드 & 푸시 (환경변수 주입 필수!)
docker build --no-cache \
  --build-arg "REACT_APP_WS_URL=ws://<ALB_DNS>:4000" \
  --build-arg "REACT_APP_API_URL=http://<ALB_DNS>" \
  -t 851957594139.dkr.ecr.ap-northeast-2.amazonaws.com/toy/k8s/front:latest \
  ./frontend
docker push 851957594139.dkr.ecr.ap-northeast-2.amazonaws.com/toy/k8s/front:latest

ECR Secret 생성

ECR은 프라이빗 레지스트리라 K8s에서 이미지를 pull하려면 인증이 필요하다.

kubectl create secret docker-registry ecr-secret \
  --docker-server=851957594139.dkr.ecr.ap-northeast-2.amazonaws.com \
  --docker-username=AWS \
  --docker-password=$(aws ecr get-login-password --region ap-northeast-2) \
  --namespace=galaga

핵심 매니페스트

backend.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: galaga-backend
  namespace: galaga
spec:
  replicas: 2
  selector:
    matchLabels:
      app: galaga-backend
  template:
    metadata:
      labels:
        app: galaga-backend
    spec:
      imagePullSecrets:
        - name: ecr-secret
      containers:
        - name: backend
          image: 851957594139.dkr.ecr.ap-northeast-2.amazonaws.com/toy/k8s/back:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 4000
          env:
            - name: PORT
              value: "4000"
            - name: DB_HOST
              valueFrom:
                secretKeyRef:
                  name: galaga-db-secret
                  key: DB_HOST
            - name: DB_PORT
              valueFrom:
                secretKeyRef:
                  name: galaga-db-secret
                  key: DB_PORT
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: galaga-db-secret
                  key: DB_USER
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: galaga-db-secret
                  key: DB_PASSWORD
            - name: DB_NAME
              valueFrom:
                secretKeyRef:
                  name: galaga-db-secret
                  key: DB_NAME
            - name: ALLOWED_ORIGINS
              value: "http://toy-alb-505402289.ap-northeast-2.elb.amazonaws.com"
          readinessProbe:
            httpGet:
              path: /health
              port: 4000
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health
              port: 4000
            initialDelaySeconds: 20
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: galaga-backend
  namespace: galaga
spec:
  type: NodePort
  selector:
    app: galaga-backend
  ports:
    - port: 4000
      targetPort: 4000
      nodePort: 30400

ingress.yaml — WebSocket 지원 설정이 핵심

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: galaga-ingress
  namespace: galaga
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "Upgrade";
spec:
  ingressClassName: nginx
  rules:
    - http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: galaga-backend
                port:
                  number: 4000
          - path: /
            pathType: Prefix
            backend:
              service:
                name: galaga-frontend
                port:
                  number: 80

Frontend Dockerfile — ARG 위치가 핵심

FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
# COPY . . 이전에 ARG/ENV 선언해야 빌드 시 환경변수가 적용됨
ARG REACT_APP_WS_URL
ARG REACT_APP_API_URL
ENV REACT_APP_WS_URL=$REACT_APP_WS_URL
ENV REACT_APP_API_URL=$REACT_APP_API_URL
COPY . .
RUN chmod +x node_modules/.bin/react-scripts && npm run build

FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Ingress Controller 설치

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/cloud/deploy.yaml

Day 4 — Worker2 추가 & 마무리

Worker2 EC2 생성 후 공통 설치 스크립트 실행, IAM Role(ec2-ecr-readonly) 부여 후 클러스터 합류.


트러블슈팅 모음

5일간 총 21건의 트러블슈팅을 겪었다. 주요 이슈만 정리한다.

1. Pod Pending — nodeSelector 불일치

0/3 nodes are available: 1 node(s) didn't match Pod's node affinity/selector

원인: yaml에 nodeSelector: tier: app/web이 설정되어 있었으나 worker 노드에 해당 레이블 없음

해결: nodeSelector 제거


2. Pod ContainerCreating — Flannel br_netfilter 에러

failed to load flannel 'subnet.env' file

원인: worker 노드에 br_netfilter 커널 모듈 미로드

해결:

modprobe br_netfilter
echo "br_netfilter" >> /etc/modules-load.d/k8s.conf
sysctl -w net.bridge.bridge-nf-call-iptables=1
# master에서 Flannel 재시작
kubectl delete pod -n kube-flannel -l app=flannel

3. ImagePullBackOff — 보안그룹 10250 포트 미오픈

dial tcp 10.10.2.224:10250: i/o timeout

원인: master → worker 간 kubelet API 포트(10250) 보안그룹 차단

해결: worker SG 인바운드에 TCP 10250 (master SG), UDP 8472 (Flannel VXLAN) 추가


4. ImagePullBackOff — ECR 인증 없음

authorization failed: no basic auth credentials

원인: ECR은 프라이빗 레지스트리, 인증 없이 pull 불가

해결: ecr-secret 생성 후 imagePullSecrets 추가


5. Backend Pod Error — DB 호스트 미설정

getaddrinfo ENOTFOUND galaga-mysql

원인: DB_HOST가 K8s 내부 서비스명으로 설정됨 (RDS 사용 중)

해결: db-secret.yamlDB_HOST를 RDS 엔드포인트로 변경


6. Ingress Admission Webhook 타임아웃

failed calling webhook: context deadline exceeded

해결:

kubectl delete validatingwebhookconfiguration ingress-nginx-admission
kubectl apply -f k8s/ingress.yaml

7. ALB 접속 타임아웃 — master 노드가 대상 그룹에 등록됨

원인: 대상 그룹에 master 노드가 포함되어 있었으나 NodePort 미오픈 → Unhealthy

해결: 대상 그룹에서 master 노드 제거, worker 노드만 유지


8. NodePort 확인 방식 착각

증상: ss -tlnp | grep 32390 결과 없음

원인: kube-proxy는 iptables로 NodePort 처리 → ss에 표시 안 됨

확인: curl http://localhost:32390 → HTML 응답 확인


9. Frontend 환경변수 미반영 — WebSocket localhost 연결

WebSocket connection to 'ws://localhost:4000/' failed

원인: Dockerfile에서 ARG 선언이 COPY . . 이후에 위치 → 빌드 캐시로 환경변수 미적용

해결: ARG/ENV 선언을 COPY . . 이전으로 이동 + --no-cache 빌드


10. WebSocket 연결 실패 — ALB:4000 리스너가 Frontend 대상 그룹으로 연결

원인: ALB:4000 리스너가 잘못된 대상 그룹에 연결됨

해결:
1. Backend Service를 NodePort(30400)로 변경
2. Backend 전용 대상 그룹 toy-backend-tg 생성 (포트 30400, 헬스체크 /health)
3. ALB:4000 리스너를 toy-backend-tg로 연결
4. 보안그룹에 TCP 4000, 30400 추가


아키텍처 분석 & 개선 방향

현재 구조의 취약점

우선순위항목문제
🔴 높음HTTPS/WSS 미적용평문 통신
🔴 높음WebSocket 인증 없음누구나 점수 조작 가능
🔴 높음점수 서버 검증 없음클라이언트 신뢰 구조
🟡 중간단일 AZAZ 장애 시 전체 다운
🟡 중간Self-managed K8s컨트롤 플레인 관리 부담
🟡 중간ECR 이미지 latest 태그롤백 불가
🟢 낮음DB 커넥션 풀 고정스케일 아웃 시 RDS 연결 초과 위험

추천 개선 방향 (EKS 기반)

Route 53 → ACM (TLS)
    ↓
ALB (HTTPS:443 단일 포트, WebSocket Upgrade 지원)
    ↓
EKS (Managed K8s, Multi-AZ)
├── Frontend Pod (Nginx + React)
└── Backend Pod (Express + WS)
    ↓
RDS Aurora MySQL (Multi-AZ)
    ↓
ElastiCache Redis (스코어보드 캐싱)

핵심 변경점:

  • Self-managed K8s → EKS: 컨트롤 플레인 AWS 관리
  • ALB 포트 분리 → 443 단일 포트: WebSocket Upgrade 헤더를 ALB가 처리
  • RDS Single → Aurora Multi-AZ: 자동 페일오버
  • 스코어보드 → ElastiCache Redis: DB 부하 감소

회고

5일간 직접 K8s를 구성하면서 EKS가 왜 편한지 체감했다. 컨트롤 플레인 관리, 보안그룹 설정, CNI 설치까지 모두 수동으로 해야 하는 게 운영 환경에서는 상당한 부담이다.

그래도 이 과정을 통해 얻은 것들:

  • K8s 내부 동작 이해: kubeadm 방식으로 직접 구성하니 각 컴포넌트의 역할이 명확해졌다
  • 보안그룹이 K8s의 핵심: 포트 하나 빠지면 클러스터 전체가 동작 안 한다
  • Docker 빌드 캐시 주의: ARG 위치에 따라 환경변수 적용 여부가 달라진다
  • Ingress 설정의 복잡성: WebSocket, host 설정, rewrite-target 등 세부 설정이 많다

다음에는 EKS + ALB Controller + Aurora Multi-AZ 조합으로 개선해볼 예정이다.


이 글은 AWS Cloud School 13기 과정에서 진행한 토이프로젝트를 정리한 내용입니다.
Kiro를 통해 Notion에 기록하고 velog에 작성하였습니다.

profile
나를 한줄로

0개의 댓글