개인 프로젝트를 위한 인프라를 고민하던 중, DevOps 경험도 쌓을 겸 쿠버네티스를 도입하기로 했습니다. 하지만 EKS 같은 완전 관리형 서비스는 비용이 부담스러웠고, 결국 가장 저렴한 EC2 인스턴스에 가벼운 쿠버네티스 배포판인 K3s를 직접 설치하는 길을 선택했습니다.
이 글은 당시 제가 작성했던 설치 셸 스크립트를 기반으로, 각 단계가 어떤 의미를 가지며 왜 그렇게 구성했는지 적어두었습니다.
자동화 스크립트
목표는 git push
를 통해 자동으로 컨테이너 이미지를 빌드하고, 클러스터에 무중단으로 배포하는 CI/CD 파이프라인을 구축하는 것입니다.
이 모든 과정을 자동화하기 위해 아래와 같은 셸 스크립트를 작성했습니다.
전체 설치 스크립트 보기 (install.sh)
#!/bin/bash
set -euo pipefail
# 🎯 환경별 설정 파일 로드
ENVIRONMENT=${1:-staging} # 기본값: staging
CONFIG_FILE="configs/${ENVIRONMENT}.env"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "❌ 설정 파일을 찾을 수 없습니다: $CONFIG_FILE"
echo "사용 가능한 환경: dev, staging, prod"
echo "사용법: $0 [dev|staging|prod]"
exit 1
fi
echo "📋 환경 설정 로드 중: $ENVIRONMENT"
source "$CONFIG_FILE"
echo "🚀 K3s 클러스터 설치를 시작합니다..."
echo "🌐 환경: $ENVIRONMENT"
echo "📍 도메인: $K8S_DOMAIN"
echo "📧 이메일: $EMAIL"
# 🌐 공인 IP 획득
echo "🌐 서버 공인 IP 확인 중..."
SERVER_PUBLIC_IP=$(curl -4 ifconfig.me 2>/dev/null)
echo "🌐 감지된 IP: $SERVER_PUBLIC_IP"
# 📦 시스템 업데이트
echo "📦 시스템 업데이트 중..."
sudo apt update && sudo apt upgrade -y
# ☸️ K3s 설치
echo "☸️ K3s 설치 중..."
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="\
--tls-san $K8S_DOMAIN \
--disable traefik \
--disable metrics-server \
--disable local-storage" sh -
# 🔧 kubeconfig 설정
echo "🔧 kubeconfig 설정 중..."
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
chmod 600 ~/.kube/config
echo 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc
export KUBECONFIG=~/.kube/config
# ⚙️ Helm 설치
echo "⚙️ Helm 설치 중..."
curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt update && sudo apt install helm -y
# 📚 Helm 저장소 추가
echo "📚 Helm 저장소 추가 중..."
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add jetstack https://charts.jetstack.io --force-update
helm repo update
if [ "$WITH_METALLB" = true ]; then
echo "🔗 MetalLB 설치 중..."
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.13.12/config/manifests/metallb-native.yaml
echo "⏳ MetalLB 초기화 대기..."
kubectl wait --namespace metallb-system \
--for=condition=ready pod \
--selector=app=metallb \
--timeout=90s
cat <<EOF | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: external-ip-pool
namespace: metallb-system
spec:
addresses:
- $SERVER_PUBLIC_IP/32
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2-adv
namespace: metallb-system
spec:
ipAddressPools:
- external-ip-pool
EOF
fi
# 📄 Nginx Ingress 설정 파일 생성
echo "📄 Nginx Ingress Values 파일 생성..."
cat > nginx-ingress-controller.yaml <<EOF
service:
type: LoadBalancer
loadBalancerIP: $SERVER_PUBLIC_IP
annotations:
metallb.universe.tf/address-pool: external-ip-pool
controller:
ingressClassResource:
default: true
ingressClass: nginx
watchIngressWithoutClass: true
resources:
requests:
cpu: $NGINX_CPU_REQUEST
memory: $NGINX_MEMORY_REQUEST
limits:
cpu: $NGINX_CPU_LIMIT
memory: $NGINX_MEMORY_LIMIT
admissionWebhooks:
enabled: true
patch:
resources:
requests:
cpu: $WEBHOOK_CPU_REQUEST
memory: $WEBHOOK_MEMORY_REQUEST
limits:
cpu: $WEBHOOK_CPU_LIMIT
memory: $WEBHOOK_MEMORY_LIMIT
defaultBackend:
enabled: false
EOF
# 🌐 Nginx Ingress 설치
echo "🌐 Nginx Ingress Controller 설치 중..."
helm upgrade --install nginx-ingress ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--values nginx-ingress-controller.yaml
if [ "$WITH_CERT_MANAGER" = true ]; then
echo "🔐 Cert-Manager 설치 중..."
helm upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.17.2 \
--set crds.enabled=true \
--set resources.requests.cpu=$CERTMANAGER_CPU_REQUEST \
--set resources.requests.memory=$CERTMANAGER_MEMORY_REQUEST \
--set resources.limits.cpu=$CERTMANAGER_CPU_LIMIT \
--set resources.limits.memory=$CERTMANAGER_MEMORY_LIMIT
echo "📜 Let's Encrypt ClusterIssuer 생성 중..."
cat > cluster-issuer.yaml <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: $EMAIL
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
ingressClassName: nginx
EOF
kubectl apply -f cluster-issuer.yaml
fi
if [ "$WITH_GITHUB_SECRET" = true ]; then
echo "📦 GitHub Container Registry Secret 설정 필요 (수동)..."
echo "kubectl create secret docker-registry ghcr-secret \\"
echo " --docker-server=ghcr.io \\"
echo " --docker-username=\$GITHUB_USERNAME \\"
echo " --docker-password=\$GITHUB_TOKEN \\"
echo " --namespace=default"
fi
# ✅ 설치 완료 및 상태 확인
echo ""
echo "🎉 K3s 클러스터 설치 완료!"
echo ""
echo "📋 설치된 구성요소:"
echo " ✅ K3s (Kubernetes)"
echo " ✅ Helm"
echo " ✅ Nginx Ingress Controller"
[ "$WITH_METALLB" = true ] && echo " ✅ MetalLB (LoadBalancer)"
[ "$WITH_CERT_MANAGER" = true ] && echo " ✅ Cert-Manager (Let's Encrypt)"
echo ""
echo "🌐 클러스터 정보:"
echo " 📍 환경: $ENVIRONMENT"
echo " 📍 도메인: $K8S_DOMAIN"
echo " 🌍 공인 IP: $SERVER_PUBLIC_IP"
echo " 📧 이메일: $EMAIL"
echo ""
echo "🔍 상태 확인 명령어:"
echo " kubectl get nodes"
echo " kubectl get pods -n ingress-nginx"
[ "$WITH_CERT_MANAGER" = true ] && echo " kubectl get pods -n cert-manager"
[ "$WITH_METALLB" = true ] && echo " kubectl get pods -n metallb-system"
echo " kubectl get svc -n ingress-nginx"
echo ""
echo "📋 kubeconfig (GitHub Secrets에 저장할 내용):"
echo "----------------------------------------"
cat ~/.kube/config | sed "s|https://127.0.0.1:6443|https://$K8S_DOMAIN:6443|g"
echo "----------------------------------------"
echo ""
echo "🚀 이제 애플리케이션을 배포할 수 있습니다!"
이제 이 스크립트의 각 단계를 자세히 살펴보겠습니다.
1단계: 기본 환경 설정
스크립트의 가장 처음은 서버의 기본 상태를 준비하는 과정입니다.
# 📦 시스템 업데이트
echo "📦 시스템 업데이트 중..."
sudo apt update && sudo apt upgrade -y
# 🌐 공인 IP 획득
echo "🌐 서버 공인 IP 확인 중..."
SERVER_PUBLIC_IP=$(curl -4 ifconfig.me 2>/dev/null)
echo "🌐 감지된 IP: $SERVER_PUBLIC_IP"
- 시스템 업데이트: 보안과 안정성을 위해 패키지 목록을 최신화하고, 설치된 패키지들을 업그레이드합니다. 클린 상태의 인스턴스에서 시작하는 기본 중의 기본입니다.
- 공인 IP 획득:
ifconfig.me
서비스를 이용해 서버의 공인 IP를 동적으로 가져와 변수에 저장합니다. 이로써 스크립트를 다른 서버에서도 재사용하기 용이해집니다.
2단계: K3s 코어 설치 및 설정
가장 핵심인 K3s를 설치하고, kubectl
로 클러스터를 제어할 수 있도록 준비합니다.
# ☸️ K3s 설치
echo "☸️ K3s 설치 중..."
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="\
--tls-san $K8S_DOMAIN \
--disable traefik \
--disable metrics-server \
--disable local-storage" sh -
# 🔧 kubeconfig 설정
echo "🔧 kubeconfig 설정 중..."
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
chmod 600 ~/.kube/config
--tls-san $K8S_DOMAIN
: K3s API 서버의 TLS 인증서에 우리가 사용할 도메인을 Subject Alternative Name(SAN)으로 추가합니다. 이렇게 해야 외부에서 IP가 아닌 도메인으로 API 서버에 접속할 때 인증서 오류가 발생하지 않습니다.--disable ...
: Nginx Ingress Controller를 직접 설치할 것이므로 내장된traefik
은 비활성화합니다. 지금 당장 필요 없는metrics-server
와local-storage
도 제외하여 리소스를 아낍니다.- kubeconfig 설정: K3s의 설정 파일(
k3s.yaml
)을kubectl
의 기본 경로(~/.kube/config
)로 복사하고 권한을 조정합니다. 이 과정을 거쳐야sudo
없이kubectl
명령을 편하게 사용할 수 있습니다.
3단계: Helm 설치 및 저장소 추가
쿠버네티스의 패키지 매니저인 Helm을 설치합니다. 애플리케이션을 차트(Chart) 단위로 쉽게 배포하고 관리할 수 있게 해주는 필수 도구입니다.
# ⚙️ Helm 설치
echo "⚙️ Helm 설치 중..."
curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt update && sudo apt install helm -y
# 📚 Helm 저장소 추가
echo "📚 Helm 저장소 추가 중..."
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add jetstack https://charts.jetstack.io
helm repo update
- 스크립트에서는
apt
패키지 매니저에 Helm의 공식 저장소를 등록하여 설치를 진행합니다. ingress-nginx
와jetstack
(Cert-Manager용) 저장소를 추가하고 로컬 캐시를 업데이트하여 차트를 설치할 준비를 마칩니다.
4단계: 네트워크 인프라 구축 (MetalLB & Nginx Ingress)
외부 트래픽이 클러스터 내부의 서비스까지 도달할 수 있는 길을 만듭니다.
MetalLB: LoadBalancer 서비스 구현
EC2 같은 단일 노드 환경은 클라우드 플랫폼처럼 LoadBalancer
타입의 서비스를 지원하지 않습니다. MetalLB는 이러한 베어메탈 환경에서 LoadBalancer
서비스에 공인 IP를 할당해주는 역할을 합니다.
# 🔗 MetalLB 설치
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.13.12/config/manifests/metallb-native.yaml
kubectl wait --namespace metallb-system --for=condition=ready pod --selector=app=metallb --timeout=90s
# 🔗 MetalLB 설정 적용
cat <<EOF | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: external-ip-pool
namespace: metallb-system
spec:
addresses:
- $SERVER_PUBLIC_IP/32
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2-adv
namespace: metallb-system
spec:
ipAddressPools:
- external-ip-pool
EOF
IPAddressPool
: MetalLB가 서비스에 할당해 줄 IP 주소의 범위를 정의합니다. 여기서는 스크립트 초반에 얻어온 서버의 공인 IP 하나만 지정했습니다.L2Advertisement
:IPAddressPool
의 IP를 어떤 노드에서 알릴지(advertise) 결정합니다. L2 모드에서는 특정 노드가 해당 IP에 대한 소유권을 네트워크에 알리게 됩니다.
Nginx Ingress Controller: 클러스터의 관문
Ingress Controller는 도메인과 경로 규칙에 따라 트래픽을 적절한 내부 서비스로 라우팅하는 ‘교통 경찰’입니다. Helm으로 설치하되, 설정을 values.yaml
파일로 분리하여 관리의 용이성을 높였습니다.
# 📄 Nginx Ingress Values 파일 생성
cat > nginx-ingress-values.yaml <<EOF
controller:
ingressClassResource:
default: true
resources:
requests:
cpu: 100m
memory: 128Mi
service:
type: LoadBalancer
EOF
# 🌐 Nginx Ingress Controller 설치
helm upgrade --install nginx-ingress ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
-f nginx-ingress-values.yaml
- values.yaml 관리: 수많은
--set
옵션을 나열하는 대신, 별도의 YAML 파일로 설정을 관리하는 것이 가독성과 재사용성 측면에서 훨씬 좋은 방법입니다. service.type: LoadBalancer
: 서비스 타입을LoadBalancer
로 지정하여 앞서 설치한 MetalLB가 공인 IP를 할당하도록 합니다.
5단계: SSL/TLS 인증서 자동화 (Cert-Manager)
Let’s Encrypt와 연동하여 HTTPS 통신에 필요한 SSL 인증서를 자동으로 발급하고 갱신합니다.
# 🔐 Cert-Manager 설치
helm upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
# 📜 Let's Encrypt ClusterIssuer 생성
cat > cluster-issuer.yaml <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: your-email@example.com # 실제 이메일로 변경
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
ingressClassName: nginx
EOF
kubectl apply -f cluster-issuer.yaml
crds.enabled=true
: Cert-Manager가 사용하는 커스텀 리소스 정의(CRD)를 함께 설치합니다. 예전에는installCRDs
옵션을 사용했지만, 최신 버전에선 이 옵션이 권장됩니다.- ClusterIssuer: 클러스터 전체에서 사용할 인증서 발급 기관을 정의합니다.
http01
챌린지 방식을 사용해 도메인 소유권을 검증하며, 이 검증 과정은nginx
Ingress를 통해 처리되도록 설정했습니다.
6단계: CI/CD 연동 및 최종 확인
외부 CI/CD 시스템이 클러스터에 배포할 수 있도록 준비하고, 모든 컴포넌트가 정상적으로 동작하는지 확인합니다.
CI/CD용 kubeconfig 생성
GitHub Actions 같은 외부 환경은 서버의 기본 kubeconfig
파일(server
주소가 127.0.0.1
로 되어 있음)을 사용할 수 없습니다. 따라서 서버 주소를 공인 도메인으로 변경한 버전을 생성해야 합니다.
echo "📋 kubeconfig (GitHub Secrets에 저장할 내용):"
echo "----------------------------------------"
cat ~/.kube/config | sed "s|https://127.0.0.1:6443|https://$K8S_DOMAIN:6443|g"
echo "----------------------------------------"
위 명령어로 출력된 내용을 복사하여 GitHub 저장소의 Settings > Secrets and variables > Actions
에 KUBECONFIG_CONTENT
와 같은 이름의 Secret으로 저장하면 됩니다.
최종 설치 확인
아래 명령어들을 통해 각 컴포넌트들이 정상적으로 설치되고 실행 중인지 확인할 수 있습니다.
# 전체 노드 상태 확인
kubectl get nodes -o wide
# 모든 네임스페이스의 Pod 상태 확인
kubectl get pods -A
# Ingress-Nginx 서비스의 EXTERNAL-IP 할당 확인
kubectl get svc -n ingress-nginx
# Cert-Manager Pod 동작 확인
kubectl get pods -n cert-manager
ingress-nginx-controller
서비스의 EXTERNAL-IP
가 서버의 공인 IP로 할당되었다면 성공입니다. 이제 샘플 애플리케이션을 배포하여 최종 테스트를 진행할 수 있습니다.