🐷

GitHub Actionsのrepository_dispatchを使ってKubernetesに爆速でデプロイしちゃおう

2025/03/06に公開

1. はじめに

マイクロサービスアーキテクチャを採用している環境では、アプリケーションのソースコードとKubernetesマニフェスト(またはHelmチャート)を別々のリポジトリで管理することが一般的です。特に、ArgoCDのようなGitOpsツールを使っている場合は、「マニフェストの定義」と「アプリケーションコード」を意図的に分離することが推奨されています。ArgoCDはGitリポジトリの状態をKubernetesクラスタに反映するため、マニフェスト専用のリポジトリを用意することでデプロイの管理が明確になります。

この構成では、アプリケーションのイメージがビルドされた後、マニフェストリポジトリでイメージタグを更新し、ArgoCDにデプロイさせるという一連のフローが必要になります。従来は、この工程に手動操作や待ち時間が発生していました:

  1. アプリケーションのビルドとイメージのPush
  2. ビルド完了を待つ
  3. マニフェストリポジトリでイメージタグを更新(手動作業あり)
  4. PRを作成してマージ(自動)
  5. ArgoCDによるデプロイを待つ

特にステージング環境など、変更後すぐにデプロイしたいケースでは、この待ち時間と手動操作が開発効率を下げる原因になっています。

GitHub Actionsのrepository_dispatchイベントを活用すれば、この一連のフローを自動化できます。アプリケーションのビルドが完了したタイミングで、マニフェストリポジトリのワークフローを自動的にトリガーし、イメージの更新からデプロイまでをシームレスに行うことが可能です。

この記事では、repository_dispatchを使って、サービスのイメージ更新からKubernetesクラスタへのデプロイまでを自動化する方法を紹介します。

2. ポイント

2.1. Kuberenetesのデプロイ側の設定

.github/workflows/staging-release.yaml のようなワークフローファイルに以下のようなトリガを記述するだけです。

name: Staging Release
on:
  repository_dispatch:
    types:
      # Subscribeするイベントの種類のようなもの
      - staging-release

2.2 イメージ更新側の設定

curlでHTTPリクエストを送って repository_dispatch を発火します。

on:
  ...

jobs:
  docker-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # イメージのビルド、Push処理
      
      - name: Trigger staging release
        run: |
          curl -X POST \
          -H "Accept: application/vnd.github.v3+json" \
          -H "Authorization: Bearer ${{ secrets.blablabla }}" \
          https://api.github.com/repos/${{ organization }}/${{ repository }}/dispatches \
          -d '{"event_type":"staging-release", "client_payload": {"source": "blablabla"}}'

3.具体例

3.1 WorkflowのYamlの具体例

Google Container Registryのイメージを更新するワークフローの例です。
早くArtifact Registryに移行したいですね(2025年3月6日現在)

bunscripts/update-image.ts を実行したのちに、make build を実行すると、Helm Chartが生成されるようになっています。(この記事では触れませんが、 cdk8s を使ってHelm Chartを生成しており、イメージのハッシュはJSONモジュールとして書き出すことで、cdk8s のコード内で参照するような運用になっています。)

Helm Chartを生成した後に、差分のPull Requestを作成・マージするとArgoCDにデプロイされます。

Pull Requestのbranch名を毎回変更したり、diffが無い場合にworkflowを終了させる(本当はcancelにした方が良いかもしれません)といった細かなTipsもあるので、実用的なExampleとしてお読みください。

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Staging Release

on:
  repository_dispatch:
    types:
      # Subscribeするイベントの種類のようなもの
      - staging-release
  # Debug用においておく
  workflow_dispatch:
    inputs:
      reason:
        description: 'Blablabla'
        required: false
        type: string

jobs:
  staging-release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - id: auth
        name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          token_format: access_token
          workload_identity_provider: ${{ vars.bablabla }}
          service_account: ${{ vars.bablabla }}

      - name: Set Google Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Update Staging Stable
        run: bun run scripts/update-image.ts --path src/env/staging/ui/image/stable.json --service ${service_name} --latest --namespace ${namespace}

      - name: Run build
        run: make build

      - name: Show diffs
        id: show-diffs
        run: |
          echo "Unstaged changes summary:"
          git status --short
          echo "Detailed unstaged changes:"
          git add --intent-to-add .
          diff-exist() {
            git --no-pager diff --exit-code HEAD
            if [ $? -ne 0 ]; then
              echo "true"
            else
              echo "false"
            fi
          }
          echo "::set-output name=has-diffs::$(diff-exist)"

      - name: Finish workflow with error if no diffs
        if: steps.show-diffs.outputs.has-diffs == 'false'
        run: exit 1

      - name: Output Time as YYYY-MM-DD-HH-MM
        id: output-time
        run: |
          echo "::set-output name=time::$(date +%Y-%m-%d-%H-%M)"

      - id: create-pr
        name: Create PR
        uses: peter-evans/create-pull-request@v7
        with:
          token: ${{ secrets.blablabla }}
          branch: automation/update-${{ steps.output-time.outputs.time }}
          delete-branch: true
          title: "blablabla"
          committer: blablabla <blablabla@example.com>
          author: blablabla <blablabla@example.com>
          labels: blablabla
          body: |
            # OVERVIEW
            - blablabla
      
      - if: steps.create-pr.outputs.pull-request-operation == 'created'
        name: Enable Pull Request Automerge
        uses: peter-evans/enable-pull-request-automerge@v1
        with:
          token: ${{ secrets.blablabla }}
          pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}

3.2 イメージ更新のスクリプトの例

小さなスクリプトだったのでやや癖の強い書き方をしてしまっています。使えそうなところだけ使ってください。

import { parseArgs } from "util"
import { z } from "zod"
import { promises as fs } from "fs"
import { $ } from "bun"

const ImageFileJson = z.object({
  fullyQualifiedDigest: z.string(),
})
type ImageFileJson = z.infer<typeof ImageFileJson>

const ImageDescribeResult = z.object({
  image_summary: z.object({
    digest: z.string().regex(/sha256:[a-f0-9]{64}/),
    fully_qualified_digest: z.string(),
    registry: z.string(),
    repository: z.string(),
  }),
})
type ImageDescribeResult = z.infer<typeof ImageDescribeResult>

async function describeLatestImage({
  imageBase,
}: {
  imageBase: string
}): Promise<ImageDescribeResult> {
  const json =
    await $`gcloud container images describe ${imageBase}:latest --format json 2>/dev/null`.json()
  return ImageDescribeResult.parse(json)
}

const Args = z.union([
  z.object({
    path: z.string(),
    from: z.string(),
    type: z.literal("from").optional().default("from"),
  }),
  z.object({
    path: z.string(),
    latest: z.boolean(),
    service: z.string(),
    namespace: z.string(),
    type: z.literal("latest").optional().default("latest"),
  }),
  z.object({
    path: z.string(),
    image: z.string(),
    service: z.string(),
    namespace: z.string(),
    type: z.literal("with-image").optional().default("with-image"),
  }),
])

const { values: _values } = parseArgs({
  args: Bun.argv,
  options: {
    path: {
      type: "string",
    },
    from: {
      type: "string",
    },
    service: {
      type: "string",
    },
    image: {
      type: "string",
    },
    namespace: {
      type: "string",
    },
    latest: {
      type: "boolean",
    },
  },
  strict: true,
  allowPositionals: true,
})

const args = Args.parse(_values)

if (args.type === "from") {
  const { path, from } = args
  const content = ImageFileJson.parse(JSON.parse(await fs.readFile(from, "utf-8")))
  await fs.writeFile(path, JSON.stringify(content, null, 2))
}

if (args.type === "latest") {
  const latest = await describeLatestImage({
    imageBase: `gcr.io/blablabla/${args.namespace}/${args.service}`,
  })
  const content: ImageFileJson = {
    fullyQualifiedDigest: latest.image_summary.fully_qualified_digest,
  }
  await fs.writeFile(args.path, JSON.stringify(content, null, 2))
}

if (args.type === "with-image") {
  const image = `gcr.io/blablabla/${args.namespace}/${args.service}@sha256:${args.image}`
  const content: ImageFileJson = {
    fullyQualifiedDigest: image,
  }
  await fs.writeFile(args.path, JSON.stringify(content, null, 2))
}

4. 結び

repository_dispatch の追加は随分前に行われており、今回の記事でも目新しいトピックは特にありません。一方で、開発者の心理として一度築き上げたCIを変更するコストが実際より高く見えてしまうため、非効率的なOpsをそのまま利用しているケースは多々あるかと思います。今回はそのようなケースの一助となればと思い、記事を書き上げました。皆様のお力になれば幸いです。

Discussion