GitHub GitHub Actions Google Cloud Cloud Scheduler Cloud Workflows

GitHub Actionsの実行遅延をCloud SchedulerとCloud Workflowsで解消する

GitHub GitHub Actions Google Cloud Cloud Scheduler Cloud Workflows

こんにちは、技術部データ基盤チームの zaimy です。

GitHub Actionsの schedule: トリガーが大幅に遅延する問題を、Cloud SchedulerとCloud Workflowsで解消した話を書きます。最終的に採用した構成だけでなく、検討して棄却した構成と棄却理由も合わせて紹介します。

  1. 背景
  2. 検討した構成
    1. 案A: Cloud SchedulerからGitHub APIを直接叩く
    2. 案B: Cloud Schedulerから起動するCloud WorkflowsでGitHub Actionsを置き換える
    3. 案C (採用): Cloud Schedulerから起動するCloud WorkflowsがGitHub Actionsをdispatchする
  3. なぜCloud KMSが必要なのか
  4. 実装
    1. Cloud KMS asymmetric keyの作成 (terraform)
    2. GitHub Appのprivate keyをCloud KMSにimport
    3. Cloud WorkflowsのYAML
  5. ハマりどころ
    1. Import methodの選択 (-aes-256 の有無)
    2. PEM → PKCS#8 DERへの変換
  6. 結果
  7. その他の構成案
  8. まとめ

背景

データ基盤チームでは、毎日の昼会で「前日のチーム内アップデート」を共有しています。GitHub Projectsのカードを更新者やステータスに基づいてアップデート種別 (例: 話題にすべき / 軽く触れる / ステータス移動) を自動分類してラベル付けするGitHub Actionsを、昼会の少し前に走らせる運用にしていました。このAction自体はラベル付与にClaudeを使う仕組みになっていて中身も面白いのですが、本記事の本題はそこではなく、起動タイミングの話です。

このActionは schedule: トリガー (cron) で平日11:55に実行するよう設定していましたが、実際の実行時刻は以下の通り、毎日60分以上の遅延がある状態でした。

日付 cron 設定 (UTC) 実際の実行時刻 (UTC) 遅延
4/22 Wed 02:55 03:55 +60min
4/21 Tue 02:55 03:57 +62min
4/20 Mon 02:55 04:02 +67min

GitHub Actionsの schedule: はベストエフォートで、混雑時は遅延・スキップされる仕様です。これは GitHub公式ドキュメント にも明記されています。

Note: The schedule event can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped.

そこで定刻で実行できるよう、スケジューラを別の仕組みに移すことにしました。

検討した構成

案A: Cloud SchedulerからGitHub APIを直接叩く

Cloud Scheduler は秒単位で定刻実行する信頼性の高いサービスです。HTTPターゲットを設定できるので、POST https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches を直接叩けばよいのでは、というアイデアです。

棄却理由: Cloud SchedulerのHTTPターゲットの認証オプションは、

  • なし (公開エンドポイント向け)
  • OAuth2トークン (Google APIs専用)
  • OIDCトークン (Cloud Run / Cloud Functions向け)

の3つだけで、Authorization: Bearer <token> のような任意ヘッダの値をSecret Manager等から動的に解決する仕組みがありません。カスタムヘッダ機能はあるものの、ヘッダ値はジョブ定義に平文ハードコードになります。つまりこの構成だと認証に必要な情報をterraform stateに平文で書くことになるため、棄却しました。

案B: Cloud Schedulerから起動するCloud WorkflowsでGitHub Actionsを置き換える

Cloud Workflows はYAMLで処理を記述するワークフローエンジンで、HTTPコール、Secret Manager / KMS / BigQueryなどのGoogle Cloudサービス連携、リトライ、分岐、ループを書けます。

データ基盤のワークフロー構成変更によるコスト84%削減とCI 34倍高速化 で紹介している通り、ペパボのデータ基盤ではすでにCloud SchedulerとCloud Workflowsの利用実績があるため、Cloud Workflowsで既存のActionを置き換えられるのでは、というアイデアです。

棄却理由: 既存のラベル付けスクリプトは500行程度のPythonで、

  • GitHub GraphQL APIのページネーション
  • Vertex AI ClaudeでのJSON分類
  • Issue / PRの差分計算 (前回のラベルと今回の分類差分だけを更新)

を含みます。これらをCloud Workflows YAMLで書き直すと、可読性と保守性が大きく落ちます。スクリプトをコンテナに入れて、Cloud Workflowsから呼び出すCloud Runで実行する構成もあり得ますが、本件の本質は定刻実行なので、ラベル付け本体の実装はGitHub Actions側に残すのが妥当と判断しました。

案C (採用): Cloud Schedulerから起動するCloud WorkflowsがGitHub Actionsをdispatchする

最終的に採用したのはこの構成です。Cloud WorkflowsからGitHub AppのJWT署名を行い、installation tokenを取得して dispatches を叩きます。

Cloud Scheduler (Asia/Tokyo, 55 11 * * 1-5)
  └─ Cloud Workflows
       ├─ Secret ManagerからGitHub App ID / Installation IDを取得
       ├─ Cloud KMS asymmetricSignでJWT (RS256) を署名
       │    └─ GitHub App private keyはCloud KMSにasymmetric keyとしてimport済み
       ├─ POST https://api.github.com/app/installations/{id}/access_tokens
       │    └─ installation tokenを取得
       └─ POST https://api.github.com/repos/{org}/{repo}/actions/workflows/{workflow}.yml/dispatches
             └─ GitHub Actionsを起動 (既存のラベル付けスクリプトが走る)

各レイヤの責務がきれいに分かれます。

レイヤ 責務
Cloud Scheduler 定刻実行
Cloud Workflows GitHub認証 + dispatch (JWT署名 + token取得 + API呼び出し)
GitHub Actions ラベル付け本体 (Vertex AI Claude / GraphQL / 差分計算)

なぜCloud KMSが必要なのか

GitHub Appのトークン取得フローはこうです。

  1. App ID + private keyでJWT (RS256) を作る
  2. JWTをBearerに付けて POST /app/installations/{id}/access_tokens → installation token (1時間有効) を取得
  3. installation tokenをBearerに付けて dispatches を叩く

問題は、1.のRS256署名です。Cloud Workflowsの標準ライブラリ (base64, text, json など) には暗号系のライブラリが含まれておらず、RSA秘密鍵で署名する関数が無いため、秘密鍵をSecret Managerから取り出してもJWTを組み立てられません。

回避策は以下の通りで、今回はCloud KMSを利用しました。

署名する場所
Cloud KMS asymmetricSign KMS (鍵をimportして署名APIを呼ぶ)
Cloud Run や Cloud Run Functionsを挟む Python pyjwt 等で署名

実装

Cloud KMS asymmetric keyの作成 (terraform)

resource "google_kms_key_ring" "workflows" {
  name     = "workflows"
  location = var.region
}

resource "google_kms_crypto_key" "github_app" {
  name     = "github-app-private-key"
  key_ring = google_kms_key_ring.workflows.id
  purpose  = "ASYMMETRIC_SIGN"

  # 既存の GitHub App private key を後から import するため初期バージョンは作らない
  skip_initial_version_creation = true

  version_template {
    algorithm        = "RSA_SIGN_PKCS1_2048_SHA256"
    protection_level = "SOFTWARE"
  }
}

resource "google_kms_crypto_key_iam_member" "github_app_signer" {
  for_each = toset([
    "prod-serviceaccount@{project}.iam.gserviceaccount.com",
    "test-serviceaccount@{project}.iam.gserviceaccount.com",
  ])

  crypto_key_id = google_kms_crypto_key.github_app.id
  role          = "roles/cloudkms.signerVerifier"
  member        = "serviceAccount:${each.value}"
}

GitHub Appのprivate keyをCloud KMSにimport

GitHub Appは外部公開鍵の登録を許さない (常にGitHub側で鍵を生成する) ので、既にSecret Manager上に登録してあるprivate keyをCloud KMSにimport jobで取り込みます。

# 1. import job を作成する。AES wrapping を含む -aes-256 つきを使う。
gcloud kms import-jobs create import-github-app \
  --location=us-central1 --keyring=workflows \
  --import-method=rsa-oaep-3072-sha256-aes-256 --protection-level=software

# 2. import job が ACTIVE になるまで数分待つ。
gcloud kms import-jobs describe import-github-app \
  --location=us-central1 --keyring=workflows \
  --format='value(state)'

# 3. private key を Secret Manager から取得して PKCS#8 DER に変換する。
#    Secret Manager 上は GitHub から download した PKCS#1 PEM 形式だが、
#    --target-key-file は PKCS#8 DER binary を要求するため変換が必要。
gcloud secrets versions access latest \
  --secret=github-app-private-key-secret \
  --out-file=/tmp/gh_app.pem
openssl pkcs8 -topk8 -nocrypt -inform PEM -in /tmp/gh_app.pem -outform DER -out /tmp/gh_app.der

# 4. private key を import する。
gcloud kms keys versions import \
  --import-job=import-github-app \
  --location=us-central1 --keyring=workflows \
  --key=github-app-private-key \
  --algorithm=rsa-sign-pkcs1-2048-sha256 \
  --target-key-file=/tmp/gh_app.der

# 5. 一時ファイルを削除する。
rm /tmp/gh_app.pem /tmp/gh_app.der

Cloud WorkflowsのYAML

JWT構築 → Cloud KMS署名 → installation token取得 → dispatchの流れをそのまま書き下します。

main:
  params: [args]
  steps:
    - init:
        assign:
          - project_id: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
          - github_app_id_secret: ${args.github_app_id_secret}
          - github_app_installation_id_secret: ${args.github_app_installation_id_secret}
          - kms_key_version_name: ${args.kms_key_version_name}
          - target_repo: ${args.target_repo}
          - target_workflow_file: ${args.target_workflow_file}
          - target_ref: ${args.target_ref}

    - get_github_app_id_secret:
        call: googleapis.secretmanager.v1.projects.secrets.versions.access
        args:
          name: ${"projects/" + project_id + "/secrets/" + github_app_id_secret + "/versions/latest"}
        result: app_id_secret_response
    - decode_github_app_id:
        assign:
          - github_app_id: ${int(text.decode(base64.decode(app_id_secret_response.payload.data)))}

    - get_github_app_installation_id_secret:
        call: googleapis.secretmanager.v1.projects.secrets.versions.access
        args:
          name: ${"projects/" + project_id + "/secrets/" + github_app_installation_id_secret + "/versions/latest"}
        result: installation_id_secret_response
    - decode_github_app_installation_id:
        assign:
          - github_app_installation_id: ${text.decode(base64.decode(installation_id_secret_response.payload.data))}

    - build_jwt_header_and_payload:
        assign:
          - now_seconds: ${int(sys.now())}
          - jwt_header_obj:
              alg: "RS256"
              typ: "JWT"
          - jwt_payload_obj:
              iat: ${now_seconds - 60}
              exp: ${now_seconds + 480}
              iss: ${github_app_id}
          - jwt_header_json: ${json.encode_to_string(jwt_header_obj)}
          - jwt_payload_json: ${json.encode_to_string(jwt_payload_obj)}
          - jwt_header_b64_std: ${base64.encode(text.encode(jwt_header_json))}
          - jwt_payload_b64_std: ${base64.encode(text.encode(jwt_payload_json))}
          - jwt_header_b64: ${text.replace_all(text.replace_all(text.replace_all(jwt_header_b64_std, "+", "-"), "/", "_"), "=", "")}
          - jwt_payload_b64: ${text.replace_all(text.replace_all(text.replace_all(jwt_payload_b64_std, "+", "-"), "/", "_"), "=", "")}
          - jwt_signing_input: ${jwt_header_b64 + "." + jwt_payload_b64}
          - jwt_signing_input_b64: ${base64.encode(text.encode(jwt_signing_input))}

    - sign_jwt_with_kms:
        call: http.post
        args:
          url: '${"https://cloudkms.googleapis.com/v1/" + kms_key_version_name + ":asymmetricSign"}'
          auth:
            type: OAuth2
          body:
            data: ${jwt_signing_input_b64}
        result: kms_sign_response

    - assemble_jwt:
        assign:
          - jwt_signature_b64_std: ${kms_sign_response.body.signature}
          - jwt_signature_b64: ${text.replace_all(text.replace_all(text.replace_all(jwt_signature_b64_std, "+", "-"), "/", "_"), "=", "")}
          - jwt: ${jwt_signing_input + "." + jwt_signature_b64}

    - exchange_jwt_for_installation_token:
        call: http.post
        args:
          url: '${"https://api.github.com/app/installations/" + github_app_installation_id + "/access_tokens"}'
          headers:
            Authorization: ${"Bearer " + jwt}
            Accept: "application/vnd.github+json"
            X-GitHub-Api-Version: "2022-11-28"
            User-Agent: "google-cloud-workflows"
        result: installation_token_response
    - extract_installation_token:
        assign:
          - installation_token: ${installation_token_response.body.token}

    - dispatch_workflow:
        call: http.post
        args:
          url: '${"https://api.github.com/repos/" + target_repo + "/actions/workflows/" + target_workflow_file + "/dispatches"}'
          headers:
            Authorization: ${"Bearer " + installation_token}
            Accept: "application/vnd.github+json"
            X-GitHub-Api-Version: "2022-11-28"
            User-Agent: "google-cloud-workflows"
          body:
            ref: ${target_ref}
        result: dispatch_response

    - log_dispatched:
        call: sys.log
        args:
          text: '${"Dispatched workflow " + target_repo + " " + target_workflow_file + " ref=" + target_ref}'
          severity: INFO
    - return_result:
        return: '${"dispatched: " + target_repo + " " + target_workflow_file}'

ポイントを補足します。

  • JWTのbase64url変換: Cloud Workflowsの base64.encode は標準base64 (+, /, =) を返すので、text.replace_all を3段ネストしてbase64url (-, _, パディングなし) に変換します。
  • Cloud KMS asymmetricSignの呼び出し: RSA_SIGN_PKCS1_2048_SHA256data フィールドに署名対象を渡せばKMS側でハッシュ計算と署名をやってくれます。

ハマりどころ

Import methodの選択 (-aes-256 の有無)

最初 --import-method=rsa-oaep-3072-sha256import-job を作ったところ、gcloud kms keys versions import

ERROR: ('target-key-file', "The file is larger than the import method's maximum size of 318 bytes.")

と弾かれました。素の rsa-oaep-3072-sha256 はRSA-OAEPで直接wrapできる最大サイズが318バイトしかなく、PKCS#8 DERのRSA 2048 private key (1200バイト超) は入りません。正解はAES wrappingを組み合わせる rsa-oaep-3072-sha256-aes-256 です。これだとAESでkey materialをwrapし、AESキーをRSA-OAEPでwrapする2段構成になり、大きな鍵もimportできます。

なお、import-job はimmutableで明示削除APIがありません (作成から3日でexpireする)。method指定を間違えると同名でやり直せないので、suffix変更 (-v2 等) で逃げる必要があります。

PEM → PKCS#8 DERへの変換

--target-key-file にはPKCS#8形式のDER (binary) でエンコードされた鍵を渡す必要があります。Secret Managerに保存していたGitHub Appのprivate keyはPEM形式 (PKCS#1) だったので、

openssl pkcs8 -topk8 -nocrypt -inform PEM -in /tmp/gh_app.pem -outform DER -out /tmp/gh_app.der

で変換してから渡しました。最初これを忘れて state: IMPORT_FAILED になり、importFailureReason: The key material in the import request couldn't be unwrapped or wasn't formatted correctly で落ちました。reimportEligible: true だったので同じversion番号に対して再importできました。

結果

定刻に「Cloud Scheduler実行 → Cloud Workflows実行 → Actions起動」まで一連の流れが安定動作するようになりました。Cloud Workflowsの実行は数秒で完了しています。

また、副次的な収穫として、Cloud KMS asymmetricSign経由でGitHub App JWTを署名するパターンが手元に残ったので、今後Cloud WorkflowsからGitHub APIを叩きたい別ユースケースがあれば容易に再利用できます。

その他の構成案

データ基盤チームでは既存資産としてCloud SchedulerとCloud Workflowsの組み合わせにおけるアラート実装や障害対応フローが存在するためCloud Workflowsを採用しましたが、制約がない場合はCloud SchedulerとCloud Run Functionsによる実装も十分選択できそうです。

まとめ

GitHub Actionsの schedule: トリガーは便利ですが、定刻実行が要件のジョブには使えません。

  • Cloud Schedulerで定刻実行させる
  • Cloud Workflowsで「GitHub認証 + dispatch」を担当する
  • Cloud KMSにGitHub App private keyを取り込んでJWT署名する

の3レイヤ構成にしたことで、ベストエフォートの実行から定刻実行に切り替えられました。

「Cloud SchedulerからGitHubを直接叩く」「Cloud Workflowsで全部やる」など、いくつかの代替案を比較検討した結果、責務がきれいに分かれて既存の運用基盤に乗るこの構成に落ち着きました。

Cloud KMSに鍵をimportする手順 (import-method、AES wrapping、PEM→PKCS#8 DER変換、IMPORT_FAILED時の再import) には地味なハマりどころが多いので、同じ構成を組む方の参考になれば嬉しいです。