Terraformとdriftctlで行うGoogle Cloud 権限管理の省力化

OGP画像

はじめに

こんにちは、ML・データ部MLOpsブロックの岡本です。

MLOpsブロックでは日々複数のGoogle Cloudプロジェクトを管理しています。これらのプロジェクトでは、データサイエンティストやプロジェクトマネージャーなど別チームのメンバーが作業することもあり、必要に応じてメンバーのGoogleアカウントへ権限を付与しています。
権限の付与はプロジェクトの管理者であるMLOpsブロックメンバーが行いますが、これは頻繁に発生する作業でありトイルとなっていました。
また権限付与後はこれらを継続的に管理し、定期的に棚卸しすることで不要になった権限を削除する必要があります。しかし当初の運用だと権限の棚卸しの対応コストが大きく、これが実施されずに不要な権限が残り続けるという課題もありました。

本記事ではMLOpsブロックで抱えていたGoogle Cloudプロジェクト内での権限管理における課題と、解決に至るまでの取り組みについて、実際の対応手順と運用後の所感を交えてご紹介します。手順の中には一部地道な手作業もありますが、組織のクラウド利用における権限管理に関する事例として参考にしていただければ幸いです。

目次

背景・課題

背景

前提として、まずはZOZOにおける全社的なGoogle Cloud利用の管理体制についてご説明します。

ZOZOでは多くのチームでGoogle Cloudを使用しており、これらの全社的な管理はワーキンググループという形でいくつかのチームから有志のメンバーが集まって行なっています。以下ではこちらのワーキンググループをGCP Adminと呼びます。

Google Cloudのリソース階層の中にはプロジェクトの上位リソースとしてフォルダ、組織という2つの階層が存在し、GCP Adminは組織レベルの権限を持っています。またGCP Adminでは組織配下にそれぞれのチームごとのフォルダを作成しています。プロジェクトの作成はGCP Adminが各チームから依頼を受けて対応し、プロジェクトはプロジェクト管理者が所属するチームのフォルダ配下に配置しています。

GCPリソース階層

cloud.google.com

この体制を取ることで、GCP Adminでは各Google Cloudプロジェクトがどのチームにより管理されているのか把握しやすくしています。加えて、GCP Adminでは組織レベルで主にセキュリティ・コスト観点について複数項目の監視・制限を作成することでガードレールを設け、ガバナンスを効かせています。
これらの取り組みの上で、プロジェクトレベルのリソースの管理については基本的に各チームの管理者に委ねており、ある程度自由度を持ってGoogle Cloudを利用できるようにしています。

ZOZOにおける全社的なGoogle Cloud管理の詳細については、TECH BLOGの「GCPの秩序を取り戻すための試み 〜新米GCP管理者の奮闘記〜」をご参照ください。

techblog.zozo.com

MLOpsブロックでは、ZOZOTOWNの推薦・検索といった案件単位でGoogle Cloudプロジェクトを作成しています。またそれぞれの案件ごとにdev(開発環境)・stg(検証環境)・qa(本番環境と類似のテスト環境)・prd(本番環境)を作成しています。これらを合計すると、2023年9月時点では約40のGoogle CloudプロジェクトがMLOpsブロックの管理対象として存在しています。
前述の通りこれらのプロジェクトでは自チームのメンバーだけでなく、データサイエンティストやプロジェクトマネージャーなど他チームのメンバーが閲覧・分析・実験などの作業でリソースを操作します。

こういった中で、MLOpsブロックメンバーは自チームのGoogle Cloudプロジェクト内に存在するリソースを把握し、主にセキュリティ・コスト観点において管理する必要があります。

課題と解決方針

MLOpsブロックでは、Terraformを利用してほぼ全てのGoogle Cloudリソースの作成・変更をコード化し、これらは全てGitGitHubで管理されています。これにより手動作業によるミスの低減やリソースの変更点の追跡容易性といった恩恵を受けています。
Terraformとは、HashiCorp, Inc.から提供されているツールであり、インフラリソースの構築をコードで行うInfrastructure as Code(IaC)を実現できます。

またGoogle Cloudでは、アクセス制御を管理する仕組みとしてIdentity and Access Management(IAM)システムが提供されています。リソースにアクセスするための複数の権限はIAMロールにまとめて、Googleアカウント・サービスアカウントなどのプリンシパルに付与します。
MLOpsブロックで権限付与の依頼に対応する際は、この仕組みで付与対象のGoogleアカウントへ必要なIAMロールを付与します。
しかし歴史的な経緯によりMLOpsブロックでは、Googleアカウントに付与するIAMロールが例外としてTerraform管理されていませんでした。そのため権限付与時はMLOpsブロックメンバーがGoogle CloudコンソールからIAMロールを付与・変更する運用が取られていました。
特に他チームのメンバーへの権限付与では次の定型作業が頻繁に発生していました。

  1. SlackでMLOpsブロック宛に権限付与の依頼が来る
  2. 必要に応じて付与するIAMロール・付与先・用途についてヒアリングする
  3. MLOpsブロックメンバーがGoogle Cloudコンソールから手動でIAMロールを付与する

こちらの運用における課題は主に次の2点です。

  • 権限付与の履歴(誰が誰に何の権限を付与したのか・誰が承認したのか)を確認しづらく、セキュリティ観点での管理コストになる
  • 権限付与・削除のたびにMLOpsブロックメンバーの工数が発生し、運用観点での対応コストになる

これらの課題を解決するために、次の対応方針を立てました。

  • GoogleアカウントのIAMロールをTerraform管理に移行すること
  • 権限付与の依頼・承認・付与の一連の流れをGitHubのPull Request上で行うようにすること

既存の運用に対して見込まれる改善点は次の2点です。

  • Gitのログや過去のPull Requestを追跡して権限付与の一連の流れを容易に確認できるため、管理コストが削減される
  • MLOpsブロックメンバーの作業はPull RequestのレビューとApprove・Mergeのみになるため、対応コストが削減される

MLOpsブロックで利用するTerraformのコードの変更はGitHubのPull Requestを通して行われ、Approve・MergeはMLOpsブロックメンバーのみが行えます。

GoogleアカウントのIAMロールについてもTerraformのコードとして定義することでGit・GitHubにより変更履歴を管理し、権限付与の一連の流れを容易に追跡可能にします。
またTerraformによるリソースの作成はCIにより自動化しています。IAMロールの変更反映時にMLOpsブロックメンバーの手動作業は発生せず、対応コストはPull RequestのレビューとApprove・Mergeのみと軽微なものになります。

次節では上記の方針をもとに具体的に行なった対応内容とつまづいたポイント、最後にしばらく運用を行なった所感をご説明します。

既存権限のTerraform管理への移行

既存権限をTerraform管理へ移行するにあたり、まずはプロジェクトに存在するIAMロールをTerraformのコードで定義し直しました。

MLOpsブロックでは、Googleアカウントに対して基本的にプロジェクトレベルのIAMロールを付与しています。今回はこれらを対象に作業しました。

次に作業手順の詳細についてご説明します。

tfファイルの作成

GoogleアカウントのIAMロールをTerraformのコードで定義するために、まずテンプレートファイル(.tf)を作成します。

ファイル構成については特に次の2点を意識しました。

  • 付与対象のGoogleアカウントをIAMロールごとに一覧できる
  • 利用用途(主にチーム単位)ごとにGoogleアカウントをまとめ、IAMロールの変更漏れを防止する

中心的なファイルは、IAMロールのリソースを定義するiam.tf、対象のGoogleアカウントを配列にまとめてIAMロールと紐付けるrole-binding.tfです。補助として利用用途でGoogleアカウントを配列にまとめているのがmembers.tfです。

ファイル構成は次の通りです。

.
├── iam.tf
├── members.tf
└── role-binding.tf

iam.tfは次のように記述します。説明のためプロバイダの指定は省いています。

resource "google_project_iam_member" "viewer" {
  project  = "example-project"
  for_each = toset(local.project_viewer_users)
  role     = "roles/viewer"
  member   = each.value
}

resource "google_project_iam_member" "bigquery_jobuser" {
  project  = "example-project"
  for_each = toset(local.bigquery_jobuser_users)
  role     = "roles/bigquery.jobUser"
  member   = each.value
}

role-binding.tfは次のように記述します。

locals{
  project_viewer_users = concat(
    local.analysis_members,
    local.project_management_members,
    [
      "user:user1@gmail.com",
    ]
  )

  bigquery_jobuser_users = local.analysis_members,
}

members.tfは次のように記述します。

locals {
  analysis_members = [
      "user:analysis-member1@gmail.com",
      "user:analysis-member2@gmail.com",
      "user:analysis-member3@gmail.com",
  ]

  project_management_members = [
      "user:pm-member1@gmail.com",
      "user:pm-member2@gmail.com",
  ]
}

上記のファイル構成をとることで次のメリットがあります。

例として、分析チームの新メンバーへ分析作業に必要な権限を付与する場合を考えます。この際members.tfのanalysis_members配列に新メンバーのGoogleアカウントを追加することで、必要なIAMロールを一括で付与できます。
特に新メンバーが複数人いる場合や複数のIAMロールを付与する場合、コンソールでの手動作業はIAMロールの付与漏れが発生しやすくなります。members.tfでGoogleアカウントをグループ化することで作業回数が減り、このようなケースでの作業漏れを低減できます。
また、role-binding.tfではIAMロールごとに付与の対象であるGoogleアカウントがグループ化されています。そのためMLOpsブロックメンバーはrole-binding.tfを見ることで誰にどのIAMロールが付与されているのか容易に確認できます。

一方、こちらの構成では付与するIAMロールごとにiam.tfrole-binding.tfの記述が増えるという懸念があります。
しかしMLOpsブロック管理のプロジェクトでGoogleアカウントに付与するIAMロールは、roles/viewerroles/editorなど基本のロールがほとんどです。その他のIAMロールについては必要に応じてアドホックに付与することが多く、量としては少ないためiam.tfrole-binding.tfの記述量は現状特に問題になっていません。

次節では、上記のテンプレートファイルを元に既存権限をTerraform管理化するため、プロジェクト内のIAMロールを一覧化する手順をご説明します。

既存IAMロールの一覧化

プロジェクト内のIAMロールをTerraformのコードで定義し直すには、MLOpsブロックが管理する約40のGoogle Cloudプロジェクトで、既存のIAMロールを一覧化する必要がありました。

プロジェクトのIAMロール一覧を取得するためにgcloud CLIでCloud Asset Inventoryのsearch-all-iam-policiesコマンドを利用しました。Cloud Asset Inventoryを用いるとプロジェクト・フォルダ・組織内のIAMポリシーを検索できます。
Cloud Asset Inventoryの詳細については次の公式ドキュメントを参照してください。

cloud.google.com

コマンド実行時のscope引数にfolder_numberを指定することで、特定のフォルダ配下のプロジェクトにあるIAMロールの一覧を取得できます。

folder_number=999999999
gcloud asset search-all-iam-policies --scope="folders/$folder_number" --query="memberTypes:user" --asset-types="cloudresourcemanager.googleapis.com/Project" --format="json(resource,policy)" > iam_policies.json

上記コマンドを実行することで次の出力結果が得られます。

[
  {
    "policy": {
      "bindings": [
        {
          "members": [
            "user:user1@gmail.com",
            "user:user2@gmail.com",
            "serviceAccount:example@example-project.iam.gserviceaccount.com",
            "group:example-group@gmail.com"
          ],
          "role": "roles/viewer"
        },
      ]
    },
    "resource": "//cloudresourcemanager.googleapis.com/projects/example-project"
  },
  {
    "policy": {
      "bindings": [
        {
          "members": [
            "user:user3@gmail.com"
          ],
          "role": "roles/editor"
        },
      ]
    },
    "resource": "//cloudresourcemanager.googleapis.com/projects/example-project-2"
  }
]

gcloudコマンドの出力では、membersフィールドにサービスアカウント・Googleグループが含まれます。フォルダ配下の全てのプロジェクトのIAMロールは出力内で配列の要素として数千行に渡り一覧化されています。

コマンドの出力をより見やすくするため、次のPythonスクリプトを作成しました。
サービスアカウントのIAMロールは既にTerraform管理されているためフィルタリングします。またIAMロールごとに付与対象のGoogleアカウントとGoogleグループをグループ化します。その後でプロジェクトごとにjsonファイルを作成して出力を分割しました。

import json
import typer
from pathlib import Path
from typing import Optional

def format_json(path: Optional[Path] = typer.Option(None)):
    output_dir_name = "outputs"
    Path(output_dir_name).mkdir(exist_ok=True)

    with open(path, encoding="utf-8") as f:
        folder_iam_list = json.load(f)
        for project_iam in folder_iam_list:
            project_id = project_iam["resource"].split('/')[-1]
            members_of_role = {}
            for role_binding in project_iam["policy"]["bindings"]:
                role = role_binding["role"]
                for member in role_binding["members"]:
                    if not (member.startswith("user:") or member.startswith("group:")):
                        continue
                    members_of_role.setdefault(role, [])
                    members_of_role[role].append(member)

            with open(f'{output_dir_name}/{project_id}.json', encoding="utf-8", mode="w") as f:
                json.dump(members_of_role, f)

if __name__ == "__main__":
    typer.run(format_json)

次のシェルスクリプトは上記のgcloudコマンドおよびPythonスクリプトを一度に実行します。
./run.sh 999999999のように対象のfolder_numberを指定してスクリプトを実行すると、outputsディレクトリ内にファイルが作成されます。これらはプロジェクトIDをファイル名として持ち、folder_numberで指定したGoogle Cloudフォルダ内のプロジェクトごとにexample-project.jsonの形式で作成されます。それぞれのファイルにはプロジェクト内のIAMロールの一覧が出力されます。

#!/bin/bash

set -eu

folder_number=$1

gcloud asset search-all-iam-policies --scope="folders/$folder_number" --query="memberTypes:user" --asset-types="cloudresourcemanager.googleapis.com/Project" --format="json(resource,policy)" > iam_policies.json
poetry run python main.py --path iam_policies.json
rm iam_policies.json

outputs/example-project.jsonの出力例は次の通りです。IAMロールごとにGoogleアカウント・Googleグループを一覧化した結果を得られています。

{
    "roles/viewer": [
        "user:user1@gmail.com",
        "user:user2@gmail.com",
        "group:example-group@gmail.com"
    ]
}

次にプロジェクトごとに生成されたjsonファイルを元に、既存のIAMロールをTerraformのコードとして定義し直しました。こちらの転記は地道に手作業で進めました。手作業による移行漏れのリスクについては、後述する差分検知の仕組みによりカバーが可能なため、ここでは問題としていません。
また、role-binding.tfで共通の権限を持つGoogleアカウントについてはTerraformの配列としてまとめてmembers.tfに定義しています。role-binding.tfではmembers.tfにまとめた配列を対象にIAMロールを紐付けました。

本節の手順によりプロジェクトのIAMロールをTerraformのコードとして定義し直し、既存の権限をTerraform管理下に置くことができました。
次節では、これらを継続的に管理するための方針と、ツールを導入することによる管理コストの省力化についてご説明します。

継続的な権限管理の方針とdriftctlによる省力化

継続的な権限管理の方針

上記の作業により、一時的にプロジェクト内のIAMロールがTerraform管理できている状態を作れましたが、それだけでは継続的にこの状態を維持できません。

IAMロールを変更できるロールを持つGoogleアカウントは、GitHubのPull Request上での権限付与の一連の流れを無視して、コンソール・CLIで既存のIAMロールを変更できてしまいます。
こうなるとTerraformのコードで管理されているIAMロールと実際にプロジェクト内に存在するIAMロールの間に差分が生じます。これでは権限付与の履歴を確認できない当初の課題が再発しています。
これはプロジェクトの権限管理の運用ルールとして、IAMロールを変更できるロールはCIのサービスアカウントのみに付与し、Googleアカウントへ付与しないことで一応回避できます。しかしこれでは緊急時にすぐ権限を付与できず、障害対応に支障が出ます。

MLOpsブロック管理のプロジェクトではセキュリティ・事故防止の観点からGoogleアカウントに対しdev環境を除いてはリソースの変更が可能なIAMロールを付与していません。特に本番環境では、プロジェクトの管理者である1人以外には基本的に閲覧以外のIAMロールを付与していません。
しかし本番稼働するAPI・バッチを運用・保守するMLOpsブロックメンバーについては、障害発生時に緊急対応のため手動でリソースを変更したいケースが発生し得ます。
このように緊急度の高いケースにおいては、Pull Requestを作成しての権限付与フローでは障害対応が遅れ、致命的な損失につながる可能性があります。これを避けるために、MLOpsブロックメンバーには本番環境においてプロジェクトのIAMロールを変更できるロールを例外的に付与しています。

そこでMLOpsブロックでは管理方針として、Terraform管理されているIAMロールとプロジェクト内に存在するIAMロールの差分を定期的に確認し、適宜修正するようにしました。
これにより全てのGoogleアカウントのIAMロールがTerraform管理された状態を継続的に維持できます。

この方針では定期的に差分の確認作業が発生しますが、ツールにより差分の確認を自動化することでMLOpsブロックメンバーの対応コストを最小限に抑えました。
次節ではこちらのツールと自動化による差分検知の仕組みについてご説明します。

driftctlの導入

Terraform管理されたIAMロールと、実際にプロジェクト内に存在するIAMロールの差分を確認するため、driftctlというツールを採用しました。driftctlはApache-2.0 licenseで利用できますが、現在Beta版での提供となっている点にご注意ください。

driftctlはSnyk Ltd.により開発されたGo製のCLIツールで、IaCのコードが実際に存在するリソースをどの程度カバーできているか測定できます。またdriftctlではコンソール・CLIなど、Terraform以外の方法でGoogleアカウントに対してIAMロールが付与された場合の差分も検知できます。
Terraform管理されたリソースのみの差分検知であればterraform planコマンドで引数に-detailed-exitcodeを指定し、コマンド終了時のexit codeにより差分の有無を判別することで対応できます。
一方でこの方法は、Terraform以外の方法でGoogleアカウントに対してIAMロールが付与された場合の差分は検知できません。
こちらの差分も検知できるという点で、driftctlはTerraform管理されているIAMロールとプロジェクト内に存在するIAMロールを完全一致させたいという今回の要件にマッチしました。

次にdriftctlの使い方を簡単にご説明します。
CLIは次のようにcurlやbrewでインストールできます。

# Linux
# x64
$ curl -L https://github.com/snyk/driftctl/releases/latest/download/driftctl_linux_amd64 -o driftctl

# macOS
$ curl -L https://github.com/snyk/driftctl/releases/latest/download/driftctl_darwin_amd64 -o driftctl

$ brew install driftctl

# Windows
# x64
$ curl https://github.com/snyk/driftctl/releases/latest/download/driftctl_windows_amd64.exe -o driftctl.exe

Terraformと実際のリソースの差分はdriftctl scanコマンドで確認でき、必要な環境変数を渡して実行することで差分を出力できます。
コマンドの詳細については次の公式ドキュメントを参照してください。

docs.driftctl.com

Google Cloudを対象にする場合はCloud Asset APIの有効化と認証するアカウントに対してプロジェクトのroles/cloudasset.viewerroles/viewerのIAMロールの付与が必要です。
Google Cloudでの認証の詳細については次の公式ドキュメントを参照してください。

docs.driftctl.com

driftctl scanの実行時に参照するTerraformのStateは--from引数で指定できます。特に指定しない場合はカレントディレクトリ配下のHCLを自動的に読み取り、使用するtfstateファイルを探します。
driftctl scanの実行と出力結果の例は次の通りです。

GOOGLE_APPLICATION_CREDENTIALS=your-creds.json\
CLOUDSDK_CORE_PROJECT=example-project\
driftctl scan --to gcp+tf

{
        "options": {
                "deep": false,
                "only_managed": false,
                "only_unmanaged": false
        },
        "summary": {
                "total_resources": 6,
                "total_changed": 0,
                "total_unmanaged": 0,
                "total_missing": 0,
                "total_managed": 6,
                "total_iac_source_count": 1
        },
        "managed": [
          {
            "id": "example-project/roles/viewer/user:user1@gmail.com",
            "type": "google_project_iam_member",
            "source": {
                    "source": "tfstate+gs://bucket/terraform/terraform.tfstate",
                    "namespace": "",
                    "internal_name": "resourcemanager_projectiamadmin"
            }
          },
          {
            "id": "example-project/roles/bigquery.jobUser/user:analysis-member1@gmail.com",
            "type": "google_project_iam_member",
            "source": {
                    "source": "tfstate+gs://bucket/terraform/terraform.tfstate",
                    "namespace": "",
                    "internal_name": "bigquery_jobuser"
            }
          },
          ... 省略
        ],
        "unmanaged": null,
        "missing": null,
        "differences": null,
        "coverage": 100,
        "alerts": null,
        "provider_name": "gcp+tf",
        "provider_version": "4.80.0",
        "scan_duration": 1,
        "date": "2023-09-14T00:00:00.000000+00:00"
}

またdriftctlではコマンド実行時に--filter引数を指定して、差分として検知する対象の絞り込み・除外ができます。
引数なしの場合、プロジェクト内のすべてのGoogle Cloudリソースを対象にTerraform管理されたリソースとの差分を出力します。今回差分を出力したいGoogle CloudリソースはプロジェクトのIAMロールのみです。そのため対象をプロジェクトのIAMロールに絞り込みました。加えてプロジェクトに存在するIAMロールにはサービスアカウントのロールも含まれるため、合わせて検知対象から除外しました。

上記の絞り込み・除外を考慮したdriftctl scanの実行コマンドは次の通りです。

GOOGLE_APPLICATION_CREDENTIALS=your-creds.json\
CLOUDSDK_CORE_PROJECT=example-project\
driftctl scan --to gcp+tf --filter $'(Type==\'google_project_iam_member\' && contains(Id, \'user:\'))'

上記により、Googleアカウントに付与されたIAMロールを対象にTerraform管理されたリソースと実際のリソースの差分を出力できます。
しかし上記のコマンドではGoogle CloudのAPI呼び出しのRate Limitにより、driftctl scanの実行時に次のエラーが発生しました。

rpc error: code = ResourceExhausted desc = Resource has been exhausted (e.g. check quota).

こちらについては同様のエラーについてのIssueが報告されており、コメントで提案されていた方法を参考に対応しました。
Rate Limitに引っかかった原因はGoogle Cloud上のリソース数が多く、リソース取得のためにGoogle CloudのAPIを呼び出す回数が多くなっていることでした。これはdriftctl scanで取得する対象のリソースを絞り込むことで解決できました。
この絞り込みには.driftignoreを利用しました。
.driftignoreに除外したいリソースを記述することで、driftctl scanの取得対象から除外できます。前述の--filter引数での除外は複雑な除外の用途で使用し、単に一連のリソースを除外するのみであれば.driftignoreを使用することを公式ドキュメントでは推奨しています。

docs.driftctl.com

次の記述を.driftignoreに追加し、google_project_iam_member以外のリソースをdriftctl scanの対象から除外しました。
*はワイルドカードでの指定となるため、すべてのリソースを対象から除外し、その上でgoogle_project_iam_memberを除外対象から外すという記述になります。

# Ignore all drifts except for google_project_iam_member
*
!google_project_iam_member

これにより、必要のないリソースがdriftctl scanの取得対象となることを回避し、上述のRate Limitによるエラーを解消できました。またdriftctl scanの実行時間も大幅に短縮されました。

次節ではdriftctlコマンドをCIで自動実行する手順についてご説明します。

driftctlの定期実行と自動化

MLOpsブロックではCIとしてGitHub Actionsを利用しています。
差分の確認を定期実行するため、GitHub ActionsのScheduleトリガーイベントによりdriftctlコマンドを定期実行するワークフローを作成しました。このワークフローはTerraform管理されたリソースと実際のリソースに差分を確認するとSlackに通知します。

ワークフローを作成した後で気がつきましたが、GitHub Actionsでのdriftctlの実行については開発元であるSnyk Ltd.からdriftctl-actionが提供されています。詳細については次の公式ドキュメントを参照ください。

docs.driftctl.com

ワークフローの大まかな流れは次の通りです。

  1. GitHub Actionsで利用するGoogle Cloudのサービスアカウントを認証して、driftctl scan実行時に必要な認証情報を環境変数にセットする
  2. driftctlをインストールし、driftctl scanコマンドにより差分を確認して結果を出力する
  3. 出力された結果のcoverageの値を確認し、差分が見つかった場合はexit 1で終了する
  4. 差分が見つかった場合のみSlackで通知する

上記の流れの2・3(driftctlのインストールから差分の確認まで)の実装は、driftctl-actionで代替できます。
以下では独自実装のワークフローについてご説明します。

ワークフローの定義は次の通りです。

name: check_user_account_project_iam_member_role_drift

on:
  schedule:
    - cron: '0 1 * * 4'

permissions:
  contents: 'read'
  id-token: 'write'

jobs:
  check-user-account-project-iam-member-role-drift:
    runs-on: ubuntu-20.04

    strategy:
      fail-fast: false
      matrix:
        cfg:
          - DIR: terraform_dir
            PROJECT_NUMBER: 202020202020

    defaults:
      run:
        working-directory: ${{ matrix.cfg.DIR }}

    outputs:
      coverage: ${{ steps.run_driftctl.outputs.coverage }}
    steps:
      - uses: actions/checkout@v3.5.2

      - name: authenticate to gcp
        uses: 'google-github-actions/auth@v1.1.1'
        with:
          workload_identity_provider: projects/${{ matrix.cfg.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/example-pool/providers/github-actions
          service_account: ci-service-account@example-project.iam.gserviceaccount.com
          create_credentials_file: true
          export_environment_variables: true

      - name: setup driftctl
        run: |
          curl -L https://github.com/snyk/driftctl/releases/latest/download/driftctl_linux_amd64 -o driftctl
          chmod +x driftctl
          mv driftctl /usr/local/bin/

      - name: run driftctl
        id: run_driftctl
        run: |
          output=$(driftctl scan --quiet --to gcp+tf --filter $'(Type==\'google_project_iam_member\' && contains(Id, \'user:\'))' --output json://stdout | jq 'del(.managed)')
          echo "coverage=$(echo "$output" | jq .coverage)" >> $GITHUB_OUTPUT
          echo $output | jq .

      - name: check coverage
        if: ${{ steps.run_driftctl.outputs.coverage != 100}}
        run: |
          echo "The driftctl result coverage value is not 100. Please check run driftctl step output." && exit 1

  slack-notice:
    needs:  [check-user-account-project-iam-member-role-drift]
    runs-on: ubuntu-20.04
    if: ${{ failure() }}
    steps:
      - uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "<!channel>"
                  }
                },
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":rotating_light: ユーザーアカウントのロールとterraformの構成に差分があります:rotating_light:\n以下のURLからrun driftctlステップの差分を確認し、差分の解消を行って下さい。\n\n*GitHub Actions URL*: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*差分検知後の対応フロー*: 対応ドキュメントのリンク"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_NOTICE }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

続いてワークフローに含まれるJobごとの処理の流れをご説明します。

初めにcheck-user-account-project-iam-member-role-drift Jobについてご説明します。

認証処理の記述は次の箇所です。
まず、authenticate to gcp StepではCIで利用するGoogle Cloudのサービスアカウントの認証を行なっています。
MLOpsブロックでは、サービスアカウントの権限を利用するすべてのリポジトリで認証にWorkload Identity連携を利用しています。ここではgoogle-github-actions/authを利用しています。
前述の通り、driftctl scanの実行時にはGOOGLE_APPLICATION_CREDENTIALS(認証情報)とCLOUDSDK_CORE_PROJECT(プロジェクト名)を指定する必要があります。認証時の引数にcreate_credentials_file: trueexport_environment_variables: trueを指定し、環境変数として参照できるようにしています。

      - name: authenticate to gcp
        uses: 'google-github-actions/auth@v1.1.1'
        with:
          create_credentials_file: true
          workload_identity_provider: projects/${{ matrix.cfg.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/example-pool/providers/github-actions
          service_account: ci-service-account@example-project-${{ matrix.cfg.ENV }}.iam.gserviceaccount.com
          export_environment_variables: true

driftctlコマンドの実行処理の記述は次の箇所です。
setup driftctl Stepでは前述した手順に従ってdriftctl CLIをインストールして実行権限を付与しています。続くrun driftctl Stepでは実際にdriftctl scanを実行しています。
ここでJobにデフォルトで指定しているworking-directoryには各IAMロールを定義したtfファイルが配置されているディレクトリを指定します。指定したディレクトリ配下には取得対象から除外するリソースを記載した.driftignoreファイルを置いています。
また--quiet引数やjqによって出力結果を整形することで、差分検知のアラートがなった際にノイズとなる情報を取り除いています。
次にcheck coverage Stepでdriftctl scanの出力結果を確認しています。
出力のうちcoverageキーの値にはTerraform管理されたリソースが実際のリソースをどの程度カバーできているかを測定した値が入っています。こちらの値が100であれば、実際に存在するリソースがすべてTerraform管理された状態です。
if: ${{ steps.run_driftctl.outputs.coverage != 100}}により値を確認し、差分があった場合はログを出力してexit 1で終了しています。

      - name: setup driftctl
        run: |
          curl -L https://github.com/snyk/driftctl/releases/latest/download/driftctl_linux_amd64 -o driftctl
          chmod +x driftctl
          mv driftctl /usr/local/bin/

      - name: run driftctl
        id: run_driftctl
        run: |
          output=$(driftctl scan --quiet --to gcp+tf --filter $'(Type==\'google_project_iam_member\' && contains(Id, \'user:\'))' --output json://stdout | jq 'del(.managed)')
          echo "coverage=$(echo "$output" | jq .coverage)" >> $GITHUB_OUTPUT
          echo $output | jq .

      - name: check coverage
        if: ${{ steps.run_driftctl.outputs.coverage != 100}}
        run: |
          echo "The driftctl result coverage value is not 100. Please check run driftctl step output." && exit 1

通知処理の記述は次の箇所です。
slack-notice Jobでは差分が検知された場合にのみSlack通知を送ります。差分があると前段のcheck coverage Stepは失敗します。この時slack-notice Jobではif: ${{ failure() }}を指定しているためJobがトリガされ、Slack通知処理が走ります。Slack通知処理にはslackapi/slack-github-actionを利用しています。
また送信するメッセージにはGitHub ActionsのRunのURL及び、差分解消の対応フローを記述したドキュメントのリンクを含めています。これによりMLOpsブロックメンバーであれば誰でも差分解消の作業ができるようにしています。

  slack-notice:
    needs:  [check-user-account-project-iam-member-role-drift]
    runs-on: ubuntu-20.04
    if: ${{ failure() }}
    steps:
      - uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "<!channel>"
                  }
                },
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":rotating_light: ユーザーアカウントのロールとterraformの構成に差分があります:rotating_light:\n以下のURLからrun driftctlステップの差分を確認し、差分の解消を行って下さい。\n\n*GitHub Actions URL*: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*差分検知後の対応フロー*: 対応ドキュメントのリンク"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_NOTICE }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

実際に差分が検知された場合のSlack通知は次の通りです。

差分検知によるSlack通知の例

通知があった場合は、メッセージに記述されたGitHub ActionsのRunのリンクに飛ぶことで検知された差分の内容を確認できます。

driftctlによる差分検知の出力例

本節ではTerraform管理されているIAMロールと実際にプロジェクト内に存在するIAMロールについて、自動で差分を検知する仕組みを作成しました。これによりMLOpsメンバーの対応工数を抑えつつ、プロジェクト内に存在するGoogleアカウントのIAMロールをすべてTerraform管理できている状態を維持できるようになりました。
次節では、システム面以外での運用フローの改善についてご説明します。

権限付与・変更依頼の運用フローの見直し

既存の運用フローについて、Slackメッセージベースの依頼のフローから、GitHubのPull Requestベースでの依頼フローへの見直しについてご紹介します。

既存の運用フローでは権限の付与・変更時に、MLOpsブロックメンバーが主体となって作業する部分がほとんどでした。一方でGitHubのPull Requestベースのフローでは主な作業は依頼者が行なうようにしています。
依頼者は権限付与・変更のPull Requestを作成し、レビュアにMLOpsブロックのTeamをアサインします。MLOpsブロックメンバーはPull Requestをレビューし、問題なければApprove・Mergeします。Terraformによるリソースの作成は元々CIにより自動化されているため、MLOpsブロックメンバーの作業は基本的にPull RequestのレビューとMergeのみになります。

実際の権限付与の依頼例は次の通りです。
権限付与が必要な背景はPull RequestのDescriptionに記載されており、MLOpsブロックメンバーはGit・GitHubの履歴を辿ることで、権限付与の経緯などを後でも確認できます。

権限付与依頼Pull Requestの例

一方で、GitHubのPull Requestベースのフローには次の2つの懸念点もあります。

  • Terraform経験が少ない依頼者にTerraformコードの記述を強制する必要がある
  • 依頼者の対応工数が増える

1つ目について、権限付与の依頼者はほとんどがエンジニアですが、データサイエンティストなど普段Terraformを使わない方も多いです。そういった方に権限付与の依頼のためにわざわざTerraformの文法の履修を強制することは総工数を増やすだけでなく、依頼のハードルを上げることにもなります。
こちらについてはドキュメントを充実させ、Terraformの知識がない方でもコピー&ペーストで作業ができるようにすることで負荷を軽減しました。ドキュメントには権限の付与・変更・削除など想定される依頼パターンごとのPull Request作成例を記載するだけでなく、プロジェクトごとにリソースを定義したリポジトリの対応表など作業で必要な情報をまとめています。
これによりプログラミング経験、Gitの利用経験があればどなたでも作業可能な状態を作っています。

2つ目は、依頼者にとって権限付与の依頼は頻繁に発生する作業ではない点・基本コピー&ペーストで済む作業であれば対応工数も少なく済む点から許容できると判断しました。

最終的に、GitHubのPull Requestベースの新しい運用フローについて手順書を作成し、作成した手順書と運用フローの変更について関係者に周知しました。既存の運用では依頼時に利用する固定のSlackチャンネルはなかったため、MLOpsブロックのチャンネルで関連チーム向けにアナウンスし、同チャンネルにメッセージをピン留めする方法を取りました。

MLOpsブロックで抱えていたGoogle Cloudプロジェクト内でのGoogleアカウントの権限管理における課題について、解決のために行なった取り組みはこれで全てとなります。

運用後の所感

MLOpsブロックが管理するプロジェクト内での権限管理における課題解決、継続的な権限管理の仕組み導入と依頼時の運用フロー見直しについて、実際に約4か月運用した現在の所感をお話しします。

まずGoogleアカウントへのIAMロールの付与・変更をTerraform管理に移行した点では、権限の一覧性向上・コード上でGoogleアカウントをグループ化できる点で非常に管理しやすくなりました。特に後者については、Googleアカウントをチームごとに配列でまとめることで、チームメンバーが増えた際の権限付与や退職者の権限削除にかかる工数の削減と対応漏れの防止につながるというメリットがありました。

余談ですが、Googleアカウントをグループ化してIAMロールを付与することはGoogleグループでも対応可能です。ただしこの方法のデメリットとして、ユーザーは独自にグループへのメンバー追加ができるという点があります。これはMLOpsブロックメンバーが把握できないところで権限が付与されてしまうといった問題や、誰にどの権限が付与されているのかが分かりにくいといった権限管理上の問題を引き起こします。
こういった理由から、ZOZOのGoogle Cloud利用においてはGoogleグループへのIAMロールの付与を廃止し、個別のGoogleアカウントへの付与に移行しました。また、Googleグループに対するIAMロールの付与があればGCP Adminで検知できる監視の仕組みを導入しています。

次に、driftctlによる差分検知の導入についてです。dev・stg環境での軽微な作業の際にコンソールから一時的に付与したIAMロールの消し忘れなど、普段の運用の中での見落としを防ぐことにも非常に役立っています。また、差分検知の仕組みの運用・保守のコストは、Rate Limitエラーが発生した以外にほとんど発生していません。ワークフロー自体も非常にシンプルであるため、運用・保守のコストに対してチームのパフォーマンス改善に大きく貢献できています。

また、権限付与の運用フローを見直した点では、依頼者が権限付与のPull Requestを作成する運用にしたことで、MLOpsブロックメンバーの対応コストは大幅に低減されたと感じています。それ以外にも次のメリットを感じています。

  • 変更がCIにより反映されるようになったことで、権限の付与など対応漏れの防止になる
  • 依頼を見落としている場合に、GitHubのPull Requestレビューのリマインダーにより気がつける

一方で運用後に残っている課題として、権限付与の新しい運用フローが依頼者に浸透しきっていないという点があります。上述のように利用者への周知は行いましたが、たびたびSlackメッセージでの依頼は届いており、その都度MLOpsブロックメンバーがアナウンス時のメッセージのリンクを共有して再度周知しています。
また依頼者がTerraformのコードを書いてPull Requestを書くハードルについても、許容はできますが課題として残っています。

今後はこういった依頼時の認知・作業負荷の削減を考えており、Slackワークフローを利用したPull Request作成の自動化の仕組みによりこれを実現することを検討しています。

終わりに

最後までお読みいただきありがとうございました。

本記事ではMLOpsブロックが抱えていたGoogle Cloudプロジェクトの権限管理における課題とその解決方法について、実際の対応手順と運用後の所感を交えてご説明しました。本記事での事例の紹介が皆様のお役に立てば幸いです。

最後になりますが、ZOZOでは一緒にサービスを作り上げてくれる方を募集中です。MLOpsブロックでも絶賛採用しているので、ご興味のある方は以下のリンクからぜひご応募ください。

hrmos.co corp.zozo.com

カテゴリー