Kubernetes を手を動かして学ぶ (3) CI/CD パイプラインと可観測性
Kubernetes を手を動かして学ぶシリーズの第3回です。第2回で EKS 上にアプリを載せたので、今回はそれを CI/CD で自動デプロイし、Probe で正しく死活監視し、Prometheus / Grafana で可観測性を持たせます。GitHub Actions のワークフローや Terraform、Go の計装コードなど、再現に必要なコードを載せていきます(AWS アカウント ID やリポジトリ名はプレースホルダにしているので置き換えてください)。
- 第1回 コンテナ化とローカルクラスタの基礎
- 第2回 AWS EKS で本番相当の構成をつくる
- 第3回(本記事) CI/CD パイプラインと可観測性
- 第4回 ArgoCD による GitOps(Pull 型 CD)
内容が多いので、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 側も MUTABLE → IMMUTABLE に変更して、タグ上書き事故をレジストリで防ぎます。
GitHub Actions ⇔ AWS の OIDC 連携
AWS 認証は、長期アクセスキーを GitHub Secrets に置く方式ではなく、OIDC 連携を使います。仕組みはこうです。
- GitHub がジョブ専用の JWT を発行する(
subクレームに「どのリポジトリのどのブランチか」が入る) - AWS 側に
token.actions.githubusercontent.comを信頼する OIDC Provider を登録する - IAM ロールの信頼ポリシーで
subとaudを条件に指定する - ジョブが
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 -k → rollout 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 すると、coredns と aws-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: Fail の MutatingWebhookConfiguration を登録します(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_release に depends_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.yaml が on: push: branches: [main] をトリガーにしていると、アプリのコードが1行も変わっていないのにビルド → push → 全 Pod 入れ替え → Ingress 再作成(= ALB 再作成)まで走ってしまいます。
対策は、CD のトリガーに paths-ignore(docs/**、**/*.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 になりますが、STATUS は Running・RESTARTS は 0 のまま。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つあります。
- ノード / OS(
node-exporter) - Kubernetes オブジェクトの状態(
kube-state-metricsが API サーバーを見る) - アプリ自身(コードに計装を入れて
/metricsを出す)
①②は「入れれば取れる」インフラ寄り、③は「アプリに手を入れて初めて取れる」ものです。ここでは③まで踏み込み、prometheus/client_golang で RED 手法(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秒——を数回待つ必要がある)。

Grafana:dashboard-as-code とサイドカー方式
最後に Grafana で可視化します。Grafana は可視化層で、メトリクスを自分では持ちません。 データソース(Prometheus)に PromQL を投げて、返ってきた時系列を描くだけです。
ダッシュボードは UI で手作りせず、最初から dashboard-as-code(定義を ConfigMap に入れて Git 管理)にします。UI 手作りはクラスタを消すと消えてしまい、「git の状態とクラスタの状態を一致させる」思想に反するためです。
ここでも「宣言と実行者の分離」が3度目の登場です。ダッシュボードのプロビジョニングはサイドカー方式で、次の3段構造です。
- ConfigMap(宣言) …
grafana_dashboard: "1"ラベル付きでダッシュボード JSON を持つ - サイドカー(実ファイル化) …
grafana-sc-dashboardが Kubernetes API を watch してラベル付き ConfigMap を検知し、中身を Grafana 本体と共有するボリュームに書き出す - 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) を足します。

まとめ
第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)