ECS Fargateでのx86_64とarmのマルチアーキテクチャ実装概略とTerraformでの構築

# はじめに
「[AWS for Games Advent Calendar 2023](https://qiita.com/advent-calendar/2023/aws-for-games)」の19日目の記事を担当する株式会社サイバード所属の[@gongon282828](https://qiita.com/gongon282828)です。

サイバードでは、技術統括部にて、女性向け恋愛シミュレーションゲーム「[イケメンシリーズ](https://ikemen.cybird.ne.jp/)」のいくつかのタイトルのインフラ周りを担当しています。

この記事では、ECS Fargateでx86_64とarmでのマルチアーキテクチャで実装する概略と、Terraformでの構築方法について記載します。



# 概要
サイバードの「イケメンシリーズ」では、直近の新規タイトルやオンプレミスからのAWS移管タイトル、アプリケーションフレームワークを一新しているタイトルに関しては、ECS Fargateを活用してコンピューティングを処理しています。

特に、その中の一部タイトルでは、x86_64とarmアーキテクチャの両方を活用して実装しています。

両方を活用する目的は以下になります。

* コストメリットのあるFARGATE SPOTを利用できるx86_64では、メインとなるコンピューティングに活用
* 突然の停止を許容できない管理画面やバッチ処理等の恒常的に処理を行うリソースには、armのSaving Planを活用

ご存じの通り、armはAWSも「サーバーレスコンテナのコストパフォーマンスが最大 40% 向上」と謳っている通り、性能面においてx86_64より優位性があるため、部分的に活用することでコスト最適化を実現できそうだと考えたのが経緯です。

https://aws.amazon.com/jp/blogs/news/announcing-aws-graviton2-support-for-aws-fargate-get-up-to-40-better-price-performance-for-your-serverless-containers/

デメリットとしては、AWSリソースをx86_64用だけでなく、arm用にも構築する必要があります。

ただし、AWSの開発にIaC(Infrastructure as Code)を実践しているのであれば、AWS側の構築についてはさほど大きな工数が発生することはありません。

サイバードでは、AWSリソースはTerraformで構築しているため、一部抜粋してTerraformでの実装方法についても記載します。

# クラスター構成

クラスター構成を抜粋したものを以下示します。
![クラスター (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3626152/b2bb4612-b2b8-8607-2a36-4eccaa1d18bf.png)

一般ユーザーアクセスのあるtask群にのみ、ALBにアクセスできるように設定します。

# キャパシティプロバイダー戦略
x86_64単体のアーキテクチャの場合、FARGATEとFARGATE SPOTのBaseとWeightを制御する必要がありますが、x86_64ではFARGATE SPOT、armではFARGATEの利用に分けているため、サービス単位でキャパシティプロバイダーを設定するシンプルなすみ分けで必要十分となります。

| ProviderName | Base | Weight |
|:-:|:-:|:-:|
|FARGATE (x86_64)|利用なし|利用なし|
|FARGATE_SPOT (x86_64)|0|1|
|FARGATE (arm)|0|1|
|FARGATE_SPOT (arm)|提供なし|提供なし|（）


# TerraformでのAWSリソースの構築

ここでは実際に、Terraformで構築する場合の例を示します。
なお、ドキュメントの可読性を高めるために、マルチアーキテクチャに必要な要素や分岐以外のパラメータについては省略します。必要に応じて公式ドキュメントを参考にした上で補完をお願いします。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs

## クラスターとECRの生成
まずは、クラスターの生成を行います。

```terraform:terraform
locals{
  arch               = ["arm","x86_64"]
}

resource "aws_ecs_cluster" "main" {
  name = "api-cluster"
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name = aws_ecs_cluster.main.name
  capacity_providers = ["FARGATE_SPOT", "FARGATE"]
}

#nginx用のarmとx86_64のECRの生成
resource "aws_ecr_repository" "nginx" {
  count = length(local.arch)  
  name  = "nginx-${element(local.arch, count.index)}"
  image_tag_mutability = "MUTABLE"
  image_scanning_configuration {
    scan_on_push = true
  }
}

#api用のarmとx86_64のECRの生成
resource "aws_ecr_repository" "api" {
  count = length(local.arch)
  name  = "api-${element(local.arch, count.index)}"
  image_tag_mutability = "MUTABLE"
  image_scanning_configuration {
    scan_on_push = true
  }
}

```

クラスターでキャパシティプロバイダーにFARGATE_SPOTとFARGATEを指定します。キャパシティプロバイダー戦略はサービスで定義するため、クラスターでの設定は不要です。

さらに、`count`でarmとx86_64用のECRを生成します。ここではnginxとapiのECRのみ作成していますが、例えば監視用、log転送用、APMでの計測用等のサイドカーを独自でイメージビルドする場合もECRを用意します。


## タスク定義の作り方
先ほど作成したECRの値を参照しつつ、task定義を作成します。

```terraform:terraform
locals{
  container_definition_variable = {
    arm  = {
      REPOSITORY_URI_NGINX = aws_ecr_repository.nginx[0].repository_url
      REPOSITORY_URI_API   = aws_ecr_repository.api[0].repository_url
    }
  }
    x86_64  = {
      REPOSITORY_URI_NGINX       = aws_ecr_repository.nginx[1].repository_url
      REPOSITORY_URI_API     = aws_ecr_repository.api[1].repository_url
    }
  }
}

data "template_file" "container_definition" {
  count    = length(local.arch)
  template = file("${path.module}/container_definition/container_definition.json")
  vars     = element(local.arch, count.index) == "x86_64" ? local.container_definition_variable["x86_64"] : local.container_definition_variable["arm"]
}

resource "aws_ecs_task_definition" "main" {
  count = length(local.arch)

  family                   = "service-${element(local.arch, count.index)}"
  requires_compatibilities = ["FARGATE"]

## 略 ##

  container_definitions = data.template_file.container_definition[count.index].rendered
  
  runtime_platform {
    operating_system_family = "LINUX"
    cpu_architecture        = element(local.arch, count.index) == "arm" ? "ARM64" : "X86_64"
  }
  
## 略 ##

}
```

ここでは、タスク定義の実体ファイルは、module内のディレクトリ「container_definition」に保管しています。なお、ECRイメージURL等は、`locals`で記載している通り、arm/x86_64ごとに変数化し、タスク定義は統一ファイルを参照するようにしています。

その他、アーキテクチャ固有の変数があれば、同じように定義可能です。
また、`template_file`の`template`を本番環境や開発環境で分岐させたい場合は、テンプレートファイル名にENVを記載したり、三項演算子をネストすることで対応可能です。

タスク定義の記載方法については割愛しますが、詳細は公式ドキュメントをご参照ください。

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/example_task_definitions.html

また、CPUアーキテクチャも`runtime_platform`の部分で`count`を使って指定します。


## サービスの作り方

最後にサービスの作り方について記載します。
先ほど作成したタスク定義をアーキテクチャごとに引用し、各taskの役割ごとに処理を分岐させます。
なお、ALBの生成については、本題とそれてしまうため割愛します。

```terraform:terraform
//serviceの構築
locals{
  ecs_service_list   = ["arm","x86_64","batch","admin",]
  public_subnet      = ["x.x.0.x/24","x.x.1.0/24"]
  security_group_ids = ["sg-xxxxxxxxxx","sg-xxxxxxxxxx"]
}

resource "aws_ecs_service" "main" {
  count = length(local.ecs_service_list)
  
## 略 ##

  task_definition  = element(local.ecs_service_list, count.index) != "x86_64" ? aws_ecs_task_definition.main[0].arn : aws_ecs_task_definition.main[1].arn

  network_configuration {
    subnets          = local.public_subnet
    security_groups  = local.security_group_ids
    assign_public_ip = true
  }

  dynamic "load_balancer" {
    for_each = element(local.ecs_service_list, count.index) == "batch" || element(local.ecs_service_list, count.index) == "admin" ? [] : [1]
  
    content {
      #ALBとターゲットグループの作成は省略
      target_group_arn = aws_lb_target_group.main.arn
      container_name   = "nginx"
      container_port   = "80"
    }
  }

  capacity_provider_strategy {
    base              =  0
    capacity_provider = element(local.ecs_service_list, count.index) == "x86_64" ? "FARGATE_SPOT" : "FARGATE"
    weight            = 1
  }

## 略 ##
}
```
先ほど作成したタスク定義を、アーキテクチャごとに指定します。
具体的には、クラスター構成で示した要件通り、一部apiと管理画面、バッチ処理のtaskにはarmのタスク定義を指定し、メインでユーザーアクセスを捌くapiには、x86_64のタスク定義を指定しています。

また、管理画面とバッチ用のtaskについは、ロードバランサーの設定を`dynamic`で除外します。

さらに、キャパシティプロバイダー戦略についても、アーキテクチャごとに要件通りに分岐させています。

以上が、マルチアーキテクチャのAWSリソースのTerraformでの構築方法になります。

# オートスケーリングについて
サービスのオートスケーリングのオプションを利用し、Service Auto Scalingの設定を入れます。

FARGATE SPOTを活用できるx86_64のサービスのみにオートスケーリングを設定し、arm側のサービスでは必要なタスクのみ設定する運用でも問題ないのですが、冗長化のためにもx86_64/armの両方で設定し、いずれもタスクの最小数と最大数で管理することをお勧めします。

# イメージビルドとデプロイについて
サイバードでは、ECS運用時のイメージビルドとデプロイはCircleCIもしくは、CodeBuildでの実装を行っています。ただし、マルチアーキテクチャを実装しているタイトルは、今のところCircleCIのみとなるため、本記事では割愛させていただきます。

特質すべき点として、2つのアーキテクチャ分のビルド/デプロイが走るため、処理時間が長くなってしまいそうではありますが、Docker公式のマルチプラットフォームビルドが可能なbuildxを活用したり、並列ビルド/デプロイを行うことで、CI/CDに単体アーキテクチャと比較してさほど大きな時間差は発生しません。

https://matsuand.github.io/docs.docker.jp.onthefly/buildx/working-with-buildx/#build-multi-platform-images

今後、CodeBuildでのマルチアーキテクチャのビルド/デプロイを構築する機会があれば、本記事にも追記したいと思います。

# 最後に
x86_64/armのマルチアーキテクチャの実装を進めていたのは昨年中頃で、まだまだarm側のOSSが追い付いていない部分が多々ありました。例えばarm対応したAPMが存在せずに負荷試験時に苦慮したり、そもそもドキュメント自体があまり存在しなかったりです。ただそのプロセスの試行錯誤についてはとても楽しいものがありました。

また、CI/CDまで記事化したかったのですが、実装方法も多種多様のため収拾がつかなくなりそうなので、コンパクトに仕上げるために割愛したことも少し心残りです。なので、次回作にご期待ください！

さらに、裏事情をお話すると、当初は本記事はクロスポストで参加予定で、サイバード社内のアドベントカレンダーにも本記事を紐づける予定でした。ただし、アドベントカレンダーに投稿する記事は、Qiita内で重複参照できないことを前日朝に気づき(絶望...)、急遽、別記事を手配しました。

「[CYBIRD Advent Calendar 2023](https://qiita.com/advent-calendar/2023/cybird)」 の19日目の記事「[外部ネットワークとAWSの複数VPCをSite-to-Site VPNとトランジェントゲートウェイで疎通する](https://qiita.com/gongon282828/private/7e8a76fdfd551afb8e0b)」も担当しているので、ぜひそちらもチェックいただければ嬉しいです！