Terraform × GCP 入門

# はじめに
Terraformを扱い始めて2ヶ月ぐらいたったので、Terraformチュートリアル的なものをハンズオン形式でまとめました。
Infrastructure as Codeやりてぇ！と思った方の事始めとなれば幸いです。
[公式のチュートリアル](https://learn.hashicorp.com/terraform/getting-started/install)が公開されているのですが、AWSのみでGCPに関するものは掲載されていません。
よって今回はGCP上でTerraformからリソースを管理する流れをみる、という目標でやっていきたいと思います。

# 想定している読者
以下のような事象に心当たりのある方を想定しています。

* Terraformを初めて触る
* GCPをちょっとだけ触ったことがあり、リソースの作成を自動化したい
* いい感じにリソースを管理したい気持ちになった

# 実行環境

実行環境をまとめました。
Cloud SDKについては入っている前提とします。
まだインストールできていない場合は[こちら](https://cloud.google.com/sdk/downloads?hl=JA)を参考にしてください。

|           | Version                            |
|-----------|------------------------------------|
|  OS       | Ubuntu 18.04.2 LTS (Bionic Beaver) |
| Terraform | v0.12.3                            |
| Cloud SDK | Google Cloud SDK 196.0.0           |

今回のGCPプロジェクトでは以下のAPIを有効にしておいてください。

* cloud pub/sub
* cloud functions
* cloud scheduler
* app engine

ここで使うコードは[ここ](https://github.com/donkomura/TerraformPractice)においてあります。

# そもそもTerraformって何？
Terraformは[HashiCorp](https://www.hashicorp.com/)社が開発しているインフラの構成管理ツールです。
HashiCorpといえば、VagrantやATLASなんて名前を聞いたことのある方も多いかと思います。

そんなHashiCorp社が開発しているTerraformはインフラのリソースをコードとして**宣言的に定義**して、その定義の状態になるようにリソースを作成・操作してくれます。

宣言的に定義することで、作成手順について考える必要がなくなるわけですね。

# インストール
TerraformのCLIツールをインストールします。

[公式サイト](https://learn.hashicorp.com/terraform/getting-started/install.html)にて、動画で懇切丁寧に説明しているので、基本的にはこれを見ながらインストールするといいでしょう。
ここではUbuntu18.04LTSの場合だけ紹介します。

### バイナリのダウンロード・解凍
バイナリは[このサイト](https://www.terraform.io/downloads.html)からダウンロードします。

```sh
$ wget https://releases.hashicorp.com/terraform/0.12.3/terraform_0.12.3_linux_amd64.zip
$ unzip terraform_0.12.3_linux_amd64.zip
```

### PATHを追加
解凍されたファイルを適当な場所に移動して、`.profile`とか`.bashrc`とかにパスを追加しておきます。

```sh
$ mv terraform ~/.tf
$ echo 'export PATH="$PATH:~/.tf"' >> ~/.bashrc
$ source ~/.bashrc
```

### コマンドの確認
結果が見れれば、インストールは完了です！

```sh

$ terraform --version
Terraform v0.12.3
```

# 構成
さて、ここから実際にTerraformで構築していきます。
今回GCP上に作成するリソースの構成を以下に示します。
Cloud Schedulerにジョブを登録しおき、定期的にSlackにメッセージを飛ばすという単純かつ簡単なアプリです。
![qiita_gcp_terraform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298005/f369e3c8-3796-ef3d-ea1c-7a3f727a8eeb.png)

## リソースの作成
Terraformの設定を書いていきます。
まずはじめに、GCPやAWSなどの各プラットフォームに依存した処理をやってくれる
プロバイダというものを定義しておきます。
これによって、実際にGCPに対して実行されるファイルの存在を気にする必要が無くなります。

```sh:main.tf
provider "google" {
  credentials = "${file("<your-credential-file-path>")}"
  project     = "${lookup(var.project_name, "${terraform.workspace}")}"
  region      = "asia-northeast1"
}
```

`credentials`について、少し補足しておきます。

Terraformというユーザーに、GCPのプロジェクトにリソースを作成するための権限を付与する必要があるわけですが、それを実現するのがサービスアカウントというものです。
詳細については[こちら](https://cloud.google.com/iam/docs/understanding-service-accounts?hl=ja)を参考にしていただきたいのですが、ざっくり説明すると、GCPのプロジェクトにリソースを作成するためのサービスアカウントを発行し、その権限情報（イメージとしては鍵）をTerraformに持たせなければ、Terraformはリソースを作れません。実際にはサービスアカウントの情報をjsonファイルにまとめた物をTerraformに渡します。

そのjsonファイルを作成する方法を以下に示します。
任意の場所にダウンロードして、そのファイルのパスを`credentials`に設定してください。

### サービスアカウントの作成方法とjsonの入手方法について[^1]

[^1]: https://cloud.google.com/iam/docs/creating-managing-service-accounts?hl=ja#creating_a_service_account

1. 「[サービスアカウント](https://console.cloud.google.com/iam-admin/serviceaccounts)」のページを開き、対象プロジェクトをクリック
2. 「サービスアカウントの作成」をクリック
3. サービスアカウントの名前(ex. terraform-sample)を入力し、説明等も入力する
4. サービスアカウントに権限を付与する
    * 今回の場合は「プロジェクト」の「owner」権限を渡すと困りにくくなりますが、基本的に権限の影響範囲は小さく保ちましょう
5. 作成したサービスアカウントの右側に点々（「操作」というカラム）があると思うので、そこをクリックすると「鍵を作成」という項目があるのでクリック
6. キータイプに「JSON」を選択して、作成をクリックするとダウンロードされます

さてプロバイダーの設定が終わったところで、図の左側のリソース、つまりCloud Schedulerから作成していきます。

ここで、Terraformのシンタックスについて述べておきます。
Terraformでは[HCL(HashiCorp Configuration Language)](https://www.terraform.io/docs/configuration/syntax.html)のシンタックスが採用されています。

リソースの設定には`resource`ブロックを使います。
`resource <リソースの種類> <リソース名>　{}`という使い方をします。
`<リソースの種類>`はプロバイダで定義されているリソースの種類のことです。
GCPであれば、`google_cloudfunctions_function`や`google_compute_instance`などがそれに当たります。Terraform内での型と捉えるとわかりやすいかもしれません。


`<リソース名>`は同じTerraformモジュール(ここでは同じディレクトリ内という認識で構いません)で参照するために使われます。任意の値で構いません。
参照する際は`<リソースの種類>.<リソースの名前>.<設定>`というようにドットつなぎで参照します。

ブロック内(`{}`)では、各リソースの設定を書いていきます。
設定の内容に関しては[Terraformのドキュメント](https://www.terraform.io/docs/providers/google/index.html)を見ながら、設定していきましょう。
以下に`Cloud Scheduler`の例を示します。

```sh
resource "google_cloud_scheduler_job" "slack-notify-scheduler" {
  name        = "slack-notify-daily"
  project     = "${lookup(var.project_name, "${terraform.workspace}")}"
  schedule    = "0 12 * * *"
  description = "suggesting your lunch"
  time_zone   = "Asia/Tokyo"

  pubsub_target {
    topic_name = "${google_pubsub_topic.slack_notify.id}"
    data       = "${base64encode("{\"mention\":\"channel\",\"channel\":\"random\"}")}"
  }
}
```

先ほど紹介した`resource`ブロックを使って、`Cloud Scheduler`のジョブを作成しています。
名前やプロジェクトを設定していくのですが、`project`の設定に`lookup`関数を使っています。
これはTerraformの関数であらかじめkey/valueを設定しておくと、第二引数をキーとして値を返してくれます。
このような関数をうまく使うことで、よりシンプルに設定を記述できるようになっています。
[ここ](https://www.terraform.io/docs/configuration/functions.html)から状況に応じて使うと良いでしょう。

ちなみに、key/valueの設定は`variable`ブロックで定義できます。
参照する際は`var.<変数の名前>`で参照できます。

`credentials`についてですが、[こちら](https://cloud.google.com/docs/authentication/getting-started?hl=ja)からTerraform用のサービスアカウントを作成し、jsonファイルをダウンロードしてください。
`google`リソースの`credentials`には直接パスを設定しても良いですが、特定の環境変数にパスを登録しておくと、Terraformのファイルにjsonのパスを書く必要はありません[^2]。

[^2]: https://www.terraform.io/docs/providers/google/getting_started.html#adding-credentials

```sh
variable "project_name" {
  default = {
    tf-sample = "<your-project>"
  }
}
```

書き方はわかったと思うので、バシバシとリソースを立てていきましょう。
以下に設定例を示しておきます。
Terraformではモジュールを跨がなければ、ファイルを分割できます。
というより、見通しをよくするためにも分割するべきです。
今回は割と小さめの設定なので、2分割にしかしていませんが、
よしなに分割することをお勧めします。

※`workspace`機能については今回は割愛します。わかりにくければ、適宜値を書き換えて下さい。

```sh:main.tf
provider "google" {
  credentials = "${file("${var.credential.data}")}"
  project     = "${lookup(var.project_name, "${terraform.workspace}")}"
  region      = "asia-northeast1"
}

data "archive_file" "function_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../src"
  output_path = "${path.module}/files/functions.zip"
}

resource "google_storage_bucket" "slack_functions_bucket" {
  name          = "${lookup(var.project_name, "${terraform.workspace}")}-scheduler-bucket"
  project       = "${lookup(var.project_name, "${terraform.workspace}")}"
  location      = "asia"
  force_destroy = true
}

resource "google_storage_bucket_object" "functions_zip" {
  name   = "functions.zip"
  bucket = "${google_storage_bucket.slack_functions_bucket.name}"
  source = "${path.module}/files/functions.zip"
}

resource "google_pubsub_topic" "slack_notify" {
  name    = "slack-notify"
  project = "${lookup(var.project_name, "${terraform.workspace}")}"
}

resource "google_cloudfunctions_function" "slack_notification" {
  name        = "SlackNotification"
  project     = "${lookup(var.project_name, "${terraform.workspace}")}"
  region      = "asia-northeast1"
  runtime     = "go111"
  entry_point = "SlackNotification"

  source_archive_bucket = "${google_storage_bucket.slack_functions_bucket.name}"
  source_archive_object = "${google_storage_bucket_object.functions_zip.name}"

  environment_variables = {
    SLACK_WEBHOOK_URL = "${var.webhook.url}"
  }

  event_trigger {
    event_type = "providers/cloud.pubsub/eventTypes/topic.publish"
    resource   = "${google_pubsub_topic.slack_notify.name}"
  }
}

resource "google_cloud_scheduler_job" "slack-notify-scheduler" {
  name        = "slack-notify-daily"
  project     = "${lookup(var.project_name, "${terraform.workspace}")}"
  schedule    = "0 8 * * *"
  description = "suggesting your morning/lunch/dinner"
  time_zone   = "Asia/Tokyo"

  pubsub_target {
    topic_name = "${google_pubsub_topic.slack_notify.id}"
    data       = "${base64encode("{\"mention\":\"channel\",\"channel\":\"random\"}")}"
  }
}
```

```sh:variable.tf
variable "project_name" {
  default = {
    tf-sample = "<your-project>"
  }
}

variable "credential" {
  default = {
    data = "<your-credential-path>"
  }
}

variable "webhook" {
  default = {
    url = "<your-webhook-url>"
  }
}
```

設定ファイルがかけたら、今度は設定ファイルで使われているプラグインのインストールと初期化を行います。

```
$ terraform init

Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "archive" (terraform-providers/archive) 1.2.2...
- Downloading plugin for provider "google" (terraform-providers/google) 2.10.0...

The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, it is recommended to add version = "..." constraints to the
corresponding provider blocks in configuration, with the constraint strings
suggested below.

* provider.archive: version = "~> 1.2"
* provider.google: version = "~> 2.10"

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

```

新規workspaceを作成します。(直接値を指定した場合はスキップして良いです)

```sh
$ terraform workspace new tf-sample
```

`terraform validate`を実行し、文法に問題がないかチェックした後、
実際にリソースを立てる前に、実行計画を確認を確認します。
ここで、作成した設定に誤りがないかチェックします。
コマンドは`terraform plan`です。

```sh

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

data.archive_file.function_zip: Refreshing state...

------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # google_cloud_scheduler_job.slack-notify-scheduler will be created
  + resource "google_cloud_scheduler_job" "slack-notify-scheduler" {
      + description = "suggesting your lunch"
      + id          = (known after apply)
      + name        = "slack-notify-daily"
      + project     = "<project-name>"
      + region      = (known after apply)
      + schedule    = "0 8 * * *"
      + time_zone   = "Asia/Tokyo"

      + environment_variables = {
            "SLACK_WEBHOOK_URL" = "<webhook-url>"
        }
      + pubsub_target {
          + data       = "eyJ0ZXh0Ijoi44GK44Gv44KI44GG77yB44GK5YWE44Gh44KD44KT77yBIiwibWVudGlvbiI6ImNoYW5uZWwiLCJjaGFubmVsIjoicmFuZG9tIn0="
          + topic_name = (known after apply)
        }
    }

  # google_cloudfunctions_function.slack_notification will be created
  + resource "google_cloudfunctions_function" "slack_notification" {
      + available_memory_mb   = 256
      + entry_point           = "SlackNotification"
      + https_trigger_url     = (known after apply)
      + id                    = (known after apply)
      + max_instances         = 0
      + name                  = "SlackNotification"
      + project               = "<project-name>"
      + region                = "asia-northeast1"
      + runtime               = "go111"
      + service_account_email = (known after apply)
      + source_archive_bucket = "<project-name>-scheduler-bucket"
      + source_archive_object = "functions.zip"
      + timeout               = 60
      + trigger_bucket        = (known after apply)
      + trigger_topic         = (known after apply)

      + event_trigger {
          + event_type = "providers/cloud.pubsub/eventTypes/topic.publish"
          + resource   = "slack-notify"

          + failure_policy {
              + retry = (known after apply)
            }
        }
    }

  # google_pubsub_topic.slack_notify will be created
  + resource "google_pubsub_topic" "slack_notify" {
      + id      = (known after apply)
      + name    = "slack-notify"
      + project = "<project-name>"
    }

  # google_storage_bucket.slack_functions_bucket will be created
  + resource "google_storage_bucket" "slack_functions_bucket" {
      + bucket_policy_only = (known after apply)
      + force_destroy      = true
      + id                 = (known after apply)
      + location           = "ASIA"
      + name               = "<project-name>-scheduler-bucket"
      + project            = "<project-name>"
      + self_link          = (known after apply)
      + storage_class      = "STANDARD"
      + url                = (known after apply)
    }

  # google_storage_bucket_object.functions_zip will be created
  + resource "google_storage_bucket_object" "functions_zip" {
      + bucket         = "<project-name>-scheduler-bucket"
      + content_type   = (known after apply)
      + crc32c         = (known after apply)
      + detect_md5hash = "different hash"
      + id             = (known after apply)
      + md5hash        = (known after apply)
      + name           = "functions.zip"
      + output_name    = (known after apply)
      + self_link      = (known after apply)
      + source         = "./files/functions.zip"
      + storage_class  = (known after apply)
    }

Plan: 5 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.
```

`(known after apply)`は実行後に値が決まります。

確認して、問題なければ実際にリソースをプロジェクト内に作成します。
コマンドは`terraform apply`です。

実行すると、実行計画とともに以下のようなメッセージが表示されます。
`yes`と入力し、エンターを入力するとリソースが作成されます。

```
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: 
```

早速、作成されたリソースを確認してみましょう。

```sh
$ gcloud beta scheduler jobs list
ID                  LOCATION         SCHEDULE (TZ)           TARGET_TYPE  STATE
slack-notify-daily  asia-northeast1  0 8 * * * (Asia/Tokyo)  Pub/Sub      ENABLED

$ gcloud functions list
NAME               STATUS  TRIGGER        REGION
SlackNotification  ACTIVE  Event Trigger  asia-northeast1

$ gcloud pubsub topics list
---
name: projects/<project-name>/topics/slack-notify
```

作成できてますね！

## リソースの変更

設定に変更を加えてみましょう。
例えば、新たにラベルをつけてみます。

```sh
labels = {
  "apps" = "slack"
}
```

```
terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

google_pubsub_topic.slack_notify: Refreshing state... [id=projects/<project-name>/topics/slack-notify]
google_storage_bucket.slack_functions_bucket: Refreshing state... [id=<project-name>-scheduler-bucket]
data.archive_file.function_zip: Refreshing state...
google_cloud_scheduler_job.slack-notify-scheduler: Refreshing state... [id=slack-notify-daily]
google_storage_bucket_object.functions_zip: Refreshing state... [id=<project-name>-scheduler-bucket-functions.zip]
google_cloudfunctions_function.slack_notification: Refreshing state... [id=<project-name>/asia-northeast1/SlackNotification]

------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # google_cloudfunctions_function.slack_notification will be updated in-place
  ~ resource "google_cloudfunctions_function" "slack_notification" {
        available_memory_mb   = 256
        entry_point           = "SlackNotification"
        environment_variables = {
            "SLACK_WEBHOOK_URL" = "<webhook-url>"
        }
        id                    = "<project-name>/asia-northeast1/SlackNotification"
      ~ labels                = {
          + "apps" = "slack"
        }
        max_instances         = 0
        name                  = "SlackNotification"
        project               = "<project-name>"
        region                = "asia-northeast1"
        runtime               = "go111"
        service_account_email = "<your-service-acccount>@appspot.gserviceaccount.com"
        source_archive_bucket = "<project-name>-scheduler-bucket"
        source_archive_object = "functions.zip"
        timeout               = 60

        event_trigger {
            event_type = "providers/cloud.pubsub/eventTypes/topic.publish"
            resource   = "slack-notify"

            failure_policy {
                retry = false
            }
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.
```

よろしければ、`terraform apply`を実行。
実際に変更が行われたかをコンソールから確認しましょう。

```sh
$ gcloud functions list --format="value(name,labels)"
SlackNotification	apps=slack
```

良さそうですね。

## リソースの削除
お片づけもコマンド一つで簡単です。

```sh
$ terraform destroy
```
# おわりに
ぽちぽちとコンソールからやるよりもファイルとして残るので、管理が楽ですよね。
後、Terraformの機能をうまく使うことで柔軟な設定をシンプルに記述できるのも魅力的です。

これからも良きTerraformライフを！

## 参考資料
* 公式のチュートリアル
https://learn.hashicorp.com/terraform/getting-started/install

* シンタックスについて
  - 公式
    https://www.terraform.io/docs/configuration/resources.html
  - 日本語
    http://micpsm.hatenablog.com/entry/2018/12/05/000000

* 次のステップ
https://qiita.com/minamijoyo/items/1f57c62bed781ab8f4d7

* Terraformのファイル分割・workspaceに関する記事です
https://qiita.com/shogomuranushi/items/e2f3ff3cfdcacdd17f99