K8s 実戦 #2 アプリデプロイ骨格 — Deployment / Service / Ingress / Helm

読了 8分

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 セットで見る 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 つのオブジェクトが運用ワークロード 1 個の標準セットです。1 つずつ押さえながら書いていきます。

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 を一度に環境変数として注入。キーを 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 — クラスタ内部の固定入口 #

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 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 自動調整 #

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

この 1 枚が何を防ぐかというと — ノードアップグレード / ノードドレイン / Cluster Autoscaler のノード回収のような 自発的 disruption 時に myshop-api Pod が同時に 2 個未満に落ちないように保証します。K8s 運用の常連事故 — 「EKS アップグレード中に Pod が一度に全部下がってダウンタイム発生」を防ぐツールです。

Helm chart で束ねる #

ここまで書いた 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 chart の標準パターンで、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

同じ chart が環境別に異なって解かれます。この 1 つのコマンドが #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 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 myshop
ALB プロビジョニング確認 (1~2 分後)
kubectl 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 サイクルで扱います。

X