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)
toy-vpc 10.10.0.0/16 (ap-northeast-2)
| 서브넷 | CIDR | 용도 |
|---|---|---|
| toy-vpc-pub-sub1~3 | 10.10.1/11/21.0/24 | Bastion, ALB |
| toy-vpc-pri-sub1~3 | 10.10.2/12/22.0/24 | K8s Master/Worker |
| toy-vpc-db-sub1~3 | 10.10.3/13/23.0/24 | RDS |
| 이름 | 역할 | 타입 | 서브넷 |
|---|---|---|---|
| toy-bastion | Bastion Host | t3.micro | pub-sub1 |
| toy-vpc-master1 | K8s Master | t3.medium | pri-sub1 |
| toy-vpc-worker1 | K8s Worker | t3.medium | pri-sub1 |
| toy-vpc-worker2 | K8s Worker | t3.medium | pri-sub2 |
K8s 클러스터 운영에 필요한 포트를 정확히 열어주는 게 핵심이다.
sg-k8s-master
sg-k8s-worker
sg-lb
sg-db (toy-rdssg)
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
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
wget https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
kubectl apply -f kube-flannel.yml
kubeadm join 10.10.2.248:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
💡 토큰 만료 시 재발급:
kubeadm token create --print-join-command
# 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은 프라이빗 레지스트리라 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;"]
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/cloud/deploy.yaml
Worker2 EC2 생성 후 공통 설치 스크립트 실행, IAM Role(ec2-ecr-readonly) 부여 후 클러스터 합류.
5일간 총 21건의 트러블슈팅을 겪었다. 주요 이슈만 정리한다.
0/3 nodes are available: 1 node(s) didn't match Pod's node affinity/selector
원인: yaml에 nodeSelector: tier: app/web이 설정되어 있었으나 worker 노드에 해당 레이블 없음
해결: nodeSelector 제거
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
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) 추가
authorization failed: no basic auth credentials
원인: ECR은 프라이빗 레지스트리, 인증 없이 pull 불가
해결: ecr-secret 생성 후 imagePullSecrets 추가
getaddrinfo ENOTFOUND galaga-mysql
원인: DB_HOST가 K8s 내부 서비스명으로 설정됨 (RDS 사용 중)
해결: db-secret.yaml의 DB_HOST를 RDS 엔드포인트로 변경
failed calling webhook: context deadline exceeded
해결:
kubectl delete validatingwebhookconfiguration ingress-nginx-admission
kubectl apply -f k8s/ingress.yaml
원인: 대상 그룹에 master 노드가 포함되어 있었으나 NodePort 미오픈 → Unhealthy
해결: 대상 그룹에서 master 노드 제거, worker 노드만 유지
증상: ss -tlnp | grep 32390 결과 없음
원인: kube-proxy는 iptables로 NodePort 처리 → ss에 표시 안 됨
확인: curl http://localhost:32390 → HTML 응답 확인
WebSocket connection to 'ws://localhost:4000/' failed
원인: Dockerfile에서 ARG 선언이 COPY . . 이후에 위치 → 빌드 캐시로 환경변수 미적용
해결: ARG/ENV 선언을 COPY . . 이전으로 이동 + --no-cache 빌드
원인: 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 인증 없음 | 누구나 점수 조작 가능 |
| 🔴 높음 | 점수 서버 검증 없음 | 클라이언트 신뢰 구조 |
| 🟡 중간 | 단일 AZ | AZ 장애 시 전체 다운 |
| 🟡 중간 | Self-managed K8s | 컨트롤 플레인 관리 부담 |
| 🟡 중간 | ECR 이미지 latest 태그 | 롤백 불가 |
| 🟢 낮음 | DB 커넥션 풀 고정 | 스케일 아웃 시 RDS 연결 초과 위험 |
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 (스코어보드 캐싱)
핵심 변경점:
5일간 직접 K8s를 구성하면서 EKS가 왜 편한지 체감했다. 컨트롤 플레인 관리, 보안그룹 설정, CNI 설치까지 모두 수동으로 해야 하는 게 운영 환경에서는 상당한 부담이다.
그래도 이 과정을 통해 얻은 것들:
다음에는 EKS + ALB Controller + Aurora Multi-AZ 조합으로 개선해볼 예정이다.
이 글은 AWS Cloud School 13기 과정에서 진행한 토이프로젝트를 정리한 내용입니다.
Kiro를 통해 Notion에 기록하고 velog에 작성하였습니다.