K8s 実戦 #4 CI/CD パイプライン — GitHub Actions / ECR / ArgoCD

読了 8分

K8s 実戦シリーズの 4 番目の記事です。#3 まで経て myshop-api は EKS・RDS・Secrets・コネクションプールまで備えた完全なサービスですが、新しいバージョンが入ってくる過程は依然として人の手に縛られています。誰かがコンテナをビルドして push し、誰かがマニフェストの image tag を変え、誰かが helm upgrade を回します。この記事ではこの流れ全体をコードで自動化します。GitHub Actions が OIDC 信頼で静的キーなしに ECR に image を push し、マニフェスト repo の Helm values を自動 commit し、上級 #6 で扱った ArgoCD がその変更を watch してクラスタに同期する 1 サイクルです。

このシリーズは K8s 実戦 6 編です。

2 つの repo モデル — コードとマニフェストの分離 #

GitOps のもっとも一般的なパターンは repo 2 つの分離です。

repo役割
myshop-api (アプリケーション repo)ソースコード、Dockerfile、GitHub Actions workflow
myshop-manifests (マニフェスト repo)Helm values、Application マニフェスト、環境別設定

この分離には 3 つの利点があります。

  • 権限の分離 — コード変更とインフラ/デプロイ変更のレビュアーが異なりうる
  • 変更の明確性 — git log を見れば「この時点でどのバージョンが prod に立っていたか」が明確
  • ArgoCD が 1 か所を見ればよい — マニフェスト repo だけ watch すればすべての環境の desired state が押さえられる

コード push の流れが次の 1 行で表現されます。

GitOps の 1 サイクル
[開発者 push] → [GitHub Actions: ビルド/テスト/ECR push]
              → [マニフェスト repo の image tag を自動 commit]
              → [ArgoCD が変更検知]
              → [クラスタに新バージョンデプロイ]

各ステップを 1 節ずつ解いていきます。

GitHub Actions — OIDC で AWS 資格情報を動的に #

GitHub Actions から AWS API を呼ぶ古い方法は IAM ユーザーの access key / secret key を GitHub Secrets に保存することでした。この方式の問題は明らかです — キーが静的なので回転が難しく、一度漏れると影響が大きいです。

新しい標準は OIDC trust です。GitHub Actions が JWT トークンを発行し、AWS IAM がそのトークンを検証して一時資格情報を発行してくれるモデル — 上級 #2 IRSA と同じ仕組みです。

OIDC provider 登録 (Terraform) #

terraform/modules/github-oidc/main.tf
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

resource "aws_iam_role" "github_actions_ecr_push" {
  name = "github-actions-myshop-api-ecr-push"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    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"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:myshop/myshop-api:ref:refs/heads/main"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecr_push" {
  role       = aws_iam_role.github_actions_ecr_push.name
  policy_arn = aws_iam_policy.ecr_push.arn
}

Conditionsub が重要なポイントです — myshop/myshop-api repo の main ブランチからトリガされた workflow だけがこの Role を取れます。別の repo、別のブランチ、別の fork はすべて拒否されます。

Workflow — ビルドと push #

.github/workflows/build.yml
name: Build and push

on:
  push:
    branches: [main]
    tags: ['v*']

permissions:
  id-token: write    # OIDC トークン発行に必要
  contents: read

env:
  AWS_REGION: ap-northeast-2
  ECR_REPOSITORY: myshop-api

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        id: meta
        run: |
          if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
            echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
          else
            echo "tag=main-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
          fi

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-myshop-api-ecr-push
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Update manifest repo
        env:
          GH_TOKEN: ${{ secrets.MANIFESTS_REPO_TOKEN }}
        run: |
          gh api repos/myshop/myshop-manifests/dispatches \
            -f event_type=update-image \
            -F client_payload[app]=myshop-api \
            -F client_payload[tag]=${{ steps.meta.outputs.tag }} \
            -F client_payload[env]=dev

主要ステップは 3 つです。

  • Configure AWS credentials (OIDC) — 上で作った IAM Role を OIDC で AssumeRoleWithWebIdentity。この 1 ステップで静的キーなしに一時資格情報を受け取る。
  • Build and push — Docker buildx でマルチプラットフォームビルド + ECR push。GHA cache で layer キャッシング。
  • Update manifest repo — repository_dispatch イベントでマニフェスト repo の別の workflow をトリガ。この workflow が Helm values を自動 commit します。

マニフェスト repo の自動 commit #

マニフェスト repo には上の dispatch を受けて values ファイルを更新する workflow を置きます。

myshop-manifests/.github/workflows/update-image.yml
name: Update image tag

on:
  repository_dispatch:
    types: [update-image]

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Update values
        run: |
          APP=${{ github.event.client_payload.app }}
          TAG=${{ github.event.client_payload.tag }}
          ENV=${{ github.event.client_payload.env }}

          yq -i ".image.tag = \"$TAG\"" charts/$APP/values-$ENV.yaml

      - name: Commit and push
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add charts/
          git commit -m "chore: bump ${{ github.event.client_payload.app }} to ${{ github.event.client_payload.tag }} (${{ github.event.client_payload.env }})"
          git push

この commit がマニフェスト repo の main ブランチに入ると、ArgoCD がその変更を watch していてクラスタに自動同期します。

ArgoCD — マニフェスト repo の watcher #

上級 #6 で扱った ArgoCD をそのまま使います。Application CRD マニフェスト 1 枚が myshop-api 1 つの環境のデプロイを担当します。

ArgoCD インストール #

ArgoCD Helm インストール
helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd \
  -n argocd --create-namespace \
  --values argocd-values.yaml
argocd-values.yaml — 一部
server:
  ingress:
    enabled: true
    ingressClassName: alb
    annotations:
      alb.ingress.kubernetes.io/scheme: internet-facing
      alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
      alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:...
    hosts:
      - argocd.myshop.example.com

configs:
  cm:
    timeout.reconciliation: 30s

ArgoCD UI は argocd.myshop.example.com で公開されます。運用環境では SSO(GitHub、Google)と組み合わせておくのが標準です。

myshop-api Application #

argocd/applications/myshop-api-prod.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myshop-api-prod
  namespace: argocd
spec:
  project: myshop

  source:
    repoURL: https://github.com/myshop/myshop-manifests.git
    targetRevision: main
    path: charts/myshop-api
    helm:
      valueFiles:
        - values.yaml
        - values-prod.yaml

  destination:
    server: https://kubernetes.default.svc
    namespace: myshop

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
      - ServerSideApply=true
    retry:
      limit: 5
      backoff:
        duration: 5s
        maxDuration: 3m
  • automated — git の変更が即座にクラスタに反映。dev に適合。
  • selfHeal: true — 誰かが kubectl edit で直接修正しても git マニフェストで自動回復。
  • prune: true — git から消えたオブジェクトはクラスタからも削除。

dev vs prod — 自動 sync 分岐 #

prod は自動 sync を切って手動トリガで行くパターンがよく使われます。

myshop-api-prod.yaml — 手動 sync
syncPolicy:
  syncOptions:
    - CreateNamespace=true
    - ServerSideApply=true
  # automated 節を削除 → 手動 sync モード

デプロイ流れが次のように分岐します。

dev vs prod デプロイ流れ
[dev]
git push → GitHub Actions ビルド → ECR push
        → マニフェスト repo commit (values-dev.yaml)
        → ArgoCD 自動 sync → dev クラスタデプロイ

[prod]
git tag v1.5.0 → GitHub Actions ビルド → ECR push
              → マニフェスト repo commit (values-prod.yaml)
              → ArgoCD UI で人が "Sync" クリック
              → prod クラスタデプロイ

prod デプロイの人ゲートが安全装置です。マニフェスト自体は git PR でレビューされ、実際の適用は運用者がもう 1 度確認します。

Application 束の標準 — App of Apps #

ArgoCD に Application マニフェストを手で適用せず、1 つのルート Application が他の Application たちを watch するようにするパターンです。

argocd/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
spec:
  source:
    repoURL: https://github.com/myshop/myshop-manifests.git
    targetRevision: main
    path: argocd/applications
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true

argocd/applications/ ディレクトリに新しい Application を作ると自動で ArgoCD に登録され、その Application が自分のマニフェストを同期します。クラスタ自体の運用も GitOps の中に入ってきます。

Image Updater — image tag 更新を ArgoCD に #

上の流れは GitHub Actions がマニフェスト repo に commit して image tag を更新しました。ArgoCD Image Updater はこのステップを ArgoCD に移すオプションです。

myshop-api-prod.yaml — Image Updater annotation
metadata:
  annotations:
    argocd-image-updater.argoproj.io/image-list: api=123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api
    argocd-image-updater.argoproj.io/api.update-strategy: semver
    argocd-image-updater.argoproj.io/write-back-method: git
    argocd-image-updater.argoproj.io/write-back-target: helmvalues:./charts/myshop-api/values-prod.yaml

ArgoCD Image Updater が ECR を定期的に polling していて新しい tag を発見するとマニフェスト repo に自動 commit します。GitHub Actions の commit ステップが必要なくなりますが、polling 周期が 5 分単位なので即時性は落ちます。コード push とマニフェスト commit の順序を git に明確に残したければ GitHub Actions commit モデルがより直感的です。

カナリー・ブルーグリーン — Argo Rollouts #

標準 Deployment の RollingUpdate はもっとも単純な無中断デプロイモデルです。より精緻なパターン(カナリー、ブルーグリーン、自動分析後 promote)は Argo Rollouts が解いてくれます。

rollout.yaml — 5% カナリー → 分析 → 100%
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: myshop-api
  namespace: myshop
spec:
  replicas: 10
  strategy:
    canary:
      canaryService: myshop-api-canary
      stableService: myshop-api-stable
      trafficRouting:
        alb:
          ingress: myshop-api
          servicePort: 80
      steps:
        - setWeight: 5
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: success-rate
        - setWeight: 25
        - pause: { duration: 10m }
        - setWeight: 50
        - pause: { duration: 10m }
        - setWeight: 100
  selector:
    matchLabels:
      app.kubernetes.io/name: myshop-api
  template:
    spec:
      containers:
        - name: api
          image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api:1.5.0
          # ... (Deployment と同じ spec)

新しいバージョンが 5% トラフィックで 5 分 → 自動分析(Prometheus メトリクスクエリ) → 通過すれば 25% → 50% → 100% の順で段階的に切り替わります。分析段階で失敗が検知されると自動でロールバックされます。

analysistemplate.yaml — 成功率分析
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  metrics:
    - name: success-rate
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc:9090
          query: |
            sum(rate(http_requests_total{app="myshop-api",status=~"2.."}[5m]))
              / sum(rate(http_requests_total{app="myshop-api"}[5m]))
      successCondition: result[0] >= 0.99
      failureLimit: 1

#5 で扱う Prometheus メトリクスがこの段階でカナリーの自動 promote / rollback の決定に直接入ります。Rollouts は #5 で扱ったオブザーバビリティスタックと組み合わさるときに真価を発揮します。

PR 流れの標準 — environments + required reviewers #

GitHub Actions の運用標準ゲートも押さえておきます。

.github/workflows/build.yml — environment 使用
jobs:
  build-prod:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.myshop.example.com
    steps:
      - ...

environment: production を GitHub Settings で作って Required reviewers を指定すれば、その環境に行く workflow は人の承認なしには始まりません。tag 1 度で prod デプロイが自動で始まることを防ぐ標準パターンです。

最初のサイクルの点検 #

GitHub Actions push → ECR → マニフェスト commit → ArgoCD sync までが 1 度回った時点で点検する項目です。

ECR image 確認
aws ecr describe-images \
  --repository-name myshop-api \
  --region ap-northeast-2 \
  --query 'imageDetails[*].[imageTags,imagePushedAt]' \
  --output table
ArgoCD Application 状態
argocd app get myshop-api-prod
argocd app sync myshop-api-prod   # 手動 sync (prod のケース)
argocd app history myshop-api-prod
デプロイされた image tag が合っているか
kubectl get deployment myshop-api -n myshop \
  -o jsonpath='{.spec.template.spec.containers[0].image}'

3 つのコマンドが一貫して新しい tag を指していれば 1 サイクルが正常動作中です。ArgoCD UI では同じ情報が視覚的に表示され、マニフェストとクラスタの間の drift も一目で見えます。

1 つの罠 — コンテナ image tag の mutability #

運用標準は image tag を immutable にすること です。同じ tag が異なるイメージを指すようにすると ArgoCD の drift detection が意味を失います。次のセットアップが必須です。

  • ECR repository に immutable tags 有効化 — Terraform で image_tag_mutability = "IMMUTABLE"
  • latest tag を prod で絶対に使わない — 常に git SHA または semver
  • image tag = git commit hash または git tag — どの commit がどの環境に立っているかが一目で見える

このセットアップが抜けると「昨日まで動作していたその tag が今日は別のイメージ」という事故が発生します。これが GitOps の source of truth が崩れるポイントです。

締めくくり #

myshop-api の新しいバージョンがクラスタに入ってくる流れをコードで自動化しました。GitHub Actions が OIDC 信頼で静的キーなしに ECR に push し、マニフェスト repo の Helm values を自動 commit し、ArgoCD がその変更を watch してクラスタに同期する 1 サイクルです。dev は自動 sync、prod は PR レビュー + GitHub environments + ArgoCD 手動 sync の 2 重ゲートを経るパターンまで押さえ、Argo Rollouts でカナリー自動 promote / rollback のより精緻な仕組みも確認しました。この時点で myshop-api はコード push 1 度で dev に自動デプロイされ、git tag 1 度で prod デプロイがキューに入る流れが定着しました。次の記事ではこのすべての動作を覗き込む 1 層を載せます — Prometheus + Grafana + Alertmanager + CloudWatch で構成する運用クラスタのオブザーバビリティスタックと核心アラームルールセットを扱います。

X