K8s 실전 #2 앱 배포 골격 — Deployment / Service / Ingress / Helm

8 분 소요

K8s 실전 시리즈의 두 번째 글입니다. #1에서 EKS 클러스터를 띄웠지만 그 위에는 우리 워크로드가 한 줄도 들어가지 않은 상태입니다. 이번 글은 그 빈 클러스터 위에 가상 서비스 myshop-api를 올리는 한 사이클입니다. Deployment부터 Ingress까지의 한 묶음을 매니페스트로 적고, AWS Load Balancer Controller로 ALB를 자동으로 만들어 외부 진입점을 잡고, 그 모든 매니페스트를 Helm 차트로 추상화해 dev / prod 두 환경에 다른 values로 배포되도록 만들겠습니다.

이번 시리즈는 K8s 실전 6편입니다.

한 묶음으로 보는 myshop-api의 매니페스트 #

myshop-api를 EKS에 올리려면 다음 객체가 필요합니다.

객체역할
Namespace워크로드를 묶는 격리 단위
ServiceAccountPod의 ID + IRSA 부착점 (DB 연결은 #3에서)
ConfigMap환경 설정값(로그 레벨, feature flag 등)
SecretDB 비밀번호 등 (실제 값은 #3에서 External Secrets로 채움)
Deployment실제 워크로드 — Pod replicas
Service클러스터 내부에서의 고정 가상 IP
Ingress외부 진입점 (ALB로 풀림)
HorizontalPodAutoscalerCPU 기반 Pod 개수 자동 조정
PodDisruptionBudget노드 업그레이드 시 최소 가용 보장

이 9개의 객체가 운영 워크로드 한 개의 표준 묶음입니다. 하나씩 짚으면서 적어 가겠습니다.

Namespace와 ServiceAccount #

00-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: myshop
  labels:
    name: myshop
    team: platform
    env: prod
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: myshop-api
  namespace: myshop
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/myshop-prod-api

Namespace의 라벨은 기초 #7에서 다룬 그 라벨입니다. team / env / cost-center 같은 라벨을 일관되게 붙여 두면 #5에서 Prometheus가 워크로드를 그루핑할 때 그대로 활용됩니다.

ServiceAccount의 IRSA annotation은 고급 #2에서 다룬 그 annotation입니다. 여기 적힌 IAM Role의 권한을 myshop-api Pod가 이 ServiceAccount로 동작하면서 자동으로 받습니다. Role 자체의 정의는 Terraform에서 관리합니다.

terraform/modules/myshop-api/irsa.tf
module "myshop_api_irsa" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.0"

  role_name = "myshop-prod-api"

  oidc_providers = {
    main = {
      provider_arn               = var.oidc_provider_arn
      namespace_service_accounts = ["myshop:myshop-api"]
    }
  }

  role_policy_arns = {
    secrets = aws_iam_policy.myshop_secrets_read.arn
  }
}

이 Terraform이 만든 Role의 ARN을 ServiceAccount annotation에 적습니다. 이후 Pod 안의 AWS SDK는 이 Role의 권한으로 Secrets Manager,S3,CloudWatch를 부를 수 있게 됩니다.

ConfigMap과 Secret #

01-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: myshop-api
  namespace: myshop
data:
  LOG_LEVEL: "info"
  FEATURE_NEW_CHECKOUT: "true"
  CACHE_TTL_SECONDS: "300"
  AWS_REGION: "ap-northeast-2"
---
apiVersion: v1
kind: Secret
metadata:
  name: myshop-api
  namespace: myshop
type: Opaque
stringData:
  DATABASE_URL: "postgresql://will-be-replaced-by-external-secrets"

Secret의 실제 값은 매니페스트에 두지 않습니다. #3에서 External Secrets Operator가 AWS Secrets Manager의 비밀을 K8s Secret으로 자동 동기화하는 흐름을 다루겠습니다. 이번 글에서는 골격만 잡아 두고, 고급 #6 GitOps에서 다룬 비밀 관리 모델로 #4에서 다시 묶입니다.

Deployment — Pod replicas #

02-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myshop-api
  namespace: myshop
  labels:
    app.kubernetes.io/name: myshop-api
    app.kubernetes.io/component: backend
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: myshop-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app.kubernetes.io/name: myshop-api
        app.kubernetes.io/component: backend
    spec:
      serviceAccountName: myshop-api
      containers:
        - name: api
          image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api:1.4.2
          ports:
            - containerPort: 8000
              name: http
          envFrom:
            - configMapRef:
                name: myshop-api
            - secretRef:
                name: myshop-api
          resources:
            requests:
              cpu: "200m"
              memory: "256Mi"
            limits:
              cpu: "1"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
          startupProbe:
            httpGet:
              path: /health/live
              port: http
            failureThreshold: 30
            periodSeconds: 10
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: myshop-api

운영 매니페스트의 표준 요소들이 모여 있습니다.

  • strategy.rollingUpdatemaxUnavailable: 0으로 새 버전이 충분히 떠올라야만 옛 버전을 내림. 무중단의 기본 보호.
  • envFrom — ConfigMap과 Secret을 한 번에 환경변수로 주입. 키 하나씩 적지 않음.
  • 세 가지 probe중급 #5에서 다룬 그 셋. liveness는 보수적으로(30s 후 시작), readiness는 즉시(5s), startup은 초기화가 긴 경우의 유예.
  • resources중급 #4에서 다룬 requests / limits. 이번 워크로드는 Burstable QoS(requests < limits).
  • topologySpreadConstraints — Pod를 여러 AZ에 분산. AZ 한 곳이 죽어도 다른 AZ의 Pod가 살아남음.
  • app.kubernetes.io/ 표준 라벨기초 #7의 그 표준. selector,모니터링,로깅이 모두 이 라벨로 그루핑됨.

Service — 클러스터 내부의 고정 진입점 #

03-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myshop-api
  namespace: myshop
  labels:
    app.kubernetes.io/name: myshop-api
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: myshop-api
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP

type: ClusterIP가 핵심입니다. Service 자체는 클러스터 내부에서만 접근 가능한 가상 IP이고, 외부 노출은 다음에 만들 Ingress가 담당합니다. 기초 #5에서 다룬 그 모델 그대로입니다.

AWS Load Balancer Controller — Ingress의 ALB 매핑 #

EKS의 Ingress는 K8s 표준 Ingress 객체이지만, 그 객체를 실제 ALB(AWS Application Load Balancer)로 풀어내려면 AWS Load Balancer Controller라는 컴포넌트가 클러스터에 설치되어 있어야 합니다.

Helm으로 설치
helm repo add eks https://aws.github.io/eks-charts
helm repo update

helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
  -n kube-system \
  --set clusterName=myshop-prod \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller

ServiceAccount는 미리 IRSA로 만들어 둡니다(Terraform에서). 이 controller가 클러스터 안에서 Ingress 객체를 watch하고 있다가, Ingress가 만들어지면 그에 맞는 ALB를 AWS API로 프로비저닝합니다.

Ingress 매니페스트 #

04-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myshop-api
  namespace: myshop
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-2:123456789012:certificate/abc-123
    alb.ingress.kubernetes.io/healthcheck-path: /health/ready
    external-dns.alpha.kubernetes.io/hostname: api.myshop.example.com
spec:
  rules:
    - host: api.myshop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myshop-api
                port:
                  number: 80

annotation 하나씩이 의미가 있습니다.

  • scheme: internet-facing — 인터넷 노출 ALB. 내부용은 internal.
  • target-type: ip — Pod IP를 ALB 타깃 그룹에 직접 등록. ALB가 Pod IP 변동을 watch.
  • listen-ports / ssl-redirect — HTTPS만 받고 HTTP는 자동 redirect.
  • certificate-arn — ACM 인증서. Route53과 묶인 도메인의 인증서를 ACM으로 발급해 둠.
  • healthcheck-path — ALB가 각 Pod에 보내는 헬스체크 경로.
  • external-dns.alpha.kubernetes.io/hostname — external-dns 컴포넌트가 이 ALB의 DNS를 Route53의 A 레코드로 자동 등록.

이 매니페스트를 적용한 순간부터 약 1~2분 후, AWS 콘솔에서 새 ALB가 떠 있고 api.myshop.example.com 도메인이 그 ALB로 연결되어 있는 모양이 만들어집니다.

HPA — 트래픽에 따른 Pod 자동 조정 #

05-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myshop-api
  namespace: myshop
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myshop-api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300

CPU 사용률이 평균 60%를 넘으면 Pod 개수를 늘리고, 떨어지면 줄입니다. 중급 #6에서 다룬 그 모델 그대로입니다. behavior의 stabilization 윈도우가 핵심 — scale-up은 30초로 빠르게, scale-down은 5분으로 보수적으로. 이 설정이 트래픽 스파이크에 빠르게 대응하면서도 일시적 dip에 Pod가 들쑥날쑥 줄어들지 않게 합니다.

PodDisruptionBudget — 가용성의 하한선 #

06-pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: myshop-api
  namespace: myshop
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: myshop-api

이 한 장이 무엇을 막느냐 — 노드 업그레이드 / 노드 드레인 / Cluster Autoscaler의 노드 회수 같은 자발적 disruption 시 myshop-api Pod가 동시에 2개 미만으로 떨어지지 않게 보장합니다. K8s 운영의 단골 사고 — “EKS 업그레이드 중에 Pod가 한 번에 다 내려가서 다운타임 발생"을 막는 도구입니다.

Helm 차트로 묶기 #

지금까지 적은 7~9장의 매니페스트는 그 자체로도 동작합니다. 그러나 dev / prod에 같은 워크로드를 다른 값으로 배포하려면 매니페스트를 환경마다 복제해야 하는 부담이 생깁니다. 이 지점에 Helm이 들어옵니다.

Chart 구조 #

charts/myshop-api/ 디렉터리
charts/myshop-api/
├── Chart.yaml
├── values.yaml             # 기본값
├── values-dev.yaml         # dev override
├── values-prod.yaml        # prod override
└── templates/
    ├── _helpers.tpl
    ├── namespace.yaml
    ├── serviceaccount.yaml
    ├── configmap.yaml
    ├── secret.yaml
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── hpa.yaml
    └── pdb.yaml

Chart.yaml #

Chart.yaml
apiVersion: v2
name: myshop-api
description: myshop API service
type: application
version: 0.1.0
appVersion: "1.4.2"
maintainers:
  - name: platform-team
    email: platform@myshop.example.com

values.yaml — 기본값 #

values.yaml
image:
  repository: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api
  tag: "1.4.2"
  pullPolicy: IfNotPresent

replicaCount: 3

resources:
  requests:
    cpu: 200m
    memory: 256Mi
  limits:
    cpu: 1
    memory: 512Mi

serviceAccount:
  create: true
  name: myshop-api
  irsaRoleArn: ""

config:
  LOG_LEVEL: info
  FEATURE_NEW_CHECKOUT: "true"
  CACHE_TTL_SECONDS: "300"

ingress:
  enabled: true
  host: api.myshop.example.com
  certificateArn: ""

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 60

pdb:
  enabled: true
  minAvailable: 2

values-prod.yaml — 환경별 override #

values-prod.yaml
replicaCount: 5

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 2
    memory: 1Gi

serviceAccount:
  irsaRoleArn: arn:aws:iam::123456789012:role/myshop-prod-api

config:
  LOG_LEVEL: warn

ingress:
  host: api.myshop.example.com
  certificateArn: arn:aws:acm:ap-northeast-2:123456789012:certificate/abc-123

autoscaling:
  minReplicas: 5
  maxReplicas: 50

Template — Deployment 한 장 예시 #

templates/deployment.yaml — 일부
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "myshop-api.fullname" . }}
  labels:
    {{- include "myshop-api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "myshop-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "myshop-api.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ .Values.serviceAccount.name }}
      containers:
        - name: api
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          envFrom:
            - configMapRef:
                name: {{ include "myshop-api.fullname" . }}
            - secretRef:
                name: {{ include "myshop-api.fullname" . }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

{{ include "myshop-api.fullname" . }} 같은 Helper는 _helpers.tpl에 정의해 둡니다. Helm 차트 표준 패턴이고, helm create로 골격을 만들면 자동으로 들어옵니다.

배포 #

dev 배포
helm upgrade --install myshop-api charts/myshop-api \
  -n myshop --create-namespace \
  -f charts/myshop-api/values.yaml \
  -f charts/myshop-api/values-dev.yaml
prod 배포
helm upgrade --install myshop-api charts/myshop-api \
  -n myshop --create-namespace \
  -f charts/myshop-api/values.yaml \
  -f charts/myshop-api/values-prod.yaml

같은 차트가 환경별로 다르게 적용됩니다. 이 한 명령이 #4 CI/CD 단계에서는 ArgoCD Application의 sync로 대체되지만, 개발 단계의 수동 배포에는 여전히 유효합니다.

cert-manager와 external-dns — 부가 컴포넌트 #

ACM 인증서를 직접 발급,갱신하는 대신 클러스터 안에서 Let’s Encrypt 인증서를 자동으로 받고 싶다면 cert-manager, Route53 DNS 레코드를 매니페스트로 자동 관리하려면 external-dns를 같이 도입합니다.

cert-manager 설치
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
  -n cert-manager --create-namespace \
  --set installCRDs=true
external-dns 설치
helm install external-dns external-dns/external-dns \
  -n external-dns --create-namespace \
  --set provider=aws \
  --set aws.region=ap-northeast-2 \
  --set "domainFilters[0]=myshop.example.com"

운영 클러스터의 표준 셋업입니다. 도메인이 추가될 때마다 Route53 콘솔을 열지 않고 매니페스트의 annotation 한 줄로 끝나는 흐름이 만들어집니다. ACM 인증서를 쓸 때는 cert-manager가 필요하지 않지만, 멀티 클러스터,멀티 클라우드 환경에서는 cert-manager가 더 일관됩니다.

첫 배포 후 점검 #

배포 직후 점검할 항목들입니다.

기본 헬스 체크
kubectl get all -n myshop
kubectl get hpa -n myshop
kubectl get pdb -n myshop
kubectl get ingress -n myshop
ALB 프로비저닝 확인 (1~2분 후)
kubectl describe ingress myshop-api -n myshop | grep "Address"
외부에서 접근
curl -i https://api.myshop.example.com/health/ready

이 네 명령으로 Pod,HPA,Ingress,외부 진입까지가 동작하는 상태인지 확인됩니다. 200 응답이 떨어지면 클러스터의 첫 워크로드가 운영에 들어선 시점입니다.

마무리 #

비어 있던 EKS 클러스터에 myshop-api를 한 묶음의 매니페스트로 올리고, AWS Load Balancer Controller로 ALB를 자동 프로비저닝해 외부 진입점을 만들고, 모든 매니페스트를 Helm 차트로 추상화해 dev / prod 두 환경에 같은 차트가 다른 values로 풀리는 흐름까지 따라갔습니다. 이 시점에서 myshop-api는 외부에서 접근 가능한 상태이지만, 데이터 저장소가 없습니다 — /health/ready만 200을 돌려주는 빈 껍데기입니다. 다음 글에서는 그 빈 곳을 채우겠습니다 — RDS PostgreSQL과의 연결, Secrets Manager로 비밀번호를 안전하게 주입하는 길, External Secrets Operator로 K8s Secret과 클라우드 비밀 저장소를 동기화하는 흐름, 그리고 커넥션 풀의 운영 원칙까지를 한 사이클로 다루겠습니다.

X