Helm 차트 구축기

프로젝트 차트 분석 — backend / ingress / external-secrets

2026. 06. 19.
KubernetesHelmDevOpsAWS

이 글의 목표: helm/ 폴더 안의 4개 차트가 각각 무엇을 하는지, values와 templates의 어느 부분이 어떻게 K8s 리소스로 변환되는지 이해한다.


우리 helm/ 폴더 전체 구조

helm/
├── backend/              # 우리가 만든 Spring Boot 백엔드
├── ingress/              # 외부 공개용 Nginx Ingress (NLB internet-facing)
├── ingress-internal/     # 내부망 Nginx Ingress (NLB internal)
└── external-secrets/     # AWS Secrets Manager 동기화 컨트롤러

설치 순서(권장):

1. external-secrets   ← Secret을 동기화해주는 컨트롤러를 먼저
2. ingress            ← 진입점(Ingress Controller)을 깔고
3. backend            ← 그 위에 앱을 올림

helm/backend — 우리 서비스 본체

Chart.yaml

apiVersion: v2
name: meeny-backend
description: Hello meeny backend
type: application
version: 1.0.0
appVersion: "1.0.0"

→ Subchart 의존성 없는 순수 우리 차트. 간단하다.

values.yaml의 핵심 부분

replicaCount: 2
 
image:
  repository: 505947591451.dkr.ecr.ap-northeast-2.amazonaws.com/meeny/backend
  tag: "0.0.1"
  pullPolicy: IfNotPresent
 
springProfilesActive: "prod"
 
env:
  DB_URL: "jdbc:postgresql://meeny-prod-postgres.xxxxx.rds.amazonaws.com:5432/meeny"
 
service:
  type: ClusterIP
  port: 80
  targetPort: 8080

읽는 법:

의미
replicaCountPod 2개 띄움
image.repositoryECR(우리 계정) 의 backend 리포지토리
image.tag컨테이너 이미지 버전 — 배포 때 가장 자주 바뀜
springProfilesActiveSpring prod 프로필 활성화 → RDS + Flyway 마이그레이션 활성화
env.DB_URLDB 호스트:포트/이름 (시크릿 X — 호스트는 비밀이 아님)
service.typeClusterIP — 외부에 직접 노출 X (Ingress가 들어옴)
service.portService 포트 (cluster 안에서 80)
service.targetPort컨테이너 포트 (Spring 8080)

Liveness / Readiness Probe

probes:
  liveness:
    path: /actuator/health/liveness
    initialDelaySeconds: 60
    periodSeconds: 10
  readiness:
    path: /actuator/health/readiness
    initialDelaySeconds: 30
    periodSeconds: 5
종류실패 시 동작
Liveness컨테이너 재시작 (서비스 안 됨 → kill)
ReadinessService에서 트래픽 제외 (Pod는 살아있음)

→ Spring Boot가 시작되는 데 시간이 걸리므로 initialDelaySeconds를 충분히(30~60초) 준다.

externalSecret 블록 — AWS Secrets Manager → K8s Secret 자동 동기화

externalSecret:
  enabled: true
  refreshInterval: 1h
  secretStore:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  data:
    - secretKey: DB_USERNAME
      remoteRef:
        key: rds!db-9e6b35bd-...
        property: username
    - secretKey: DB_PASSWORD
      remoteRef:
        key: rds!db-9e6b35bd-...
        property: password
    - secretKey: JWT_SECRET
      remoteRef:
        key: meeny/prod/backend/jwt
        property: secret

핵심: DB 비밀번호를 values.yaml에 적지 않는다. 대신 ESO(External Secrets Operator)가 1시간마다 AWS Secrets Manager에서 가져와 K8s Secret으로 만들어준다.

templates/externalsecret.yaml 가 위 블록을 ExternalSecret 리소스로 변환한다.

templates/deployment.yaml 에서는 envFrom: secretRef: backend-secret 으로 그 Secret을 통째로 환경변수로 주입한다.

→ 결과: Pod 안에서 System.getenv("DB_PASSWORD") 처럼 읽을 수 있고, values.yaml/git/Helm history 어디에도 비밀번호가 남지 않는다.

templates/ 가 만드는 K8s 리소스

템플릿 파일만드는 리소스
deployment.yamlDeployment (Pod 2개)
service.yamlService (ClusterIP, 80→8080)
ingress.yamlIngress (className: nginx, path /)
externalsecret.yamlExternalSecret (Secrets Manager → backend-secret)

설치 후 클러스터에는 다음이 생성된다.

Deployment/backend
ReplicaSet/backend-xxxxx
Pod/backend-xxxxx (×2)
Service/backend
Ingress/backend
ExternalSecret/backend
Secret/backend-secret    ← ESO가 자동 생성

helm/ingress — 외부 공개용 Nginx Ingress Controller

Chart.yaml

apiVersion: v2
name: meeny-ingress
description: Nginx Ingress Controller with AWS NLB
type: application
version: 1.0.0
appVersion: "1.9.6"
 
dependencies:
  - name: ingress-nginx
    version: 4.9.1
    repository: https://kubernetes.github.io/ingress-nginx

dependencies 가 핵심. 외부 ingress-nginx 차트를 그대로 가져다 씀, 우리는 values만 덮어쓰는 wrapper 차트다.

values.yaml (전체)

ingress-nginx:                                    # ← subchart 이름
  controller:
    service:
      type: LoadBalancer
      annotations:
        # NLB 사용
        service.beta.kubernetes.io/aws-load-balancer-type: "external"
        service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
        # 퍼블릭 (internet-facing)
        service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
        service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
    resources:
      requests: { cpu: 100m, memory: 128Mi }
      limits:   { cpu: 500m, memory: 256Mi }
    replicaCount: 2
    affinity:
      podAntiAffinity:
        preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchLabels:
                  app.kubernetes.io/name: ingress-nginx
              topologyKey: kubernetes.io/hostname

읽는 법:

의미
service.type: LoadBalancer외부에 노출되는 Service. AWS Load Balancer Controller가 NLB를 자동 생성
aws-load-balancer-type: externalAWS Load Balancer Controller(외부 컨트롤러) 사용 (Legacy in-tree X)
aws-load-balancer-nlb-target-type: ipNLB가 Pod IP로 직접 라우팅 (NodePort 우회 = 빠름)
aws-load-balancer-scheme: internet-facing퍼블릭 NLB — 외부 인터넷에서 접속 가능
cross-zone-load-balancing-enabled: trueAZ 간 트래픽 균등 분산
replicaCount: 2컨트롤러 자체를 2개 (HA)
podAntiAffinity가급적 다른 노드에 배치 — 한 노드 죽어도 다른 곳 살아있게

→ 이 차트를 깔면 인터넷에 노출된 NLB 1개 가 생기고, 그 NLB가 nginx 컨트롤러로 트래픽을 보낸다.

backend/templates/ingress.yaml 의 Ingress 리소스가 nginx 컨트롤러에 의해 라우팅 룰로 변환된다.


helm/ingress-internal — 내부망용 Nginx Ingress

helm/ingress 와 거의 같은데 2가지가 다르다.

ingress-nginx:
  controller:
    # 외부 ingress와 충돌 방지: 별도 IngressClass + controller class
    ingressClass: nginx-internal
    ingressClassResource:
      name: nginx-internal
      enabled: true
      default: false
      controllerValue: "k8s.io/ingress-nginx-internal"
    electionID: ingress-controller-leader-internal
 
    service:
      annotations:
        service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"  # ← 차이점

① IngressClass 분리

같은 클러스터에 ingress-nginx 컨트롤러를 두 개 깔면 둘 다 모든 Ingress 리소스를 처리하려고 충돌한다.

→ 외부용은 ingressClassName: nginx, 내부용은 ingressClassName: nginx-internal클래스를 나눠 각자 자기 거만 처리하게 한다.

electionID도 분리한다: 두 컨트롤러가 같은 leader election lock을 잡으려 다투면 안 되므로.

② Scheme: internal vs internet-facing

  • internet-facing → 퍼블릭 IP 가진 NLB (인터넷에서 접속 가능)
  • internal → VPC 내부 IP만 (사내망/VPN/Bastion에서만 접속)

→ 우리는 외부 트래픽은 ingress, 내부 운영용 도구는 ingress-internal 로 분리한다.

→ Backend Ingress가 ingressClassName: nginx (외부) 또는 nginx-internal (내부) 를 고르면 어느 LB로 들어올지 결정된다.


helm/external-secrets — 비밀값 동기화 시스템

Chart.yaml

apiVersion: v2
name: meeny-external-secrets
description: External Secrets Operator + ClusterSecretStore for AWS Secrets Manager
type: application
version: 1.0.0
appVersion: "0.10.4"
 
dependencies:
  - name: external-secrets
    version: 0.10.4
    repository: https://charts.external-secrets.io

→ 외부 ESO(External Secrets Operator) 차트 + 우리가 추가한 ClusterSecretStore 정의.

values.yaml

# Subchart 설정
external-secrets:
  installCRDs: true                  # ESO 전용 CRD 설치 (ExternalSecret 등)
  replicaCount: 1
  serviceAccount:
    create: false                    # ← 미리 만든 SA 사용
    name: external-secrets
 
# 우리 차트가 추가로 만들 ClusterSecretStore
clusterSecretStore:
  enabled: true
  name: aws-secretsmanager
  region: ap-northeast-2
  serviceAccount:
    name: external-secrets
    namespace: external-secrets

흐름 ⭐ 중요

[ AWS Secrets Manager ]
        │
        │  ESO (Pod) 가 IRSA(IAM Role for SA)로 인증
        │  → external-secrets ServiceAccount에 IAM Role이 묶여있음
        ▼
[ ClusterSecretStore: aws-secretsmanager ]
        │
        │  backend 차트의 ExternalSecret이 이 store를 참조
        ▼
[ ExternalSecret/backend ]
        │
        │  주기적으로(refreshInterval=1h) 가져와
        ▼
[ Secret/backend-secret ] (K8s 표준 Secret)
        │
        │  Pod가 envFrom: secretRef 로 환경변수 주입
        ▼
[ backend Pod ]

핵심 포인트:

  • ServiceAccount + IRSA: Pod에 AWS 인증을 안전하게 부여하는 K8s 표준 방식
  • create: false 인 이유: SA는 Terraform(EKS 모듈)으로 미리 만들어 IAM Role과 묶어둠
  • ClusterSecretStore vs SecretStore: Cluster 는 클러스터 전역, Secret 는 네임스페이스 한정. 우리는 backend가 어느 ns에 있든 쓸 수 있게 ClusterSecretStore 사용

templates/clustersecretstore.yaml

{{- if .Values.clusterSecretStore.enabled -}}
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: {{ .Values.clusterSecretStore.name }}
spec:
  provider:
    aws:
      service: SecretsManager
      region: {{ .Values.clusterSecretStore.region }}
      auth:
        jwt:
          serviceAccountRef:
            name: {{ .Values.clusterSecretStore.serviceAccount.name }}
            namespace: {{ .Values.clusterSecretStore.serviceAccount.namespace }}
{{- end }}

→ "AWS Secrets Manager 서울 리전, 인증은 external-secrets/external-secrets ServiceAccount(IRSA로 IAM Role 부여됨)로" 라는 선언.

auth.jwt 는 K8s ServiceAccount 토큰을 STS에 제출 → IAM Role 가져옴 → AWS API 호출 가능.


차트 간 의존 관계 정리

[external-secrets 차트]
   │ ESO Pod + ClusterSecretStore 설치
   │
   ▼
[ingress / ingress-internal 차트]
   │ Nginx Ingress Controller 설치 (NLB 생성됨)
   │
   ▼
[backend 차트]
   │ Deployment + Service + Ingress + ExternalSecret 설치
   │
   ▼ (런타임에)
   ExternalSecret → backend-secret(K8s Secret) → Pod env
   Ingress → IngressClass(nginx) 컨트롤러 → NLB → 인터넷

한눈 요약

차트무엇을 깔까핵심 키
backend앱 Pod + Service + Ingress + ExternalSecretimage.tag, env.DB_URL, externalSecret.data
ingressnginx 컨트롤러 (퍼블릭 NLB)aws-load-balancer-scheme: internet-facing
ingress-internalnginx 컨트롤러 (내부 NLB)ingressClass: nginx-internal, scheme: internal
external-secretsESO + ClusterSecretStoreclusterSecretStore.region, IRSA 사용 SA

다음 글에서는 install / upgrade / rollback / template / lint 등 실전 명령어를 본다.