K8s 実戦 #2 アプリデプロイ骨格 — Deployment / Service / Ingress / Helm
K8s 実戦シリーズの 2 番目の記事です。#1 で EKS クラスタを立てましたが、その上には私たちのワークロードが 1 行も入っていない状態です。この記事はその空のクラスタの上に仮想サービス myshop-api を載せる 1 サイクルです。Deployment から Ingress までの 1 セットをマニフェストで書き、AWS Load Balancer Controller で ALB を自動で作って外部入口を押さえ、そのすべてのマニフェストを Helm chart で抽象化して dev / prod の 2 つの環境に異なる 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 運用チェックリスト — アップグレード / バックアップ・リカバリ / コスト / セキュリティ
1 セットで見る 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 つのオブジェクトが運用ワークロード 1 個の標準セットです。1 つずつ押さえながら書いていきます。
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 を一度に環境変数として注入。キーを 1 つずつ書きません。- 3 つの probe — 中級 #5 で扱ったその 3 つ。liveness は保守的に(30s 後に開始)、readiness は即時(5s)、startup は初期化が長いケースの猶予。
resources— 中級 #4 で扱った requests / limits。今回のワークロードは Burstable QoS(requests < limits)。topologySpreadConstraints— Pod を複数の AZ に分散。AZ 1 か所が死んでも別の 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 1 つずつに意味があります。
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この 1 枚が何を防ぐかというと — ノードアップグレード / ノードドレイン / Cluster Autoscaler のノード回収のような 自発的 disruption 時に myshop-api Pod が同時に 2 個未満に落ちないように保証します。K8s 運用の常連事故 — 「EKS アップグレード中に Pod が一度に全部下がってダウンタイム発生」を防ぐツールです。
Helm chart で束ねる #
ここまで書いた 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 chart の標準パターンで、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同じ chart が環境別に異なって解かれます。この 1 つのコマンドが #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 1 行で終わる流れが作られます。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この 4 つのコマンドで Pod・HPA・Ingress・外部進入までが動作する状態か確認されます。200 応答が返ってくればクラスタの最初のワークロードが運用に入った時点です。
締めくくり #
空だった EKS クラスタに myshop-api を 1 セットのマニフェストで載せ、AWS Load Balancer Controller で ALB を自動プロビジョニングして外部入口を作り、すべてのマニフェストを Helm chart で抽象化して dev / prod の 2 つの環境に同じ chart が異なる values で解かれる流れまで追いました。この時点で myshop-api は外部からアクセス可能な状態ですが、データストアがありません — /health/ready だけ 200 を返す空の殻です。次の記事ではその空白を埋めます — RDS PostgreSQL との接続、Secrets Manager でパスワードを安全に注入する道、External Secrets Operator で K8s Secret とクラウド秘密保存所を同期する流れ、そしてコネクションプールの運用原則までを 1 サイクルで扱います。