FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

Terraformを使って複数のAWSアカウントにAWS Integrationを導入する

これは Datadog Advent Calendar 2019 13日目の記事です。

門脇(@blac_k_ey)です。

PYXISのデータ基盤チームでSREっぽいことを行っている傍ら、インフラチームとして横断的なインフラの管理・運用を行っています。

モチベーション

最近PYXISチームではモニタリングツールとしてDatadogの採用が進んでいます。
PYXISのインフラは主にAWSを利用しており、DatadogのAWS Integrationを導入することで各種サービスのメトリクスを取得することが可能になります。

ここで課題になるのが弊社のAWSのアカウント構成です。
PYXISチームのAWSアカウント管理は以下のような構成になっています。

このように、基幹となるAWSアカウントからAssumeRoleを使って、各プロジェクトで利用しているAWSアカウントにログインするという形を取っています。

Terraformで各プロジェクトアカウントに対してAWS Integrationを導入しようとしたとき、アカウントひとつひとつにTerraform用のユーザを用意することを考えました。
しかし、この場合だと各アカウントにユーザを用意する必要があるためcredentialの管理が大変ですし、stateファイルも各プロジェクトに散らばってしまうので管理が難しくなってしまいます。
なるべく最小の手間でAWS Integrationを導入したいため、これでは骨が折れます。

そこで、今回は以下のような構成を取りました。

基幹アカウントにTerraform用ユーザ、各プロジェクトのアカウントにTerraformを利用できる権限を持ったRoleを用意し、AssumeRoleできるようにします。
これにより、credentialの管理は基幹アカウントのユーザだけになり、stateファイルもいちアカウントにまとめることができました。
必要なものはプロジェクトアカウントのRoleと、基幹→プロジェクトアカウントにAssumeRoleする権限だけになりますが、この部分は弊社の情シスがAWSアカウント作成時に用意してくれるものです。
AWS Integrationの導入のスピードも早まり、Credential管理などの運用の負担もかなり減りました。

以下から、実際のTerraformを見ていきます。

Terraformの準備

表題の通り、Terraformを使ってDatadogのAWS Integrationを設定していきます。

結論から入ってしまいますが、以下は今回の成果物となるTerraformです。

main.tf

terraform {
  required_version = "= 0.12.12"

  backend "s3" {
    bucket               = "account-a-terraform"
    workspace_key_prefix = "datadog_aws_integration_terraform"
    key                  = "terraform.tfstate"
    region               = "ap-northeast-1"
    role_arn             = "arn:aws:iam::111111111111:role/TerraformLoginRole"
  }
}

provider "aws" {
  version = "~> 2.24.0"
  region  = local.aws_region

  assume_role {
    role_arn = var.workspace_iam_role_arn[terraform.workspace]
  }
}

provider "datadog" {
  version = "~> 2.4.0"
  api_key = var.datadog_api_key
  app_key = var.datadog_app_key
}

##### AWS #####

data "aws_iam_policy_document" "datadog_integration_iam_policy_document" {
  # https://docs.datadoghq.com/integrations/amazon_web_services/?tab=allpermissions#datadog-aws-iam-policy
  statement {
    sid = "all"

    effect = "Allow"

    actions = [
      "apigateway:GET",
      "autoscaling:Describe*",
      "budgets:ViewBudget",
      "cloudfront:GetDistributionConfig",
      "cloudfront:ListDistributions",
      "cloudtrail:DescribeTrails",
      "cloudtrail:GetTrailStatus",
      "cloudwatch:Describe*",
      "cloudwatch:Get*",
      "cloudwatch:List*",
      "codedeploy:List*",
      "codedeploy:BatchGet*",
      "directconnect:Describe*",
      "dynamodb:List*",
      "dynamodb:Describe*",
      "ec2:Describe*",
      "ecs:Describe*",
      "ecs:List*",
      "elasticache:Describe*",
      "elasticache:List*",
      "elasticfilesystem:DescribeFileSystems",
      "elasticfilesystem:DescribeTags",
      "elasticloadbalancing:Describe*",
      "elasticmapreduce:List*",
      "elasticmapreduce:Describe*",
      "es:ListTags",
      "es:ListDomainNames",
      "es:DescribeElasticsearchDomains",
      "health:DescribeEvents",
      "health:DescribeEventDetails",
      "health:DescribeAffectedEntities",
      "kinesis:List*",
      "kinesis:Describe*",
      "lambda:AddPermission",
      "lambda:GetPolicy",
      "lambda:List*",
      "lambda:RemovePermission",
      "logs:Get*",
      "logs:Describe*",
      "logs:FilterLogEvents",
      "logs:TestMetricFilter",
      "logs:PutSubscriptionFilter",
      "logs:DeleteSubscriptionFilter",
      "logs:DescribeSubscriptionFilters",
      "rds:Describe*",
      "rds:List*",
      "redshift:DescribeClusters",
      "redshift:DescribeLoggingStatus",
      "route53:List*",
      "s3:GetBucketLogging",
      "s3:GetBucketLocation",
      "s3:GetBucketNotification",
      "s3:GetBucketTagging",
      "s3:ListAllMyBuckets",
      "s3:PutBucketNotification",
      "ses:Get*",
      "sns:List*",
      "sns:Publish",
      "sqs:ListQueues",
      "support:*",
      "tag:GetResources",
      "tag:GetTagKeys",
      "tag:GetTagValues",
      "xray:BatchGetTraces",
      "xray:GetTraceSummaries",
    ]

    resources = [
      "*",
    ]
  }
}

resource "aws_iam_policy" "datadog_integration_iam_policy" {
  name_prefix = "DatadogAWSIntegrationPolicy"
  policy      = data.aws_iam_policy_document.datadog_integration_iam_policy_document.json
}

resource "aws_iam_role_policy_attachment" "datadog_integration_iam_role_policy_attachment" {
  policy_arn = aws_iam_policy.datadog_integration_iam_policy.arn
  role       = aws_iam_role.datadog_integration_iam_role.name
}

data "aws_iam_policy_document" "datadog_integration_assume_role_iam_policy_document" {
  statement {
    actions = [
      "sts:AssumeRole",
    ]

    principals {
      identifiers = [
        "arn:aws:iam::${local.datadog_account_id}:root",
      ]

      type = "AWS"
    }

    condition {
      test = "StringEquals"

      values = [
        datadog_integration_aws.aws_integration.external_id,
      ]

      variable = "sts:ExternalId"
    }
  }
}

resource "aws_iam_role" "datadog_integration_iam_role" {
  name               = "DatadogAWSIntegrationRole"
  assume_role_policy = data.aws_iam_policy_document.datadog_integration_assume_role_iam_policy_document.json
}

##### Datadog #####

resource "datadog_integration_aws" "aws_integration" {
  account_id                       = var.aws_account_id[terraform.workspace]
  account_specific_namespace_rules = {}
  filter_tags = [
    "datadog-enabled:true",
  ]
  host_tags = [
    "aws_account_name:${terraform.workspace}",
  ]
  role_name = local.datadog_aws_integration_role_name
}

variables.tf

locals {
  aws_region         = "ap-northeast-1"
  datadog_account_id = "464622532012" # DatadogのアカウントID
}

variable "workspace_iam_role_arn" {
  type = map(string)
  default = {
    account-a = "arn:aws:iam::111111111111:role/TerraformLoginRole"
    account-b = "arn:aws:iam::222222222222:role/TerraformLoginRole"
  }
}

variable "aws_account_id" {
  type = map(string)
  default = {
    account-a = "111111111111"
    account-b = "222222222222"
  }
}

variable "datadog_api_key" {
  type = string
}

variable "datadog_app_key" {
  type = string
}

重要な部分をかいつまんで説明します。

AWS Integrationから参照されるIAMロールを設定

AWS Integrationで各サービスのメトリクスを取得するために必要な権限を追加していきます。

必要な権限はDatadogのドキュメントに記載されており、AWSのサービス追加やDatadog側のIntegrationの追加などで時々変更がありますので、監視したいサービスの権限が含まれているかよく確認しておきましょう。

data "aws_iam_policy_document" "datadog_integration_iam_policy_document" {
  # https://docs.datadoghq.com/integrations/amazon_web_services/?tab=allpermissions#datadog-aws-iam-policy
  statement {
    sid = "all"
    effect = "Allow"
    actions = [
      "apigateway:GET",
      "autoscaling:Describe*",
      "budgets:ViewBudget",
      ...
    ]
    resources = ["*"]
  }
}

また、Datadogが管理しているAWSのアカウントからAssumeRoleできる権限を追加します。

data "aws_iam_policy_document" "datadog_integration_assume_role_iam_policy_document" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      identifiers = ["arn:aws:iam::${local.datadog_account_id}:root"]
      type = "AWS"
    }

    condition {
      test = "StringEquals"
      values = [datadog_integration_aws.aws_integration.external_id]
      variable = "sts:ExternalId"
    }
  }
}

DatadogにAWS Integrationを設定

TerraformのDatadog Providerを使ってAWS Integrationを設定します。

resource "datadog_integration_aws" "aws_integration" {
  account_id                       = var.aws_account_id[terraform.workspace]
  account_specific_namespace_rules = {}
  filter_tags = [
    "datadog-enabled:true",
  ]
  host_tags = [
    "aws_account_name:${terraform.workspace}",
  ]
  role_name = local.datadog_aws_integration_role_name
}

filter_tags を使うことで、指定のタグを持ったEC2インスタンスのみを監視下に置くことが可能です。
これにより必要以上にHost単位の課金がかかることを抑えられます。

host_tags はこのAWS Integrationから送信されたメトリクスにタグを振ることができます。
ここではアカウント名を追加することで、どのアカウントから送られてきたメトリクスか分かりやすくしています。

また、ここで重要になるのが role_name をローカル変数から取ってきているという点です。
前項でIAMロールの定義を行っているのでそこからロール名を引っ張りたいところです。
しかし、IAMロールがこの aws_integrationexternal_id を参照しているため、循環参照となってしまうため無効な定義となってしまいます。

Workspacesでアカウントごとの差分とStateを管理

TerraformにはWorkspacesという機能があります。
これを使うことで、本番・ステージング…といった環境ごとにState管理できるようになります。

今回はAWSアカウント名をworkspace名として管理していきます。

$ terraform workspace new account-a
$ terraform workspace new account-b

workspaceごとに設定値を変更したい場合があります。
その場合はvariablesを map とし、workspace名をキーとしてデフォルト値をもたせます。

variable "workspace_iam_role_arn" {
  type = map(string)
  default = {
    account-a = "arn:aws:iam::111111111111:role/TerraformLoginRole"
    account-b = "arn:aws:iam::222222222222:role/TerraformLoginRole"
  }
}

あとは変数の利用側を var.mapの変数名[terraform.workspace] とすればよいです。

provider "aws" {
  version = "~> 2.24.0"
  region  = local.aws_region

  assume_role {
    role_arn = var.workspace_iam_role_arn[terraform.workspace]
  }
}

上記の設定により、ひとつのIAMユーザから複数のアカウントにAssumeRoleし、Terraformを適用できます。

また、

terraform {
  required_version = "= 0.12.12"

  backend "s3" {
    bucket               = "account-a-terraform"
    workspace_key_prefix = "datadog_aws_integration_terraform"
    key                  = "terraform.tfstate"
    region               = "ap-northeast-1"
    role_arn             = "arn:aws:iam::111111111111:role/TerraformLoginRole"
  }
}

GitLab CI で terraform play/apply を自動化する

以上でTerraformの準備は整いました。
GitLab CIを使ってデプロイを自動化していきましょう。

stages:
  - plan
  - apply

image:
  name: hashicorp/terraform:0.12.12
  entrypoint: [""]

## 環境変数の設定
.env_account-a: &env_account-a
  variables:
    AWS_ACCOUNT_NAME: account-a

.env_account-b: &env_account-b
  variables:
    AWS_ACCOUNT_NAME: account-b

## terraform plan
.plan: &plan
  stage: plan
  script:
    - terraform fmt -check=true -diff=true
    - terraform init
    - terraform workspace new $AWS_ACCOUNT_NAME || true
    - terraform workspace select $AWS_ACCOUNT_NAME
    - terraform validate
    - terraform plan
  artifacts:
    paths:
      - '.terraform'

plan account-a:
  <<: *env_account-a
  <<: *plan

plan account-b:
  <<: *env_account-b
  <<: *plan

## terraform apply
.apply: &apply
  stage: apply
  script:
    - terraform apply -auto-approve
  when: manual
  only:
    - master

apply account-a:
  <<: *env_account-a
  <<: *apply
  dependencies:
    - plan account-a

apply account-b:
  <<: *env_account-b
  <<: *apply
  dependencies:
    - plan account-b

CI設定のVariablesにTerraform用ユーザのcredentialを追加しておきましょう。

これで、新しいアカウントでAWS Integrationを導入したい場合は、TerraformのvariablesにRoleのARNなどを追加し、GitLab CIのジョブを追加すればOKですね。

デプロイしてみる

あとはGitLab CIのジョブを走らせるだけです!



これで、AWS Integrationを複数のアカウントに展開することができました!

まとめ

  • DatadogのAWS IntegrationをTerraformでデプロイするには
    • AWSのIAM Roleを設定して、
    • Datadog Providerの aws_integration を設定する
  • 複数のアカウントにAWS Integrationを展開したい場合は
    • 各アカウントにAssumeRoleできるユーザを用意するとcredentialとstateファイルの管理が楽になる

このほか、TerraformのDatadog ProviderではMonitorやDashboardの設定も可能なので、
各アカウント共通で使えるアラートや、ダッシュボードなどを用意するのも面白そうだなと思いました。

まだまだDatadogの活用が始まったばかりなので、色々いじって知見をためていきたいです。