GitHub Actionsのrepository_dispatchを使ってKubernetesに爆速でデプロイしちゃおう
1. はじめに
マイクロサービスアーキテクチャを採用している環境では、アプリケーションのソースコードとKubernetesマニフェスト(またはHelmチャート)を別々のリポジトリで管理することが一般的です。特に、ArgoCDのようなGitOpsツールを使っている場合は、「マニフェストの定義」と「アプリケーションコード」を意図的に分離することが推奨されています。ArgoCDはGitリポジトリの状態をKubernetesクラスタに反映するため、マニフェスト専用のリポジトリを用意することでデプロイの管理が明確になります。
この構成では、アプリケーションのイメージがビルドされた後、マニフェストリポジトリでイメージタグを更新し、ArgoCDにデプロイさせるという一連のフローが必要になります。従来は、この工程に手動操作や待ち時間が発生していました:
- アプリケーションのビルドとイメージのPush
- ビルド完了を待つ
- マニフェストリポジトリでイメージタグを更新(手動作業あり)
- PRを作成してマージ(自動)
- 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日現在)
bun
で scripts/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