TerraformでGitHubを管理する

こんにちは、SREの@babarotです。

10Xでは GitHub Organization のリソースを Terraform で管理しています。メンバーの追加・削除、チーム構成、78リポジトリの設定(Repository Ruleset、アクセス権、GitHub Environments)など、可能な限りすべてコードで定義してPull Requestベースで変更・運用しています。

この仕組みで目指しているのは「SREだけが管理する」のではなく「誰でも安全に変更できるセルフサービス」です。メンバーの追加はtfvarsに1行足してPRを出すだけ、リポジトリの設定変更は各チームが自分でPRを出せる、新規リポジトリの作成はGitHub Actionsでリポジトリ名を入力するだけ、といったようなイメージです。ガバナンスとセルフサービスを両立する仕組みを、約2年かけて段階的に構築してきました。

この記事では、その全体像を紹介します。

なぜGitHubをTerraformで管理するのか

エンジニアリング組織が拡大すると、GitHubの運用にまつわる問題が増えていきます。当初は以下のような課題がありました。

  • 新規リポジトリを作るたびに手動で設定する必要がある
  • OrganizationのOwner権限を持つ人を制限したい・見えるようにしたい
  • 誰がどのリポジトリにアクセスできるかが見えない
  • GitHubユーザー名と社員名の対応がわからない、不要なアクセスの棚卸しがしづらい
  • リポジトリごとのブランチ保護設定がバラバラ

Terraformでコード化することで、これらの変更はすべてPull Requestにできます。レビューを経てマージされ、CIで自動的にapplyされます。「誰がいつ何を変えたか」はgit logに残り、git blameで意図を追跡できます。

全体構成

この仕組みは2つのリポジトリで成り立っています。

1つ目は infrastructure リポジトリです。この中の terraform/resources/github/ ディレクトリに、Organization のメンバー・チーム定義と、リポジトリごとの設定が入っています。メンバーやチームはルート階層の terraform.tfvars で一元管理し、リポジトリの設定は repos/ 配下にリポジトリごとのディレクトリを切って管理しています。

2つ目は tfmodule-gh-repo-kit リポジトリです。これはリポジトリの設定を標準化するためのTerraformモジュールで、各リポジトリの定義はこのモジュールを呼び出す形で書きます。Ruleset、アクセス権、Environmentといったリソースをひとまとめに管理でき、デフォルト値でガバナンスがきく設計になっています。

graph TB
    subgraph infrastructure リポジトリ
        subgraph "terraform/resources/github/"
            ROOT["ルート階層<br/>メンバー・チーム・Org Role"]
            REPOS["repos/ 配下<br/>リポジトリごとの設定"]
        end
    end

    subgraph tfmodule-gh-repo-kit
        MODULE["Terraform Module<br/>リポジトリ設定の抽象化"]
    end

    REPOS -->|"module source"| MODULE
    ROOT -->|"単一 tfstate"| GCS1["GCS<br/>10x-github-terraform"]
    REPOS -->|"リポジトリごとに<br/>独立 tfstate"| GCS2["GCS<br/>10x-github-terraform/repos/*"]

メンバー・チーム管理

terraform.tfvars への一元化

terraform.tfvars は Terraform が自動的に読み込むファイルです。カスタム名の .tfvars ファイルでは -var-file フラグが必要になりますが、terraform.tfvarsterraform plan を実行するだけで自動的にロードされます。CIのワークフローに特別な引数を足す必要がなく、Terraformユーザーであれば「データの入力先はここだ」と直感的にわかる名前でもあります。

この自動ロードの仕組みを活かして、メンバーとチームの定義をこのファイルに集約しています。データの入力はこのファイルだけで行うルールにしており、リソース定義やロジックを含む .tf ファイルは編集する必要がありません。メンバーを追加したい人はリストに1行足すだけで、HCLのリソース構造を理解する必要はありません。

users = [
  { email = "a-san@10x.co.jp", id = "ghida", role = "admin" },
  { email = "b-san@10x.co.jp",  id = "ghidb",   role = "admin" },

  { email = "c-san@10x.co.jp", id = "ghidc" },
  { email = "d-san@10x.co.jp", id = "ghidd" },
  // ... 60名超
]

teams = [
  {
    name    = "SRE",
    members = ["babarot", "horimislime", "soiya"],
  },
  {
    name        = "Engineers"
    description = "10XのSWEが所属するチーム",
    members     = ["swdyh", "kitak", "hisaichi5518", ...],
  },
  // ...
]

メンバーの追加は1行追加してPRを出すだけです。

チームメンバーシップのrole(maintainer / member)は、そのユーザーがOrg adminかどうかで自動判定されます。

resource "github_team_membership" "members" {
  for_each = { /* flatten して team-member ペアに展開 */ }

  team_id  = github_team.teams[each.value.team_name].id
  username = each.value.username
  role = contains(
    [for user in var.users : user.id if user.role == "admin"],
    each.value.username,
  ) ? "maintainer" : "member"
}

Organization Role

SREとセキュリティチームには、カスタムOrganization Roleで全リポジトリのmaintain権限を一括付与しています。リポジトリごとに個別設定する必要がないので、新しいリポジトリが増えても自動的にカバーされます。

resource "github_organization_role_team" "sre_is_all_repos_maintainer" {
  team_slug = "sre"
  role_id   = data.github_organization_role.all_repository_maintain.role_id
}

リポジトリの管理

1リポジトリ = 1ディレクトリ + 1 tfstate

各リポジトリは repos/{リポジトリ名}/ ディレクトリに2ファイルで定義します。

repos/
├── stailer-server/
│   ├── module_gh_repo_kit.tf   # モジュール呼び出し
│   └── backend.tf              # GCS prefix = "repos/stailer-server"
├── tfmodule-gh-repo-kit/
│   ├── module_gh_repo_kit.tf
│   └── backend.tf
└── ... (78リポジトリ)

tfstateはリポジトリごとにGCS上で分離しています。

terraform {
  backend "gcs" {
    bucket = "10x-github-terraform"
    prefix = "repos/stailer-server"
  }
}

stateはできるだけ独立した単位で分割することが重要です。最初はsmall startで始めたためすべてのリポジトリを単一のtfstateで管理していましたが、リポジトリ数が増えるにつれてplan/applyが遅くなり、1つのリポジトリのapply失敗が他に波及するリスクも出てきました。リポジトリごとにstateを分離することで、変更のあったリポジトリだけがplan/applyの対象になります。GitHub APIのRate Limitの影響も受けにくくなります。

tfmodule-gh-repo-kit

リポジトリの設定はすべて自社開発のTerraformモジュール tfmodule-gh-repo-kit に集約しています。

  • github_repository
  • github_repository_ruleset
  • github_team_repository
  • github_repository_collaborator
  • github_repository_environment
  • など

シンプルなリポジトリの定義はこんな感じです。

module "repository" {
  source = "git@github.com:10xinc/tfmodule-gh-repo-kit.git?ref=v0.1.13"

  name        = "tfmodule-gh-repo-kit"
  description = "社内GitHub Repositoryを管理するためのTerraform Moduleです。"
  maintainers = ["SRE"]

  general = {
    default_branch     = "main"
    allow_rebase_merge = false
    allow_squash_merge = false
  }

  rulesets = [
    {
      name        = "main branch"
      target      = "branch"
      enforcement = "active"
      conditions = {
        ref_name = { include = ["~DEFAULT_BRANCH"], exclude = [] }
      }
      rules = {
        require_pull_request = {
          required_approving_review_count = 1
          require_code_owner_review       = true
        }
        block_force_push  = true
        restrict_deletion = true
      }
      bypass_actors = []
    },
  ]
}

モジュールにはデフォルト値が設定されているので、明示的に書かなくても vulnerability_alerts: truedelete_branch_on_merge: true などのガバナンスが効きます。

モジュールは独立したリポジトリでバージョン管理しており、Songmu/tagprを使ったタグベースのsemverでリリースしています。Renovateが各リポジトリのmodule source refを自動更新してくれるので、モジュールの改善が組織全体に波及します。サプライチェーン攻撃対策として、リリースにはSREの承認を必須にしています。

bypass_actors の宣言的管理

Repository Rulesetの bypass_actors で、特定のファイルパスへの書き込みを許可するアクターを制御できます。たとえば、.github/**/* への書き込みをadminと特定のbotに限定し、それ以外のユーザーやAI Agentからの変更をブロックできます。

{
  name        = "Restrict push to .github"
  target      = "push"
  enforcement = "active"
  rules = {
    restricted_file_paths = [".github/**/*"]
  }
  bypass_actors = [
    { actor_id = 4, actor_type = "RepositoryRole", bypass_mode = "always" },  # admin
    { actor_id = module.repository.github_app_ids.renovate, actor_type = "Integration", bypass_mode = "always" },
    { actor_id = module.repository.github_app_ids.dependabot, actor_type = "Integration", bypass_mode = "always" },
    { actor_id = module.repository.github_app_ids.copilot_coding_agent, actor_type = "Integration", bypass_mode = "always" },
  ]
}

bypass_actors に渡す GitHub App ID はモジュール側で locals として管理しており、module.repository.github_app_ids.renovate のように名前で参照できます。マジックナンバーを調べて埋め込む必要がありません。

locals {
  github_app_ids = {
    copilot_code_review  = 946600
    copilot_coding_agent = 1143301
    dependabot           = 29110
    devin                = 811515
    github_actions       = 15368
    octo_sts             = 1457659
    policy_bot           = 3437305
    renovate             = 2740
  }
}

Terraform でこの設定をコード管理しているからこそ、78リポジトリに対して一貫したポリシーを適用できています。10Xでは Devin、GitHub Copilot coding agent、Claude といった AI Agent も人間と同じ PR フローで Terraform の変更を出していますが、こうした Ruleset があることで、変更できる範囲を宣言的に制御できています。

セルフサービスの設計

この仕組みで意識しているのは、SREがすべてを管理するのではなく、各チームが自分たちのリポジトリを自分で管理できるようにすることです。

各チームがリポジトリ設定を変更できる

各リポジトリの設定は repos/{リポジトリ名}/module_gh_repo_kit.tf に書かれています。Issueタブの有効化、マージ方式の変更、Rulesetのレビュー要件の調整など、リポジトリの設定変更は各チームのメンバーが自分でPRを出せます。

実際に、SWEがIssueタブを有効にしたり、チーム異動に伴ってアクセス権を変更したりするPRが日常的に出ています。チームに閉じたPRについてはSREフリーでマージ可能になっており、依頼されればレビューこそしますが、SREが代わりに作業するということはなくなりました。

workflow_dispatchで新規リポジトリを作成

新しいリポジトリを作りたいときは、GitHub Actionsの workflow_dispatch でリポジトリ名と説明を入力するだけです。

sequenceDiagram
    actor User
    participant GHA as GitHub Actions
    participant Template as テンプレート生成
    participant PR as Pull Request

    User->>GHA: workflow_dispatch<br/>(リポジトリ名, 説明)
    GHA->>Template: リポジトリ名からtfファイル生成
    Template-->>GHA: module_gh_repo_kit.tf + backend.tf
    GHA->>PR: 自動でPR作成
    PR-->>User: レビュー & マージ
    Note over GHA: terraform apply で<br/>リポジトリ作成

ワークフローの中でテンプレートから module_gh_repo_kit.tfbackend.tf を生成し、自動でPRが作成されます。テンプレートにはデフォルトのRuleset(main branchへのPR必須、1 approval、CODEOWNER review必須)が含まれているため、新規リポジトリでも最初からガバナンスが効いた状態になります。

「新しいリポジトリを作ったけどブランチ保護を設定し忘れた」ということが構造的に起きません。

メンバー管理もセルフサービス

チーム異動が発生した場合、マネージャーの作業を待つ必要はありません。メンバー自身が terraform.tfvars のチーム定義を書き換えてPRを出せます。レビューが通ればCIで自動的にapplyされます。

CIでの認証: tfファイルからトークンスコープを動的に決める

Terraform GitHub Providerでリポジトリの設定を terraform plan するには、対象リポジトリへの administration: read 相当の権限が必要です。terraform apply であれば administration: write も必要になります。

素朴に実装すると、CIに渡すトークンにOrganization内の全リポジトリへのwrite権限を持たせることになります。これなら全リポジトリのplan/applyが通りますが、あるリポジトリの設定変更PRから、無関係な全リポジトリへのwrite権限が行使できてしまいます。

10Xではこの問題を、Terraformコードの中身からトークンスコープを動的に決定することで解決しています。

hcl2json でリポジトリ名を抽出する

前述の通り、各リポジトリの設定は repos/{リポジトリ名}/module_gh_repo_kit.tf に書かれています。このファイルには必ずリポジトリ名が含まれています。

module "repository" {
  source = "git@github.com:10xinc/tfmodule-gh-repo-kit.git?ref=v0.1.13"
  name   = "stailer-server"
  ...
}

CIのmatrix jobは、変更のあったディレクトリごとに並列実行されます。各jobが必要とするリポジトリは、対象ディレクトリのtfファイルを読まなければわかりません。そこで hcl2json を使います。

hcl2json はHCLをJSONに変換するツールです。上記のHCLを変換すると以下のJSONが得られます。

{
  "module": {
    "repository": [{"name": "stailer-server", "source": "..."}]
  }
}

ここから jq でリポジトリ名を取り出し、スコープ対象のリポジトリリストを組み立てます。

- name: Extract target repository name for token scoping
  run: |
    # repos/* の場合のみトークンスコープを制限
    # teams/* 等はリポジトリ横断のアクセスが必要なためスコープ制限しない
    if [[ "${DIR}" == terraform/resources/github/repos/* ]]; then
      repo_name=$(hcl2json < "${DIR}/module_gh_repo_kit.tf" | \
        jq -r '.module.repository[0].name')
      echo "repositories<<EOF" >> "${GITHUB_OUTPUT}"
      echo "tfmodule-gh-repo-kit"
      echo "tfmodule-team-kit"
      echo "tfmodule-service-kit"
      echo "${repo_name}"
      echo "EOF" >> "${GITHUB_OUTPUT}"
    fi

スコープには対象リポジトリに加えて、Terraform module sourceの解決に必要なモジュールリポジトリ群を含めています。terraform init でモジュールをダウンロードする際にこれらへのアクセス権が必要になるためです。

なお、新規リポジトリはまだGitHub上に存在しないため、スコープに含めるとトークン取得が422エラーで失敗します。実際のワークフローではGitHub APIで存在確認を行い、存在しない場合はスコープなし(Organization全体)にフォールバックしています。

スコープ付きトークンの発行

抽出したリポジトリ名リストを使って、GitHub App Installation Tokenを発行します。トークンの発行には yagihash/ghat を使っています。

- name: Get GitHub API token
  uses: yagihash/ghat@ddb363556d0ce66d8b01687e221e887100a98867 # v2.3.0
  with:
    app_id: ${{ vars.GH_APP_TF_APP_ID }}
    kms_project_id: ${{ vars.GH_APP_KMS_PROJECT_ID }}
    repositories: ${{ steps.target-repo.outputs.repositories }}

ghatの内部では以下の処理が行われています。

  1. GitHub App IDと有効期限からJWTのヘッダとペイロードを組み立てる
  2. Google Cloud KMSの AsymmetricSign APIにダイジェストを送り、署名だけを返してもらう。秘密鍵はKMSの外に一切出ない
  3. 署名済みJWTでGitHub APIの POST /app/installations/{id}/access_tokens を呼び、repositories フィールドに先ほど抽出したリポジトリ名リストを渡す
  4. GitHub APIは、指定されたリポジトリだけにスコープされたInstallation Access Tokenを返す
  5. ワークフロー終了時に DELETE /installation/token を呼び、トークンを自動失効させる

GitHub Appの秘密鍵をKMSで管理する仕組みの詳細は、GitHub Appの秘密鍵をGitHub Secretsから追い出す で紹介しています。

これにより、stailer-serverリポジトリを変更するPRのmatrix jobには、stailer-serverリポジトリとモジュールリポジトリ群だけへのwrite権限を持つトークンが渡されます。他のリポジトリへの権限は一切ありません。

GitHub App自体もplan用とapply用で分離しています。GitHub Environmentごとに異なるApp IDを設定し、Workload Identity Federationの attribute_condition でapply用のAppはmainブランチのapplyワークフローからしかアクセスできないように制限しています。PRのブランチからapply用のトークンを取得することはできません。

なぜこの仕組みが必要か

「GitHub AppのInstallation Tokenをリポジトリスコープで発行する」こと自体は珍しくありません。ポイントは、スコープの対象をTerraformコードから動的に決定していることです。

78リポジトリがそれぞれ独立したtfstateを持ち、CIはmatrixで並列実行される構成なので、各matrix jobが必要とするリポジトリはtfファイルの中身を見ないとわかりません。hcl2json でHCLをパースしてリポジトリ名を取り出すことで、「このPRではstailer-serverリポジトリだけ」「このPRではtfmodule-gh-repo-kitリポジトリだけ」と、必要最小限のスコープを自動的に決定しています。

CI/CDパイプライン

plan / apply

GitHub Actionsで、変更のあったディレクトリだけをmatrixで並列実行します。

flowchart LR
    PR["PR作成"] --> Detect["変更ディレクトリ検出"]
    Detect --> Matrix["matrix 展開<br/>(max 5並列)"]
    Matrix --> Conftest["conftest<br/>ポリシーチェック"]
    Conftest --> Plan["terraform plan"]
    Plan --> Comment["PRにplan結果<br/>コメント"]

    Merge["main マージ"] --> Detect2["変更ディレクトリ検出"]
    Detect2 --> Matrix2["matrix 展開"]
    Matrix2 --> Rollback["rollback<br/>防止チェック"]
    Rollback --> Apply["terraform apply"]
    Apply -->|失敗| Notify["Slack通知<br/>+ PRコメント"]

変更ディレクトリの検出には action-changed-objects を使っています。apply時にはrollback防止チェックも行っています。PRがマージされてからapplyが実行されるまでの間に、同じディレクトリに後続の変更がマージされていないかを確認し、古い状態で上書きされることを防ぎます。

conftest によるポリシーチェック

plan時にはconftestによるポリシーチェックが走ります。HCLファイルの静的チェックと、plan結果に対するチェックの2段階です。GitHub関連だと以下のようなポリシーが設定されています。

ポリシー 効果
退職者のGitHub ID残留検出 GitHub APIからOrg メンバー一覧をリアルタイム取得し、tfファイル内のGitHub IDを検証
外部コラボレーターの検証 許可リストにない外部ユーザーがアクセス権に含まれていないか検証
github_branch_protection 使用禁止 Rulesetへの一本化を強制
hashicorp/github provider 使用禁止 integrations/github を強制
SRE/securityチームへの冗長な権限付与禁止 Org Roleで全リポジトリmaintain権限を持つチームに、リポジトリ単位でpull/push/maintain等を付与しても無意味なため、conftest で弾く
removed/moved/import の命名規則 ファイル名の衝突防止

この中だと退職者検出の仕組みは面白いです。GitHub Organization のメンバー一覧をGitHub APIからリアルタイムに取得し、それをSingle Source of Truthとして、Terraformファイル内のGitHub IDをチェックします。組織を離れたメンバーのIDがリポジトリのアクセス権に残っていたり、typoで存在しないユーザーが指定されていたりすると、PRのインラインコメントで指摘されます。

deny_invalid_repo_user contains {"msg": reason} if {
    some module_name, module_configs in input.module
    module_config := module_configs[0]
    contains(module_config.source, "tfmodule-gh-repo-kit")
    user := module_config.access.users[_]
    github_id := user.name
    not is_valid_member(github_id)
    not is_outside_collaborator(github_id)
    reason := sprintf(
        "`module.%v`: access.users[].name - @%v は 10X のメンバーではありません",
        [module_name, github_id],
    )
}

メンバー一覧を毎回APIから取得するため、ポリシーファイル自体のメンテナンスは不要です。conftestの導入経緯や全体像についてはこちらの記事で紹介しています。

設計の勘所

この仕組みを段階的に構築してきた中で、特に重要だった設計判断をまとめます。

tfstateをリポジトリごとに分離する

最初はフラット構成(単一tfstate)で始めましたが、リポジトリ数が増えると以下の問題が出ました。

  • plan/applyが遅くなる(全リポジトリのdataを取得するのでGitHub API Rate Limitに当たる)
  • 1つのリポジトリのapply失敗が他に波及する
  • CODEOWNERSでリポジトリ単位のレビュー制御ができない

リポジトリごとにstateを分離し、変更のあったディレクトリだけをCI対象にすることで、これらの問題がすべて解消されます。

トークンスコープを対象リポジトリに絞る

前述の通り、tfファイルからリポジトリ名を抽出し、そのリポジトリだけにスコープされたGitHub App Installation Tokenを発行しています。「CIにどの権限を渡すか」をコードから動的に決める仕組みは、tfstate分離と組み合わせることで実現できました。

モジュールで標準化し、テンプレートでデフォルトを適用する

共通モジュールでリポジトリ設定を抽象化することで、デフォルト値によるガバナンス(vulnerability_alerts: truedelete_branch_on_merge: true など)が効きます。新規リポジトリ作成時にはテンプレートからデフォルトのRulesetが生成されるので、ガバナンスの漏れが構造的に起きません。

セルフサービスとガードレールを同時に整備する

「自由に変更できる」だけではカオスになっていきがちです。conftestで「やってはいけないこと」を機械的に弾く仕組みがあるからこそ、各チームに変更を任せられます。レビューだけでは見落としがちな退職者のID残留や外部ユーザーの誤指定を、CIが自動で検出してくれる安心感があります。こういうものはAIにやらせるのではなく、lintでやる強みですね。

まとめ

この仕組みを構築する前は、GitHub の設定変更は SRE への依頼事項でした。今では、メンバーの追加もリポジトリの設定変更も、各チームが自分で PR を出して完結しています。レビューも各チームに閉じており、チームに閉じたレビューについてはSREのノータッチが実現しています。

これは最初から完成した形ではなく、フラット構成 → tfstate分離 → モジュール化 → Ruleset移行と、運用上の課題に対応しながら段階的に進化させてきたものです。この仕組みはまだ発展途上で、リポジトリ数やチーム構成の変化に合わせて今後も変わっていくと思います。GitHub管理をTerraformで始めようとしている方、あるいはすでに運用していて課題を感じている方に、何か参考になれば幸いです。