Kubernetes を手を動かして学ぶ (3) CI/CD パイプラインと可観測性

Kubernetes を手を動かして学ぶシリーズの第3回です。第2回で EKS 上にアプリを載せたので、今回はそれを CI/CD で自動デプロイし、Probe で正しく死活監視し、Prometheus / Grafana で可観測性を持たせます。GitHub Actions のワークフローや Terraform、Go の計装コードなど、再現に必要なコードを載せていきます(AWS アカウント ID やリポジトリ名はプレースホルダにしているので置き換えてください)。

内容が多いので、CI/CD → Probe → 監視 の順で進めます。第4回の GitOps と対比させるため、今回の CD はあえて Push 型(外部の CI がクラスタの API サーバーを叩く方式)で組んでいます。

Push 型 CD と、イミュータブルなイメージタグ

CD の方式は、GitHub Actions が外からクラスタの API サーバーを kubectl apply で叩く Push 型です。パイプラインは「PR で CI / main マージで CD」の2本構成にします。

ここで最初のクイズです。

Q. CI が同じ v3 タグでイメージを再ビルド・再 push し、kubectl apply したら、Pod はどうなる?

答えは Pod は再作成されず、古いイメージのまま動き続ける、です。ここで第1回の pod-template-hash が関わってきます。Deployment のマニフェストが1文字も変わらなければ Pod テンプレートのハッシュは変化せず、Pod は作り直されません。既存の Pod は、起動時に pull した古いイメージのまま動き続けます。

これが「CI/CD ではイメージタグを git の commit SHA のようなイミュータブルな値にする」という実務原則の直接的な理由です。副次的に「今クラスタで動いているのがどのコミットか」が一意に確定するという利点もあります。ECR 側も MUTABLEIMMUTABLE に変更して、タグ上書き事故をレジストリで防ぎます。

GitHub Actions ⇔ AWS の OIDC 連携

AWS 認証は、長期アクセスキーを GitHub Secrets に置く方式ではなく、OIDC 連携を使います。仕組みはこうです。

  1. GitHub がジョブ専用の JWT を発行する(sub クレームに「どのリポジトリのどのブランチか」が入る)
  2. AWS 側に token.actions.githubusercontent.com を信頼する OIDC Provider を登録する
  3. IAM ロールの信頼ポリシーsubaud を条件に指定する
  4. ジョブが sts:AssumeRoleWithWebIdentity で一時クレデンシャルを取得する
sequenceDiagram
  participant GH as GitHub Actions ジョブ
  participant IDP as GitHub OIDC Provider
  participant STS as AWS STS
  GH->>IDP: ID トークン(JWT)を要求<br/>(id-token write 権限が必要)
  IDP-->>GH: JWT(sub=repo:.../ref:refs/heads/main)
  GH->>STS: AssumeRoleWithWebIdentity(JWT を提示)
  STS->>STS: 信頼ポリシーの sub / aud を検証
  STS-->>GH: 一時クレデンシャル

キモは信頼ポリシーの sub 条件です。ここを緩めると、任意のリポジトリ/ブランチからロールを引けてしまいます。今回は repo:<org>/<repo>:ref:refs/heads/main に限定しました(結果として PR のジョブはロールを引けず、CI が AWS に触れない設計になります)。

実装でつまずきやすい罠をいくつか。

  • permissions: id-token: write が無いと OIDC は動かない。 これを書かないと GitHub は ID トークンを発行しません。デフォルトの permissions に含まれないため明示が必須で、最頻出の罠です。
  • thumbprint_list はもう不要。 かつては OIDC Provider の TLS 証明書の SHA-1 指紋(thumbprint)を登録し、GitHub の証明書更新のたびに CI が壊れる地雷でしたが、2023年7月以降、AWS は中間 TLS 証明書のピン留めをやめ、信頼済みルート CA で検証するようになったため、実質参照されなくなりました(GitHub Changelog(2023-07-13))。実際、thumbprint を空にしても terraform apply は通ります。
  • HCL のオブジェクト重複キーはエラーにならず後勝ちで上書き。 信頼ポリシーの Condition 内で StringEquals を2回書くと、aud 条件が静かに消滅します(terraform console で確認できる)。サイレント失敗の典型なので注意。

Terraform で書くとこうなります。sub 条件が「どのリポジトリの main ブランチか」を縛っている点に注目してください。

# github-oidc.tf
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [] # 前述のとおり空でよい
}

resource "aws_iam_role" "github_actions" {
  name = "study-k8s-github-actions"
  assume_role_policy = jsonencode({
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          "token.actions.githubusercontent.com:sub" = "repo:<GITHUB_OWNER>/<REPO>:ref:refs/heads/main"
        }
      }
    }]
  })
}

IAM と RBAC の2層構造、そして Access Policy の粒度

第2回で学んだ「IAM 権限と Kubernetes RBAC は独立レイヤー」が、ここでも効きます。GitHub Actions 用ロールに ECR push 権限と eks:DescribeCluster を与えても、EKS の Access Entry が無ければ kubectl は一切通りません。 IAM ポリシー(AWS API を叩く権限)と、Access Entry + Access Policy(クラスタ内で何ができるか)の2層が必要です。

さらに、Access Policy の粒度にも1つ罠があります。AmazonEKSAdminPolicy は名前に反して「クラスタ管理者」ではありません。Kubernetes の admin ClusterRole 相当で「名前空間の中では何でもできる」ですが、StorageClass のようなクラスタスコープのリソースは対象外です。

CD ロールに cluster-admin を持たせたくないので、StorageClass を Kustomize から Terraform 管理(kubernetes_storage_class)に移し、CD ロールは AmazonEKSEditPolicy の最小権限に留めます。StorageClass は EBS CSI Driver と一体の「インフラ」であり、アプリの CD とは分離するのが筋が良い、という整理です。

CD ワークフローの本体はこうなります。OIDC でロールを Assume → ECR に commit SHA タグで push → kustomize edit set image でタグを差し替えて kubectl apply -krollout status で完了を待つ、という流れです。

# .github/workflows/cd.yaml(Push 型・抜粋)
name: cd
on:
  push:
    branches: [main]
    paths-ignore: ['docs/**', '**/*.md'] # ← 地雷3の対策(後述)
  workflow_dispatch:

permissions:
  id-token: write # ← これが無いと OIDC が動かない
  contents: read

jobs:
  cd:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<AWS_ACCOUNT_ID>:role/study-k8s-github-actions
          aws-region: ap-northeast-1
      - id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      - name: Build & Push
        run: |
          IMAGE=${{ steps.login-ecr.outputs.registry }}/study-k8s:${{ github.sha }}
          docker build -t "$IMAGE" .
          docker push "$IMAGE"
      - name: Deploy
        run: |
          aws eks update-kubeconfig --name study-k8s-cluster
          cd k8s/overlays/eks
          kustomize edit set image \
            hello-app=${{ steps.login-ecr.outputs.registry }}/study-k8s:${{ github.sha }}
          kubectl apply -k .
          kubectl rollout status deployment/study-k8s-app-deployment

つまずきやすい地雷

Push 型 CD を組むと、いくつもの地雷が待っています。どれも「一見無関係なコンポーネントが、思わぬ経路で絡む」タイプなので、ここが一番の学びどころです。

地雷1:admission webhook がアドオンの作成を拒否する

空の state から一発で terraform apply すると、corednsaws-ebs-csi-driver のアドオン作成が CREATE_FAILED になることがあります。エラーはこれです。

AdmissionRequestDenied: failed calling webhook "mservice.elbv2.k8s.aws":
no endpoints available for service "aws-load-balancer-webhook-service"

原因の連鎖はこうです。ALB Controller は、Service を対象にした failurePolicy: FailMutatingWebhookConfiguration を登録します(type: LoadBalancer の Service を検知して NLB を作るため)。一方 CoreDNS のアドオンには kube-dns という Service が含まれます。Terraform が明示的な依存関係の無いまま ALB Controller を先に作ってしまったため、API サーバーが kube-dns Service を admission する過程で ALB Controller の webhook を呼びに行き、そのとき ALB Controller の Pod がまだ Ready でなく(Endpoints が空)、failurePolicy: Fail により拒否されます。

graph TD
  A["空の state から一括 apply"] --> B["ALB Controller が先に作られ<br/>Service 向け failurePolicy:Fail の<br/>webhook を登録"]
  B --> C["CoreDNS アドオンの<br/>kube-dns Service を作成"]
  C --> D["API サーバーが webhook を呼ぶ"]
  D --> E{"ALB Controller Pod は Ready?"}
  E -->|"Endpoints が空"| F["拒否 → CoreDNS が CREATE_FAILED"]
  F --> G["DNS 不在 → ebs-csi が sts を<br/>名前解決できず CrashLoop(二次被害)"]

ebs-csi-controller の CrashLoopBackOff は二次被害です。CoreDNS 不在 → クラスタ内 DNS が死ぬ → IRSA のために sts.ap-northeast-1.amazonaws.com を名前解決できない → AWS API を叩けず失敗、という連鎖です。CoreDNS が復旧した瞬間、Pod の再作成なしに自力回復することから、これが二次被害だと分かります。

恒久対策は、ALB Controller の helm_releasedepends_on = [module.eks] を足し、アドオンを含む EKS モジュール全体の完了後に Helm chart が入るようにすることです。ここでの要点は「Terraform は明示しない限り順序を保証しない」ことです。そして「failurePolicy: Fail の admission webhook は、それを提供する Pod が落ちると対象リソースを誰も作れなくなる」——これは実務でも有名な地雷で、その小型版にあたります。

地雷2:ヘルスチェックがアプリのカウンタを増やし続ける

新品の EBS ボリュームのはずなのに、curl を1回叩いただけで visit count: 11 が返り、その後何もアクセスしなくても30秒で 24 まで増える——こんなことが起こります。

犯人は ALB のヘルスチェックです。ターゲットグループの HealthCheckPath はデフォルトが /、間隔が15秒。アプリの / ハンドラは「アクセスのたびに visits テーブルに1行 INSERT する」実装なので、ヘルスチェックのたびにカウンタが増えます。2 Pod × 複数 AZ × 15秒間隔で加速します。

ポイント:ヘルスチェック先のエンドポイントに副作用があってはいけない。 DB 書き込みや外部 API 呼び出しが混ざっていると、誰も使っていないのに DB が太り、メトリクスが汚れ、コストがかかる。

正しくは、副作用のない専用エンドポイント(/healthz 等)を用意します。これは次の Probe の話に直結します。

地雷3:ドキュメント変更でも CD が走ってしまう

後片付けで kubectl delete して ALB の消滅を待っていると、消えたはずの ALB と EBS が作り直されることがあります。

犯人は CD です。docs/ を追記しただけの PR をマージしても、cd.yamlon: push: branches: [main] をトリガーにしていると、アプリのコードが1行も変わっていないのにビルド → push → 全 Pod 入れ替え → Ingress 再作成(= ALB 再作成)まで走ってしまいます。

対策は、CD のトリガーに paths-ignoredocs/****/*.md など)を設定してアプリに影響しない変更でデプロイを走らせないこと、そして学習用途でクラスタを都度 destroy する運用では workflow_dispatch(手動トリガー)を併用することです。これは「Push 型 CD は git の状態とクラスタの状態が自動では一致しない」という性質の裏返しでもあります。第4回の GitOps では、この乖離が「Out of Sync」として可視化されます。

liveness と readiness の Probe を正しく分ける

地雷2への恒久対策として、副作用のないヘルスチェックエンドポイントと Probe を整備します。同時に、第1回から保留していた「DB 起動待ち」問題も解決できます。

エンドポイントは2つに分けます。

  • /healthz(liveness 用)… 依存先を一切見ず、常に 200 を返す
  • /readyz(readiness 用)… DB 接続を PingContext(2秒タイムアウト)でチェックする

liveness と readiness は、失敗したときの挙動が根本的に違います。

  • liveness 失敗 → コンテナを再起動する
  • readiness 失敗 → Service の振り分け先から外すだけ(Pod は生かす)
graph TD
  subgraph LIVE["liveness → /healthz"]
    L["依存先を見ない・常に 200"] -->|"失敗したら"| LR["コンテナを再起動する"]
  end
  subgraph READY["readiness → /readyz"]
    R["DB を Ping する"] -->|"失敗したら"| RR["Service から外すだけ<br/>(Pod は再起動しない)"]
  end

だから「再起動しても直らない依存先(DB など)」を liveness でチェックしてはいけません。入れると、DB がダウンしたとき全 Pod が一斉に無意味な再起動ループに落ち、二次災害になります。DB を見るのは readiness だけ、が鉄則です。

あわせてアプリを serve-first に作り替えます。従来は DB に繋がるまでブロックしてから HTTP サーバーを起動していたため、DB が遅いとクラッシュしていました。sql.Open が実接続しない(遅延接続する)性質を使い、先に HTTP サーバーを起動 → スキーマ準備を別 goroutine でリトライ → 成功したらフラグを立てる、という構造にします。DB がまだでも /healthz は 200、/readyz だけが NotReady を報告し、DB が来たら自動で Ready になります(クラッシュしません)。

// app/main.go(serve-first 版・抜粋)
var schemaReady atomic.Bool // DB 準備完了フラグ

// 別 goroutine で DB が来るまでリトライし、成功したらフラグを立てる
func ensureSchema() {
	for {
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		err := db.PingContext(ctx)
		if err == nil {
			err = initSchema(ctx, db) // CREATE TABLE IF NOT EXISTS ...
		}
		cancel()
		if err == nil {
			schemaReady.Store(true)
			return
		}
		time.Sleep(2 * time.Second) // 実際は指数バックオフ
	}
}

// liveness: 依存先を見ず、プロセスが生きていることだけ返す
func healthzHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}

// readiness: スキーマ準備済み かつ 今この瞬間 DB に到達できる ときだけ Ready
func readyzHandler(w http.ResponseWriter, r *http.Request) {
	if !schemaReady.Load() {
		http.Error(w, "schema not ready", http.StatusServiceUnavailable)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()
	if err := db.PingContext(ctx); err != nil {
		http.Error(w, "db not reachable", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	db, _ = openDB() // sql.Open は実接続しない(遅延接続)
	go ensureSchema() // ← DB を待たずに先へ進む
	http.HandleFunc("/healthz", healthzHandler)
	http.HandleFunc("/readyz", readyzHandler)
	// ... アプリのハンドラと ListenAndServe
}

Deployment 側は、この2つを liveness / readiness に割り当てます。

# app-deployment.yaml の Probe(抜粋)
readinessProbe:
  httpGet: { path: /readyz, port: 8080 }
  initialDelaySeconds: 2 # serve-first なので早く撃ってよい
  periodSeconds: 3
  failureThreshold: 3
livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
  initialDelaySeconds: 30
  periodSeconds: 5
  failureThreshold: 3

kind で PostgreSQL を replicas=0 にして DB 障害を再現すると、約9秒後に両 Pod が 0/1 NotReady になりますが、STATUSRunningRESTARTS0 のまま。replicas=1 に戻すと、Pod の再作成・再起動なしに自力で 1/1 に復帰します。liveness / readiness を分けておくと、こうして意図通りに動きます。

Prometheus:Pull 型の監視モデル

ここから可観測性です。Prometheus は、監視対象が push するのではなく、Prometheus 本体が定期的に対象の /metrics を叩きに行く(スクレイプ)Pull 型です。これは CD の「Push 型 vs Pull 型」と同じ対立軸で、第4回の ArgoCD にも通じます。

導入は実務のデファクトスタンダードである kube-prometheus-stack(Prometheus Operator + node-exporter + kube-state-metrics + Grafana + Alertmanager を一括導入)を Helm で入れます。監視できるレイヤーは3つあります。

  1. ノード / OS(node-exporter
  2. Kubernetes オブジェクトの状態(kube-state-metrics が API サーバーを見る)
  3. アプリ自身(コードに計装を入れて /metrics を出す)

①②は「入れれば取れる」インフラ寄り、③は「アプリに手を入れて初めて取れる」ものです。ここでは③まで踏み込み、prometheus/client_golangRED 手法(Rate / Errors / Duration)に沿ったメトリクスを実装します。

// app/main.go(計装・抜粋)
var (
	httpRequestsTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{Name: "http_requests_total"},
		[]string{"path", "status"}, // Rate / Errors 用(status で 5xx を分離)
	)
	httpRequestDuration = promauto.NewHistogramVec(
		prometheus.HistogramOpts{Name: "http_request_duration_seconds", Buckets: prometheus.DefBuckets},
		[]string{"path"}, // Duration 用(分布を bucket で数える)
	)
)

// ハンドラを包んで、リクエスト数とレイテンシを記録するミドルウェア
func instrument(path string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: 200}
		next(rec, r)
		httpRequestDuration.WithLabelValues(path).Observe(time.Since(start).Seconds())
		httpRequestsTotal.WithLabelValues(path, strconv.Itoa(rec.status)).Inc()
	}
}

func main() {
	// / だけ計装。/healthz・/readyz は高頻度で叩かれるので計装しない
	http.HandleFunc("/", instrument("/", helloHandler))
	http.Handle("/metrics", promhttp.Handler()) // Prometheus が叩く先
	// ...
}

ServiceMonitor と Operator:またしても「宣言と実行者の分離」

アプリを Prometheus にスクレイプさせるには ServiceMonitor という CRD を使います。ここで第1回の Ingress とまったく同じ構図が再演されます。

  • ServiceMonitor は「このラベルの Service の /metrics をスクレイプして」という宣言にすぎない
  • それを読んで Prometheus の scrape config を生成・リロードするのが Prometheus Operator(コントロールプレーン的役割)
  • 実際に /metrics を叩いて取り込むのは Prometheus 本体

第1回の「Ingress ⇔ Ingress Controller」と同型です。

graph LR
  SM["ServiceMonitor<br/>(宣言 / CRD)"] -->|"読み取り"| OP["Prometheus Operator<br/>scrape config を生成・リロード"]
  OP --> PROM["Prometheus 本体"]
  PROM -->|"Pod IP を直接スクレイプ"| APP["アプリの /metrics"]

ServiceMonitor は、スクレイプ対象の Service をラベルで選びます。Service 側にはポート名(http)が要ります(ServiceMonitor は数値ではなくポート名で参照するため)。

# k8s/base/service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: study-k8s-app-monitor
  labels:
    release: monitoring # ← これが無いと拾われない(後述のサイレント失敗)
spec:
  selector:
    matchLabels:
      app: study-k8s-app # この Service を対象にする
  endpoints:
    - port: http # app-service.yaml で付けたポート名

そして、ここでもサイレント失敗の罠があります。kube-prometheus-stack の Prometheus は serviceMonitorSelector で「release: <リリース名> ラベル付きの ServiceMonitor だけ」を拾う設定になっています。ラベルを付け忘れても kubectl apply は成功します(CRD のスキーマ検証は通る)が、Operator のセレクタにマッチせず黙って無視され、ターゲットに現れずエラーも出ません。「CR の保存(API サーバーの検証)」と「それを使うかどうか(Operator のフィルタ)」が独立レイヤーである、という典型例です。

RED を PromQL で導出するとこうなります(histogram_quantile公式ドキュメント参照)。

# Rate(req/s)
sum(rate(http_requests_total[1m]))
# p95 レイテンシ
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

rate() は最低2サンプル必要なので、ターゲットが up した直後は空になります(スクレイプ間隔——kube-prometheus-stack ではデフォルト30秒——を数回待つ必要がある)。

Prometheus の Targets 画面。study-k8s-app-monitor がアプリの2 Pod を 2/2 UP でスクレイプしている

Grafana:dashboard-as-code とサイドカー方式

最後に Grafana で可視化します。Grafana は可視化層で、メトリクスを自分では持ちません。 データソース(Prometheus)に PromQL を投げて、返ってきた時系列を描くだけです。

ダッシュボードは UI で手作りせず、最初から dashboard-as-code(定義を ConfigMap に入れて Git 管理)にします。UI 手作りはクラスタを消すと消えてしまい、「git の状態とクラスタの状態を一致させる」思想に反するためです。

ここでも「宣言と実行者の分離」が3度目の登場です。ダッシュボードのプロビジョニングはサイドカー方式で、次の3段構造です。

  1. ConfigMap(宣言)grafana_dashboard: "1" ラベル付きでダッシュボード JSON を持つ
  2. サイドカー(実ファイル化)grafana-sc-dashboard が Kubernetes API を watch してラベル付き ConfigMap を検知し、中身を Grafana 本体と共有するボリュームに書き出す
  3. Grafana 本体(読む) … そのフォルダを読むだけ。ConfigMap の存在自体は知らない

第1回の Ingress、この回の ServiceMonitor とまったく同じ形です。ConfigMap の骨格はこれだけで、data にダッシュボードの JSON を入れます。

# k8s/base/grafana-dashboard.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: study-k8s-app-dashboard
  labels:
    grafana_dashboard: '1' # ← サイドカーが拾う目印。これが無いと出ない
data:
  study-k8s-app-red.json: |
    { ... ダッシュボード定義(RED の3パネル)の JSON ... }

そして例によって、grafana_dashboard: "1" ラベルを付け忘れると kubectl apply は成功するのにダッシュボードは黙って出ません。

RED の可視化で1つ面白いのは「No data」の意味です。エラー率 sum(rate(http_requests_total{status=~"5.."}[1m])) は、まだ5xx が一度も発生していないと http_requests_total{status="5.."} という系列自体が存在せず、 「エラー0件」ではなく「No data」 になります。「エラーが0」と「系列が存在しない」は別物で、これは Counter の系列が WithLabelValues() を初めて呼んだときに遅延生成される、という Prometheus の性質から来ます。0 と見せたいときは or vector(0) を足します。

Grafana の study-k8s-app / RED ダッシュボード。Request rate と Latency p95 に線が出て、Error rate は No data

まとめ

第3回では、CI/CD と可観測性を通して、これまでの概念が何度も再演されています。

  • pod-template-hash(第1回)→ イミュータブルなイメージタグの理由
  • IAM と RBAC の2層構造(第2回)→ CD ロールの Access Entry
  • 宣言と実行者の分離(第1回の Ingress)→ ServiceMonitor / Operator、ConfigMap / サイドカー
  • Push 型 vs Pull 型(Push 型 CD、Pull 型監視)→ 第4回の GitOps への伏線

特に地雷(admission webhook、ヘルスチェックの副作用、docs 変更での暴走)は、どれも「一見無関係なコンポーネント同士が、思わぬ経路で影響し合う」という Kubernetes の難しさを体現しています。

そして Push 型 CD の構造的な弱点——「git の状態とクラスタの状態が自動では一致しない」——を解消するのが、次回の GitOps です。

→ 第4回 ArgoCD による GitOps(Pull 型 CD)

参考文献