Terraform CloudでGitOpsを使用したCI/CDパイプラインを構築する


インフラエンジニアの寺岡です。
今回の内容はタイトル通りTerraform Cloudについてです。
概要から実際の使い方まで順を追ってまとめてみたいと思います。

Terraformの実行環境

皆さんはTerraformを実行するときにどの環境を利用していますか?
最も基本的なのはそれぞれのローカル環境だと思います。
ダウンロードページ(※1)からバイナリをダウンロードして
ローカルから直接terraform applyを実行するパターンですね。
一人で検証する際はこれで良いですが、実際に構築作業を行う際は複数人で扱うことが殆どです。
このときに各個人それぞれローカル環境で実行していると以下のような問題があります。

問題点

  • GitへのPush忘れで各個人のローカルにあるコードに差分が発生する可能性がある
  • tfstateファイルの共有ができない
  • 記述したコードをレビューする仕組みがない
  • 誰でも自由にApply出来てしまう
  • クラウドプラットフォームへのアクセスキーなどをローカル管理しなければならない

これらの問題をTerraform Cloud利用することで解決することができます。

Terraform Cloudとは

Terraformの公式サイト(※2)の一部を引用します。

Terraform Cloud is an application that helps teams use Terraform together. It manages Terraform runs in a consistent and reliable environment, and includes easy access to shared state and secret data, access controls for approving changes to infrastructure, a private registry for sharing Terraform modules, detailed policy controls for governing the contents of Terraform configurations, and more.

要約すると、Terraform Cloudはチームが一緒にTerraformを使用するのに役立つアプリケーションであり
以下のような、チームでTerraformを利用する場合に必要な機能を提供してくれるSaaSとなっています。

主な機能

  • 一貫した信頼性の高いTerraformの実行環境
  • 状態ファイル(tfstate)やアクセスキーなどのシークレットの共有
  • インフラストラクチャへの変更を承認するためのアクセス制御
  • Terraformのモジュールを共有するためのプライベートレポジトリ
  • Terraformの構成内容を管理するためのポリシー制御

Terraform Cloudは基本的に無料で利用できますが一部の機能は有料プランでなければ使えません。
チームにおいてどの機能が必要なのかを判断して適切なプランを選択してください。
プランごとの機能と料金はTerraformの価格ページ(※3)にまとめられています。

なお、これ以降に記載するTerraform Cloudの利用方法は全て無料枠で設定することが可能なので
まずは無料枠から始めてみて、足りない機能があれば有料プランに後から切り替えるのが良いでしょう。

構成図

構成図上の登場人物とワークフローをまとめます。

登場人物

Team A

プロジェクトA(以下「PRJ A」と記載します)で利用するシステムの開発に携わるメンバーが所属するチームです。
後述のSREsが記述したTerraformのモジュールを利用しながらPRJ A用のAWSアカウントに構築していきます。

Team B

プロジェクトB(以下「PRJ B」と記載します)で利用するシステムの開発に携わるメンバーが所属するチームです。
後述のSREsが記述したTerraformのモジュールを利用しながらPRJ BのAWSアカウントに構築していきます。

SREs

SRE(Site Reliability Engineer)が所属する、他のTeamのシステム開発を補助する目的のチームです。
役割的にPlatform Teamという呼び方も出来ると思います。
Team AおよびTeam BがPRJで利用するTerraformのモジュールを記述していきます。
また、後述のWorkSpaceなどのTerraform Cloud自体の設定の管理も担います。

GitLab

Team A・Team B・SREsが記述したTerraformのコードを管理します。
Terraform Cloudではこのようなソースコードを管理するサービスをVCS Providerと呼びます。
今回はGitlabのCommunity Editionを利用しています。
もちろんEnterprise EditionやGithubにも対応しています。(※4)

Repository Module VPC

SREsが記述したAWSのVPCを構築するためのTerraformのモジュールを管理するリポジトリです。
Terraform CloudではVCS Provider内のリポジトリはVCS Repositoryと呼びます。

Repository PRJ A

Team AがPRJ A用に記述したTerraformのコードを管理するリポジトリです。
概要はRepository Module VPCと同様です。

Repository PRJ B

Team AがPRJ B用に記述したTerraformのコードを管理するリポジトリです。
概要はRepository Module VPCと同様です。

WorkSpace PRJ A

Terraform CloudにおけるPRJ A用のワークスペースです。
WorkSpaceはTerraformのコードで記述された構成をPRJごとやサービスごとなど
意味のある単位に分割するための論理グループです。(※5)

WorkSpace PRJ B

Terraform CloudにおけるPRJ B用のワークスペースです。
概要はWorkSpace PRJ Aと同様です。

Private Module Registry

Terraform Registry(※6)とほぼ同じ機能を提供するプライベートリポジトリです。
SREsが記述したTerraformのモジュールはここで管理します。

AWS Cloud(PRJ A)

PRJ A用のAWSアカウントです。
Team Aはこのアカウントに対して構築していきます。

AWS Cloud(PRJ B)

PRJ B用のAWSアカウントです。
Team Bはこのアカウントに対して構築していきます。

ワークフロー

括弧内は作業を実施する人物 or ツールです。

1. Push Module Code(SREs)

SREsのローカルでTerraoformのModuleを書いてGitにPushします。
コードをModule化しておくことでSREsとTeamの間で作業範囲を適切に分割することができます。

2. Import Module Code(SREs)

Terraform CloudのPrivate Module RegistryにPushしたModuleをインポートします。

3. Create WorkSpace(SREs)

各Teamで利用するためのTerraform CloudのWorkSpaceを作成します。

4. Push Prj Infra Code(Team A or Team B)

各TeamでSREsが記述したModuleを利用して記述したTerraformのコードをGitにPushします。

5. VCS Provider and Automatic Plan(Terraform Cloud)

GitのPushイベントをTerraform Cloudで検知してPushされたコードに対して自動的にterraform planを実行します。
この仕組みがあることでGitへの変更を起点にCI/CDのフローを自動実行するGitOpsの恩恵を得ることができます。

6. Code Review and Approve(Team A or Team B)

terraform planが終わるとTerraform Cloud側でApply待ちの状態になります。
設定を反映する前にPlanの結果に対してレビューを行い
変更内容が意図したものであればApplyを承認します。

7. Apply(Terraform Cloud)

実際に対象環境に対して変更を反映します。

8. Notification(Terraform Cloud)

Terraform Cloudで何らかの処理が実行されたときにSlackなどに通知します。

実装とワークフローの確認

今回はSREsがTerraformのModuleをPushするところから
Team BがPRJ BのAWSアカウントにVPCを作成するところまで確認してみたいと思います。
前提としてTerraoform CloudのアカウントとOrganizationを作成した後から記載します。
事前にサインアップページ(※7)から準備を進めておきましょう。

TerraformのModuleの記述(SREs)

今回はVPC構築用のModuleを記述します。
ディレクトリの階層構造は以下のようになります。

$ tree 
.
├── README.md
├── examples
│   └── vpc
│       ├── main.tf
│       ├── outputs.tf
│       ├── provider.tf
│       ├── terraform.tfstate
│       ├── terraform.tfstate.backup
│       ├── terraform.tfvars
│       └── variables.tf
├── main.tf
├── outputs.tf
└── variables.tf

2 directories, 11 files

ディレクトリのルートにある3つのファイルがModule本体です。
これはTeam A or Team Bが記述したコードから読み込まれることになるため
README.mdにModuleの概要や仕様を明記しておき
examples以下で具体的な使用例をコードとして残しておくと後から使ってもらいやすいです。

main.tfにはVPCを作成するResourceを記述します。

main.tf

resource "aws_vpc" "vpc" {
  cidr_block           = var.vpc_config.cidr_block
  enable_dns_support   = var.vpc_config.enable_dns_support
  enable_dns_hostnames = var.vpc_config.enable_dns_hostnames

  tags = {
    Name = var.vpc_config.name
  }
}

resource "aws_subnet" "public" {
  for_each                = var.public_subnet_config.subnets
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = each.key
  cidr_block              = each.value
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.public_subnet_config.name}-${substr(each.key, -2, 0)}"
  }
}

resource "aws_subnet" "dmz" {
  for_each                = var.dmz_subnet_config.subnets
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = each.key
  cidr_block              = each.value
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.dmz_subnet_config.name}-${substr(each.key, -2, 0)}"
  }
}

resource "aws_subnet" "private" {
  for_each                = var.private_subnet_config.subnets
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = each.key
  cidr_block              = each.value
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.private_subnet_config.name}-${substr(each.key, -2, 0)}"
  }
}

resource "aws_route_table" "public" {
  count  = var.public_subnet_config.route_table_name != "" ? 1 : 0
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = var.public_subnet_config.route_table_name
  }
}

resource "aws_route_table" "dmz" {
  count  = var.dmz_subnet_config.route_table_name != "" ? 1 : 0
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = var.dmz_subnet_config.route_table_name
  }
}

resource "aws_route_table" "private" {
  count  = var.private_subnet_config.route_table_name != "" ? 1 : 0
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = var.private_subnet_config.route_table_name
  }
}

resource "aws_internet_gateway" "igw" {
  count  = var.public_subnet_config.internet_gateway_name != "" ? 1 : 0
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = var.public_subnet_config.internet_gateway_name
  }
}

resource "aws_route" "public" {
  count                  = var.public_subnet_config.route_table_name != "" ? 1 : 0
  route_table_id         = aws_route_table.public[0].id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw[0].id
  depends_on             = [aws_route_table.public]
}

resource "aws_route" "dmz" {
  count                  = var.dmz_subnet_config.route_table_name != "" ? 1 : 0
  destination_cidr_block = "0.0.0.0/0"
  route_table_id         = aws_route_table.dmz[0].id
  nat_gateway_id         = aws_nat_gateway.natgw[0].id
  depends_on             = [aws_route_table.dmz]
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public[0].id
}

resource "aws_route_table_association" "dmz" {
  for_each       = aws_subnet.dmz
  subnet_id      = each.value.id
  route_table_id = aws_route_table.dmz[0].id
}

resource "aws_route_table_association" "private" {
  for_each       = aws_subnet.private
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private[0].id
}

resource "aws_eip" "natgw" {
  count = var.dmz_subnet_config.route_table_name != "" ? 1 : 0
  vpc   = true

  tags = {
    Name = var.dmz_subnet_config.nat_gateway_name
  }
}

resource "aws_nat_gateway" "natgw" {
  count         = var.dmz_subnet_config.route_table_name != "" ? 1 : 0
  allocation_id = aws_eip.natgw[0].id
  subnet_id     = aws_subnet.public[keys(aws_subnet.public)[0]].id

  tags = {
    Name = var.dmz_subnet_config.nat_gateway_name
  }

  depends_on = [aws_internet_gateway.igw]
}

outputs.tfにはModuleで作成したResourceの情報を出力するOutputを記述します。

outputs.tf

output "vpc" {
  value = aws_vpc.vpc
}

output "public_subnet" {
  value = aws_subnet.public
}

output "dmz_subnet" {
  value = aws_subnet.dmz
}

output "private_subnet" {
  value = aws_subnet.private
}

variables.tfにはModuleが受け取る変数の構造を記述します。
variableには必ずdescriptionとデフォルト値を記述しましょう。
理由は後述します。

variables.tf

variable "vpc_config" {
  description = "VPC Config"
  type = object({
    name                 = string
    cidr_block           = string
    enable_dns_support   = bool
    enable_dns_hostnames = bool
  })
  default = {
    name                 = ""
    cidr_block           = ""
    enable_dns_support   = false
    enable_dns_hostnames = false
  }
}

variable "public_subnet_config" {
  description = "Subnet Config for Public"
  type = object({
    name                  = string
    route_table_name      = string
    internet_gateway_name = string
    subnets               = map(string)
  })
  default = {
    name                  = ""
    route_table_name      = ""
    internet_gateway_name = ""
    subnets               = {}
  }
}

variable "dmz_subnet_config" {
  description = "Subnet Config for DMZ"
  type = object({
    name             = string
    route_table_name = string
    nat_gateway_name = string
    subnets          = map(string)
  })
  default = {
    name             = ""
    route_table_name = ""
    nat_gateway_name = ""
    subnets          = {}
  }
}

variable "private_subnet_config" {
  description = "Subnet Config for Private"
  type = object({
    name             = string
    route_table_name = string
    subnets          = map(string)
  })
  default = {
    name             = ""
    route_table_name = ""
    subnets          = {}
  }
}

examples以下は具体的な使用例をコードとして残しておきます。
Moduleの読み込み方や変数の渡し方の部分ですね。
AWSのアクセスキーなどはterraform.tfvarsではなく環境変数から読み込みましょう。

examples/provider.tf

provider "aws" {
  access_key = var.access_key
  secret_key = var.secret_key
  region     = var.region

  assume_role {
    role_arn = var.role_arn
  }
}

examples/variables.tf

variable "project" {
  description = "Project Name"
}

variable "environment" {
  description = "Environment"
}

variable "access_key" {
  description = "AWS Access Key"
}

variable "secret_key" {
  description = "AWS Secret Key"
}

variable "role_arn" {
  description = "AWS Role ARN for Assume Role"
}

variable "region" {
  description = "AWS Region"
}

examples/terraform.tfvars

###########################
# Project
###########################
project     = "terraform-vpc-module"
environment = "local"
region      = "ap-northeast-1"

examples/main.tf

module "vpc" {
  source = "../../"

  vpc_config = {
    name                 = "vpc-${var.project}-${var.environment}"
    cidr_block           = "10.0.0.0/16"
    enable_dns_support   = true
    enable_dns_hostnames = true
  }

  public_subnet_config = {
    name                  = "subnet-${var.project}-${var.environment}-public"
    route_table_name      = "route-${var.project}-${var.environment}-public"
    internet_gateway_name = "igw-${var.project}-${var.environment}"
    subnets = {
      ap-northeast-1a = "10.0.10.0/24"
      ap-northeast-1c = "10.0.11.0/24"
      ap-northeast-1d = "10.0.12.0/24"
    }
  }

  dmz_subnet_config = {
    name             = "subnet-${var.project}-${var.environment}-dmz"
    route_table_name = "route-${var.project}-${var.environment}-dmz"
    nat_gateway_name = "nat-${var.project}-${var.environment}"
    subnets = {
      ap-northeast-1a = "10.0.20.0/24"
      ap-northeast-1c = "10.0.21.0/24"
      ap-northeast-1d = "10.0.22.0/24"
    }
  }

  private_subnet_config = {
    name             = "subnet-${var.project}-${var.environment}-private"
    route_table_name = "route-${var.project}-${var.environment}-private"
    subnets = {
      ap-northeast-1a = "10.0.30.0/24"
      ap-northeast-1c = "10.0.31.0/24"
      ap-northeast-1d = "10.0.32.0/24"
    }
  }
}

examples/outputs.tf

output "vpc_id" {
  value = module.vpc.vpc.id
}

ここまで記述したらGitLabにレポジトリを作成してMasterブランチにPushしましょう。
今回は事前にTerraform AWS Module VPCという名前でリポジトリを作成しておきました。

GitのCommitに対してタグを付与します。
Terraform Cloudではこのタグに応じてModuleをバージョン管理することができます。

$ git tag v1.0.0
$ git push origin v1.0.0

Terraform CloudにModuleをインポートする(SREs)

GitLabにあるModuleをインポートするには
Terraform CloudにVCS providerの設定を追加する必要があります。

今回はGitLabを使用するのでGitLab用の設定手順(※8)を見ながら進めましょう。
それ以外のVCS providerを使用する場合も手順があるので対象のものをご参照ください。(※9)
その後にTerraform CloudのコンソールからSettings > VCS Providersの項目に追加されていると思います。
インポートはTerraform CloudのコンソールからSettings > Modules > Add moduleで追加できます。

先ほど追加したVCS providerが表示されるので選択します。

選択するとVCS Repositoryが表示されるので先ほどModuleをPushしたレポジトリを選択します。

確認画面でPublish moduleをクリックします。

Publishが終わるとModuleのREADME.mdとGitでタグ付けしたバージョンが読み込まれていることがわかります。

Moduleに渡す必要があるvariableの一覧も見れます。
variableを記述するときにdescriptionとデフォルト値を記述しておけば
この画面で詳細を確認できるようになります、便利ですね。

実行すると作成されるResourceの一覧も見れます、素晴らしい。

PRJ B用のWordSpaceを作成する(SREs)

Terraform CloudのWordSpaceを作成してTeam Bに渡します。
Terraform CloudのコンソールからWordspaces > New workspaceで作成できます。

まずはWorkSpaceのみ作成して後から設定を追加したいのでNo VCS connectionを選択します。

WorkSpaceの名前を入力してCreate workspaceをクリックします。
名前のフォーマットは自由ですが、team-name_prj-name_environmentのようにすると管理しやすいでしょう。

作成されるとこのように一覧に表示されます。

PRJ B用のリポジトリを作成してTerraformのコードをPushする(Team B)

PRJ B用にTerraformのコードを記述します。
Team Bでは予めSREsが記述したModuleを利用するようにします。

ディレクトリ構造

$ tree
.
├── backend.tf
├── main.tf
├── outputs.tf
├── providers.tf
└── variables.tf

0 directories, 5 files

まずここで重要なのがbackend.tfです。
今回Terraformを実行した後の状態ファイル(tfstate)はTerraform Cloud上で管理するために
remote backendに作成したWorkSpaceを指定します。

backend.tf

terraform {
  backend "remote" {
    hostname     = "app.terraform.io"
    organization = "Org-Name"

    workspaces {
      prefix = "team_b_prj_b_prod"
    }
  }
}

provider.tf

provider "aws" {
  access_key = var.access_key
  secret_key = var.secret_key
  region     = var.region

  assume_role {
    role_arn = var.aws_role_arn
  }
}

variableの記述をしておきます。
変数に値を格納する場合はterraform.tfvarsか環境変数かのいずれかですが
今回は値自体をTerraform Cloud上で管理するのでローカルにはどちらも用意しません。

variables.tf

#####################
# Project
#####################
variable "project" {
  description = "Project Name"
}

variable "environment" {
  description = "Environment"
}

#####################
# AWS Common
#####################
variable "access_key" {
  description = "AWS Access Key"
}

variable "secret_key" {
  description = "AWS Secret Key"
}

variable "role_arn" {
  description = "AWS Role ARN for Assume Role"
}

variable "region" {
  description = "AWS Region"
}

main.tfはsourceでPrivate Module RegistryにインポートしたModuleを指定します。

main.tf

module "vpc" {
  source  = "app.terraform.io/Org-Name/module-vpc/aws"
  version = "1.0.0"

  vpc_config = {
    name                 = "vpc-${var.project}-${var.environment}"
    cidr_block           = "10.0.0.0/16"
    enable_dns_support   = true
    enable_dns_hostnames = true
  }

  public_subnet_config = {
    name                  = "subnet-${var.project}-${var.environment}-public"
    route_table_name      = "route-${var.project}-${var.environment}-public"
    internet_gateway_name = "igw-${var.project}-${var.environment}"
    subnets = {
      ap-northeast-1a = "10.0.10.0/24"
      ap-northeast-1c = "10.0.11.0/24"
      ap-northeast-1d = "10.0.12.0/24"
    }
  }

  dmz_subnet_config = {
    name             = "subnet-${var.project}-${var.environment}-dmz"
    route_table_name = "route-${var.project}-${var.environment}-dmz"
    nat_gateway_name = "nat-${var.project}-${var.environment}"
    subnets = {
      ap-northeast-1a = "10.0.20.0/24"
      ap-northeast-1c = "10.0.21.0/24"
      ap-northeast-1d = "10.0.22.0/24"
    }
  }

  private_subnet_config = {
    name             = "subnet-${var.project}-${var.environment}-private"
    route_table_name = "route-${var.project}-${var.environment}-private"
    subnets = {
      ap-northeast-1a = "10.0.30.0/24"
      ap-northeast-1c = "10.0.31.0/24"
      ap-northeast-1d = "10.0.32.0/24"
    }
  }
}

outputs.tf

output "vpc_id" {
  value = module.vpc.vpc.id
}

ここまで記述したらGitにPushしておきましょう。

PRJ B用のWorkSpaceにVariableを追加する(Team B)

一覧から作成したWorkSpaceを選択するとVariablesという項目があり
そこでWorkSpaceで利用する変数の値を管理することができます。

特徴的な部分はAWSのアクセスキーなどの秘匿情報がSensitive Valueとして保存されていることです。
このようにしておくと値の編集をすることは出来ますが画面やAPIの結果に表示されなくなるので
隠しておきたい値を追加するときに非常に便利です。

PRJ B用のWorkSpace設定を変更する(Team B)

WorkSpaceのSettingsから以下の2つの設定を変更していきます。

Notificationsの設定に通知を飛ばしたいSlackのチャンネルの設定を追加する

Terraform CLoudの手順(※8)を参考にして設定を追加していきます。
WebHookを利用することになるので事前にSlack側で設定しておきましょう。

Version Controlの設定を追加する

WorkSpaceで読み込むVCS RepositoryとしてPRJ B用のリポジトリを登録します。

WorkSpaceの画面からSettings > Version Controlに移動します。

事前に登録しているVCS Providerを選択します。

該当のリポジトリを選択します。

Update VCS settingsをクリックします。

しばらくするとリポジトリからのTerraformのコードの読み込みが完了します。
variableの設定は既に行っているのでこのままQueue planをクリックしてみましょう。

Terraform Cloud上でまだ手動ですがterraform planが実行されます。
Planが完了するとそのままApplyは実行されることはなく以下のように承認待ちの状態で止まるので
Applyを実行する場合はPlanの結果を確認してConfirm & Applyから承認する必要があります。

承認すると以下のようにApplyが実行されて対象の環境に設定が反映されます。

状態ファイル(tfstate)もしっかりTerraform Cloud上で管理されています。

Notificationsの設定でSlackのチャンネルを指定していたので通知もバッチリです。

GitOpsが出来るかどうか確認する(Team B)

一旦手動実行で問題ないことは確認できました。
今回はGitへのPushに反応して自動実行したいですよね。
Terraformのコードを一部変更して動作を確認してみましょう。
VPCのPublicサブネットの名前の一部を変更してGitにPushしてみます。

GitのPushに反応して自動でPlanが実行され始めました。

当然今回もApply前に止まりますし変更した部分だけを差分として出力してくれていますね。
問題ないので承認してApplyしましょう。

Applyも正常に完了しました、問題なさそうですね。

まとめ

いかがでしたでしょうか。
チームでTerraformを利用する場合は冒頭で記述した問題点を考慮する必要が出てきます。
Terraform Cloudはその際に便利な機能が揃っており、チームでの利用を強力にサポートしてくれます。
無料枠でも多くの機能を利用できますので皆さんも是非試してみてください。

参考URL

※1 https://www.terraform.io/downloads.html
※2 https://www.terraform.io/docs/cloud/index.html
※3 https://www.hashicorp.com/products/terraform/pricing/
※4 https://www.terraform.io/docs/cloud/vcs/index.html
※5 https://www.terraform.io/docs/cloud/workspaces/index.html
※6 https://registry.terraform.io
※7 https://app.terraform.io/signup/account
※8 https://www.terraform.io/docs/cloud/vcs/gitlab-eece.html
※9 https://www.terraform.io/docs/cloud/vcs/index.html
※10 https://www.terraform.io/docs/cloud/workspaces/notifications.html#slack


この記事をかいた人

About the author

寺岡佑樹

2016年新卒入社、現在5年目。
SREとして、社内の運用業務の仕組みの検討・実装をしつつ
社外で技術的な登壇を行うエバンジェリスト的な側面も持つ。

・GitHub
https://github.com/nezumisannn

・登壇経歴
https://github.com/nezumisannn/my-profile

・発表資料(SpeakerDeck)
https://speakerdeck.com/nezumisannn