K8s 실전 #2 앱 배포 골격 — Deployment / Service / Ingress / Helm
K8s 실전 시리즈의 두 번째 글입니다. #1에서 EKS 클러스터를 띄웠지만 그 위에는 우리 워크로드가 한 줄도 들어가지 않은 상태입니다. 이번 글은 그 빈 클러스터 위에 가상 서비스 myshop-api를 올리는 한 사이클입니다. Deployment부터 Ingress까지의 한 묶음을 매니페스트로 적고, AWS Load Balancer Controller로 ALB를 자동으로 만들어 외부 진입점을 잡고, 그 모든 매니페스트를 Helm 차트로 추상화해 dev / prod 두 환경에 다른 values로 배포되도록 만들겠습니다.
이번 시리즈는 K8s 실전 6편입니다.
- #1 EKS 클러스터 셋업 — Terraform / eksctl / IRSA / 애드온
- #2 앱 배포 골격 — Deployment / Service / Ingress / Helm ← 이번 글
- #3 DB 연동 — RDS / Secrets Manager / External Secrets / 커넥션 풀
- #4 CI/CD 파이프라인 — GitHub Actions / ECR / ArgoCD
- #5 모니터링,알람 — Prometheus / CloudWatch / Alertmanager
- #6 운영 체크리스트 — 업그레이드 / 백업,복구 / 비용 / 보안
한 묶음으로 보는 myshop-api의 매니페스트 #
myshop-api를 EKS에 올리려면 다음 객체가 필요합니다.
| 객체 | 역할 |
|---|---|
Namespace | 워크로드를 묶는 격리 단위 |
ServiceAccount | Pod의 ID + IRSA 부착점 (DB 연결은 #3에서) |
ConfigMap | 환경 설정값(로그 레벨, feature flag 등) |
Secret | DB 비밀번호 등 (실제 값은 #3에서 External Secrets로 채움) |
Deployment | 실제 워크로드 — Pod replicas |
Service | 클러스터 내부에서의 고정 가상 IP |
Ingress | 외부 진입점 (ALB로 풀림) |
HorizontalPodAutoscaler | CPU 기반 Pod 개수 자동 조정 |
PodDisruptionBudget | 노드 업그레이드 시 최소 가용 보장 |
이 9개의 객체가 운영 워크로드 한 개의 표준 묶음입니다. 하나씩 짚으면서 적어 가겠습니다.
Namespace와 ServiceAccount #
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-apiNamespace의 라벨은 기초 #7에서 다룬 그 라벨입니다. team / env / cost-center 같은 라벨을 일관되게 붙여 두면 #5에서 Prometheus가 워크로드를 그루핑할 때 그대로 활용됩니다.
ServiceAccount의 IRSA annotation은 고급 #2에서 다룬 그 annotation입니다. 여기 적힌 IAM Role의 권한을 myshop-api Pod가 이 ServiceAccount로 동작하면서 자동으로 받습니다. Role 자체의 정의는 Terraform에서 관리합니다.
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 #
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 #
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.rollingUpdate—maxUnavailable: 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 — 클러스터 내부의 고정 진입점 #
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: TCPtype: 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 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-controllerServiceAccount는 미리 IRSA로 만들어 둡니다(Terraform에서). 이 controller가 클러스터 안에서 Ingress 객체를 watch하고 있다가, Ingress가 만들어지면 그에 맞는 ALB를 AWS API로 프로비저닝합니다.
Ingress 매니페스트 #
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: 80annotation 하나씩이 의미가 있습니다.
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 자동 조정 #
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: 300CPU 사용률이 평균 60%를 넘으면 Pod 개수를 늘리고, 떨어지면 줄입니다. 중급 #6에서 다룬 그 모델 그대로입니다. behavior의 stabilization 윈도우가 핵심 — scale-up은 30초로 빠르게, scale-down은 5분으로 보수적으로. 이 설정이 트래픽 스파이크에 빠르게 대응하면서도 일시적 dip에 Pod가 들쑥날쑥 줄어들지 않게 합니다.
PodDisruptionBudget — 가용성의 하한선 #
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/
├── 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.yamlChart.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.comvalues.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: 2values-prod.yaml — 환경별 override #
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: 50Template — Deployment 한 장 예시 #
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로 골격을 만들면 자동으로 들어옵니다.
배포 #
helm upgrade --install myshop-api charts/myshop-api \
-n myshop --create-namespace \
-f charts/myshop-api/values.yaml \
-f charts/myshop-api/values-dev.yamlhelm 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를 같이 도입합니다.
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
-n cert-manager --create-namespace \
--set installCRDs=truehelm 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 myshopkubectl 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과 클라우드 비밀 저장소를 동기화하는 흐름, 그리고 커넥션 풀의 운영 원칙까지를 한 사이클로 다루겠습니다.