SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

TerraformとCircleCIによるAWSアカウント管理のコード化

f:id:yuma124:20190122161339p:plain

この1月に普通自動二輪の免許を取得したので、早く暖かくなってツーリングに行きたいなとそわそわしている笹原です。

みなさんはAWSを使った開発をするときに、どうやってIAMユーザを管理してますか?

開発者に権限を与えすぎれば セキュリティリスクは高まる ので、与える権限を少なくしたいですが、少なくしすぎても 開発・運用時に作業や権限の申請が頻繁に必要 になります。

また、入社時や退職時にIAMユーザの作成・削除をしたり、定期的な棚卸しを実施したりといった運用も管理が煩雑になっていると意外と工数がとられたりするものです。

今回はAWSユーザ管理を管理者からの一方通行ではなく、TerraformとCircle CIを使って開発者からのPull Requestベースでやってみた事例を公開したいと思います!

AWSのマルチアカウント構成

f:id:yuma124:20190122013047p:plain

AWS Organizationsが入ってからこのような構成が一般的になってきてると思います。

AWSアカウントはLogin用のアカウントを一つ作成して、実際に利用するServiceアカウントにはAssumeRoleを行うことで入るようにしています。

  • Login
    • 開発者用のIAMユーザを作成する
  • Service
    • サービス毎、本番・開発等の環境毎にAWSアカウントを作成
    • 開発者自身が利用するIAMユーザは基本的に作成せずに、loginアカウントからAssumeRoleを行う

Terraform

AWSのマルチアカウント構成を運用する上で、AWSコンソール上から手作業でIAMユーザを管理するのは、管理者の負担になるだけでなく属人化も招くので、Terraformで管理します。

下準備

各AWSアカウントの管理をするにあたって、それぞれのアカウントでTerraformを実行することになるので、各アカウントにAssumeRoleできるIAMユーザをLoginアカウント上に作成します。

まず、各AWSアカウント上で、LoginアカウントからAssumeRoleするためのIAMロールを作成します。 このとき、いずれCI上でTerraformを実行することを見据えて、オプションのMFAはチェックを外したままにしておきます。

f:id:yuma124:20190122020519p:plain

次に、Loginアカウント上でIAMユーザを作成します。

このIAMユーザには、IAMFullAccessとS3FullAccessに加えて、カスタムポリシーで各アカウントへのAssumeRoleを許可しておきましょう。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": "arn:aws:iam::*:role/Terraform"
        }
    ]
}

ディレクトリ構成

Terraformのディレクトリ構成はこんな感じです。

Terraformコマンドの実行はルートディレクトリから行います。

この構成のポイントは

  • 外部変数と内部変数と出力については別途専用のファイル( variables.tf と local.tf と outputs.tf )を用意
  • IAMロールやIAMグループの作成だったり、各アカウントに対する処理は大部分が共通化できるのでmodule化
├── README.md
├── locals.tf
├── outputs.tf
├── main.tf
├── login-account.tf
├── service-1-account.tf
├── service-2-account.tf
├── ...
├── modules/
│   ├── account/
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   ├── main.tf
│   ├── iam/
│   │   ├── group/
│   │   │   ├── variables.tf
│   │   │   ├── outputs.tf
│   │   │   ├── main.tf
│   │   ├── role/

Serviceアカウント

各Serviceアカウントで行うことは以下の2つです。

  • LoginアカウントからAssumeRoleすることができるIAMロールを作成する
  • IAMロールに対して、各アカウント内で必要な権限を付与する

各ServiceアカウントのTerraform実行は、modules/accountを呼び出して実行します。

以下のように、呼び出し元から対象のServiceアカウントのAWSアカウントIDを渡してやり、下準備で作成したロールにAssumeRoleします。

service-1-account.tf

locals {
  aws_region = "ap-northeast-1"

  aws_accounts = {
    service_1_account = "000000000001"
    service_2_account = "000000000002"
  }
}

module "service_1_account" {
  source = "./modules/account"

  aws_region     = "${local.aws_region}"
  aws_account_id = "${local.aws_accounts["service_1_account"]}"
}

modules/account/main.tf

provider "aws" {
  region = "${var.aws_region}"

  assume_role {
    role_arn = "arn:aws:iam::${var.aws_account_id}:role/Terraform"
  }
}

CircleCI

さて、Terraformを使うことで、AWSのIAMユーザ管理の属人化は薄れましたが、Terraformをどのように実行するかには依然として問題はあります。

開発者もTerraformプロジェクトを書き換えて、ローカルで実行できてしまうのでは元も子もありません。

そこで、Terraformの実行をローカルではなくCI上で行うことにします。

弊社ではCircleCI上でTerraformを実行しています。

Job概要

f:id:yuma124:20190122025941p:plain

Jobの流れは図のとおりです。

masterブランチでのみ、terraform applyが走るようになっています。

これでAWSアカウント権限に関する承認・付与を、Pull Requestベースで実行することが可能になりました。

補足が必要そうな点として、Planの前にLintが入っています。これは、terraform fmtを実行したときに変更されるファイルがあるかどうかを見ています。

設定

使用している設定ファイルも晒します。

工夫したのは

  • versionが2.1から利用できる機能を利用している
    • orbsを使った簡単なSlack通知
    • executorsによる共通化
  • terraform planを実行した結果をpersist_to_workspaceを使ってterraform apply時に渡すことでplanで確認したとおりに実行している

といったところです。

version: 2.1

orbs:
  slack: circleci/slack@volatile

executors:
  my-executor:
    working_directory: ~/work
    docker:
      - image: hashicorp/terraform:0.11.10

jobs:
  checkout_code:
    executor: my-executor
    steps:
      - restore_cache:
          keys:
            - source-v1-{{ .Revision }}
            - source-v1-
      - checkout
      - save_cache:
          key: source-v1-{{ .Revision }}
          paths:
            - .git
      - run: terraform init -input=false
      - persist_to_workspace:
          root: ~/
          paths: work
  lint:
    executor: my-executor
    steps:
      - attach_workspace:
          at: ~/
      - run:
          name: terraform fmt
          command: |
            if [ $(terraform fmt | grep -v .terraform | tee fmt_result.txt | wc -l) -gt 0 ]; then
              echo "Format of this terraform files is not appropriate:"
              echo
              cat fmt_result.txt
              echo
              echo "Please run terraform fmt"
              exit 1
            fi
  plan:
    executor: my-executor
    steps:
      - attach_workspace:
          at: ~/
      - run: terraform plan -input=false -out=terraform.plan
      - persist_to_workspace:
          root: ~/
          paths: work
  send-approval-link:
    docker:
      - image: buildpack-deps:trusty
    steps:
      - slack/notify:
          message: |
            Please check and approve Job to deploy.
            https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}
  apply:
    executor: my-executor
    steps:
      - attach_workspace:
          at: ~/
      - run: terraform apply -input=false --auto-approve terraform.plan

workflows:
  version: 2
  plan_and_apply:
    jobs:
      - checkout_code
      - lint:
          requires:
            - checkout_code
      - plan:
          requires:
            - lint
      - send-approval-link:
          requires:
            - plan
          filters:
            branches:
              only: master
      - waiting-for-approval:
          type: approval
          requires:
            - plan
          filters:
            branches:
              only:
                - master
      - apply:
          requires:
            - hold

運用フロー

実際の運用フローとしてはこういった形になります。

f:id:yuma124:20190122131627p:plain

ただ、Terraform 0.11の仕様ではこのフローでカバーできない範囲があり、そこでは手作業が発生しています。

運用上の問題点: IAMユーザの削除時に意図しない変更が行われる

IAMユーザはTerraform上に以下のように設定を記載しています。

locals {
  user_1 = "user_1"
  user_2 = "user_2"
  user_3 = "user_3"

  aws_iam_users = [
    "${local.user_1}",
    "${local.user_2}",
    "${local.user_3}",
  ]
}

resource "aws_iam_user" "iam_users" {
  count         = "${length(local.aws_iam_users)}"
  name          = "${element(local.aws_iam_users, count.index)}"
  path          = "/"
  force_destroy = true
}

ここからuser_2をそのままコードからのみ削除を行って、terraform planを行うと

  ~ aws_iam_user.iam_users[1]
      name:        "user_2" => "user_3"

  - aws_iam_user.iam_users[2]

として、意図した変更と異なる差分が出力されます。

これは、tfstateファイル内のリソースアドレスに、リストの場合はindexが使用されていることが原因です。

Terraform 0.12ではfor_eachが追加されており、リソースアドレスにindexを使わない方法が提供されるので、解決予定ではあります。

HashiCorp Terraform 0.12 Preview: For and For-Each

Terraform 0.12は2019 1Qでリリース予定ではありますが、それまでは手動でリソースアドレスの書き換えを行う必要があります。

terraform state mvを使って、削除したいリソースをリストの最後に持っていくことで削除のみが行われるようになります。

$ terraform state mv aws_iam_user.iam_users[1] aws_iam_user.iam_users[3]
$ terraform state mv aws_iam_user.iam_users[2] aws_iam_user.iam_users[1]
$ terraform state mv aws_iam_user.iam_users[3] aws_iam_user.iam_users[2]

今後について

今回はAWSのIAMユーザ管理をTerraformとCircleCIを使って行いましたが、利用している開発者向けサービスはAWSだけではありません。

TerraformはAWSやGCPのようなPaaSのみならず、GitHubやNew RelicのようなSaaSまでカバーしているので、そういったところもコード化していく予定です。