Terraform + AWS 〜ECSを無停止で切り替えるPublic ↔︎ Private〜

# 記事を書こうと思ったきっかけ

筆者は現在、fjord bootcamp(以下FBC)にて[テイスティングノート](https://github.com/yuma-matsui/tasting_note)という自作サービスの開発をしているのですが、
インフラの構築にTerraformを使用しており、ECSを停止せずにサブネットを切り替えるという貴重な経験をしたので備忘も兼ねて記事にまとめようと思ったのがきっかけです。

Terraformを使用することで効率的且つサービスをStopせずに切り替得られることに大きな魅力とインフラの楽しさを感じました。

拙い説明もありますがお付き合い願います。

※ Terraform、AWSの基礎に関する記事ではありませんのでご注意ください。

# 対象読者
- TerraformでECSを管理している方
- ECSを停めずにお引越し(サブネットの切り替え)したいと思っている方

# 筆者が引っ越しに迫られた理由
FBCでは嬉しいことにメンター（現役エンジニア）の方々に自作サービスのコードレビューをしていただけます。
TerraformでECSを初めて構築したときのPRにこんなコメントをいただきました。

> ECSサービスがpublic subnetsで動いているのが少し気になりました。
ECSサービスをprivateにおいて、publicにはALBだけを置く方がセキュリティ的に堅牢なため。

PublicとPrivateの大きな違いは下記です。

<dl>
<dt>Public Subnet</dt>
<dd>インターネット上のどこからでもアクセス可能</dd>
<dt>Private Subnet</dt>
<dd>同じVPC内のIPアドレスからのみアクセス可能</dd>
</dl>

今回、自分が開発をしているサービスはフロントエンドにReact、バックエンドにRailsのAPIモードを採用しており、RailsサービスをECSに配置している構成です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/930395/7bf72cba-efaa-f8cd-7e1e-150815a40752.png)

基本的にAPIへのアクセスはフロントエンドサービスからのみだったため、ECSをPrivateに置き、同VPC内に配置したALBからのアクセスのみを許可することでサービスを堅牢に保ちましょうというコメントでした。

そこで設計を下記に変更しました。

![Image](https://user-images.githubusercontent.com/70259961/207860470-3dcbc817-e591-4f66-a920-edaadf2e49e4.png)

ポイントとしてはECSをPrivateに配置する場合は合わせてNATが必要になる点です。
ECS側から外部のネットワークに出ていく経路を確保してあげるためです。
本記事は無停止での切り替えにフォーカスしているため、NAT周りの構築については別の記事でまとめようと思います。

さて、話がそれましたが引越しが必要になった理由は**サービスを堅牢に保つため**です。

ですが引越しをして数日間経過したときに気づくのです。

.....NAT超高いじゃん😱
そこで本リリースまではPublicで運用しようと思い、Public→Privateに切り替えたものを再度Private→Publicに切り替えました。
ということで2つ目のお引越し理由は**NATの料金が予想以上に高かった**ことです。

実は初めのお引越し（Public→Private）の際はサービスを停止せずに切り替える試みをしましたが、自力ではアイディアが浮かばず、実装後にメンターの方に方法を教えていただく感じでした。
なので自力で無停止で切り替えられるいい機会だと思ったのも1つの理由です。

# 無停止での切り替え方法
さて、前置きが長くなりましたが本題に入ります。
今回はPrivate→Publicの切り替えについてまとめますが、同様の手順で逆の切り替えも対応可能です。

初めに手順をざっくりとまとめます。

1. 新しいALBターゲットグループ（以下TG）を作る
1. 新しいECSサービスを作る(TGに１を紐づける)
1. 旧ECSに紐づいているALBのリスナーに１のTGへリクエストが向くようなリスナールールを追加する(ダミーのドメインを指定)
1. ３のドメインを正しいものにする
1. 旧ECSに紐づいているリスナーの`default_action`に新しいTGを紐づける
1. ５と同じタイミングで旧ECSと旧TG、３を削除する

一度の`terraform apply`では不可能なので複数回実行することで実現します。
それではそれぞれについて細かい部分の解説をしていきます。

## 1. 新しいALBターゲットグループを作る
新しいECSに紐づけるターゲットグループです。
基本的には旧ECSで使用しているターゲットグループと同じ内容で問題ありません。

nameとtags内のnameのみ変更しています。
```terraform
// 旧
resource "aws_alb_target_group" "ecs_target_group" {
  name        = "${var.project}-ecs-tg"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = aws_vpc.vpc.id
  target_type = "ip"
  health_check {
    healthy_threshold   = 5
    path                = "/api/v1/health_check"
    unhealthy_threshold = 2
    protocol            = "HTTP"
    port                = 3000
  }

  tags = {
    Name = "${var.project}-ecs-tg"
  }
}

// 新
resource "aws_alb_target_group" "ecs_target_group_v2" {
  name        = "${var.project}-ecs-tg-v2"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = aws_vpc.vpc.id
  target_type = "ip"
  health_check {
    healthy_threshold   = 5
    path                = "/api/v1/health_check"
    unhealthy_threshold = 2
    protocol            = "HTTP"
    port                = 3000
  }

  tags = {
    Name = "${var.project}-ecs-tg-v2"
  }
}

```

## 2. 新しいECSサービスを作る
１で作成したターゲットグループを紐づけたECSを作成します。
また、サブネットタイプがパブリック↔︎プライベートのように変更になる場合は`network_configuration`内の`assign_public_ip`、`subnets`を切り替えることも忘れないようにしましょう。

```terraform
// 旧ECS
resource "aws_ecs_service" "tasting_note" {
  name                              = "${var.project}-service"
  cluster                           = aws_ecs_cluster.tasting_note.arn
  task_definition                   = aws_ecs_task_definition.rails_task.arn
  desired_count                     = 2
  launch_type                       = "FARGATE"
  platform_version                  = "1.4.0"
  health_check_grace_period_seconds = 60

  network_configuration {
    assign_public_ip = false
    security_groups  = [aws_security_group.ecs_sg.id]
    subnets          = [for subnet in aws_subnet.private : subnet.id]
  }

  load_balancer {
    target_group_arn = aws_alb_target_group.ecs_target_group.arn
    container_name   = "${var.project}-container"
    container_port   = 3000
  }

  lifecycle {
    ignore_changes = [
      task_definition
    ]
  }
}

// 新ECS
resource "aws_ecs_service" "tasting_note_v2" {
  name                              = "${var.project}-service-v2"
  cluster                           = aws_ecs_cluster.tasting_note.arn
  task_definition                   = aws_ecs_task_definition.rails_task.arn
  desired_count                     = 2
  launch_type                       = "FARGATE"
  platform_version                  = "1.4.0"
  health_check_grace_period_seconds = 60

  network_configuration {
    // publicの場合true
    assign_public_ip = true
    security_groups  = [aws_security_group.ecs_sg.id]
    subnets          = [for subnet in aws_subnet.public : subnet.id]
  }

  load_balancer {
    // 1で作成したecs_target_group_v2.arnを指定
    target_group_arn = aws_alb_target_group.ecs_target_group_v2.arn
    container_name   = "${var.project}-container"
    container_port   = 3000
  }

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

## 3. 新ターゲットグループにリクエストが向くようなリスナールールを作成する

今回のお引っ越しにおけるポイントの１つです。

リスナールールを追加する理由としては１で作成した新ターゲットグループへのルーティングが設定されていないとECSの起動に失敗するためです。

ですが旧ECSを停止するまでは実際のリクエストが届かないようにしたいので、`condition`内の`host_header`、`values`にダミーのURLを与えることで実際にリクエストがあってもルーティングされないようにします。


```terraform
resource "aws_lb_listener_rule" "v2" {
  // 旧target_groupに紐づけているhttpsリスナー
  listener_arn = aws_lb_listener.https.arn

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.ecs_target_group_v2.arn
  }

  condition {
    host_header {
      // ダミードメインを指定することでリクエストがルーティングされないようにする
      values = ["example.com"]
    }
  }
}
```
ここまで行ったら一旦`terraform apply`します。

## 4. ３で作ったリスナールールに正しいドメインを指定する
ドメインを変更したら`terraform apply`します。
この時点で新ECSにリクエストが届くようになります。

## 5. 旧ECSに紐づいているリスナーのdefault_actionに新しいターゲットグループを紐づける

ここまで特に説明はしてきませんでしたが、旧ターゲットグループにはhttpsリスナーとhttpsへリダイレクトさせるhttpリスナーを用意しています。

こちらの手順では、このhttpsリスナーの`default_action`で紐付けされているターゲットグループを新ターゲットグループに変更します。

```terraform
// 変更前
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = aws_acm_certificate.tokyo.arn
  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.ecs_target_group.arn
  }
}

// 変更後
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = aws_acm_certificate.tokyo.arn
  default_action {
    type             = "forward"
    // ecs_target_group_v2.arnに変更
    target_group_arn = aws_alb_target_group.ecs_target_group_v2.arn
  }
}
```

## 6. ５と同じタイミングで旧ECSと旧ターゲットグループ、3を削除する
最後に不要なリソースをお掃除して終了です。

以上が大まかな手順となります。

Public→Privateの場合はECS作成時の`assign_public_ip`がfalseになり、subnetsにprivateサブネットを指定すればOKです。

この場合はNATの作成も発生するのでPrivate→Publicよりも手順が増えますが、これについては別の記事でまとめようと思います。

# まとめ
タスクを行ってみて、率直な感想としては「すごい楽ちん」です。
また、時間をかけず効率的に実装ができたので、Terraformでインフラを管理することのメリットを体感できたのも良かったかなと思います。

個人的にインフラ周りの学習は楽しいので、今後もTerraformやAWS周りの知識のインプットとアウトプットをバランスよくしていけたらなと思います。

少々長い記事となりましたがお付き合いいただきありがとうございました！