Atlantisのマルチクラウドへの対応について

SREの菅原です。

カンムではAWSGCP、Datadogなど様々をIaaS・SaaSをterraformで管理しているのですが、以前は「GitHub Actionsでplan」「管理者や開発者が手元でapply」というフローになっており、terraform applyの実行が管理者や一部の権限を持った開発者に集中してしまい、インフラの変更作業の速度が落ちてしまっている状態でした。

しかし、Atlantisという「Pull Request上でterraform plan・applyを実行する」ツールを導入したことで、うまくapply権限を各開発者に委譲することができるようになったので、Atlantisの運用について、特にマルチクラウドへの対応について書きます。

Atlantis

www.runatlantis.io

AtlantisはWebhookでGitHubのPull Requestのイベントを受け取って、Pull Request上でplanとapplyを実行するアプリケーションです。

Atlantisを使ったterraformのワークフローは以下のようになります。

  1. tfファイルを編集してPull Requestを出す
  2. Pull Requestが作成されるとterraform planの結果がコメントされる
  3. Pull RequestがApproveされたら atlantis appy とコメントする
  4. terraform applyが実行されてPull Requestがマージされる

公式ドキュメントの動画を見てもらうと雰囲気が伝わるかもしれません。

Atlantisのよい点は

  • terraform applyを開発者に委譲しやすい
  • applyできるための条件があり無用に強大な権限を委譲することがない
  • plan・applyの結果がPull Requestに記録される
  • applyに失敗した変更をmainブランチにマージせずに修正できる
  • 「atlantis applyをコメント」→「terraform applyを実行」→「結果がコメントされる」という流れがCLIで実行している感覚に近く「確実にapplyに成功した」という手応えを得られる
    • ※terraform実行時の出力はWebターミナルから確認できます

などです。

AWSマルチアカウントへの対応

カンムのAtlantisはAWS Fargateで動かしています。 Fargateで動かす場合、基本的にECSタスクのIAMロールの権限がAtlantisに付与されることになるのですが、複数のAWSアカウントのterraform plan・apply権限を一つのIAMロールに付与するのは最小権限の原則から望ましくはありません。 複数のECSサービスを動かせば異なるIAMロールを渡せますが運用のコストが増えるので避けたいところです。

そこでterraformの各プロジェクトごと、またそれぞれのapply・planで別々のIAMロールにAssumeRoleして不要な権限を持たないようにすることを考えました。

しかし、Atlantis自体にはAssumeRoleをするための機能はありません。 公式ドキュメントにも

It's up to you how you provide credentials for your specific provider to Atlantis: https://www.runatlantis.io/docs/provider-credentials

とあり、Atlantisを使う側で頑張ってほしいような雰囲気を感じます。

terraformではtfファイル内に直接IAMロールを記述しなくても .aws/config に設定があれば、AWS_PROFILE環境変数でAssumeRoleすることができます。

[default]
region = ap-northeast-1
credential_source = EcsContainer

[profile my-project-plan]
role_arn = arn:aws:iam::123456789012:role/atlantis-plan
$ export AWS_PROFILE=my-project-plan
$ terraform plan # atlantis-planロールで実行される

またAtlantisはワークフローをカスタマイズすることができ、plan・apply実行時に任意の処理を入れることができます。

www.runatlantis.io

そこでterraformプロジェクト、plan・applyごとに異なるAWS_PROFILEを設定してterraformを実行するカスタムワークフローを定義した独自のAtlantisのDockerイメージ作成しました。

Atlantisのカスタマイズ

AtlantisのDockerイメージを作成するためのリポジトリの構成はこんな感じです。

docker-atlantis/
├── Dockerfile
└── files/
    ├── etc/
    │   └── atlantis/
    │       ├── config.yaml
    │       └── repos.yaml
    ├── home/
    │   └── atlantis/
    │       ├── .aws/
    │       │   └── config
    │       ├── .markdown_templates/
    │       │   └── plan_success_wrapped.tmpl
    │       └── .sev.toml
    └── usr/
        └── local/
            └── sbin/
                ├── gcp-assume-role.sh
                ├── repo-config-generator.sh
                ├── terraform-apply.sh
                ├── terraform-init.sh
                └── terraform-plan.sh
# Dockerfile

FROM ghcr.io/runatlantis/atlantis:v0.30.0-debian

USER root

RUN apt-get update && \
  apt-get install -y \
  jq \
  apt-transport-https \
  ca-certificates \
  gnupg \
  curl

RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \
  echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && \
  apt-get update && \
  apt-get install -y google-cloud-cli

ARG SEV_VERSION=0.8.0
ARG SEV_SHA256SUM=a191501dc0f2c17b7d56c2a720a5db6b2abc3c0f9e8e185456387aafd15b0913
RUN curl -sSfLo sev.deb https://github.com/winebarrel/sev/releases/download/v${SEV_VERSION}/sev_${SEV_VERSION}_amd64.deb && \
  echo "$SEV_SHA256SUM sev.deb" > sha256sum.txt && \
  sha256sum -c sha256sum.txt && \
  dpkg -i sev.deb && \
  rm sev.deb sha256sum.txt

COPY files/ /
RUN chown -R atlantis:atlantis /home/atlantis

USER atlantis
CMD [ \
  "server", \
  "--config=/etc/atlantis/config.yaml", \
  "--repo-config=/etc/atlantis/repos.yaml" \
  ]

AssumeRoleするためのカスタムワークフローは /etc/atlantis/repos.yaml に定義されています。

workflows:
  with-aws-profile:
    plan:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-plan -- terraform-init.sh
            output: hide
        - run:
            command: sev ${PROJECT_NAME}-plan -- terraform-plan.sh
            output: strip_refreshing
    apply:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-apply -- terraform-apply.sh
# terraform-plan.sh

#!/bin/bash
terraform${ATLANTIS_TERRAFORM_VERSION} plan -input=false -refresh -out $PLANFILE
# terraform-apply.sh

#!/bin/bash
terraform${ATLANTIS_TERRAFORM_VERSION} apply $PLANFILE

ワークフロー実行時、プロジェクト名が環境変数PROJECT_NAMEで渡されるので、それを使ってterraform実行時のAWS_PROFILEを切り替えています。*1

「command:」の箇所に出てくる sev は引数のプロファイル名に紐付く環境変数渡してコマンドを実行するラッパーツールです。 github.com

plan時に PROJECT_NAME=foo-proj が渡されると .sev.toml から [foo-proj-plan] を検索して環境変数AWS_PROFILEを設定し、terraform-plan.shを実行します。

# /home/atlantis/.sev.toml

[foo-proj-plan]
AWS_PROFILE = "foo-proj-plan"

.aws/config に foo-proj-planプロファイルが定義されており、role_arnで指定されたIAMロールにAssumeRoleしてterraformが実行されます。

# /home/atlantis/.aws/config

[profile foo-proj-plan]
role_arn = arn:aws:iam::123456789012:role/atlantis-plan

このような仕組みでAWSアカウントごと、plan・applyごとに異なるIAMロールにAssumeRoleできるようにしました。

GCPへの対応

GCPのterraformはサービスアカウントで実行されます。 サービスアカウントはWorkload Identity連携を使うことでAWSのIAMロールから認証情報を発行することができます。

cloud.google.com

たとえば、terraform planを行うためのサービスアカウントの定義は以下のようになります。

resource "google_service_account" "terraform_plan" {
  account_id   = "terraform-plan"
  display_name = "terraform-plan"
}

resource "google_project_iam_binding" "viewer" {
  project = "..."
  role    = "roles/viewer"

  members = [
    "serviceAccount:${google_service_account.terraform_plan.email}",
  ]
}

resource "google_service_account_iam_binding" "terraform_workload_identity_user" {
  service_account_id = google_service_account.terraform_plan.name
  role               = "roles/iam.workloadIdentityUser"
  members = [
    "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.atlantis_plan.name}/attribute.aws_role/arn:aws:sts::223456789012:assumed-role/ecs-atlantis",
  ]
}

resource "google_iam_workload_identity_pool" "atlantis_plan" {
  provider = google-beta

  display_name              = "atlantis-plan"
  workload_identity_pool_id = "atlantis-plan"
}

resource "google_iam_workload_identity_pool_provider" "atlantis_plan" {
  provider = google-beta

  workload_identity_pool_id          = google_iam_workload_identity_pool.atlantis_plan.workload_identity_pool_id
  workload_identity_pool_provider_id = "atlantis-plan"
  display_name                       = "atlantis-plan"
  attribute_condition                = "assertion.arn.startsWith('arn:aws:sts::223456789012:assumed-role/ecs-atlantis/')"
  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",
  }

  aws {
    account_id = "223456789012"
  }
}

このサービスアカウントを使うために、別のカスタムワークフローとラッパーコマンド gcp-assume-role.sh をAtlantisに追加します。

  with-gcp-creds:
    plan:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-plan -- gcp-assume-role.sh /usr/local/sbin/terraform-init.sh
            output: hide
        - run:
            command: sev ${PROJECT_NAME}-plan -- gcp-assume-role.sh /usr/local/sbin/terraform-plan.sh
            output: strip_refreshing
    apply:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-apply -- gcp-assume-role.sh /usr/local/sbin/terraform-apply.sh
# gcp-assume-role.sh

#!/bin/bash
set -e
CREDS_JSON=.gcp-creds.json

gcloud iam workload-identity-pools create-cred-config \
  $GCP_WORKLOAD_IDENTITY_POOL_PROVIDER \
  --service-account="$GCP_SERVICE_ACCOUNT" \
  --aws \
  --output-file="$CREDS_JSON"

trap "rm -f $CREDS_JSON" EXIT
export GOOGLE_APPLICATION_CREDENTIALS="$CREDS_JSON"

AWS_CREDS=$(curl -sSf http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
export AWS_ACCESS_KEY_ID=$(echo "$AWS_CREDS" | jq -r .AccessKeyId)
export AWS_SECRET_ACCESS_KEY=$(echo "$AWS_CREDS" | jq -r .SecretAccessKey)
export AWS_SESSION_TOKEN=$(echo "$AWS_CREDS" | jq -r .Token)

source "$1"

また、sev では 環境変数 GCP_WORKLOAD_IDENTITY_POOL_PROVIDER と GCP_SERVICE_ACCOUNT を設定します。

[gcp-proj-plan]
GCP_WORKLOAD_IDENTITY_POOL_PROVIDER = "projects/098765432109/locations/global/workloadIdentityPools/atlantis-plan/providers/atlantis-plan"
GCP_SERVICE_ACCOUNT = "terraform-plan@my-proj.iam.gserviceaccount.com"

gcp-assume-role.sh でラップすることECSタスクのIAMロールから認証情報を取得し、サービスアカウントの権限でterraformを実行できるようになります。

Datadogへの対応

Datadogは単純なAPIキーでterraformを実行するので、APIキーを格納するAWS Secrets Managerのシークレットを作成し、そのシークレットの取得権限をもつIAMロールにAssumeRoleすることで権限を分けています。

resource "aws_secretsmanager_secret" "atlantis_datadog_DD_API_KEY" {
  name = "atlantis/datadog/DD_API_KEY"
}

resource "aws_secretsmanager_secret" "atlantis_datadog_DD_APP_KEY" {
  name = "atlantis/datadog/DD_APP_KEY"
}

resource "aws_iam_role" "atlantis_datadog" {
  name = "atlantis-datadog"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "sts:AssumeRole"
        Principal = {
          AWS = [
            "arn:aws:iam::223456789012:role/ecs-atlantis",
          ]
        }
      },
    ]
  })
}

resource "aws_iam_role_policy" "atlantis_datadog_terraform" {
  role = aws_iam_role.atlantis_datadog.name
  name = "terraform"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow",
        Action = [
          "s3:GetObject",
          "s3:PutObject",
        ]
        Resource = "${aws_s3_bucket.terraform.arn}/datadog.tfstate"
      },
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem"
        ],
        Resource = aws_dynamodb_table.terraform_lock.arn,
      },
    ]
  })
}

resource "aws_iam_role_policy" "atlantis_datadog_secret_access" {
  role = aws_iam_role.atlantis_datadog.name
  name = "secret-access"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "secretsmanager:GetSecretValue",
        Resource = [
          aws_secretsmanager_secret.atlantis_datadog_DD_API_KEY.arn,
          aws_secretsmanager_secret.atlantis_datadog_DD_APP_KEY.arn,
        ]
      }
    ]
  })
}

sev にはSecrets Managerから値を取得する機能があり、以下のように「secretsmanager://〜」とプレフィックスをつけると、指定されたsecret idの値で環境変数 DD_API_KEY と DD_APP_KEY を設定してterraformを実行します。

# .sev.toml

[datadog-plan]
AWS_PROFILE = "datadog"
DD_API_KEY = "secretsmanager://atlantis/datadog/DD_API_KEY"
DD_APP_KEY = "secretsmanager://atlantis/datadog/DD_APP_KEY"
# .aws/config

[profile kanmu-datadog]
role_arn = arn:aws:iam::000456789000:role/atlantis-datadog

このようにしてAPIキーを使うSaaSでも権限を分離するようにしています。

まとめ

Atlantisの導入に際して汎用的なマルチクラウド対応の仕組みを作ることで、様々なIaaS・SaaSを素早くAtlantisに対応させることができるようになりました。

Atlantisのマルチクラウド対応についてはあまり知見が見つからなかったのですが、比較的単純な仕組みで対応できたのはAtlantis自体の柔軟さによるところが大きいと思います。

terraformの自動化のためのAtlantisの導入、とてもオススメです。