Terraformでマイクローサービスのインフラ (AWS)をゼロから構築する

## はじめに
エアークローゼットのエンジニアアインです。
この記事はエアークローゼット Advent Calendar 2023 17日目の記事になります。

https://qiita.com/advent-calendar/2023/air-closet

## Terraformとは？

Terraformは、コードを使用してインフラリソースを作成および管理できるInfrastructure as Code（IaC）ツールです。

Terraformには把握すべきいくつかの基本的な概念があります。

- Terraform state
- Terraform resource
- Terraform variable
- Terraform data source

次に上記の概念をざっくり説明させて頂きます。

### Terraform state

これは、Terraformがメタデータを追跡し、実際のインフラリソースと現在のterraformソースの間の参照を提供するツールとして利用されます。

以下の画像と同じイメージとなります。

![288647503-371205e9-5771-49df-b9d8-d8c88ff6bd2f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/cdd4eacc-158b-f58d-98f1-57ec7d637d36.png)
*画像1*

Terraform stateは単独での作業だけでなく、チームでの作業時に特に強力です。単純に言えば、Terraform stateを基に、チームのメンバーは以下を理解できる:

- 自分のローカルのTerraformソースは実際のインフラリソースとどのように「異なる」のか。
- 自分のローカルのTerraformソースは実際のインフラリソースとどのように「CONFLICT」のか。

Terraform stateはJSON形式で`terraform.tfstate`ファイルとしてに保存されます。ただし、これをクラウド環境に保存し、ちゃんと暗号化するべきです。

### Terraform resource

ResourceはTerraformの基本要素であり、各リソースブロックは仮想ネットワーク（Virtual-network）、コンピュートインスタンス（Compute-instances）などの一つまたは複数のインフラストラクチャオブジェクトを記述します。

```terraform
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr

  enable_dns_hostnames = true
  enable_dns_support   = true

  tags   = {
    Name = "${var.app_name}-vpc"
  }
}
```

上記の例では、名前が "main" である`aws_vpc`を持っています。この中には、次のような情報が含まれてる:

- cidr_block
- tags
- dns_hostnames
- ...


各リソースはそれぞれのアウトプットを持っており（これは各リソースのpublicプロパティと認められます）。

`aws_ec2`インスタンスリソースが作成されると、次のような基本的なpublicプロパティがあります:

- arn
- public_ip

詳しくはここのリンクを参考してみてください: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance#attribute-reference

### Terraform variable

他のプログラミング言語と同様に、Terraformも変数に独自の定義を持っています。

Terraformには2つの基本的な変数のタイプがあります:

- Input変数
- Local変数

**Input変数**は、次の形式で定義されます。

```terraform
variable app_name {
  type = string
}
```

`variable` キーワードを利用して変数の`type`も特定します。

これらの変数は、あるモジュールの「引数」として認められます。

例:

```terraform
// Module A

variable app_name {
  type = string
}
```

上記のコードでは、モジュールAを定義していて、このモジュールは 「app_name」という「引数」を必要とします。別のモジュールがこのモジュールAを使用する場合、以下のように実施しないといけません。

```terraform
module "A" {
  source   = "./module_A"

  app_name = "Sample App"
}
```

**Local変数**は、以下のような形式で定義されます:

```terraform
locals {
  test_variable = "test"
}
```

これらのLocal変数は、それが定義されたモジュールの内部でのみ有効です。

### Terraform data source

Terraform data sourceは、Terraformによって定義されていないリソースを使用できるようにするものであり、例えば、システム内の既存のインフラストラクチャリソースへの参照などが挙げられます。

個人の経験から言えば、Terraform data sourceは特に以前は「手動」でインフラストラクチャのセットアップが行われていたシステムに対して非常に便利だと感じています。

Terraform data sourceを使用すると、これらの「legacy resources」への参照が可能になります。

例:

```terraform
data "aws_ami" "example" {
  most_recent = true

  owners = ["self"]
  tags = {
    Name   = "app-server"
    Tested = "true"
  }
}
```

上記のコードは既存にあるaws_amiに参考します。

## マイクロサービスシステムのインフラストラクチャアーキテクチャ

今回は、3つのマイクロサービス（A、B、C）を展開します。それぞれのマイクロサービスに対して、基本的なアーキテクチャに基づいて実行します。

**ネットワークアーキテクチャ**（VPC、パブリックサブネット、プライベートサブネット）

![288771113-da08fd8d-4644-4a4b-ab7b-112b1fe44791.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/da9111b5-b775-7a80-4888-97c48be67800.png)
*画像 2*

図の各構成要素を分析します。ここでは、各サービスに対して別々のVPCを作成します。

VPCには、当然ながら`public_subnet`と`private_subnet`が欠かせません。そして、`private_subnet`内のリソースが外部のインターネットにアクセスできるようにするために、`nat_gateway`を設定し、それを`public_subnet`内に配置します。

データベース（ここではAWS RDS）は`private_subnet`内に配置され、ただし、次の2つの入力のみを受け入れます：

- VPCの内部。
- Proxy (外部のインターネットから来れば)。

サービスのサーバーコードを実行するAWS ECSはprivate_subnet内に配置されます。

**アプリケーションアーキテクチャ**（ECS、ロードバランサー）

![288774107-7334d9cf-345d-4f41-9f90-d0496814e654.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/81a8a7da-a12f-39a8-cce5-33d162b330bc.png)
*画像 3*

ここでは、ECSの前にApplication Load Balancerを配置し、ECS内部ではnginx load balancerを配置して、ECSサービス内で実行されるサーバーコードを保護するリバースプロキシとしての役割を果たします。

## コードを参照する

フルソースコードは下記のリンクまで参考してみてください。
https://github.com/tuananhhedspibk/NewAnigram-Infrastructure

### Folder構築

モジュール単位で各リソースを管理します。

- network: vpc, public_subnet, private_subnet
- ecs_api: ecs_task_definition, ecs_service
- ecs_cluster: cluster
- proxy: database proxy
- rds: database

一番外側は、以下のようなmain.tfファイルになります:

```terraform
module "network" {
  source = "./network"
  // ...
}

module "proxy" {
  source = "./proxy"
  // ...
}

module "rds" {
  source = "./rds"
  // ...
}

module "ecs_cluster" {
  source = "./ecs_cluster"
  // ...
}

module "ecs_api" {
  source = "./ecs_api"
  // ...
}
```

### Network Module

このモジュールでは、次のものを定義します：

- vpc
- public_subnet
- private_subnet
- nat_gateway

vpcは

```terraform
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.app_name}-vpc"
  }
}
```

vpcを定義する際に最も重要なのは、それに`cidr_block`を指定することです。`cidr_block`はVPCのネットワークアドレス（仮想アドレス）と認められます。

`public_subnet`は以下のようになります：

```terraform
resource "aws_subnet" "public" {
  vpc_id = aws_vpc.main.id // id của vpc resource

  count                   = length(var.public_subnets_cidr) // public_subnetの数
  cidr_block              = element(var.public_subnets_cidr, count.index) // cidr_block設定 - サブネットのネットワークアドレスつまりサブネット分けること
  availability_zone       = element(var.availability_zones, count.index)
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.app_name}-public-subnet-${element(var.availability_zones, count.index)}"
  }
}
```

`public_subnet`はパブリックなサブネットであり、つまりグローバルなインターネットから見ることができるものです。したがって、`map_public_ip_on_launch`属性を`true`に設定して、このサブネットにパブリックIPアドレスがあるようにし、グローバルなインターネットから見えるようにします。

`private_subnet`は

```terraform
resource "aws_subnet" "public" {
  vpc_id = aws_vpc.main.id // id của vpc resource

  count                   = length(var.private_subnets_cidr) // private_subnetの数
  cidr_block              = element(var.private_subnets_cidr, count.index) // cidr_block設定 - サブネットのネットワークアドレスつまりサブネット分けること
  availability_zone       = element(var.availability_zones, count.index)
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.app_name}-public-subnet-${element(var.availability_zones, count.index)}"
  }
}
```

`private_subnet`はプライベートであり、つまり外部のインターネットから見えないものとなり、`map_public_ip_on_launch`属性`false`に設定してプライベートサブネットは`public_ip`アドレスをアサインされないはずです。

`nat_gateway`に対しては

```terraform
// NATにアサインするElasticIp定義
resource "aws_eip" "nat" {
  vpc        = true
  depends_on = [aws_internet_gateway.main]
}

// NAT gateway
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id = element(aws_subnet.public.*.id, 0) // nat_gatewayをpublic_subnetに配置すること
  tags = {
    Name = "${var.app_name}-nat"
  }
}
```

nat_gatewayは`private_subnet`内のインスタンスが外部インターネットに接続できることを許可するんですが、外部インターネットから`private_subnet`内のインスタンスに接続を禁止します。

詳しくは　https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html にてご確認ください。

上記のnat_gateway構築コートには

- nat_gatewayのElasticIp設定する
- private_subnet内のインスタンスが外部インターネット接続するためnat_gatewayをpublic_subnetに配置する

### Proxy module

ここのProxyはただのEC2インスタンスとなっており、Proxyセットアップするの真の目的は

- RDSは`private_subnet`内に配置されるからエンジニアがRDS接続するため
- RDSに接続することを制限すること

![288911676-c3e89d04-0372-4dd0-bf67-5d66417c30f6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/8ea8f51f-971c-a5a3-5da2-6f6dbc604e46.png)
*画像 4*

画像4を見るとエンジニアがSSH Tunel通じてProxyに入り、ProxyからRDSに行くというイメージとなります。
だからこそ、ProxyはRDS守るためのシールドとして利用されるようにします。

Proxyの設定は

```terraform
resource "aws_security_group" "proxy" {
  name          = "${var.app_name}-proxy-sg"
  description   = "Allow ssh connect to db proxy"
  vpc_id        = var.vpc_id

  // Inbound rule
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  // Outbound rule
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.app_name}-proxy-sg"
  }
}

resource "aws_instance" "proxy" {
  instance_type   = "t2.micro"
  ami             = local.ami

  subnet_id       = var.subnet_id // public_subnet id
  security_groups = [aws_security_group.proxy.id]
  key_name        = "key_pair_name"

  tags = {
    Name = "${var.app_name}-db-proxy"
  }
}
```

上記のコードのように、SSHを介してプロキシへのアクセスを制限するために、Proxyのセキュリティグループのインバウンドを設定して、ポート22だけを許可します。

ProxyはRDSの前にある「バッファー領域」としての役割しか果たさないため、マイクロインスタンスが1つあれば十分です。そしてもちろん、Proxyを外部インターネットからアクセスできるようにするために、それをpublic_subnetに配置する必要があります。

### RDS Module

RDSインスタンスと、そのインスタンスを含むクラスターを定義しています。主な目的はデータを保存することです（言い換えれば、これはサービスのデータベースです）。

```terraform
resource "aws_rds_cluster" "this" {
  cluster_identifier              = "${var.app_name}-mysql-cluster"
  engine                          = local.rds_engine
  engine_version                  = "8.0.mysql_aurora.3.02.0" // エンジン定義: mysql または postgreSQL

  db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.default.name
  db_subnet_group_name            = aws_db_subnet_group.aurora_subnet_group.name
  vpc_security_group_ids          = [aws_security_group.this.id]
  port                            = var.port

  database_name                   = var.database_name // DB名
  master_username                 = var.master_username
  master_password                 = var.master_password

  skip_final_snapshot             = false
  final_snapshot_identifier       = "${var.app_name}-mysql-final-snapshot"
}

resource "aws_rds_cluster_instance" "this" {
  identifier              = "${var.app_name}-mysql-identifier"
  cluster_identifier      = aws_rds_cluster.this.id

  db_subnet_group_name    = aws_db_subnet_group.aurora_subnet_group.name
  db_parameter_group_name = aws_db_parameter_group.default.name

  engine                  = local.rds_engine
  instance_class          = "db.t3.medium" // rdsインスタンスのキャパシティ
}
```

基本的にはrdsインスタンスの設定は複雑作業が特にないと思って、詳しくは https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/rds_cluster_instance にてご参考ください。

ただ一つ注意すべき点があります。master_username および master_password は実際の運用では使用しないでください。厳密に言うと、使用してはいけないというわけではありませんが、むしろ、代わりに以下のような方法をご検討ください:

- エンジニアごとに特定アカウントと紐付ける権限を指定する
- サービスも前に指定したアカウントでDBに接続する

上の作業はシステムが落ちるまたは事故がある時に原因調査またはだれか何のSQLを実行したのを明確するためなんです。

### ecs_clusterとecs_api modules

これらの2つのモジュールを1つにまとめる理由は、それらが両方ともECSに関連しているからです。2つのサブモジュール、クラスターとAPIに分割する主な目的は、単にecs_clusterの定義とecs_serviceを分離することです。

`ecs_cluster`に対しては

```terraform
resource "aws_ecs_cluster" "this" {
  name = "${var.app_name}"
}
```

`ecs_api`は

```terraform
resource "aws_ecs_service" "this" {
  depends_on      = [aws_lb_listener_rule.this]

  name            = var.app_name

  desired_count   = 1
  launch_type     = "FARGATE"
  // fargate使用する目的はAWSが各サーバインスタンスを管理してくれる。

  cluster         = var.cluster_name
  task_definition = aws_ecs_task_definition.this.arn // task_defintionに参照する

  network_configuration {
    subnets         = var.subnet_ids
    security_groups = [aws_security_group.this.id]
  }

  load_balancer {
    container_name   = "nginx"
    container_port   = local.port_nginx
    target_group_arn = var.lb_target_group_arn
  }
}
```

追跡できるようにECSサービスのアーキテクチャの一部を再掲します。

![288774107-7334d9cf-345d-4f41-9f90-d0496814e654.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/4024ae81-1eb2-1801-21b5-56ccf8edb494.png)
*画像 5*

```terraform
load_balancer {
  container_name   = "nginx"
  container_port   = local.port_nginx
  target_group_arn = var.lb_target_group_arn
}
```

上記のコードは、nginxリバースプロキシを作成します。この場合、すべてのサービスへのリクエストは、サービスに入る前にnginxを通過します。

ECSに関する概要は、以下の図を参照してください。

![284002822-70305f62-d7d1-4eb2-9ee9-cddcdab3b854.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/4f7af2ba-098e-5c79-a76b-f9f444677a6a.png)
*画像 6*


画像 6は、ECSの動作方法を示しています。その中には：

- ECRはサービスに対応するdockerイメージを保存する
- task_definitionは、サービスのブループリント（JSONファイルでパラメータやコンテナなどが定義されている）です。

基本的に、ここでECSでサービスを運用する場合、次の手順が必要です：

1. サービスをDocker化して、それをDockerイメージに変換する
2. このDockerイメージをregistryにアップロードしする（ここではECRを使用）
3. registryから、task_definitionを構築します
4. task_definitionからECS内に実施されるサービスを構築する

task_definitionの設定は
```terraform
resource "aws_ecs_task_definition" "this" {
  family                = "newanigram-api"

  container_definitions = data.template_file.task_definition.rendered // task_definition定義JSONファイルに参照する

  cpu                   = "256"
  memory                = "512"
  network_mode          = "awsvpc"

  task_role_arn         = aws_iam_role.ecs_iam_role.arn
  execution_role_arn    = aws_iam_role.ecs_iam_role.arn
}

// task_definition定義JSONファイル
data "template_file" "task_definition" {
  template = file("./ecs_api/task_definition.json")

  vars = {
    account_id        = local.account_id
    region            = local.region

    repository_api    = "api"
  }
}
```

task_definitionのメインな内容は

```json
[
  {
    "name": "api",
    "image": "${account_id}.dkr.ecr.${region}.amazonaws.com/${repository_api}:${api_tag}",
    "cpu": 0,
    "memory": 128,
    "portMappings": [
      
        "containerPort": ${port_api},
        "hostPort": ${port_api}
      }
    ],
  }
]
```

## Execution

terraform実行ため `terraform apply`コマンドで行きます。実行される前にこれから新規作成・変更ある・削除されるリソースがプレビューできます。

![289043438-cd124433-839e-4a01-a7fc-8e125f8d59fc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/edf603c6-b37c-d5db-5d81-2324b9a59e07.png)

実行後にAWSコンソール上にこんな感じな結果がもらえます。

vpc:

![289045977-096698c9-1346-4b8a-8c1f-e226e8cc1433.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/b27b51c5-d0f5-c5bb-87e7-d9b83345d5a7.png)

rds:

![289045996-dc805799-d47e-4f74-8e0b-e6505ae8a504.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/e39738f5-118d-255d-6943-c7c67bae766d.png)

proxy:
![289046035-31bed9c4-c18a-4755-9d0d-106800ed7742.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/6e4538ac-13c6-7686-8a17-2c84e4166bda.png)

ecs:
![289046058-9f43e432-3c9b-41dc-9b48-16847d64ea78.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135669/bc7ec9fc-7df5-a76b-37f4-d492e02ba291.png)

terraformコードでのすべての設定情報は、AWSコンソール上で正確に反映されています（赤く囲まれた部分を見て詳細を確認できます）。

## まとめ
それでは、どのようにしてterraformを使用してAWS上でゼロからマイクロサービスのためのインフラストラクチャを構築したかを簡単に紹介しました。

読者が基本的なマイクロサービスのインフラストラクチャアーキテクチャやterraformでインフラストラクチャを展開する方法について、より視覚的な理解を得ることができることを期待しています。

次の記事を楽しみにしています。どうもありがとうございます。

また、エアークローゼットはエンジニア採用活動も行っておりますので、興味のある方はぜひご覧ください！

https://corp.air-closet.com/recruiting/developers/

https://www.wantedly.com/companies/airCloset/projects