집에서 MSA 환경을 k8s로 만들기

이전회사에서 일할 때 MSA 환경이 정말 잘 갖춰져 있었다. 이후 퇴사하고나서 Monolithic 사용, 운영 하다보니 마음 한편에 "MSA 환경을 처음부터 끝까지 한번 만들어보고 싶다"는 생각이 계속 들었고, 뭘 만들까 고민하다가 블로그를 골랐다. User 서비스, Post 서비스 정도면 MSA 구조 잡기에 충분하니까.

왜 집에서?

처음엔 Vultr나 DigitalOcean 같은 클라우드를 생각했다. 근데 계산해보니까 MSA로 서비스 3-4개 띄우고, DB 붙이고, 모니터링까지 하면 월 $50은 훌쩍 넘더라. 그냥 사이드 프로젝트인데 그건 좀 아까웠다.

그래서 집에 굴러다니던 Synology DS718+ 를 꺼냈다. 원래 안쓰고 있엇는데, 전원버튼만 누르면 개인 서버가 완성됐다.

Synology에서 Docker를 직접 돌릴 수도 있지만, VMM(Virtual Machine Manager) 으로 Ubuntu VM을 올렸다. 이유:

  • Docker Desktop보다 네이티브 Docker가 안정적
  • SSH로 접속해서 일반 서버처럼 관리 가능
  • 스냅샷 기능으로 롤백도 쉬움

Ubuntu 22.04 LTS를 설치하고, Docker랑 kubectl만 깔면 준비 끝이다.

Kind로 K8s 클러스터 구축

IaC 환경을 구성해보고 싶었고, 지금은 많이 익숙한 Kubernetes를 선택했다. 물론 NAS 하나에 프로덕션급 클러스터를 올릴 순 없으니까 Kind(Kubernetes in Docker) 로 구성했다.

Kind는 Docker 컨테이너 안에서 K8s 노드를 실행하는 방식이다. 가볍고 빠르게 클러스터를 만들 수 있어서 로컬 개발이나 CI 환경에서 많이 쓴다. 단점이라면 단일노드라는 점이지만, 어차피 나에게는 별 문제가 되진 않았다.

클러스터 설정

# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
  apiServerAddress: "0.0.0.0"  # 외부에서 접근 가능하도록
  apiServerPort: 6443
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"
  extraPortMappings:
  - containerPort: 80
    hostPort: 80
    protocol: TCP
  - containerPort: 443
    hostPort: 443
    protocol: TCP
  - containerPort: 5432   # PostgreSQL
    hostPort: 5432
  - containerPort: 6379   # Redis
    hostPort: 6379

핵심 포인트:

  • apiServerAddress: "0.0.0.0": NAS 외부(내 맥북)에서 kubectl로 접근하려면 필요하다
  • extraPortMappings: Kind 컨테이너 안의 포트를 호스트로 노출한다. DB 접근용으로 5432, 6379도 열어뒀다
  • ingress-ready=true: nginx ingress를 설치하기 위한 라벨

클러스터 생성 스크립트

매번 명령어 치기 귀찮아서 스크립트로 만들어뒀다:

#!/bin/bash
set -e

# 기존 클러스터 삭제
if kind get clusters 2>/dev/null | grep -q "flatcoke"; then
    echo "기존 클러스터 삭제 중..."
    kind delete cluster --name flatcoke
fi

# 클러스터 생성
kind create cluster --name flatcoke --config=kind-config.yaml

# nginx ingress 설치
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

# ingress 준비 대기
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/name=ingress-nginx \
  --timeout=90s

echo "완료! 열린 포트: 80, 443, 5432, 6379"

스크립트 하나로 클러스터 생성부터 ingress 설치까지 끝난다. 뭔가 꼬이면 그냥 다시 돌리면 된다.


GitHub Container Registry

Docker Hub나 ECR 대신 ghcr.io를 썼다. 이유:

  • GitHub Actions랑 연동이 자연스러움
  • private repo도 무료
  • 이미 GitHub에 코드가 있으니까 한 곳에서 관리

각 서비스의 Dockerfile을 빌드해서 ghcr.io에 푸시하면, K8s deployment에서 바로 당겨올 수 있다:

# deployment.yaml
spec:
  containers:
    - name: post
      image: ghcr.io/flatcoke/post:latest
  imagePullSecrets:
    - name: github-regcred  # GitHub PAT으로 만든 시크릿

private 이미지를 당기려면 imagePullSecrets가 필요하다. GitHub PAT(Personal Access Token)으로 시크릿을 만들어두면 된다:

kubectl create secret docker-registry github-regcred \
  --namespace=post \
  --docker-server=ghcr.io \
  --docker-username=<GITHUB_USERNAME> \
  --docker-password=<GITHUB_PAT> \
  --docker-email=<EMAIL>

SOPS로 시크릿 관리

AWS SSM이나 Secrets Manager가 없으니까 대안이 필요했다. SOPS를 선택한 이유:

  • Git에 암호화된 시크릿을 커밋할 수 있음
  • 버전 관리가 됨
  • 별도의 시크릿 서버가 필요 없음

설정 방법

먼저 age 키를 생성한다:

brew install sops age
age-keygen -o ~/.config/sops/age/keys.txt

생성된 키 파일은 이렇게 생겼다:

# created: 2024-01-01T00:00:00+09:00
# public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AGE-SECRET-KEY-1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

public key는 암호화할 때 쓰고, secret key는 복호화할 때 쓴다. secret key는 절대 Git에 커밋하면 안 된다.

시크릿 암호화

# 평문 시크릿 작성 (임시 파일)
cat > /tmp/db-secret.yaml <<EOF
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: post
stringData:
  POSTGRES_PASSWORD: "my-secret-password"
  POSTGRES_USER: "postgres"
EOF

# 암호화 (.sops.yaml에 age public key 설정 필요)
sops -e /tmp/db-secret.yaml > k8s/secrets/db-secret.yaml

# 원본 삭제
rm /tmp/db-secret.yaml

암호화된 파일은 이렇게 생겼다:

apiVersion: v1
kind: Secret
metadata:
    name: db-credentials
    namespace: post
stringData:
    POSTGRES_PASSWORD: ENC[AES256_GCM,data:xxxxxxxxxxxxxxxx,iv:xxx,tag:xxx,type:str]
    POSTGRES_USER: ENC[AES256_GCM,data:xxxxxxxx,iv:xxx,tag:xxx,type:str]
sops:
    age:
        - recipient: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxxxxxxxxx
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2024-01-01T00:00:00Z"
    mac: ENC[AES256_GCM,data:xxx,iv:xxx,tag:xxx,type:str]
    version: 3.8.1

stringData 값들이 ENC[...]로 암호화되어 있다. 이 파일은 Git에 커밋해도 안전하다.

시크릿 적용

적용할 때는 복호화해서 kubectl에 파이프:

sops -d k8s/secrets/db-secret.yaml | kubectl apply -f -

편하게 쓰려고 스크립트로 만들어뒀다:

#!/bin/bash
# apply.sh - 모든 암호화된 시크릿 적용

for file in k8s/secrets/*.yaml; do
    echo "Applying $file..."
    sops -d "$file" | kubectl apply -f -
done

시크릿 수정

암호화된 파일을 직접 수정하려면:

sops k8s/secrets/db-secret.yaml

에디터가 열리면서 복호화된 내용이 보인다. 수정하고 저장하면 자동으로 다시 암호화된다.

Helm Chart로 템플릿화

MSA는 반복의 연속이다. User 서비스, Post 서비스, 나중에 추가될 서비스들... 전부 비슷한 구조다:

  • Deployment
  • Service
  • Ingress
  • HPA (Horizontal Pod Autoscaler)
  • PDB (Pod Disruption Budget)

서비스마다 이걸 다 복붙하면 유지보수가 지옥이 된다. 그래서 공통 Helm Chart 템플릿을 만들었다.

구조

iac/k8s/
+-- charts/
|   +-- microservice/           # 공통 템플릿
|       +-- Chart.yaml
|       +-- values.yaml         # 기본값
|       +-- templates/
|           +-- deployment.yaml
|           +-- service.yaml
|           +-- ingress.yaml
|           +-- hpa.yaml
|           +-- pdb.yaml
|           +-- namespace.yaml
+-- user/
|   +-- helm/
|       +-- values.yaml         # User 서비스 설정만
+-- post/
    +-- helm/
        +-- values.yaml         # Post 서비스 설정만

공통 템플릿 (values.yaml)

# charts/microservice/values.yaml (일부)
name: ""

image:
  repository: ghcr.io/flatcokecom
  tag: latest
  pullPolicy: Always

imagePullSecrets:
  - name: github-regcred

ports:
  http: 8080
  grpc: 9090

resources:
  requests:
    memory: "128Mi"
    cpu: "100m"
  limits:
    memory: "256Mi"
    cpu: "500m"

probes:
  readiness:
    path: /health
    port: 8080
  liveness:
    path: /health
    port: 8080

ingress:
  enabled: true
  className: nginx

hpa:
  enabled: true
  minReplicas: 1
  maxReplicas: 3
  targetCPUUtilizationPercentage: 70

서비스별 설정

각 서비스는 자기만의 설정만 정의하면 된다:

# user/helm/values.yaml
name: user

namespace:
  create: true

env:
  ENV: "prod"

secret:
  name: user-secret
  dbKeys:
    - DB_HOST
    - DB_PORT
    - DB_USER
    - DB_PASSWORD
    - DB_NAME

ingress:
  host: user.flatcoke.com
# post/helm/values.yaml
name: post

namespace:
  create: true

env:
  ENV: "prod"
  GRPC_ADDR: "0.0.0.0:9090"
  HTTP_ADDR: "0.0.0.0:8080"

secret:
  name: post-secret
  dbKeys:
    - DB_HOST
    - DB_PORT
    - DB_USER
    - DB_PASSWORD
    - DB_NAME
  extraKeys:
    - R2_ACCESS_KEY_ID
    - R2_SECRET_ACCESS_KEY

extraEnv:
  USER_SERVICE_ADDR: "user-service.user.svc.cluster.local:9090"

ingress:
  host: post.flatcoke.com

배포

# User 서비스 배포
helm upgrade --install user ./charts/microservice \
  -f ./user/helm/values.yaml \
  --namespace user

# Post 서비스 배포
helm upgrade --install post ./charts/microservice \
  -f ./post/helm/values.yaml \
  --namespace post

새 서비스를 추가할 때 values.yaml 하나만 만들면 된다. Deployment, Service, Ingress, HPA, PDB 전부 자동으로 생성된다.

마무리

집에 있는 NAS에 Kind로 K8s를 올리고, SOPS로 시크릿을 관리하고, ghcr.io로 이미지를 배포하는 환경을 구축했다.

클라우드 없이도 MSA 인프라를 구축할 수 있다는 걸 알게 됐다. 물론 프로덕션에서는 이렇게 안 하겠지만, 사이드 프로젝트로 경험해보기엔 충분하다.

집에서 MSA 환경을 k8s로 만들기 | flatcoke.com