AWS食わず嫌いのプログラマーがTerraformハンズオンに参加したときのログを晒す

本稿は、社内で行われた @reoring 主催のTerraformハンズオンに参加したときの作業ログです。一応、ハンズオンを再現しやすいように書いたつもりですが、TerraformもAWSも疎いため誤りがあったらお教えください。

## 読者対象者

* プログラマーでTerraformでAWSの環境構築を自動化してみたい人。
    * ちなみに、筆者
        * AWS: 管理画面でEC2立てたり、S3バケット作ったりはできるが、VPSに逃げがち。
        * プログラミング歴: PHP15年。他にGo言語などもやったことある。
        * インフラ: 小規模なウェブアプリをホスティングするために、アプリに関係がある周辺のインフラ知識だけつまみ食いしたレベル。

## ハンズオンで得るもの

* TerraformでAWS上にEC2インスタンスを建てられるようになる。(hello worldレベル)
* Terraformコードを再利用性・保守性があるものにするモジュールの作り方が分かるようになる。


# Terraformハンズオン

では、Terraformハンズオンをはじめよう。

## 必要ツールをインストールする

```sh
$ brew install tfenv awscli packer

# 特定のバージョンのterraformをインストールする
$ tfenv install 0.12.7
```

## 開発環境

Terraform初心者はシンタックスハイライトや構文チェックしてくれるエディタがあったほうがいいので、下記のとおり環境をととのえておいたほうが断然良い。

* IntelliJ IDEA Community(無料)をインストールしておく。
    * https://www.jetbrains.com/idea/download/#section=mac
* IntelliJを起動したら、HashiCorp Terraform/HCL Language supportプラグインをインストールする。
    * Preferences→Pluginsを開き、Marketplaceタブを選択→「terraform」で検索→「HashiCorp Terraform/HCL Language support」の「Install」をクリックする。

<img width="1310" alt="Preferences.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889/44d0647e-79da-b6e5-fe04-dcfe23e60dbd.png">


## 教材をダウンロード

```sh
$ git clone git@github.com:reoring/terraform-handson.git
$ cd terraform-handson
```

* ハンズオンで使った資料: [terraformハンズオン - Qiita](https://qiita.com/reoring/items/828ce1810d39b3bd8bef)
    * 本稿を使って追体験するだけなら、こちらの資料を読む必要はありません。

## AWSプロファイルに認証情報を入れる

自分のIAMの認証情報を入れる。(ハンズオンでは、主催者がハンズオン用に用意したものを使った)

```shell
$ aws configure --profile terraform-hello-world
AWS Access Key ID [None]: **************
AWS Secret Access Key [None]: **********************************
Default region name [None]: ap-southeast-1
Default output format [None]:
```

`profile`は好きな名前をつけていい。ここでは`terraform-hello-world`にした。後々使うので覚えておくこと。

※実験のためシンガポールリージョン(ap-southeast-1)を指定しています。

認証情報が入ったか確認する:

```shell
$ cat ~/.aws/credentials
[terraform-hello-world]
aws_access_key_id = ***************
aws_secret_access_key = *********************************
```

## packerでdocker入りのamazon linux2をAMIをビルドする

まずPackerのディレクトリにいく。

```shell
$ cd amazonlinux2-with-docker
```

シンガポールリージョンにAMIを作りたいので、Packerの設定を変更しておく:

```diff:amazon-linux2-docker.json
  "variables": {
-   "aws_region": "ap-northeast-1",
+   "aws_region": "ap-southeast-1",
    "aws_profile": "{{env `AWS_PROFILE`}}"
  },
```

下記のコマンドを実行すると、EC2上でビルドが走り、AMIが作られる:

```shell
$ AWS_PROFILE=terraform-hello-world packer build amazon-linux2-docker.json
```


2分くらいかかります。ビルドが一旦始まれば、AWS EC2コンソール(管理画面)上にもAMIが表示されるようになります。


## terraformを初期化する

```shell
$ cd ../terraform-ec2
$ terraform init
```

このinitコマンドは、実行場所配下のtfファイルを読み込み、.terraformというディレクトリを作成する。

```
.terraform
└── plugins
    └── darwin_amd64
        ├── lock.json
        └── terraform-provider-aws_v2.26.0_x4
```

これは依存するプロバイダ(=AWSなどのIaaS)を扱う上で必要なプラグインをダウンロードしたもの。

## variables.tfのAWSプロファイル名を自分のものに変更する

```diff:variables.tf
variable "aws_profile" {
  type        = string
  description = "AWSのプロファイル名"
- default     = "sandbox"
+ default     = "terraform-hello-world"
}
```

これをしないとエラーになります:

```
Error: error validating provider credentials: error calling sts:GetCallerIdentity: NoCredentialProviders: no valid providers in chain. Deprecated.
	For verbose messaging see aws.Config.CredentialsChainVerboseErrors

  on main.tf line 5, in provider "aws":
   5: provider "aws" {

```

## terraformのplanを確認する


自分のIPアドレスを調べておく。

```shell
$ curl https://httpbin.org/ip
{
  "origin": "117.102.178.110, 117.102.178.110"
}
```

VPCの接続元IP制限はどうするか聞かれるので上で調べた自分の`IP + "/32"`を入力する。32はネットマスクでそのホスト1つだけという意味。

```shell
$ terraform plan
var.admin_ip
  Enter a value: 117.102.178.110/32
```

### トラブルシューティング

#### SSHの公開鍵がローカルにない

```
Error: Error in function call

  on key-pair.tf line 3, in resource "aws_key_pair" "master-key":
   3:   public_key = file(var.path_to_public_key)
    |----------------
    | var.path_to_public_key is "~/.ssh/id_rsa.pub"

Call to function "file" failed: no file exists at /Users/suin/.ssh/id_rsa.pub.
```

このエラーが出たら鍵のパスを調整する:

```diff
variable "path_to_public_key" {
  type    = string
- default = "~/.ssh/id_rsa.pub"
+ default = "~/.ssh/id_myrsa.pub"
}
```

※なお、ECDSAの鍵は使えなかった。

## planの内容

* `+`が追加されるもの

```
Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                          = "ami-0ebd006a05952cc33"
      + arn                          = (known after apply)
      + associate_public_ip_address  = true
      + availability_zone            = (known after apply)
      + cpu_core_count               = (known after apply)
      + cpu_threads_per_core         = (known after apply)
```

* 上の`var.admin_ip`で設定した`117.102.178.110/32`はセキュリティグループのingressの設定に現れる。
* ingressはincommingな通信のこと。
* `117.102.178.110/32`からは繋がるという意味。


```
      + ingress                = [
          + {
              + cidr_blocks      = [
                  + "117.102.178.110/32",
                ]
```

### なぜ`var.admin_ip`が聞かれたのか？

variables.tfにデフォルト値がないため。

```variables.tf
variable "admin_ip" {
  type = string
  // ここに default = "117.102.178.110/32" と書くと聞かれなくなる
}
```

または、`terraform.tfvars` に値をセットすることもできる。

```terraform.tfvars
admin_ip = "117.102.178.110/32"
```

`terraform.tfvars`というファイル名は特殊で、そのファイルがあると`plan`コマンドに`-var-file terraform.tfvars`オプションをセットしたのと同じ意味になる。それ以外のファイル名は`-var-file`プションを使ってファイル名を指定する。

```shell
# production専用のtfvarsを作った場合の例
$ terraform plan -var-file terraform.production.tfvars
```

### そもそも`plan`とは？

tfファイルと現在のAWS環境を突き合わせて、実行計画を見せてくれるもの。`plan`はAWS環境を参照するだけで変更は加えない。

`plan`の出力結果のうち次のサマリーの部分がとても重要。

```
Plan: 3 to add, 0 to change, 0 to destroy.
```

changeやdestoryが出ている場合は、よくチェックする必要がある。名前を変えただけなのに、AWS的にはdestoryしてからaddするといったオペレーションがあったりする。例えば、セキュリティグループの名称など。セキュリティグループの再作成は一瞬と言っても、瞬断するなどの副作用があったりする。そういう副作用がある場合は、メンテナンス時間にやらなければならなかったりするので、サマリーはよく見る習慣をつける。

## 実行する

`terraform apply`を実行して

```shell
$ terraform apply

...[略]...

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

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:
```

`yes`と入力することで、反映が始まる。

しばらくすると実行結果が表示される:

```
...[略]...

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

instance_ip = 18.138.255.66
```

この`instance_ip`は反映によって作られたEC2インスタンスのグローバルIPアドレスになる。
Outputsに出す情報は、outputs.tfで設定されたものになる。

```outputs.tf
output "instance_ip" {
  value = aws_instance.web.public_ip
}
```

SSHでログインできるか確認してみよう:

```
ssh -i ~/.ssh/id_rsa ec2-user@18.138.255.66
```

## 破壊する

applyと反対に作った環境を無かったことにするにはdestoryコマンドを実行する。

```
terraform destory
```

## main.tfのコードリーディング

main.tfを読み解き、HCL言語を理解する。

```main.tf
// メタ情報
terraform {
  required_version = "= 0.12.7" // terraform 0.12.7でしかこの設定実行できませんよという意味
}

// ここからAWSの設定
provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile
}

// EC2インスタンス作成に仕様するAMIの情報をAWSからとってくる宣言。
// data "データタイプ" "変数名" の書式で定義していく。とってきたデータは変数名に代入される。
// JavaScriptで言ったら const amazonLinux2 = awsAmi({ most_recent: true... }) みたいなイメージ。
data "aws_ami" "amazon-linux2" {
  // とってくるAMIの条件設定
  most_recent = true // 最新のAMI1件
  owners      = ["self"]

  // AMI名で絞り込み
  filter {
    name   = "name"
    values = ["docker-amazon-linux2-*"]
  }

  // 仮想化タイプで絞り込む
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  } // あえてSQLでいうと、次のようなイメージ:
    // SELECT * FROM aws_ami 
    //   WHERE name LIKE "docker-amazon-linux2-%" 
    //     AND virtualization-type = "hvm" 
    //   ORDER BY created_at DESC LIMIT 1;
  // 他に指定できる条件は公式サイト参照: https://www.terraform.io/docs/providers/aws/d/ami.html#attributes-reference
}

resource "aws_instance" "web" {
  // インスタンスの設定をお好みでここに書く
  ami           = data.aws_ami.amazon-linux2.image_id // 10行目で定義したデータを参照
  instance_type = "t2.micro"

  associate_public_ip_address = true
  key_name                    = aws_key_pair.master-key.key_name

  vpc_security_group_ids = [aws_security_group.web.id]
  // 他に指定できる設定値は公式サイト参照: https://www.terraform.io/docs/providers/aws/d/instance.html
}
```

## モジュール化にチャレンジ

リソースの定義をモジュール化して、コードの再利用にチャレンジする。

### 再利用可能にするリソース定義

main.tfの下記の部分をモジュール化して再利用できるようにする。

```main.tf
// main.tfより抜粋
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon-linux2.image_id
  instance_type = "t2.micro"

  associate_public_ip_address = true
  key_name                    = aws_key_pair.master-key.key_name

  vpc_security_group_ids = [aws_security_group.web.id]
}
```

再利用可能な状態になると、下記のように同じようなEC2インスタンスをコピペ少なめで量産できるようになる。

```main.tf
module "web" {
  source = "./modules/instance"
  ami_id = data.aws_ami.amazon-linux2.image_id
  instance_type = "t2.micro"
  enable_public_ip = true
  key_name = aws_key_pair.master-key.key_name
  security_group_id = aws_security_group.web.id
}

module "web2" {
  source = "./modules/instance"
  ami_id = data.aws_ami.amazon-linux2.image_id
  instance_type = "t2.micro"
  enable_public_ip = true
  key_name = aws_key_pair.master-key.key_name
  security_group_id = aws_security_group.web.id
}

module "web3" {
  source = "./modules/instance"
  ami_id = data.aws_ami.amazon-linux2.image_id
  instance_type = "t2.micro"
  enable_public_ip = true
  key_name = aws_key_pair.master-key.key_name
  security_group_id = aws_security_group.web.id
}
```

### モジュール化をやってみよう

まずmodules/instanceというディレクトリをほり、そこに3つのtfファイルを作る:

```
mkdir -p modules/instance
touch modules/instance/{main,outputs,variables}.tf
```

こんな構造になる。

```
├── main.tf
├── modules ... 今作ったディレクトリ
│   └── instance
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
```

ファイルの内容は次のようにする:

```modules/instance/main.tf
resource "aws_instance" "ec2_instance" {
  ami           = var.ami_id
  instance_type = var.instance_type

  associate_public_ip_address = var.enable_public_ip
  key_name                    = var.key_name

  vpc_security_group_ids = [var.security_group_id]
}

```

```modules/instance/variables.tf
variable "security_group_id" {
  type = string
}

variable "key_name" {
  type = string
}

variable "enable_public_ip" {
  type = bool
}

variable "instance_type" {
  type = string
}

variable "ami_id" {
  type = string
}
```

```outputs.tf
output "instance_ip" {
  value = aws_instance.ec2_instance.public_ip
}
```

これでモジュール化が完了。

このモジュールを使うかたちにmain.tfを修正する

```main.tf
// コメントアウト
//resource "aws_instance" "web" {
//  ami           = data.aws_ami.amazon-linux2.image_id
//  instance_type = "t2.micro"
//
//  associate_public_ip_address = true
//  key_name                    = aws_key_pair.master-key.key_name
//
//  vpc_security_group_ids = [aws_security_group.web.id]
//}

module "web" {
  source = "./modules/instance"
  ami_id = data.aws_ami.amazon-linux2.image_id
  instance_type = "t2.micro"
  enable_public_ip = true
  key_name = aws_key_pair.master-key.key_name
  security_group_id = aws_security_group.web.id
}
```

次に、outputs.tfも修正する

```diff:outputs.tf
output "instance_ip" {
- value = aws_instance.web.public_ip
+ value = module.web.instance_ip
}
```

以上でモジュール化のチャレンジはおわり。

最後に、`terraform init`をするとモジュールが使えるようになる。

```
terraform init
```

### 公開されているサードパーティ製

[Cloud Posse](https://github.com/cloudposse)にはサードパーティ製モジュールがたくさん公開されている。これを使うもよし、コードリーディングして技を盗むもあり。

## Q&A

### main.tfが長くなったら？

ファイルを分ける。例えば、セキュリティグループのリソース設定を切り出して、security-group.tfを作る。main.tfからリンク(JavaScriptやGo言語の`import`のようなこと)する必要はない。ディレクトリ配下のtfファイルがすべて読まれる。

### CloudFormationの違いは？

TerraformはいろんなIaaSに対応している、CloudFormationはAWSにしか対応していない。

CloudFormationはJSONやYAMLをひたすら書く感じ。TerraformはHCL言語という書きやすいDSLで書く。

CloudFormationは手続き型プログラミング。Terraformは宣言型プログラミング。

### HCLって？

HCLはHashiCorp Configuration Languageというプログラミング言語。Terraformのtfファイルもこの言語で記述する。

HCLには2つのバージョンがあり、HCL1とHCL2がある。HCL2は最近リリースされたもののため、ネット上にはHCL1の情報が多いことには要注意。

HCL2はHCL1の下位互換性がある。

### terraform.tfstateはgitで管理する？

gitでは管理しない。secretは含まれないがインフラ構成の実態など機密情報が含まれているため、公開は避けたほうがいい。特にグループでインフラを保守するときは、backendというTerraformの仕組みを使って、terraform.tfstateをS3に同期してグループで共有するようにしておく。

### tfファイルのlintをするには？

`terraform validate`コマンドで設定ファイルの静的チェックが行える。

### tfファイルの自動整形をするには？

`terraform fmt`コマンドで設定ファイルのコードが自動整形できる。`go fmt`のようなイメージ。

### このハンズオンではPackerでAMIを作ったけど、Packerは必要なの？

もちろん、AWSが提供しているAMIを使ってもいい。その場合、Packerは不要。

## 所感

* 思っていたよりTerraformは簡単だった。(たぶんひとりでやると難しく、教わると簡単というタイプのツールだと感じた)
* AWSは別途覚えないとならないが、Terraform自体はプログラマならすぐ覚えられる言語だと思った。
* TerraformのエコシステムにはGoの香りを感じた。go getとterraform getとか。
* @reoring ありがとう😌