AWS ECSのCPUアーキテクチャをArmに変更してコスト削減

こんにちは、インフラ/Misocaチームのfuku710です。
MisocaチームではAWSのコスト削減の一環として、ECS上で動いているコンテナアプリケーションのCPUアーキテクチャをArmプロセッサに変更する取り組みを行いました。
その過程でやってきたことや工夫したことなどについて書きたいと思います。

AWS Fargate Gravitonについて

MisocaではECSのコンテナの実行にAWS Fargateを使用しています。
現在FargateではArmアーキテクチャのCPUとしてAWS Graviton2がサポートされており、x86ベースのFargateからGraviton2ベースのFargateに変更することにより約20%のコスト削減が見込めます。

Linux/X86 Linux/ARM
1時間当たりのCPU(vCPU) 0.05056USD 0.04045USD
1時間当たりのメモリ(GB) 0.00553USD 0.00442USD

aws.amazon.com

また、パフォーマンスとしてもx86と比べて最大19%高くなると記述されています。

aws.amazon.com

ECSをArm対応するまでの流れ

ECSにおいてx86で動いているコンテナをArmで動かすためには以下の二つの作業が必要となります。

  • Armアーキテクチャに対応したコンテナイメージを作成する
  • ECSのタスク定義ファイルでCPUアーキテクチャにArmを指定する

ここで肝となるのはコンテナイメージの作成です。
Dockerでは1つのイメージで複数のアーキテクチャに対応したマルチプラットフォームイメージを作成できます。
マルチプラットフォームイメージとしてイメージを作ることにより、コンテナが動作するプラットフォームに応じて自動で適切なイメージが選択されて実行できます。
これにより、ECSのタスク定義でCPUアーキテクチャを指定するだけでArm環境に移行できます。

マルチプラットフォームイメージを作成するための方法はいくつかありますが、1つはQEMUを使ったビルドです。
QEMUはプロセッサエミュレータであり、これを利用してビルドすることによりx86のマシン上でx86のイメージとArmのイメージ両方を作成することができます。
この方法のメリットとしてアーキテクチャごとにマシンを用意する必要がないため、既存のCI/CDの変更を少なくしてイメージの作成が行えます。
しかし、デメリットとしてQEMUはエミュレータであるためイメージによっては非常に時間がかかる可能性があります。

私たちも当初はQEMUを使ったビルドのアプローチをとりましたがビルド時間が増加し、特にRuby on Railsが動くコンテナのビルドではbundle installで非常に時間がかかり、数分で終わっていたビルドが30分近くかかるようになってしまいました。
これでは運用上もコスト上もよろしくないため、アーキテクチャ別の環境を用意してそれぞれの環境でビルドしてマルチプラットフォームイメージを作成するという方針に切り替えました。

MisocaではAWS CodeBuildとGitHub Actionsの2つのサービス上でコンテナイメージをビルドをしていたため、それぞれのサービスで対応した内容を紹介します。

AWS CodeBuild

MisocaではAWS CodePipelineのフロー上でCodeBuildによるビルド/デプロイを行っています。
CodeBuildでは動作するビルド環境のイメージを選択できるため、x86環境のCodeBuildとArm環境のCodeBuildを作成しCodePipelineのビルドステージで2つのCodeBuildを並列に動かすようにします。
ポイントは各環境のビルド後にマルチプラットフォームイメージをプッシュするステージを用意している点です。
ここで各プラットフォームのイメージへの参照しているマルチプラットフォームイメージを作成してプッシュする処理を行います。

マルチプラットフォームイメージを作成するCodePipelineの構成

AWSの構成管理にTerraformを使用しているため、Terraformでの記述を示します。

CodeBuildでArm環境を利用する場合はenvironmenttypeARM_CONTAINERにしたうえでimageで利用可能なイメージを指定します。

Docker images provided by CodeBuild - AWS CodeBuild

CodePipelineでは各環境でビルドした際のイメージ名をマルチプラットフォームイメージをプッシュするCodeBuildに渡すようにします。

resource "aws_codebuild_project" "app_x86_64" {
  /* 省略 */
  environment {
    compute_type                = "BUILD_GENERAL1_MEDIUM"
    image                       = "aws/codebuild/standard:7.0"
    type                        = "LINUX_CONTAINER"
    image_pull_credentials_type = "CODEBUILD"
    privileged_mode             = true
  }
}

resource "aws_codebuild_project" "app_aarch64" {
  /* 省略 */
  environment {
    compute_type                = "BUILD_GENERAL1_SMALL"
    image                       = "aws/codebuild/amazonlinux2-aarch64-standard:3.0"
    type                        = "ARM_CONTAINER"
    image_pull_credentials_type = "CODEBUILD"
    privileged_mode             = true
  }
}

resource "aws_codebuild_project" "push_app" {
  /* 省略 */
}

resource "aws_codepipeline" "pipeline-deploy" {
 /* 省略 */
  stage {
    name = "Build"

    action {
      name            = "BuildDockerImageOnX86_64"
      category        = "Build"
      owner           = "AWS"
      provider        = "CodeBuild"
      version         = "1"
      input_artifacts = ["Source"]
      namespace       = "CodeBuildVariablesX86_64"

      configuration = {
        PrimarySource = "Source"
        ProjectName   = aws_codebuild_project.app_x86_64.name
      }
    }

    action {
      name            = "BuildDockerImageOnAarch64"
      category        = "Build"
      owner           = "AWS"
      provider        = "CodeBuild"
      version         = "1"
      input_artifacts = ["Source"]
      namespace       = "CodeBuildVariablesAach64"

      configuration = {
        PrimarySource = "Source"
        ProjectName   = aws_codebuild_project.app_aarch64.name
      }
    }
  }

  stage {
    name = "Push"

    action {
      name             = "PushToECR"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      version          = "1"
      input_artifacts  = ["Source"]
      output_artifacts = ["ImageDefinition"]
      namespace        = local.codebuild_namespace

      configuration = {
        ProjectName   = aws_codebuild_project.push_app.name
        PrimarySource = "Source"
        EnvironmentVariables = jsonencode(
          [
            {
              "name" : "X86_64_BUILT_IMAGE_NAME",
              "value" : "#{CodeBuildVariablesX86_64.BUILT_IMAGE_NAME}",
              "type" : "PLAINTEXT"
            },
            {
              "name" : "AARCH64_BUILT_IMAGE_NAME",
              "value" : "#{CodeBuildVariablesAach64.BUILT_IMAGE_NAME}",
              "type" : "PLAINTEXT"
            }
          ]
        )
      }
    }
  }
  /* 省略 */
}

イメージをビルドするbuildspecファイルは共通でuname -mの出力をイメージタグにすることで、アーキテクチャごとに別のタグでECRにプッシュします。

version: 0.2

env:
  exported-variables:
    - BUILT_IMAGE_NAME
phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin $IMAGE_FQDN
      - export BUILT_IMAGE_NAME=$REPOSITORY_URL:$(uname -m)

  build:
    commands:
      - echo Build started on `date`
      - echo Building the Docker image...
      - docker build -t $BUILT_IMAGE_NAME
      - echo Build completed on `date`

  post_build:
    commands:
      - echo Pushing the Docker image...
      - docker push $BUILT_IMAGE_NAME

マルチプラットフォームイメージをプッシュするbuildspecファイルはdocker buildxを使ってマルチプラットフォームイメージを作成します。
imagetools createコマンドで引数に各プラットフォームのイメージ名、--tagオプションに新しく作成するイメージ名を指定することでマルチプラットフォームイメージがプッシュされます。

version: 0.2

env:
  exported-variables:
    - BUILT_IMAGE_NAME
phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin $IMAGE_FQDN
      - export BUILT_IMAGE_NAME=$REPOSITORY_URL:latest

  build:
    commands:
      - >-
        docker buildx imagetools create
        --tag $BUILT_IMAGE_NAME
        $X86_64_BUILT_IMAGE_NAME
        $AARCH64_BUILT_IMAGE_NAME

GitHub Actions

GitHub Actionsではアプリケーションの共通となるイメージなどを定期的に作成しています。
こちらもCodeBuild同様にアーキテクチャの環境別にビルドをしてマルチプラットフォームイメージを作成したいところです。
実はこれまでGitHubがホストするランナーでArmアーキテクチャに対応しているものはなく、Arm環境上でワークフローを動かしたい場合はセルフホステッドランナーを使い自前でランナーを用意する必要がありました。
しかし、6月の初めからGitHubがホストするランナーとしてArmランナーが提供されるようになり、より手軽にArm環境上でビルドするワークフローを構築できるようになりました。

github.blog

ArmランナーはGitHub Actionsのより大きなランナー(larger runner)という枠組みで提供されており、使用するためにはGitHub Team または GitHub Enterprise Cloudプランである必要があります。
通常のランナーと異なり事前にOrganizationの設定から新しくGitHubホステッドランナーとして追加する必要があります。

docs.github.com

ランナー自体の利用料金は同スペックの場合、標準ランナー$0.008/1minに対してArmランナー$0.005/1minで割安になりますが、より大きなランナーとして管理されるランナーはアカウントに含まれているGitHub Actionsの利用時間(GitHub Teamであれば3000分/月)を使用することができないため、独立して課金されることには注意してください。

Organizationにランナーの追加が完了したら、ワークフローファイルのruns-onプロパティにランナーのラベルを指定することで該当のランナーを使用することができます。

GitHub-hosted runnersの一覧

GitHub Actionsではmatrixを使用することによりジョブを並列に動作することができます。 Dockerのドキュメントには複数のランナーでビルドするワークフローファイルの例がありますが、そこから以下のようにstrategyrun-onを変更してランナーを分けるようにすればx86とArmのランナーそれぞれでビルドすることができます。

  build:
    strategy:
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-latest
          - platform: linux/arm64
            runner: Misoca-Linux-ARM64-runners
    runs-on: ${{ matrix.runner }}

ECSの対応

Arm対応のイメージがビルドできる環境が整ったら、ECSのFargateでGraviton2を利用できるようにします。
これ自体の対応は簡単で、タスク定義ファイルにruntimePlatformプロパティを記述して、operatingSystemFamilyLINUXcpuArchitectureARM64に設定することでコンテナがFargate Gravion上で動作します。

    "runtimePlatform": {
        "operatingSystemFamily": "LINUX",
        "cpuArchitecture": "ARM64"
    },

成果

実際にはSavings Plansなどによる考慮も必要ですが、以下のCost Explorerのグラフを見るとArmに切り替えてから期待通りに時間あたりのコストが下がっていることが確認できます。

また、レスポンスタイムの改善もみられました。
以下のグラフでは実線がArmに切り替えた後のレスポンスタイム、点線がその前の週のレスポンスタイムです。
パフォーマンスの改善はそこまで期待していなかったのでうれしい誤算です。

一方、今回のArm対応により月1回動くバッチ処理の時間が長くなったり、CIの動作が不安定になるといった問題も発生しました。
バッチ処理に関してはx86でのイメージのビルドも残してx86とArmを併用して運用するという方法で解決することにしました。
本来であればArm環境移行後にx86のビルド処理を削除してコストを抑えるつもりだったので残念ですが、マルチプラットフォームイメージとしてイメージを作ったことにより、このような対応が容易にできたのは良かったです。

まとめ

ECSで動いているコンテナをFargateのGraviton2で動かせるように対応した結果、期待以上にコストの削減とパフォーマンスの改善を達成することができました。
しかし、特定の処理でのパフォーマンス悪化やCIでの問題が発生することがあったので、そういったリスクにも注意しながらArm環境への移行を進めたほうがいいでしょう。


また弥生では一緒に働く仲間を募集しています。 ぜひエントリーお待ちしております。

herp.careers