【Terraform入門】SSH接続できるEC2を簡単に作成する

# はじめに

EC2で何かすぐ検証したい場合などにTerraformを使ってEC2を作成してSSH接続できると便利です。
手動作成に頼っていると手順確認に時間がかかったり操作ミスをしたりゴミが残ったりしてしまいがちですが、Terraformだとその不安を限りなく小さくできます。
多少知識は必要になるため、本記事ではサンプルコードを用意してすぐにEC2を起動してSSHできるよう手順を簡潔にまとめてみました。Terraformを触ったことがない方でもわかるように導入手順からまとめたつもりです。

# 環境

本記事の手順は以下の環境で動作確認を行っています。

- OS: macOS Monterey 12.3.1
- Terraform: 1.1.9
- AWS CLI: 2.5.4
  
# Terraformのインストール

Homebrewを使って、以下だけでインストールできます。

```
$ brew tap hashicorp/tap
$ brew install hashicorp/tap/terraform
```

手順の最新情報は以下を参照して下さい。

https://learn.hashicorp.com/tutorials/terraform/install-cli

# AWS CLIのインストール

以下からpkgファイルをダウンロードしてGUIで簡単にインストールできます。

https://awscli.amazonaws.com/AWSCLIV2.pkg

手順の最新情報は以下を参照して下さい。

https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/getting-started-install.html

インストール後、AWS CLIにAWSアクセスキーの設定が必要です。
アクセスキーを[IAMで取得して](https://docs.aws.amazon.com/ja_jp/powershell/latest/userguide/pstools-appendix-sign-up.html)、`aws configure`で設定します。
以下のように`--profile`を指定してプロファイルを作成しておくのがおすすめです。

```
$ aws configure --profile my-profile-name

AWS Access Key ID [None]: my-access-key
AWS Secret Access Key [None]: my-secret-key
Default region name [None]: ap-northeast-1
Default output format [None]: json
```

環境変数に上で設定したプロファイル名を設定します。

```
$ export AWS_DEFAULT_PROFILE=my-profile-name
```

環境変数を毎回設定するのが面倒な場合は、ログインシェルの設定ファイル(`.zshrc`など)に登録すると良いと思います。

# Terraformのセットアップ

私が作成したサンプルコードがGitHubにあります。

https://github.com/inayuky/terraform-sampler

これをクローンして準備します。
`terraform init`ではTerraformの実行に必要なバイナリが`.terraform`配下にダウンロードされます。

```
$ git clone https://github.com/inayuky/terraform-sampler
$ cd terraform-sampler/ec2-ssh
$ terraform init
```

# 鍵の作成

SSH接続に必要な秘密鍵と公開鍵を作成します。
以下のコマンドだけで作成できます。
秘密鍵の`ec2_key`と公開鍵の`ec2_key.pub`という２つのファイルが作成されます。

```
$ ssh-keygen -N "" -f ec2_key
```

以上で準備は完了です。

# インスタンス作成

`terraform plan`でどんなリソースが作成されるかを確認できます。
エラーが出力されないことを確認します。

```
$ terraform plan
```

`terraform apply`で作成できます。

```
$ terraform apply
```

本当に実行していいか確認が求められるので、`yes`を入力します。
(ここで課金が発生することになるので注意してください。デフォルトでは`t3.micro`という小さいインスタンスタイプを指定しているため、1時間あたり2円未満のはずですが。）

```
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: 11 added, 0 changed, 0 destroyed.

Outputs:

public_ip = "13.112.146.55"
ssh_command = "ssh -i ec2_key ec2-user@13.112.146.55"
```

表示されたSSHコマンドをそのまま入力するとSSH接続できます。(接続できるまで数秒かかるかもしれません)
以下のように表示されるので`yes`と入力します。

```
inayuky ec2-ssh % ssh -i ec2_key ec2-user@13.112.146.55
The authenticity of host '13.112.146.55 (13.112.146.55)' can't be established.
ED25519 key fingerprint is SHA256:btJ3uBhFnw1O+LyvHnpmFNwGmvYl/Hv0MdvdPj99RZo.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? 
```

これでインスタンスが作成され、SSH接続できることを確認できました。

```
Warning: Permanently added '13.112.146.55' (ED25519) to the list of known hosts.

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/
4 package(s) needed for security, out of 4 available
Run "sudo yum update" to apply all updates.
[ec2-user@ip-10-0-0-137 ~]$ 
```

# インスタンス削除

作成するとインスタンスは起動状態になり、課金が発生するので(停止したとしてもEBSボリュームの料金はかかりますが)、不要になったら削除します。

```
$ terraform destroy
```

# サンプルコードの解説

コメントにある程度わかるように情報を記載したつもりですが、もう少し解説しておこうと思います。

## 変数

- 鍵の名前とリソース名は変数で定義している
- デフォルト値を設定しているので、なにも指定しない場合この値が採用される
- 値を変更する場合、ソースを直接変更する以外にも`terraform apply -var 'key_name=my_key' -var 'resource_name=sample2'`のようにコマンド実行時に指定することもできる。

```variables.tf
# 鍵の名前
# 秘密鍵のファイル名に相当する名前
variable "key_name" {
  type    = string
  default = "ec2_key"
}

# リソース名
# 簡単のため全リソースでここで設定した共通の名前を使用する
variable "resource_name" {
  type    = string
  default = "sample1"
}
```

## EC2

- 最新AmazonLinux2のイメージ(AMI)を使用して起動する
- インスタンスタイプは`t3.micro`を指定しているが、任意に変更できる
- ElasticIP(EIP)はコメントを外すだけで使える(EIPはインスタンス停止時に課金が発生するので注意)
- `sg.tf`でセキュリティグループを追加した場合は、インスタンス側でも指定する必要がある
- EBSは指定しなくてもデフォルトのものが使用される。コメントを外して任意のサイズとタイプの設定も可能。

```ec2.tf
# 最新のAmazonLinux2のイメージ
data "aws_ami" "latest_amzn2" {
  owners      = ["amazon"]
  most_recent = true
  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

# キーペア
# 変数で指定した値を設定
resource "aws_key_pair" "this" {
  key_name   = var.key_name
  public_key = file("${var.key_name}.pub")
}

# EIPを使う場合は以下を追加する
# resource "aws_eip" "this" {
#   instance = aws_instance.this.id
#   vpc      = true
# }

# インスタンス
resource "aws_instance" "this" {
  instance_type = "t3.micro" # インスタンスタイプは任意に設定する

  vpc_security_group_ids = [aws_security_group.ssh.id]
  # httpのSGを追加する場合は以下のようにする
  # vpc_security_group_ids = [aws_security_group.ssh.id, aws_security_group.http.id]

  # EBSのサイズとタイプを指定する場合は以下のように追加する
  # root_block_device {
  #   volume_size = 30 # 単位GB
  #   volume_type = "gp3" # gp2がデフォルト。 他はstandard, gp3, io1, io2, sc1, st1。
  # }

  ami = data.aws_ami.latest_amzn2.id

  subnet_id = aws_subnet.public.id

  key_name = aws_key_pair.this.key_name

  tags = {
    Name = var.resource_name # インスタンス名
  }

  lifecycle {
    ignore_changes = [
      ami # インスタンスに変更を加えようとしたら、AMIが新しくなっていてインスタンス再作成が要求されるのを防止するため
    ]
  }
}
```

## セキュリティグループ

- SSH接続用のSGのみ作成している
- HTTPなど追加したい場合は、コメントを参照

```sg.tf
# SSHのセキュリティグループ
resource "aws_security_group" "ssh" {
  name   = "ssh_sg"
  vpc_id = aws_vpc.this.id
}

resource "aws_security_group_rule" "ingress" {
  type              = "ingress"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"] # 接続元を限定する場合は変更する
  security_group_id = aws_security_group.ssh.id
}

resource "aws_security_group_rule" "egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.ssh.id
}

# セキュリティグループの追加方法
# たとえば、httpのセキュリティグループが必要な場合は以下のように追加する
# resource "aws_security_group" "http" {
#   name   = "http_sg"
#   vpc_id = aws_vpc.this.id
# }

# resource "aws_security_group_rule" "http_ingress" {
#   type              = "ingress"
#   from_port         = 80
#   to_port           = 80
#   protocol          = "tcp"
#   cidr_blocks       = ["0.0.0.0/0"]
#   security_group_id = aws_security_group.http.id
# }

# resource "aws_security_group_rule" "http_egress" {
#   type              = "egress"
#   from_port         = 0
#   to_port           = 0
#   protocol          = "-1"
#   cidr_blocks       = ["0.0.0.0/0"]
#   security_group_id = aws_security_group.http.id
# }
```

## VPC

- EC2を起動してSSH接続するのに必要な最低限のリソースを作成している
- VPCはCIDRが同一のものが存在しても複数作成が可能であり、VPCが異なれば他の環境と干渉することはない(あえてそういう設定を加えなければ)ため、安心して作成/削除ができる

```network.tf
# VPC
resource "aws_vpc" "this" {
  cidr_block           = "10.0.0.0/16" # 値が既存VPCと重複しても作成可能
  enable_dns_support   = true          # AWSのDNSサーバによる名前解決を有効
  enable_dns_hostnames = true          # VPC内のリソースにパブリックDNSホスト名を自動的に割り当てる

  tags = {
    Name = var.resource_name
  }
}

# サブネット
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = "10.0.0.0/24"
  map_public_ip_on_launch = true              # 起動したインスタンスにパブリックIPを自動割当
  availability_zone       = "ap-northeast-1a" # AZ指定

  tags = {
    Name = var.resource_name
  }
}

# インターネットゲートウェイ
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = var.resource_name
  }
}

# ルートテーブル
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = var.resource_name
  }
}

# ルートテーブルのエントリ
resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.this.id
  destination_cidr_block = "0.0.0.0/0"
}

# サブネットとルートテーブルの関連付け
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
```

## output

- apply時などにターミナルに出力する値
- 構成変更してもたまに値が更新されないことがあったが、`terraform refresh`か`terraform apply -refresh-only`を実行すると更新される模様

```output.tf
# パブリックIPを出力
output "public_ip" {
  value = aws_instance.this.public_ip
}

# sshコマンドを出力
output "ssh_command" {
  value = "ssh -i ${var.key_name} ec2-user@${aws_instance.this.public_ip}"
}
```

## provider

- リージョン等を指定する。(リージョン指定しなくてもAWS CLIのデフォルトリージョンで動作はすると思う)
- AWSの認証情報も指定できたりするが、ソースに入れるとやっかいなことが多いので、認証情報は環境変数やAWS CLI側に持たせるのが好み

```provider.tf
provider "aws" {
  region = "ap-northeast-1" # 東京リージョン
}
```

# 関連Tips

関連して覚えておくと便利なTipsを以下に記載します。

## EC2の状態確認や電源操作をコマンドから簡単に実行する方法

以下の記事で、簡単にEC2の状態確認や電源操作をする方法を書いたので、Terraformと合わせて使用すると、EC2の作成/状態確認/起動停止/削除までコマンドで簡単にできて便利なので、参考にしてください。

https://qiita.com/inayuky/items/a03c5219b776fbafc9d4

## 作成したリソースの確認方法

以下で、作成したリソースの一覧が確認できます。

```
$ terraform show
```

これだと全てのリソースが表示されるので、リソース毎に確認したい場合は以下です。

まず、以下でリソース名一覧を表示します。

```
$ terraform state list
```

実行例

```
inayuky ec2-ssh % terraform state list
data.aws_ami.latest_amzn2
aws_instance.this
aws_internet_gateway.this
aws_key_pair.this
aws_route.public
aws_route_table.public
aws_route_table_association.public
aws_security_group.ssh
aws_security_group_rule.egress
aws_security_group_rule.ingress
aws_subnet.public
aws_vpc.this
```

以下のようにリソース名を指定して確認できます。

```
$ terraform state show aws_instance.this
```

実行例

```
inayuky ec2-ssh % terraform state show aws_instance.this
# aws_instance.this:
resource "aws_instance" "this" {
    ami                                  = "ami-0f9a314ce79311c88"
    arn                                  = "arn:aws:ec2:ap-northeast-1:123454912345:instance/i-09cac7dead23465a76"
    associate_public_ip_address          = true
    availability_zone                    = "ap-northeast-1a"
    cpu_core_count                       = 1
    cpu_threads_per_core                 = 2
    disable_api_termination              = false
    ebs_optimized                        = false
    get_password_data                    = false
    hibernation                          = false
    id                                   = "i-09cac7dead23465a76"
・・・以下省略・・・
```

## SSH接続時の確認を省略する方法

SSHのデフォルトの動作だと新しいIPアドレスに接続するたびに確認を求められると思いますが、`~/.ssh/config`に以下の設定を追加しておくと省略できます。

```
host *
        StrictHostKeyChecking no
        UserKnownHostsFile=/dev/null
```

一応以下のようにSSHコマンド実行時にオプションで渡すこともできます。

`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null host_name`


# 参考情報
https://booth.pm/ja/items/1318735
Pragmatic Terraform on AWS

https://qiita.com/koki_develop/items/45cdde3d27bd75f1bfd5
TerraformでVPC・EC2インスタンスを構築してssh接続する

https://blog.serverworks.co.jp/automate-latest-ami-ec2
【CloudFormation / Terraform】EC2の最新版のAMI IDを自動的に取得、構築する (Windows / Linux)