kikumotoのメモ帳

インフラ・ミドル周りを中心に、興味をもったことを適当な感じで。twitter : @takakiku

AWS Fargateで稼働するAtlantisから、GCPリソースを構築する - 修正版

google/internal/externalaccount: Adding metadata verification · golang/oauth2@ec4a9b2 · GitHub

の変更により、以下の仕組みは hashicorp/google バージョン v4.59.0 移行では動作しなくなりました。


AWS FargateでAtlantisを動かして、AWSリソースに対するTerraformのPlanのレビューやApplyをPR上で行なっていたのですが、GCPリソースに対しても必要になってきたので、その時に行った作業のメモ。

なお、AWS Fargateで稼働するAtlantisから、GCPリソースを構築する - 失敗編 - kikumotoのメモ帳 で一度環境を作ったのですが、やり方がまずかったので改めて取り組んだメモとなります。
こちらの記事だけで話しが完結するように、一部失敗編と同じ内容の記載はあります。

前提

前提としては、GCPの鍵ファイルを生成せずにやりたいというのがあります。
鍵ファイルの管理・ローテーションは煩わしいし、穴になる可能性もあるので。

タスクロール

Fargate上のタスクとしてAtlantis を動かしており、すでにタスクロールがあるのでこのタスクロールを利用します。

問題点と解決方法の概要

問題点

以下のGCP側の手順の最後で、構成ファイルをダウンロードするのですが、このファイルに関連する問題があります。

なお、構成ファイルは以下のようなものです。

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/atlantis/providers/xxxxxxxx",
  "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/yyyyyyy@project-name.iam.gserviceaccount.com:generateAccessToken",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": {
    "environment_id": "aws1",
    "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
    "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
    "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
  }
}
問題1:シークレットをファイルとして扱えない

環境変数 GOOGLE_APPLICATION_CREDENTIALS に構成ファイルのパスを指定して利用します。
Google Cloud 上では、Google Cloud Secret Manager に登録したシークレットを、Cloud Run などでファイルパスとしてマウントできるのですが、AWSの Secrets Manager や Systems Manager Parameter Store に登録したシークレットをECS タスクにファイルパスとして提供する方法がありません。

構成ファイルは鍵ファイルほど秘匿性が高くないとは思っているのですが、それでもあまり漏らしたくもないので、シークレットとして扱いたいです。
なので、シークレットとして扱いつつ、ファイルとして扱う方法を考える必要があります。

問題2;認証構成ファイルのメタデータURLがEC2用

構成ファイルの credential_source -> url ですが、これは EC2 用のメターデータURLです。 一方でECSの場合は、

にあるように

なものとなります。
また、戻ってくる値も異なります。
このECS用のメタデータURLを、Terraformが(Terraform が利用しているGCPライブラリが)正しく処理できないという点が大きな問題です。

解決方針

問題1

これは、 qiita.com

を参考にさせていただきました。

  • シークレットにファイルの内容を保存
  • 該当シークレットを環境変数に指定(コンテナ定義)
  • 環境変数の内容をファイルに書き出す。

という感じです。

このために、カスタムな Atlantis イメージを作成します。

問題2

上記のEC2メタデータURLにアクセスすると、ロール名が返ってきます。続いて、メタデータURLに返ってきたロール名を付け加えて(例;http://169.254.169.254/latest/meta-data/iam/security-credentials/sampleEC2Role)アクセスすると、以下のような構造のJSONが得られます。

{
    "Code": "Success",
    "LastUpdated" : "<LAST_UPDATED_DATE>",
    "Type" : "AWS-HMAC",
    "AccessKeyId": "<ACCESS_KEY_ID>",
    "SecretAccessKey": "<SECRET_ACCESS_KEY>",
    "Token": "<SECURITY_TOKEN_STRING>",
    "Expiration": "<EXPIRATION_DATE>"
}

一方で、ECSメタデータURLにアクセスすると、その時点で以下のような構造のJSONが得られます。

{
    "AccessKeyId": "<ACCESS_KEY_ID>",
    "Expiration": "<EXPIRATION_DATE>",
    "RoleArn": "<TASK_ROLE_ARN>",
    "SecretAccessKey": "<SECRET_ACCESS_KEY>",
    "Token": "<SECURITY_TOKEN_STRING>"
}

ECSメタデータURLの情報からEC2メタデータURLが返すデータは生成ができそうなので、この変換をするWebアプリを、Atlantisと同じタスク内に別コンテナとして用意することにしました。
このWebアプリのエンドポイントを構成ファイルの credential_source -> url に指定するようにします。

対応作業

GCPGoogle Cloud)側

GCP側では、サービスアカウント、Workload Identity Pool の作成を行なっていきます。 ドキュメントとしては「Workload Identity 連携の構成」のところ。

必要なリソースをTerraformで書くと以下のような感じに。 aws_account_idaws_task_role_name は適宜、自分の環境のものを設定する感じです。

locals {
  aws_account_id     = "000000000000"
  aws_task_role_name = "ecs_task_role"
}

####
# Atlantis が利用するサービスアカウント

resource "google_service_account" "atlantis" {
  account_id  = "atlantis"
  description = "Service Account for Atlantis (Managed by Terraform)"
}

resource "google_project_iam_member" "atlantis" {
  project = var.gcp_project
  role    = "roles/editor"
  member  = "serviceAccount:${google_service_account.atlantis.email}"
}

####
# AWS 連携のための Workload Identity

resource "google_iam_workload_identity_pool" "atlantis" {
  workload_identity_pool_id = "atlantis"
  display_name              = "atlantis"
  description               = "Pool for Atlantis (Managed by Terraform)"
}

resource "google_iam_workload_identity_pool_provider" "atlantis" {
  workload_identity_pool_provider_id = "aws-atlantis"
  workload_identity_pool_id          = google_iam_workload_identity_pool.atlantis.workload_identity_pool_id

  aws {
    account_id = local.aws_account_id
  }

  attribute_mapping = {
    "google.subject"     = "assertion.arn"
    "attribute.aws_role" = "assertion.arn.contains('assumed-role') ? assertion.arn.extract('{account_arn}assumed-role/') + 'assumed-role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn"
  }

  attribute_condition = "attribute.aws_role=='arn:aws:sts::${local.aws_account_id}:assumed-role/${local.aws_task_role_name}'"
}

####
# Workload Identity とサービスアカウントの関連付け

resource "google_service_account_iam_binding" "atlantis" {
  service_account_id = google_service_account.atlantis.id
  role               = "roles/iam.workloadIdentityUser"

  members = [
    "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.atlantis.name}/attribute.aws_role/arn:aws:sts::${local.aws_account_id}:assumed-role/${local.aws_task_role_name}"
  ]
}

これをapplyしたら、構成ファイルを取得します。 プール詳細画面の右側にあるペインで、接続済みサービスアカウント に行けば、ダウンロードできます。

AWS

パラメータストアの準備

問題1の解決の流れと、問題2の解決に関連したファイル内容の修正となります。

私の場合は、パラメータストアに構成ファイルのデータを保存し、それを GOOGLE_APPLICATION_CREDENTIALS_DATA 環境変数で取得できるようにして、 GOOGLE_APPLICATION_CREDENTIALS 環境変数の指すファイルに保存しています。

保存するデータについては、

  • credential_source -> region_url は削除
  • credential_source -> url は、http://127.0.0.1:8080/latest/meta-data/iam/security-credentials というようにしています。同居コンテナのWebアプリはローカルホストとして参照できるので、合わせてそのWebアプリがListenしているポートを指定している感じです。
カスタム Atlantis イメージの作成

構成ファイルの環境変数からのファイル化をするために、カスタム Atlantis Dockerイメージを作成します。

こんな wrapper.sh

#!/usr/bin/dumb-init /bin/sh
set -e

echo $GOOGLE_APPLICATION_CREDENTIALS_DATA | jq . > $GOOGLE_APPLICATION_CREDENTIALS

を用意して、Docerfile が以下のような感じにしています。

FROM ghcr.io/runatlantis/atlantis:latest

RUN apk update && apk add jq

COPY wrapper.sh /usr/local/bin/wrapper.sh

ENTRYPOINT ["wrapper.sh"]
CMD ["server"]

なお、wrapper.sh のところで、jq を通して $GOOGLE_APPLICATION_CREDENTIALS に書き出していますが、これは必須ではなくて ECS Exec で入って調査するような時に読みやすいようにしておきたかった、ぐらいな感じです。

そしてDockerビルドしたイメージをECRレポジトリに登録しておきます。

メタデータ変換Webアプリ

github.com

にWebアプリの実装を置いています。

これをDockerビルドしイメージをECRレポジトリに登録しておきます。

タスク定義・コンテナ定義

もとのタスク定義・コンテナ定義を修正します。

{
    "containerDefinitions": [
        {
            "name": "atlantis",
            "image": "<作成したカスタムAtlantisイメージのレポジトリURL>",
            "environment": [
-- snip --
                {
                    "name": "GOOGLE_APPLICATION_CREDENTIALS",
                    "value": "/path/to/gcp_credentials.json"
                }
            ],
            "secrets": [
-- snip --
                {
                    "name": "GOOGLE_APPLICATION_CREDENTIALS_DATA",
                    "valueFrom": "<パラメータストアのパラメータ名>"
                }
            ],
-- snip --
        },
        {
            "name": "imitate-ec2-metadata-url",
            "image": "<作成したメタデータ変換WebアプリのレポジトリURL>",
            "environment": [
                {
                    "name": "PORT",
                    "value": "8080"
                }
            ],
-- snip --
        }
    ],
-- snip --
}

これでデプロイします。

以上で、PR上でAtlantisからGCPにapplyできるようになります。

1つのAtlantisで複数クラウドを管理したい方の参考になれば!

補足:複数のGCPプロジェクトを対象とする場合

複数のGCPプロジェクトを対象とする場合は、上記で作成したサービスアカウント(のメールアドレス)に対して、各プロジェクトのIAMで権限付与すればOKです。