TerraformでWorkspaceを使わずに複数環境をDRYに設定する

# TL;DR

[Workspace機能](https://www.terraform.io/docs/state/workspaces.html)を用いて複数環境を管理する代わりに、シンボリックリンクを駆使して共通設定を使い回します。

どうしてWorkspaceを使わないの？ …という理由については、本記事の下の方に書いています。

# サンプルリポジトリ

サンプルリポジトリを https://github.com/progrhyme/sample-terraform-symlink に作りました。
※動作確認はしていないので、何かありましたらプルリクエストなど下さい。

# 構成概要

## ディレクトリ構成

```sh
.
├── dev/
│   ├── .envrc
│   ├── dev.auto.tfvars # dev環境の共通変数定義
│   ├── app/
│   │   ├── app-main.tf -> ../../shared/app-main.tf
│   │   ├── app-variables.tf -> ../../shared/app-variables.tf
│   │   ├── backend.tf
│   │   ├── common-variables.tf -> ../../shared/common-variables.tf
│   │   ├── dev.auto.tfvars -> ../dev.auto.tfvars
│   │   ├── global.auto.tfvars -> ../../shared/global.auto.tfvars
│   │   ├── main.tf
│   │   ├── provider.tf -> ../../shared/provider.tf
│   │   ├── terraform.tfvars
│   │   └── variables.tf
│   └── infra/
│       ├── backend.tf
│       ├── common-variables.tf -> ../../shared/common-variables.tf
│       ├── dev.auto.tfvars -> ../dev.auto.tfvars
│       ├── global.auto.tfvars -> ../../shared/global.auto.tfvars
│       ├── infra-main.tf -> ../../shared/infra-main.tf
│       ├── infra-outputs.tf -> ../../shared/infra-outputs.tf
│       ├── infra-variables.tf -> ../../shared/infra-variables.tf
│       ├── main.tf
│       ├── outputs.tf
│       ├── provider.tf -> ../../shared/provider.tf
│       ├── terraform.tfvars
│       └── variables.tf
├── modules/
│   ├── compute/
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── db/
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   └── network/
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
├── prod/
│   ├── .envrc
│   ├── prod.auto.tfvars # prod環境の共通変数定義
│   ├── app/
│   │   ├── :
│   │   └── variables.tf
│   └── infra/
│       ├── :
│       └── variables.tf
└── shared/
    ├── app-main.tf         # appの共通レシピ
    ├── app-variables.tf    # appの共通変数設定
    ├── common-variables.tf # globalと環境ごとの共通変数設定
    ├── global.auto.tfvars  # 全環境・セグメントで共通の変数定義
    ├── infra-main.tf       # infraの共通レシピ
    ├── infra-outputs.tf    # infraの共通Output
    ├── infra-variables.tf  # infraの共通変数設定
    └── provider.tf
```

## terraform実行例

```sh
cd dev/infra
terraform init # 初回のみ
terraform apply
```

このように `dev/infra` や `prod/app` といったディレクトリがterraformを実行する場所になるわけですが、以降はこのterraform実行単位を「セグメント」と称することにします。

## ポイント

- トップ階層に `dev/`, `prod/` といった環境単位のディレクトリを置いています。
- `dev/.envrc`, `prod/.envrc`は、[direnv](https://github.com/direnv/direnv)を使って `AWS_PROFILE` を切り替えるためのものです。
- 環境ごとに共通の変数定義は `dev/dev.auto.tfvars`, `prod/prod.auto.tfvars` にまとめています。
- そのほか環境をまたいで共有するレシピは `shared/` 配下に置きます。

# メリット

- 設定をDRYにできる。
- どの変数を修正したらどの環境/セグメントに影響するかがわかりやすい。
- direnvによって、環境ごとにクラウドの認証情報が異なる場合に、ディレクトリ移動だけで切り替えが可能。

# 課題

割とsymlinkが多く、複雑な構成になってしまったので、かえってわかりにくいという意見もあるかもしれません。

# 利用しているテクニック

## Variable Filesの自動読込み

terraformを実行すると、カレントディレクトリの `terraform.tfvars` ファイルと `*.auto.tfvars` というsuffixのファイルが自動的に読み込まれます。

See https://www.terraform.io/docs/configuration/variables.html#variable-files

例えば、 `dev/infra` セグメントでは、 `global.auto.tfvars`, `dev.auto.tfvars`, `terraform.tfvars` の3つのVariables Filesをロードしています。

ちなみに、このファイル群が読み込まれる順序を把握していれば、最初に読み込まれるファイルでデフォルト値を定義し、後に読み込まれるファイルで上書きするという手法も使えますが、最終的にどの変数定義が使われるかがわかりにくくなるので、今はそういうことはしない方針にしています。

## Remote Stateの参照

`infra`で構築したネットワークの情報を `app` から参照するようにしています。
以下に、サンプルリポジトリ中で関係するコードの一部を示します:

```Terraform:dev/app/backend.tf
data "terraform_remote_state" "infra" {
  backend = "s3"

  config {
    bucket = "<backet name>"
    key    = "dev-infra.tfstate"
    region = "<region>"
  }
}
```

```Terraform:shared/app-main.tf
module "compute" {
  source = "../../modules/compute"

  compute_bastion  = "${var.compute_bastion}"
  ec2_ssh_key_name = "${var.ec2_ssh_key_name}"

  network = "${data.terraform_remote_state.infra.network}" # ここで参照
}
```

参考:

- https://www.terraform.io/docs/state/remote.html

## VariableやOutputでmapを活用

上記の `network` 変数もそうですが、適宜mapを使うことで、variableやoutputを何度も記述する手間を減らしています。

例:

```Terraform:modules/network/outputs.tf
output "outputs" {
  value = "${map(
    "vpc_main_id", "${aws_vpc.main.id}",
    "security_group_main_default", "${aws_vpc.main.default_security_group_id}",
    "subnet_main_public1", "${aws_subnet.main_public.0.id}",
    "subnet_main_public2", "${aws_subnet.main_public.1.id}",
    "subnet_main_private1", "${aws_subnet.main_private.0.id}",
    "subnet_main_private2", "${aws_subnet.main_private.1.id}",
  )}"
}
```

```Terraform:shared/infra-variables.tf
variable "vpc_main"     { type = "map" }
variable "subnets_main" { type = "map" }
variable "db_main"      { type = "map" }
```

```Terraform:dev/infra/terraform.tfvars
vpc_main = {
  cidr = "10.0.0.0/16"
}

subnets_main = {
  public  = ["10.0.0.0/24", "10.0.1.0/24"]
  private = ["10.0.2.0/24", "10.0.3.0/24"]
}

db_main = {
  family            = "mysql5.7"
  engine            = "mysql"
  engine_version    = "5.7.19"
  instance_class    = "db.t2.micro"
  storage_type      = "gp2"
  allocated_storage = 20
  name              = "<db-name>"
  username          = "<db-username>"
  password          = "<db-password>"
}
```

参考:

- https://www.terraform.io/docs/configuration/variables.html#maps
- https://www.terraform.io/docs/configuration/interpolation.html#map-key-value-

# Workspace機能について

## Workspace機能を使うために

以上のような構成を前提とすると、環境間で構築するコンポーネントに差分がなく、WorkspaceごとにVariable Filesを上手く切り替える仕組みを整えれば、Workspace機能を使っても良さそうです。

「当該Workspaceでだけロードされるファイル」という仕組みがWorkspace機能の方で実装されたら、コンポーネント差異も吸収できるので、もっと使いやすくなりそうですね。

## 試してみてつらかったところ

[Terraform Best Practices in 2017 - Qiita](https://qiita.com/shogomuranushi/items/e2f3ff3cfdcacdd17f99)のエントリを参考に一度、試してみたのですが、以下がつらみでした:

- `lookup`地獄。書くのがつらかった。
- Terraformのmapは要素に異なる型のものを入れられないといった制約があり、すべてをmapに押し込めるのが難しいと感じた。

# まとめ

Terraformでsymlinkを使って設定を共通化する構成例を紹介しました。

たぶん、似たようなことをやっているプロジェクトは多いと思いますが、意外と記事など探してもあまり見つからなかったので、書いてみました。

どなたかの参考になれば幸いです。