Cloud Ace Tech Blog
🚀

GitHub から GitLab へ:Cloud Build 第 2 世代で CI/CD を移行した実践

こんにちは、クラウドエース第三開発部の賀渝華です。中国・四川省の出身で、来日して 9 年になります。

背景として、弊社では会社全体としてソース管理を GitHub から GitLab へ移行していく方針があります。その流れの中で、先日、担当している案件の Google Cloud プロジェクト群の継続的インテグレーション/継続的デリバリー(以下、CI/CD)を GitLab へ移す作業を担当することになりました。

「リポジトリを移すだけでしょ?」と軽く考えていたのですが、いざ着手すると Cloud Build の世代差やトリガーの作り直し、既に開いていた Merge Request(以下、MR)に対して Cloud Build が遡及発火しないといった細かい挙動でつまずき、想像以上に学びの多い作業になりました。

せっかくなので、同じ移行に挑む方が少しでも楽になるよう、実際にハマったポイントや進め方を含めて実践記録として残しておきます。

この記事の対象読者・前提

この記事は、次のような方を想定しています。

  • Google Cloud の Cloud Build で CI/CD を運用している方
  • ソース管理を GitHub → GitLab へ移したい方(または移行中の方)
  • Terraform で Cloud Build トリガーを管理している方

前提知識・技術スタックの目安は次のとおりです。

項目 内容
プロダクト Cloud Build(第 2 世代)、Secret Manager、IAM
IaC(Infrastructure as Code) Terraform(本事例では 1.6.x 系)
VCS(Version Control System) GitLab(グループ配下の複数リポジトリ)
移行前 Cloud Build 第 1 世代の github {} トリガー(リージョン global
移行後 Cloud Build 第 2 世代の repository_event_config {}(リージョン asia-northeast1

要約・結論

この記事を読むと、次ができるようになります。

  1. Cloud Build 第 1 世代(GitHub)→ 第 2 世代(GitLab)の切替方針を立てられます
  2. Terraform のトリガー定義を github {} から repository_event_config {} へ書き換えられます
  3. 切替 apply(destroy/recreate)の危険ポイントと、検証の進め方が分かります
  4. 移行中によくつまずく点(Secret Manager / リポジトリ ID / リージョン / 旧第 1 世代接続解除の OAuth)を先回りできます

背景・選定理由

何が課題だったか

担当している案件の Google Cloud プロジェクト群では、複数リポジトリの CI/CD を集約プロジェクト(例: *-cicd)の Cloud Build で動かしていました。トリガーは Terraform 管理で、当時は第 1 世代の GitHub App 連携(github {})でした。

一方で、組織方針としてソース管理を GitLab へ寄せることになり、次の制約が同時に乗りました。

  • 既存の GitHub トリガーを、GitLab 起点のビルドに置き換える必要がありました
  • インフラ定義(Terraform)とアプリ/API リポジトリが複数あり、切替の瞬間に CI が止まる時間を最小化する必要がありました

なぜ Cloud Build 第 2 世代 + GitLab か

Cloud Build には世代の違いがあります。

  • 第 1 世代: GitHub App が前提です。トリガーは実質 global になります
  • 第 2 世代: GitHub / GitLab / Bitbucket などをホスト接続(connection)経由で扱います。ホスト接続・リポジトリ リンク・トリガーがリージョン付きになります

GitLab を正にするなら、実質第 2 世代一択です。本事例では既存の CI/CD 集約プロジェクトに GitLab ホスト接続を作る方針を採用しました(構成変更が最小のため)。

なお、第 2 世代の GitLab ホスト接続は Secret Manager を使うため、ホスト接続を作る前に CI/CD プロジェクトで Secret Manager API を有効化しておく必要があります。本事例ではコンソールから手動で有効化しました。

全体アーキテクチャ(移行後)

ポイントは次です。

  • GitLab 上のイベントが、ホスト接続経由で Cloud Build に届きます
  • Terraform はホスト接続そのものより「トリガー定義」をコード管理します(ホスト接続はコンソール作成でも問題ありません)
  • ビルド履歴・トリガー一覧は、これまでコンソールで global を見ていた場合、切替後はホスト接続で選んだリージョン(本事例は asia-northeast1)を指定して確認します。リージョンを切り替えないと、移行前の旧履歴しか見えません

実装・操作手順

ここからは、実際に手を動かした順番でまとめていきます。

全体の流れは次のとおりです。迷子になったら、この図に戻ってきてください。なお、Step 8 は該当する場合のみで、実施タイミングは Step 7-3 より前です(詳細は本文)。

Step 0. 方針を固める(切替前に決めること)

切替前に決めておくとよい項目です。

  1. ホスト接続を置く Google Cloud プロジェクト
  2. ホスト接続のリージョン(一度決めるとトリガーもそこに揃えます)
  3. ホスト接続の作成方法(コンソール手動 / Terraform)。権限や初期構築の都合で手動でも問題ありません
  4. 検証の順序(本事例: 手動 plan/apply で切替 → MR plan → main push の apply → ドキュメント修正 → 旧第 1 世代接続の後始末)

事前準備(権限・API)

作業の途中で権限不足に詰まらないよう、先に次を揃えておくと安心です。

Google Cloud 側(CI/CD プロジェクト)

項目 内容
Cloud Build の権限 ホスト接続の作成・リポジトリ リンクに必要な権限を付与する(本事例では Cloud Build 管理者(roles/cloudbuild.admin)を付与し、ホスト接続の作成・リポジトリ リンクを実施した)
Secret Manager API ホスト接続作成前に有効化が必要(本事例ではコンソールから手動)
Cloud Build サービス エージェント Secret 作成後に、service-<PROJECT_NUMBER>@gcp-sa-cloudbuild.iam.gserviceaccount.comroles/secretmanager.secretAccessor を付与する(Secret 本体は Step 3 で自動作成。付与手順も Step 3)

プリンシパルを誤って「プロジェクト ID」で書いてしまうと IAM 付与に失敗します。プロジェクト番号付きのサービス エージェント形式を使うのがポイントです。

GitLab 側

  • Cloud Build ホスト接続用の Group Access Token 2 種類(詳細は Step 1-2)

Step 1. GitLab 側の準備

まず、リポジトリの受け皿となる GitLab グループ/サブグループが必要です。私の場合は会社のガイドラインに沿って申請し、専任チームに作成してもらいましたが、権限があれば自分で作ることもできます。

リポジトリ移行は、迷わず GitLab の GitHub Import 機能をおすすめします。最初は「手動で clone して push すればいいか」と思っていたのですが、Import を使ったら履歴・ブランチ・(条件次第で)Issue / MR(GitHub の Pull Request から変換)までまとめて取り込めて、拍子抜けするほど楽でした。

1-1. GitHub Import の手順(推奨)

前提:

  • 取り込み先の GitLab グループ/サブグループへの作成権限があること
  • GitHub 側で Import 用の classic Personal Access Token(以下、PAT。少なくとも repo スコープが必要)を用意すること
    • GitHub 組織(Organization)が SAML SSO(Single Sign-On)必須の場合は、トークンに対して SSO Authorize が必要になることがあります

手順の概要:

  1. GitLab にログインし、New project / Create new project → Import project → GitHub を選択
  2. 認証方法を選択(GitHub OAuth または Personal Access Token)
  3. Import 可能なリポジトリ一覧が表示されたら、取り込み先の Namespace(グループ)を指定し、対象リポジトリを選択して Import を開始
  4. 完了後、ブランチ保護やデフォルト ブランチ(main)を確認

複数リポジトリがある場合も、同じ画面から連続で Import できるのが便利でした。Import 後は GitLab 上の URL が正になるので、ローカル リポジトリの向き先は次のコマンドで切り替えます。

git remote set-url origin git@gitlab.com:<group>/<repo>.git
git remote -v
git fetch

1-2. Cloud Build ホスト接続用トークン

あわせて、Cloud Build の GitLab ホスト接続作成時に使うトークンを用意します。推奨は Group Access Token(個人 PAT より、退職・権限変更の影響を受けにくい)です。

Cloud Build の GitLab ホスト接続では、用途の異なる 2 種類のトークンを求められます。本事例では対象グループ(Settings → Access Tokens)で、次の 2 種類を作成しました。

トークン名(例) Scopes Role 用途
gitlab-api-token api Owner※ API アクセス用(ホスト接続・Webhook 登録などの操作)
gitlab-read-token read_api Maintainer 読み取り用(リポジトリ/API の参照)

作成手順(概要):

  1. GitLab の対象グループ → Settings → Access Tokens → Add new token
  2. Token name / Expiration date を設定
  3. 上表の Role と Scopes を選択(api 用と read_api 用でそれぞれ作成)
  4. Create group access token → 表示されたトークンをコピー(再表示不可)

※Role は環境の権限設計に合わせて調整してください。Group Access Token/Project Access Token では、接続用途として Maintainer 以上が求められることが多く、本事例では API アクセス用に Owner、読み取り用に Maintainer を付与しました。個人 PAT で代替する場合も、同様に api / read_api 相当のスコープを持つトークンを用意します。

作成した 2 種類のトークンは、次の Step 3(ホスト接続作成)で入力します。

Step 2. Secret Manager / IAM の前提を満たす

事前準備で挙げた項目のうち、ホスト接続作成前に済ませておくものです。第 2 世代の GitLab ホスト接続は、API トークンを Secret Manager に置きます(Secret 本体の作成は次の Step 3 でコンソールが自動作成します)。

確認ポイント:

  • CI/CD プロジェクトで Secret Manager API が有効か
  • 後続の Step 3 で Secret が自動作成されたあと、Cloud Build サービス エージェントへ roles/secretmanager.secretAccessor を付与できる準備があるか(付与そのものは Step 3 で行います)

Step 3. GitLab ホスト接続を作成し、リポジトリをリンク

コンソール(Cloud Build → リポジトリ → 第 2 世代)でホスト接続を作成します。必要な権限は事前準備を参照してください(本事例では Cloud Build 管理者)。権限が無いとコンソール上で作成できず、途中で止まります。本事例でも当初は権限不足で作成できず、SRE(Site Reliability Engineering)担当/管理者に権限付与を依頼してから進めました。

ホスト接続作成時に、Step 1-2 で用意した GitLab の 2 種類のトークン(API アクセス用 / 読み取り用)を入力すると、コンソールが Secret Manager 上に秘密情報を自動作成してくれます。あらかじめ Secret を手で作っておく必要はありません。作成後は、Cloud Build サービス エージェントに、その Secret への roles/secretmanager.secretAccessor が付与されているかを確認してください。

作成後、ホスト接続のリソース名は概ね次の形になります。

projects/<PROJECT_ID>/locations/<REGION>/connections/<CONNECTION_NAME>

例(イメージ):

projects/example-cicd/locations/asia-northeast1/connections/example-gitlab-connection

続けて、CI 対象リポジトリをホスト接続へリンクします。

落とし穴: リポジトリ ID が「短縮名」にならない

コンソール経由でリンクすると、リポジトリ識別子が次のようにグループパスをフラット化した長い名前になることがあります。

# 期待しがち
my-infra-vpc

# 実際(例)
org-group-subgroup-my-infra-vpc

ここは地味に一度ハマりました。短縮名のつもりで書くと plan/apply で延々と不一致になるので、Terraform の repository 参照は必ずこの実 ID に合わせます。本事例では locals にプレフィックスを切り出して、全トリガーで共通利用するようにしました。

locals {
  cloudbuild_connection = "projects/<PROJECT_ID>/locations/asia-northeast1/connections/<CONNECTION_NAME>"
  cloudbuild_repo_prefix = "org-group-subgroup-"
}

Step 4. Terraform のトリガー定義を書き換える

第 1 世代の典型例:

resource "google_cloudbuild_trigger" "plan" {
  name     = "trigger-xxx-terraform-plan"
  filename = "cloudbuild/plan.yaml"

  github {
    owner = "example-org"
    name  = "my-infra-xxx"
    pull_request {
      branch = "^main$"
    }
  }
}

第 2 世代では次のイメージです。

resource "google_cloudbuild_trigger" "plan" {
  name     = "trigger-xxx-terraform-plan"
  location = "asia-northeast1" # ホスト接続と同じリージョン
  filename = "cloudbuild/plan.yaml"

  repository_event_config {
    repository = "${local.cloudbuild_connection}/repositories/${local.cloudbuild_repo_prefix}my-infra-xxx"
    pull_request {
      branch = "^main$"
    }
  }
}

注意: 修正対象は「典型例の 1 ファイル」では終わらない

正直、ここが一番の山場でした。トリガー定義がモジュール化+呼び出し側+直書きリソースに散らばっていて、気づけば修正ファイルが十数個になっていました。上の「典型例」だけ直して満足していると、まず間違いなくどこかに取り残しが出ます(実際に一度やらかしました)。

こういう「同じパターンの機械的な置換を、広範囲に漏れなく適用する」作業こそ、AI(コーディング エージェント)に任せると効率的です。置換ルールを一度言語化して渡せば、モジュールも直書きも横断的に洗い出してくれるので、人間は最終的な差分レビューに集中できます。

実際に触ったレイヤの例:

レイヤ やること
locals / 共通定義 versions.tf github_owner を廃止し、cloudbuild_connection / cloudbuild_repo_prefix を追加
モジュール本体 modules/cloudbuild/*/main.tf github {}repository_event_config {}location 追加
モジュール変数 modules/cloudbuild/*/variables.tf github_owner 削除、cloudbuild_connection / trigger_location 追加、ignored_files 既定値を .gitlab/**
呼び出し側 run_trigger.tf / functions_trigger.tf / tf_trigger_*.tf 引数差し替え、repository 名に prefix 付与
直書きトリガー container-image-push.tf モジュール経由でない google_cloudbuild_trigger も同じ変換が必要(ここを忘れやすい

修正漏れ防止のコツ:

  1. まずリポジトリ全体で残存を検索します
    grep -rnE 'github \{|github_owner|\.github/\*\*' --include='*.tf' .
    
  2. github { / github_owner が 0 件になるまで直します
  3. location / trigger_location / cloudbuild_connection の渡し忘れがないか、呼び出し側を一覧で確認します
  4. モジュール経由ではない直書きリソース(画像 push 用トリガーなど)を別枠でチェックします

書き換えのチェックリスト:

  • github {}repository_event_config {} に置換すること(モジュール+直書きの両方)
  • location(またはモジュールの trigger_location)をホスト接続のリージョンに合わせること
  • ignored_files.github/**.gitlab/** に更新すること(必要なら)
  • 呼び出し側の github_owner 引数を削除し、cloudbuild_connection を渡すこと
  • grepgithub { / github_owner が残っていないこと

トリガー構成を InSpec 等で検証している場合は、切替後の apply(Step 7-3)より前に期待値も更新する必要があります(手順は Step 8)。

Step 5. terraform plan で差分を読む

切替直後の apply は、GitLab トリガーがまだ無い(または旧 GitHub トリガーしか無い)状態で行うことが多いため、最初の切替だけは手動 plan → 手動 apply で進めることになります。ここは緊張する場面で、雑に流すと意図しない destroy を見逃しかねないので、差分は一行ずつ目で追うくらいの気持ちで見ていきます。

5-1. 作業ディレクトリと事前準備

cd <infra-cicd>/src
terraform init
  • state backend(GCS 等)へ到達できること
  • 実行アカウントに、対象プロジェクトの Cloud Build トリガー作成/削除権限があること
  • tfplan / .terraform/ を誤って commit しないよう、先に .gitignore を整えておくこと

5-2. plan の実行

terraform plan -out=tfplan

-out=tfplan で保存しておくと、次の apply で「さっき見た差分」とズレる事故を防げます。

5-3. 差分の読み方(本切替で見るべき点)

切替 plan では、だいたい次のような差分になります。

  • 旧トリガー destroy(location = global の第 1 世代)
  • 新トリガー create(location = asia-northeast1 の第 2 世代)
  • 表示上は ~ location = "global" -> "asia-northeast1" # forces replacement のように表示されることもあります

location は ForceNew なので、「同じ名前のまま in-place update」ではなく作り直しになります。これは想定どおりです。イメージとしては次のように、同名トリガーが global から asia-northeast1 へ「一度消えて生え直す」動きになります。

plan 精査で確認するポイント:

  • destroy / create の件数が、期待するトリガー数と概ね一致していること
  • 意図しない別リソース(IAM・Artifact Registry 等)が混ざっていないこと
  • 新トリガーの repository が、コンソールでリンクしたフラット化 ID になっていること
  • pull_request / push(branch / tag)のイベント定義が欠けていないこと

件数イメージ(環境による): 本事例ではおおよそ「数十 destroy / 数十 create」規模でした。0 change や、逆に想定外の大量差分が出たら、書き換え漏れか state 参照先の誤りを疑います。

Step 6. terraform apply で切替を実行する

plan の差分が問題なければ、保存した plan を適用します。

terraform apply tfplan

実行前の前提条件:

  • Step 3 の GitLab ホスト接続+リポジトリ リンクが完了していること
  • Step 4 の Terraform 書き換えおよび Step 5 の plan 精査が完了していること
  • 関係者に「この実行により旧 GitHub トリガーが削除され、新 GitLab トリガーへ切り替わる」旨を周知済みであること

apply 後すぐ確認すること:

  • コンソールのトリガー一覧(リージョン=ホスト接続のリージョン)に新トリガーがあること
  • global 側の GitHub トリガーが消えていること(または使われていないこと)
  • Apply complete! と、create / destroy 件数が plan と一致していること

補足: 切替 apply を手動で行う理由は、GitLab 側の apply トリガー自体が「まだ存在しない/これから作る対象」だからです。切替後は main への push で自動 apply が回るようになります。

Step 7. 切替後の検証(ここが本番)

7-1. コンソール確認

Cloud Build のトリガー一覧で、次を確認します。

  • リージョンがホスト接続と同じであること(本事例は asia-northeast1
  • ソースが GitLab リポジトリになっていること

ビルド履歴も、これまで global を見ていた場合は、ホスト接続で選んだリージョンを指定して見ます。global のまま見ると、移行前の旧履歴しか見えません。

7-2. MR(pullRequest)トリガーの実証

最大の不確実要素は、GitLab の MR イベントで plan が発火するかです。

既に開いている MR は、トリガー作成前に開かれたものだと遡及発火しないことがあります。再発火の確実な方法は、source ブランチへの空コミット push です。

git commit --allow-empty -m "ci: re-trigger plan on GitLab"
git push

GitLab の MR 画面では、Cloud Build の結果が external ステージとして見えます。個別ステップ(terraform-plan-dev など)は Google Cloud のビルド詳細側にあります。

7-3. push(apply)トリガーの実証

移行ブランチを main にマージすると、push(例: ^main$)の apply トリガーが発火します。ここで push 経路も実証できます。InSpec 等でトリガー構成を検証している場合は、このマージより前に期待値を更新してください(Step 8)。

本事例では、複数リポジトリの apply / deploy トリガーが同じ時間帯にそろって緑になった瞬間、ようやく「切替完了」とホッとひと息つけました。

Step 8. テストコード(InSpec 等)も忘れずに

Terraform の apply パイプライン後段でトリガー構成を InSpec 検証している場合、期待値がまだ github 構造のままだと失敗します。Step 7-3 で main へマージすると apply パイプラインが走るため、その前に期待値を更新しておく必要があります(Step 4 でも触れました)。実トリガー JSON は概ね次の形です。

{
  "name": "trigger-xxx-terraform-plan",
  "repositoryEventConfig": {
    "repository": "projects/.../repositories/<flat-repo-id>",
    "repositoryType": "GITLAB",
    "pullRequest": { "branch": "^main$" }
  }
}

対応の要点:

  • gcloud builds triggers list--region=<ホスト接続のリージョン> を付けます(無いと global しか見えず空になります)
  • 突合を githubrepositoryEventConfig へ変更します
  • リポジトリ名は短縮名のまま期待値に置き、コード側でプレフィックス補完しても構いません

Step 9. GitHub 前提のドキュメントを GitLab 仕様へ直す

CI/CD が動いても、リポジトリ内の README や手順書が GitHub 前提のままだと後続の開発者が迷います。Pull Request → MR、GitHub Flow → GitLab Flow、GitHub ホストの画像 URL(Organization 閉鎖後にリンク切れになる)などを見直しておきましょう。

横断チェックには grep が便利です。

grep -rnEi 'github\.com|GitHub|Pull Request|Github Flow|githubusercontent' --include='*.md' .

CI 切替本体とは独立した作業ですが、移行完了の定義に含めておくと漏れません。

Step 10. 旧第 1 世代接続の後始末

切替後、Google Cloud コンソールの第 1 世代に旧 GitHub リポジトリ接続が残ることがあります。

ここでよく出るのが次のエラーです。

Failed precondition for GITHUB_APP: GitHub credentials missing or invalid
Error processing oauth callback

この第 1 世代の GitHub OAuth 再認証がなかなかの曲者で、直接 OAuth URL を叩くと高確率で失敗しました。しばらく格闘した末、本事例では次の遠回りな手順が結局いちばん確実でした。

  1. Cloud Build → トリガーを作成します
  2. ソースで第 1 世代 → リポジトリを接続します
  3. 「GitHub (Cloud Build GitHub App)」を選び、出てきた認証フローを完了します
  4. トリガー作成自体はキャンセルして構いません
  5. 元のリポジトリ画面に戻り、不要な接続を接続解除します

これで第 1 世代の接続を空にできました。

加えて GitHub 側では、将来の完全削除/アーカイブ時に次を整理します。

  • Organization(組織)の Google Cloud Build GitHub App
  • Import 用 PAT
  • (リポジトリを削除するなら)Deploy keys はリポジトリ削除時に自動的に削除されます

まとめ

やってみて改めて感じたのは、GitHub → GitLab の Cloud Build 移行は「リポジトリを移す」だけでは終わらない、ということでした。振り返ると、本質は次の三点に集約されます。

  1. 第 2 世代ホスト接続(リージョン付き)を正にすること
  2. Terraform トリガーを repository_event_config に揃えること
  3. MR plan / main push apply の両方で発火を実証すること

特に「既存 MR は遡及発火しない」「リポジトリ ID がフラット化される」「旧接続解除の OAuth が落ちる」の三つは、私が実際に時間を溶かしたポイントです。事前に知っているだけで、当日の心理的な負担がかなり違うと思います。

一方で、移行が終わっても次のような宿題が残りがちです。

  • 開発者全員の remote 切替周知と、旧 GitHub のアーカイブ/削除
  • Dependabot 相当(Renovate / GitLab Dependency Scanning)の再整備

同じ移行に取り組む方にとって、この記事がチェックリスト代わりになったり、切替当日のちょっとした安心材料になれば幸いです。最後まで読んでいただき、ありがとうございました。

参考

Cloud Ace Tech Blog
Cloud Ace Tech Blog

Discussion