【AWS × Terraform】Terraformでエロ画像検出APIを爆速デプロイしてみた

叡智に富んだ読者さん、ご機嫌よう。

[前回の投稿](https://qiita.com/sugichaaaan/items/826bf81a853ef951a541)からエロ画像検出APIサービスの運用を続けている私ですが、最近ちょっと困ったことが起きました。

なんと、仕事を辞めてから爪に火をともす生活をしている私にとって、予期しないコストが発生するなんてまさに「おはぎゃー！！！」な状況です。



11日間で450円、これが1ヶ月放置すると1300円に膨れ上がるという恐ろしい現実に直面しました。


![スクリーンショット 2024-09-12 16.42.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/826f5cf4-7140-af4e-953e-884916307a24.png)


ラーメン1,2杯分のお金が、何もしていないのに飛んでいくなんて恐ろしい・・・ :ramen: :cry: 

ということで今回は、コマンド一つでエロ画像検出APIサーバーをサクッと立ち上げてシャットダウンできる、そんな魔法のようなコードを作っていきたいと思います！

これでお金もかからない！Terraformの力を借りて、自動化していきましょう！

https://github.com/sugizakikun/nsfw-infra

## 前提知識

![スクリーンショット 2024-09-25 16.17.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/0f928097-879c-9fb7-8c4a-b6b4bb180a00.png)



前回の記事ではECSタスクにIPv4アドレスが割り当てられることを確認しました :cat: 

しかし、そのアドレスは動的に割り当てられるため、ECSを起動するたびにS3バケットのポリシーや環境変数を都度変更しなければならないという手間が発生していました。

「それなら、ECSタスクにElastic IPを紐づければ済むのでは？」とひらめいたものの、AWSは公式に「**FargateタスクとElastic IPアドレスを直接紐づけることはできない**」と発表しています。


> 静的 IP アドレスまたは Elastic IP アドレスを Fargate タスクに直接追加することはできません。Fargate タスクで静的 IP または Elastic IP を使用するには、まず Network Load Balancer で Fargate サービスを作成する必要があります。その後、タスクの Elastic IP アドレスをロードバランサーにアタッチします。


https://repost.aws/ja/knowledge-center/ecs-fargate-static-elastic-ip-address

どうやら、固定のIPアドレスからECSタスクにアクセスするためには、**Network Load Balancer (NLB)** を使ってElastic IPを割り当てる必要があるようです。

https://qiita.com/sugichaaaan/items/1258d760ea3efd4e4410


## 用意しておくべきもの

### variable.tfの設定

- ElasticIP 割り当てID
- VPCのID
- VPC パブリックサブネットのID

上記3つについては事前に用意しておきましょう。
ElasticIP 割り当てIDは[一覧テーブル](https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Addresses:)の左から4番目に表示されます。

![スクリーンショット 2024-09-21 0.05.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/293c5403-e0cf-51de-ae0a-126b7a992ffc.png)

値を取得し終えたらvariable.tfを以下のように変更してください。

[**variable.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/variable.tf)

```variable.tf
variable "vpc_id" {
  default = "<VPCのID>"
}

variable "public_subnet_ids" {
  default = ["<VPC パブリックサブネットのID>"] 
}

variable "eip_alloc" {
  default = "<ElasticIP 割り当てID>"
}
```

### ECRリポジトリの設定
以下のリンクを参考に手元にあるdocker-imageをaws ecrにpushしてください。

:::note info
[ECRの設定](https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9#ecr%E3%81%AE%E8%A8%AD%E5%AE%9A)
:::

## 1. NLBの設定

この章では[**nlb.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/nlb.tf)について解説します。

### 1-1. NLBターゲットグループの設定
```nlb.tf
# NLBのターゲットグループを作成
resource "aws_lb_target_group" "ecs_tg" {
    target_type = "ip"
    name        = "ecs-target-group"
    port        = 5000
    protocol    = "TCP"
    vpc_id      = var.vpc_id

    health_check {
        port               = "traffic-port"
        protocol           = "TCP"
    }
}

```

| **属性**    | **説明**                                                   |
|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| target_type | `ip` が指定されており、IPアドレスを使ってターゲットを指定するタイプのターゲットグループを作成しています。ECSタスクの各コンテナに動的なIPアドレスを割り当てる場合に使用します。 |
| port        | ターゲットがリッスンするポート番号。ここでは `5000` 番ポートが使用されています。                                                                               |
| protocol    | 使用するプロトコル。この場合、通信に `TCP` プロトコルを使用します。                                                                                           |
| vpc_id      | ターゲットグループが作成されるVPCのIDです。変数 `var.vpc_id` を使ってVPCのIDを指定しています。                                                               |
| health_check| ターゲットの正常性を確認するためのヘルスチェックを設定しています。`port` と `protocol` はどちらも `TCP` で設定され、ロードバランサーがターゲットのヘルスを監視します。 |

### 1-2. Network Load Balancer (NLB) を作成

```nlb.tf
# NLBを作成
resource "aws_lb" "nlb" {
    name               = "ecs-nlb"
    internal           = false  # パブリック NLB の場合は false
    load_balancer_type = "network"
    ip_address_type    = "ipv4"
    idle_timeout       = 60

    subnet_mapping {
        subnet_id       = var.public_subnet_ids[0]
        allocation_id   = var.eip_alloc
    }
}

```

| **属性**          | **説明**                                                                                                                                                           |
|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| internal        | `false` が指定されており、これはパブリック向けのNLBであることを示しています。内部（プライベート）向けのNLBの場合は `true` に設定します。                                   |
| load_balancer_type | `network` が指定されており、作成されるロードバランサーはNetwork Load Balancerであることを示しています。                                                             |
| ip_address_type | `ipv4` を指定して、IPv4アドレスを使用します。                                                                                                                        |
| idle_timeout    | コネクションがアイドル状態になった際に、NLBがそのコネクションを終了するまでの待ち時間（秒単位）です。この場合は `60秒` が指定されています。                                 |
| subnet_mapping  | NLBのサブネットマッピングを設定しています。                                                                                                                          |
| └ subnet_id    | NLBが所属するサブネットIDを指定しています。例では、変数 `var.public_subnet_ids[0]` を使用して最初のパブリックサブネットを指定しています。                                |
| └ allocation_id | Elastic IP（EIP）のアロケーションIDを指定して、NLBに静的なIPアドレスを関連付けています。このIDは `var.eip_alloc` に格納された値を参照します。                           |


### 1-3. リスナーを作成
```nlb.tf
# リスナーを作成
resource "aws_lb_listener" "nlb_listener" {
    load_balancer_arn = aws_lb.nlb.arn
    port              = 5000
    protocol          = "TCP"

    default_action {
        type             = "forward"
        target_group_arn = aws_lb_target_group.ecs_tg.arn
    }
}
```

| 属性 | 説明 |
| :--- | :--- |
| load_balancer_arn | 1-2で作成したNLB（`aws_lb.nlb`）のARNを指定する。このリスナーがそのNLBに紐付けられる。|
| port | リスナーが待ち受けるポート番号。この例では 5000 番ポートが指定される。| 
| protocol | 使用するプロトコルを`TCP`に設定。リスナーはこのプロトコルで通信を受け取る | 
|  default_action | リスナーが受け取ったリクエストに対して実行するデフォルトのアクション。 | 
|  └ type| アクションのタイプは `forward` で、リクエストをターゲットグループに転送します。 | 
|  └ target_group_arn | 先ほど作成したターゲットグループ（`aws_lb_target_group.ecs_tg`）のARNを指定して、リクエストをそのターゲットグループに転送します。 | 


上記の手順によりElastic IPを持ったNLBが設定され、指定したターゲットグループにリクエストを転送するようになります。

## 2. ECSの設定

この章では[**fargate.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/fargate.tf)について解説します。

1. ECSクラスターの定義
2. ECSタスクの設定
3. ECSサービスの設定

### 2-1. ECSクラスターの定義

```fargate.tf
resource "aws_ecs_cluster" "nsfw_cluster" {
    name = "nsfw_api_cluster"
}
# 省略
```

### 2-2. ECSタスクの定義

```fargate.tf
# 省略
resource "aws_ecs_task_definition" "nsfw_task" {
    family                      = "nsfw-api-container"
    cpu                         = "256"
    memory                      = "512"
    network_mode                = "awsvpc"
    requires_compatibilities    = ["FARGATE"]
    container_definitions       = file("./container_definition.json")
    execution_role_arn          = module.ecs_task_execution_role.iam_role_arn
}
# 省略
```

コンテナ定義ファイルである[`container_definition.json`](https://github.com/sugizakikun/nsfw-infra/blob/main/container_definition.json)を読み込んでください。

`image`には[ECRの設定](https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9#3-3-%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%82%92push)で作成されたイメージURIを設定してください。

```json
"image": "<自身のAWSアカウントID>.dkr.ecr.us-east-1.amazonaws.com/detect-noodie-image:latest",
```

#### タスク実行ロールの定義 
ECSタスクの実行に必要なIAMロールの設定を行います。タスク実行ロールがタスクに正しく紐付いていない場合、以下のようなエラーが表示され、タスクが起動しません。

```
An error occurred (ClientException) when calling the RegisterTaskDefinition operation: Fargate requires task definition to have execution role ARN to support log driver awslogs.
```


[**ecs_task_execution_role.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/ecs_task_execution_role.tf)

```ecs_task_execution_role.tf
data "aws_iam_policy" "ecs_task_execution_role_policy" {
    arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

data "aws_iam_policy_document" "ecs_task_execution" {
    source_json = data.aws_iam_policy.ecs_task_execution_role_policy.policy

    statement {
        effect      = "Allow"
        actions     = ["ssm:GetParameters", "kms:Decrypt"]
        resources   = ["*"]
    }
}

module "ecs_task_execution_role" {
    source      = "./iam_role"
    name        = "ecs-task-execution"
    identifier  = "ecs-tasks.amazonaws.com"
    policy      = data.aws_iam_policy_document.ecs_task_execution.json
}
```

[**iam_role/main.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/iam_role/main.tf)
```iam_role/main.tf
variable "name" {}
variable "policy" {}
variable "identifier" {
  default = "ecs-tasks.amazonaws.com"
}

resource "aws_iam_role" "default" {
  name               = var.name
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = [var.identifier]
    }
  }
}

resource "aws_iam_policy" "default" {
  name   = var.name
  policy = var.policy
}

resource "aws_iam_role_policy_attachment" "default" {
  role       = aws_iam_role.default.name
  policy_arn = aws_iam_policy.default.arn
}

output "iam_role_arn" {
  value = aws_iam_role.default.arn
}

output "iam_role_name" {
  value = aws_iam_role.default.name
}
```

`iam_role/main.tf`は、オブジェクト指向プログラミングにおける**クラス**に相当します。一方、module "ecs_task_execution_role"は**インスタンス**に相当します。

IAMロールやIAMポリシーは非常に汎用性が高いため、同じようなコードを毎回書くのではなく、このようにテンプレート化することで、コードの**再利用性**と**可読性**が大幅に向上します。


### 2-3. ECSサービスの設定

```fargate.tf
# 省略
resource "aws_ecs_service" "nsfw_service" {
    name                                = "nsfw_api_service"
    cluster                             = aws_ecs_cluster.nsfw_cluster.arn
    task_definition                     = aws_ecs_task_definition.nsfw_task.arn
    desired_count                       = 1
    launch_type                         = "FARGATE"
    platform_version                    = "1.3.0"

    # ネットワーク設定(後述)
    network_configuration{...}

    # NLBとの紐付け(後述)
    load_balancer {...}

    lifecycle {
        ignore_changes = [task_definition]
    }
}
```


#### ネットワークの設定
```fargate.tf
    # ネットワーク設定
    network_configuration {
        assign_public_ip = true
        security_groups = [module.nsfw_api_sg.security_group_id]

        subnets = var.public_subnet_ids
    }
```

この `network_configuration` ブロックは、ECSタスクのネットワーク設定を定義しています

| 属性 | 説明 |
| :--- | :--- |
| assign_public_ip | `true`に設定するとECSタスクにパブリックIPアドレスが自動的に割り当てられ、タスクがインターネット経由で外部と通信できるようになる。|
| security_groups | `module.nsfw_api_sg.security_group_id` という変数を使用して、セキュリティグループのIDを参照。セキュリティグループは、タスクの通信に対するインバウンドとアウトバウンドのルールを管理する。| 
| subnets | タスクが配置されるサブネットを指定。`var.public_subnet_ids` という変数に格納された複数のパブリックサブネットIDを参照して、タスクがパブリックサブネット内に配置される。| 


これら設定によりタスクはインターネットに接続可能なパブリックIPを持ち、指定されたセキュリティグループに従い、パブリックサブネットに配置されます。

`module.nsfw_api_sg.security_group_id`は以下で生成されたセキュリティグループのIDのことです。

#### セキュリティグループの設定
[**nsfw_api_sg.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/nsfw_api_sg.tf)
```nsfw_api_sg.tf
module "nsfw_api_sg" {
    source      = "./security_group"
    name        = "nsfw_api_sg"
    vpc_id      = var.vpc_id
    port        = 5000
    protocol    = "tcp" 
}
```

上記のコードは `./security_group` モジュールを呼び出して、新しいセキュリティグループを作成しています。モジュールのパラメータとして以下が渡されています。

| 属性 | 説明 |
| :--- | :--- |
| name | セキュリティグループの名前（`nsfw_api_sg`）|
| vpc_id | このセキュリティグループが属するVPCのID。<br>`var.vpc_id` を指定する。| 
| port | インバウンド通信で許可するポート番号。<br>この場合、5000 ポートが指定される。 | 
| protocol | 使用するプロトコル。ここでは `tcp` プロトコルを使用します。 | 



[**security_group/main.tf**](https://github.com/sugizakikun/nsfw-infra/blob/main/security_group/main.tf)
```security_group/main.tf
variable "name" {}
variable "vpc_id" {}
variable "port" {}
variable "protocol" {}

resource "aws_security_group" "default"{
    name = var.name
    vpc_id = var.vpc_id
}

resource "aws_security_group_rule" "ingress"{
    type        = "ingress"
    from_port   = var.port
    to_port     = var.port
    protocol    = var.protocol
    cidr_blocks  = ["0.0.0.0/0"]
    security_group_id = aws_security_group.default.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.default.id
}

output "security_group_id" {
  value = aws_security_group.default.id
}
```

これらのコードはサービス設定画面の「ネットワーキング > セキュリティグループ」に対応します。

ここで注意して欲しいのが、画面上にはインバウンドルール(`ingress`)の設定画面だけが表示されているものの、裏側ではアウトバウンドルール(`egress`)も設定される点です。

terraform上ではアウトバウンドルールの設定も明示的に行いましょう。

![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3430313536312f63393936343865322d326137622d653430632d303031302d6234643432373866386433652e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/fdaf6cd1-b87c-8e71-3604-38f00d32477c.png)

#### NLBとの紐付け

```fargate.tf
    # NLBとの紐付け
    load_balancer {
        target_group_arn    = aws_lb_target_group.ecs_tg.arn
        container_name      = "nsfw-api-container"
        container_port      = 5000
    }
    #省略
```

この load_balancer ブロックは、ECSサービスに対してロードバランサーの設定を行う部分です。


| 属性 | 説明 |
| :--- | :--- |
| target_group_arn | ECSタスクのトラフィックを転送するためのターゲットグループを指定。`aws_lb_target_group.ecs_tg.arn` という変数で、定義されたターゲットグループ（ecs_tg）のARNを参照する。|
| container_name| ECSタスク内でロードバランサーと連携させるコンテナの名前（`nsfw-api-container`）を指定。この名前は、**タスク定義で指定されたコンテナ名と一致している必要がある。**| 
| container_port | 指定されたコンテナがリッスンするポート番号を指定。この例では、5000番ポートを使ってコンテナが外部からのリクエストを受け付ける。 | 

この設定により、ロードバランサーは`nsfw-api-container`コンテナの5000番ポートにリクエストを転送し、そのトラフィックは指定されたターゲットグループ経由で処理されるように設定されています。

## 3. terraformでサクッとAPIサーバーを構築

terraformを使えばUIでポチポチ設定しなおす手間が省けたり、サービスの消し忘れによる不本意な料金発生に悩まされる必要がなくなります。


```bash
terraform init
```


```bash
terraform apply
```
上記のコマンドを叩き、3分程待ちましょう,
以下のように表示されたら成功です！

```
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
```

使わなくなった時は以下のコマンドでdestroyすれば綺麗さっぱりなくなります。

```bash
terraform destroy
```

NLBとECSを付けっぱなしにすると月に3000円以上取られますが、
terraformで自分の使いたいにだけ立ち上げるとElasticIpの料金しか取られません。

以下のURLを打ち込めば前回と同様、APIレスポンスが返却されます。

```
http://<ElasticIP>:5000/?url=https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgA0XNyhytw0thfWMu4X6yK89mQ4lwuqPWmOTy4-UQ4NCOLFiYMJfetMdfCze2aGIZra6_SSqLtHvl-zXFmlFVqfu5RySPBi7gEXg8sP-QFPePD88dSmb0EQUU9nRZneIm36zSbUMT60nE/s400/animal_stand_neko.png
```



## まとめ

この記事では、Amazon ECSのタスクにElastic IPを直接紐付けられない制約に対し、Terraformを活用してNetwork Load Balancer（NLB）を用いた解決策を紹介しました！ :smile: 

ECSタスクに動的に割り当てられるIPアドレスの管理が課題となっている場合,
NLBを構築しElastic IPを固定することができます。

この構成を使用することで、IPアドレスの動的変更による運用上の手間を削減し、安定したサービス運用が可能となります！Terraformを使った自動化の利点を最大限に活用し、今後もシステムのスケーラビリティや信頼性を高めるアプローチを模索していきたいですね :cat: 

最後までお読みいただき誠にありがとうございます！

## 関連記事

https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9

## 参考にしたコード

https://github.com/tmknom/example-pragmatic-terraform