円安に負けない!共通バックエンドAPIサーバーARM対応プロジェクト

こんにちは。ソーシャル経済メディア「NewsPicks」のSREチームの飯野です。 

SREでは2023年から円安に負けないコスト削減を継続して行なっていますが、最近は圧倒的な円安におされ気味です。 2024年1月-6月の間に141→161円の変動はちょっと厳しすぎますよね。

今回は2024年1月から3月にかけて行なったNewsPicksの共通バックエンドAPIサーバーのARM対応プロジェクトについて話したいと思います。

ARM対応はコスト削減を目的とした施策です。適用範囲の見誤りがあり、当初の想定ほど大きなコスト削減は実現できませんでしたが、活発に変更が行われるプロダクトに段階的に変更を加えてリリースすることができました。

ARM対応をするにあたり、何を考えてどの順番で着手したかという情報は、今後ARM対応を行う開発者に参考になりそうなので紹介します。

ARM対応計画

円安に負けないコスト削減を目指します。NewsPicksの共通バックエンドAPIサーバー、常駐ワーカー、定期実行ワーカー全てをARM対応し、ARMのEC2インスタンス/Fargateに切り替えることで、最も大きなアプリケーションのEC2/ECSのコストを約15%削減*1することを目標にスタートしました。

共通バックエンドAPIサーバーは、ニュースやコメントといったNewsPicksとしてコアな情報を配信するサーバーで、検索や課金など特定操作で利用される用途別マイクロサービスと異なり、ユーザーのサービス利用状況に応じて常時サービス提供が必要な重要サーバーです。通勤時間帯やお昼休みなどニュースがよく読まれる時間帯では数十台のインスタンスが必要とされており、ARM対応によるコスト削減のインパクトを狙うにはこの本丸を攻略することは避けて通れませんが、重要サーバーゆえに慎重な計画が求められました。

取り掛かるにあたり、次の内容を意識して作業を進めました。

  • サービスへの影響を抑えるため、段階的なリリースを行えるようにする。
    • 少しずつリリースすることで一回のリリースでのサービスへの影響が小さくなります。切り戻しも容易になります。
    • 長い間マージされないブランチが登場しないため開発への影響も小さくなります。
  • ARM対応の開始から終了までAWSのコストが上がらないようにする。
    • 最終的なコストも必要以上に上がらないようにします。

ARMのコンテナイメージを作成する

ARMのコンテナイメージを作成するための環境を用意する

すぐにARMのコンテナイメージの作成に取り掛かりたいのですが、その前にARMのコンテナイメージを作成するための環境が必要です。NewsPicksではCIにCodeBuildを利用しているので、

tech.uzabase.com

既存のCodeBuildプロジェクト(AMD64)とは別にARM64のCodeBuildプロジェクトを作成してコンテナイメージを作成することにしました。

この方法を選択した理由としては次のことを考えていました。

  • dockerの知識だけでARMのコンテナイメージを作成したい。
    • docker buildxなどの追加の知識は現時点では仕入れずすぐに作業に取り掛かりたいです。
  • batch buildを利用すると常にARMのビルドが実行されてしまうためコストがかかる。
    • 検証段階なので必要になった時だけビルドを動かしたいです。
  • 既存コードからの変更範囲が小さくいためつまづきにくい。
    • イメージ作成の試行錯誤に集中できます。

cdkで定義する場合は ecrAssets.Platform を渡してcodebuild.IBuildImage を返す関数があると取扱いしやすいです。

import * as codebuild from "aws-cdk-lib/aws-codebuild";
import * as ecrAssets from "aws-cdk-lib/aws-ecr-assets";

function createBuildImage(
    scope: Construct,
    name: string,
    props: { projectRoot: string; platform: ecrAssets.Platform; }
): build.IBuildImage {
    const { projectRoot, platform } = props;
    const buildImageAsset = new ecrAssets.DockerImageAsset(scope, name, {
        directory: `${projectRoot}/codebuild/docker`,
        target: "build",
        platform,
    });
    switch (platform) {
        case ecrAssets.Platform.LINUX_AMD64:
            return codebuild.LinuxBuildImage.fromEcrRepository(buildImageAsset.repository, buildImageAsset.imageTag);
        case ecrAssets.Platform.LINUX_ARM64:
            return codebuild.LinuxArmBuildImage.fromEcrRepository(buildImageAsset.repository, buildImageAsset.imageTag);
        default:
            throw new Error(`invalid platform ${platform.platform}`);
    }
}

const buildImage = createBuildImage(this, "ARM64BuildImage", {
    projectRoot,
    platform: ecrAssets.Platform.LINUX_ARM64,
});

const project = new codebuild.Project(this, "Project", {
    projectName: "build-arm64",
    environment: {
        buildImage: buildImage,
        computeType: codebuild.ComputeType.LARGE,
        privileged: true,
    },
    source: codebuild.Source.gitHub({
        owner: "owner",
        repo: "repo",
        reportBuildStatus: true,
        cloneDepth: 1,
        webhook: false, // 手動で動かすのでwebhookは設定しない。
    }),
    buildSpec: buildSpec,
    vpc: vpc,
    subnetSelection: { subnets },
    securityGroups: [sgo],
});

ARMのコンテナイメージを作成する

環境が整ったのでコンテナイメージの作成に取りかかります。CPUアーキテクチャの違いによりビルドが失敗するので、ビルドスクリプトやDockerfileにCPUアーキテクチャのif文を追加して差異を吸収していきます。ARMで動作しないライブラリの更新も必要です。

次のような対応を地道に行なっていきました。

  • Dockerのビルドオプションとして —platform を指定します。
case "$(uname -m)" in
    aarch64|arm64)
    DOCKER_PLATFORM="linux/arm64";;
    *)
    DOCKER_PLATFORM="linux/amd64";;
esac

BUILD_OPTION="--platform ${DOCKER_PLATFORM}
  • マルチステージビルドでバイナリのツールを導入している場合はアーキテクチャを指定します。
ARG qsv_version=0.128.0

RUN set -eux \
    && apt-get install -y --no-install-recommends --no-install-suggests unzip \
    && case "$(uname -m)" in \
        aarch64) \
            arch="aarch64-unknown-linux-gnu" \
            ;; \
        *) \
            arch="x86_64-unknown-linux-musl" \
            ;; \
    esac \
    && curl -L --fail https://github.com/jqnatividad/qsv/releases/download/${qsv_version}/qsv-${qsv_version}-${arch}.zip -o qsv.zip \
    && unzip qsv.zip -d /tmp
  • ARMで動作しない古いライブラリの更新を行います。

イメージの作成ができたら、イメージのタグにCPUアーキテクチャを追加します。これは既存のイメージを上書きしないために行います。イメージ以外に成果物があれば、必要に応じて対応を行いましょう。

  • イメージのタグにCPUアーキテクチャを含めます。
    • 既存のイメージにも-amd64のタグを追加しておくと、-arm64と対応できるので、デプロイフローの修正時に扱いやすくなります。
case "$(uname -m)" in
    "aarch64")
    SUFFIXES=("-arm64");;
    *)
    SUFFIXES=("" "-amd64");;
esac
for SUFFIX in "${SUFFIXES[@]}"; do
    docker tag newspicks-server/app-api:latest "${REPO_PREFIX}/app-api:commit-${COMMIT}${SUFFIX}"
    docker push "${REPO_PREFIX}/app-api:commit-${COMMIT}${SUFFIX}"
done

ARMのコンテナイメージ作成後の状況

イメージが作成できると次のような状態になります。

イメージのSuffix(CPU) キャパシティプロバイダ ECSサービスのCPU 利用状況
なし (AMD64) app-capacity-provider AMD64 既存のデプロイフローで利用する
-amd64 (AMD64) - - 未使用
-arm64 (ARM64) - - 未使用

デプロイフローのAMD/ARM対応

デプロイフローを修正して、-amd64-arm64のイメージをいつでもデプロイできるようにします。

ChatOpsの変更

イメージが作成できたので、デプロイの準備を行います。NewsPicksではChatOpsを使ってデプロイを行っています。

tech.uzabase.com

リリース完了まで検証で何度も使うので、少し便利にしておきましょう。具体的には、ChatOpsでCPUアーキテクチャが指定できるようにすると良さそうです。

@deploybot release --arch arm64 # arm64のイメージをデプロイ
@deploybot release --arch amd64 # amd64のイメージをデプロイ。--arch未指定の場合はこちらの動作を行う

CapacityProviderの準備

NewsPicksではコストの観点からECS on EC2を利用しています。

tech.uzabase.com

既存のCapacityProviderとは別に、AMD64/ARM64のEC2インスタンスを起動するCapacityProviderを追加で作成しておきます。

app-capacity-provider-amd64
app-capacity-provider-arm64

ServiceとTaskDefinitionの変更

ChatOpsで指定されたCPUアーキテクチャでTaskDefinitionを作成します。重要な点は次の三つのCPUアーキテクチャが一致していることです。

  1. ServiceのCapacityProvider
  2. TaskDefinitionのRuntimePlatform
  3. ContainerDefinitionのイメージ
const arch = "arm64"; // "amd64" | "arm64", ChatOpsで指定した値が入る
const serviceProps = {
    capacityProviderStrategy: [
        {
            capacityProvider: `app-capacity-provider-${arch}` // 1
        }
    ]
}
const cpuArchitecture = arch == "arm64" ? "ARM64" : "X86_64";
const TaskDefinitionProps = {
    runtimePlatform: { cpuArchitecture }, // 2
    containerDefinitions: [
        {
            image: `${repo}:commit-${commitId}-${arch}` // 3
        }
    ]
}
    

デプロイフロー修正後の状態

ChatOpsでのarchの指定(デフォルトamd64)を追加することで、いつでもARM版がデプロイできるようになりました。これで動作確認が捗りますね。

イメージのSuffix(CPU) キャパシティプロバイダ ECSサービスのCPU 利用状況
なし (AMD64) app-capacity-provider AMD64 未使用
-amd64 (AMD64) app-capacity-provider-amd64 AMD64 ChatOpsで—arch amd64を指定した場合に利用する(デフォルトの動作)
-arm64 (ARM64) app-capacity-provider-arm64 ARM64 ChatOpsで—arch arm64を指定した場合に利用する

デプロイして発覚した見落とし

いざデプロイしてみると常駐ワーカーのデプロイが成功しません。調べてみるとFarget SpotではARM64はサポートされていませんでした。

• ARM64 アーキテクチャの Linux タスクでは、Fargate Spot キャパシティプロバイダーはサポートされません。

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs-arm64.html

NewsPicksの常駐ワーカーがFarget/Farget Spotを利用するようになった経緯は次の記事にまとまっています。

tech.uzabase.com

コスト削減を目的にARM対応を行う中でコスト増加してしまうことは避けたいです。このため、常駐ワーカー、定期実行ワーカーについてはAMDを維持することにしました。*2

見落としに対応した状態

Fargate/Fargate Spotを考慮すると、次のような状態になりました。

イメージのSuffix(CPU) キャパシティプロバイダ ECSサービスのCPU 利用状況
なし (AMD64) app-capacity-provider - 未使用
-amd64 (AMD64) app-capacity-provider-amd64 AMD64 ChatOpsで—arch amd64を指定した場合に利用する(デフォルトの動作)
-amd64 (AMD64) fargate/fargate spot AMD64 常駐ワーカー、定期実行ワーカーで利用する
-arm64 (ARM64) app-capacity-provider-arm64 ARM64 ChatOpsで—arch arm64を指定した場合に利用する

リリース前の見直し

最小限の工数で段階的に検証を行ってきましたが、このままリリースするとコスト的に問題があるので見直します。

コンテナイメージのマルチアーキテクチャ対応によるコスト削減

ARMのコンテナイメージを作成するためにCodeBuildプロジェクトを複製しました。ビルドが並列で動くためビルド時間は短いですが、コストは2倍かかります。作業開始当初はARMへ完全移行する計画でしたが、Fargate Spotの利用が必須のため、今後もAMD64とARM64の両方のイメージを作成する必要があります。バッチビルドに書き直してコードビルドプロジェクトを統合しても、コスト削減はできません。

各所に相談した上で、現時点では円安を重く見てコスト削減の対応を行うことになりました。

バッチビルドを利用せず、一回のビルドでAMD64/ARM64のコンテナイメージを作成できれば、ARMのコンテナイメージ分のdocker buildやdocker pushの時間だけのコスト増で済みます。今回はdocker buildxを使って、一回のビルドで複数のCPUアーキテクチャのイメージを作成することにします。

  • docker buildxの環境を作成します。
docker buildx create --platform linux/amd64,linux/arm64 --use --bootstrap \
  --driver-opt image=public.ecr.aws/vend/moby/buildkit:buildx-stable-1
  • docker buildx buildでイメージを作成します。
    • 高速化のためにparallel*3を使って並列実行しています*4。docker buildx buildは負荷が高い処理なので、実際のIOやメモリ使用率から2並列で実行しています。
# 202407時点ではbuildxのみを使ってbuildとregistoryへのpushのタイミングを分離することはできない。
# --output "type=docker"でローカルのdockerにイメージを保存しておき、別のタイミングでtag, pushを行う。
# https://github.com/docker/buildx/issues/1152
# https://github.com/docker/buildx/issues/166#issuecomment-592811561
for kind in api assets contents; do
    for arch in amd64 arm64; do
        >> parallel_docker_build.txt echo \
            docker buildx build \
            --platform linux/$arch \
            --output "type=docker" \
            -t newspicks-server/app-${kind}:latest-$arch \
            --build-arg "BASE_IMAGE=${REPO_PREFIX}/app-base:${APP_BASE_HASH}" \
            -f codebuild/docker-app/Dockerfile.${kind} codebuild/docker-app
    done
done
for arch in amd64 arm64; do
    >> parallel_docker_build.txt echo \
        docker buildx build \
        --platform linux/$arch \
        --output "type=docker" \
        -t newspicks-server/batch:latest-$arch \
        --build-arg "BASE_IMAGE=${REPO_PREFIX}/batch-base:${BATCH_BASE_HASH}" \
        -f codebuild/docker-batch/Dockerfile codebuild/docker-batch
done
cat parallel_docker_build.txt | parallel --verbose --jobs 2
  • 作成したイメージにタグをつけてdocker pushします。
    • デプロイフローがマルチアーキテクチャのイメージに対応していなくても良いように、それぞれのタグをつけています。
    • 高速化のためにparallelを使って並列実行しています。CPUコア数だけ並列実行します。
APPS=("app-api" "app-assets" "app-contents" "batch")
ARCHS=("amd64" "arm64")
# push images
> parallel_docker_push.txt echo
for APP in "${APPS[@]}"; do
    for ARCH in "${ARCHS[@]}"; do
        docker tag newspicks-server/${APP}:latest-${ARCH} "${REPO_PREFIX}/${APP}:commit-${COMMIT}-${ARCH}"
        >> parallel_docker_push.txt echo docker push "${REPO_PREFIX}/${APP}:commit-${COMMIT}-${ARCH}"
    done
done
cat parallel_docker_push.txt | parallel --verbose
  • 作成したタグを元にdocker manifest createとdocker manifest pushを行います
    • 高速化のためにparallelを使って並列実行しています。CPUコア数だけ並列実行します。
# push manifests
> parallel_docker_manifest_push.txt echo
for APP in "${APPS[@]}"; do
    MANIFEST_CREATE="docker manifest create --amend ${REPO_PREFIX}/${APP}:commit-${COMMIT}"
    for ARCH in "${ARCHS[@]}"; do
        MANIFEST_CREATE+=" ${REPO_PREFIX}/${APP}:commit-${COMMIT}-${ARCH}"
    done
    eval $MANIFEST_CREATE
    >> parallel_docker_manifest_push.txt echo docker manifest push --purge ${REPO_PREFIX}/${APP}:commit-${COMMIT}
done
cat parallel_docker_manifest_push.txt | parallel --verbose

parallelは指定がなければすべてのCPUコアを利用します。我々はCodeBuildの8コアのインスタンスを使っているため並列実行で高速化できていますが、ビルド環境によって並列実行のオーバーヘッドの方が大きい場合は直列化した方がよさそうです。

デプロイフローのマルチアーキテクチャ対応

ChatOpsで指定されたCPUアーキテクチャでTaskDefinitionを作成するのは変わらないですが、imageがマルチアーキテクチャに対応したのでタグに変更が不要になりました。

  • ContainerDefinitionのイメージ
const TaskDefinitionProps = {
    containerDefinitions: [
        {
            image: `${repo}:commit-${commitId}` // マルチアーキテクチャ対応しているのでarchの指定は不要となった。
        }
    ]
}

見直し後状態

コンテナイメージのマルチアーキテクチャ対応後は次のような状態になります。

イメージのSuffix(CPU) キャパシティプロバイダ ECSサービスのCPU 利用状況
なし (マルチアーキテクチャ) app-capacity-provider - 未使用
なし (マルチアーキテクチャ) app-capacity-provider-amd64 AMD64 ChatOpsで—arch amd64を指定した場合に利用する(デフォルトの動作)
なし (マルチアーキテクチャ) fargate/fargate spot AMD64 常駐ワーカー、定期実行ワーカーで利用する
なし (マルチアーキテクチャ) app-capacity-provider-arm64 ARM64 ChatOpsで—arch arm64を指定した場合に利用する
-amd64 (ARM64) - - マルチアーキテクチャのイメージ(manifest)から参照される
-arm64 (ARM64) - - マルチアーキテクチャのイメージ(manifest)から参照される

リリースとお掃除

最後はリリースです。slackに次のコマンドを入力して本番にARM64のアプリケーションをデプロイします。

@deploybot release --arch arm64
# @deploybot release --arch amd64 # 切り戻しを行う場合はこちらを入力

お掃除

リリース後は並行稼働用に追加したif文や使わなくなったCapacityProviderの削除や参照CPU切り替えをしていきます。お掃除が終わるまでがARM対応です。

お掃除後の状態

最終的には次のシンプルな形に落ち着きました。

イメージのSuffix(CPU) キャパシティプロバイダ ECSサービスのCPU 利用状況
なし (マルチアーキテクチャ) app-capacity-provider ARM64 共通バックエンドAPIサーバーで利用する
なし (マルチアーキテクチャ) fargate/fargate spot AMD64 常駐ワーカー、定期実行ワーカーで利用する
-amd64 (ARM64) - - マルチアーキテクチャのイメージ(manifest)から参照される
-arm64 (ARM64) - - マルチアーキテクチャのイメージ(manifest)から参照される

ARM対応で得られた効果

  • 本番環境のEC2のうち、共通バックエンドAPIサーバーのコストが15%ほど削減できました。
  • CodeBuildの時間はマルチアーキテクチャのDockerイメージ作成の影響で170秒ほど増加し、コストは30%ほど増加してしまいました。
  • 本番の削減効果の方が大きいため、トータルはコスト削減は行えました。
  • (副次的な効果)開発で利用していたEC2のSpotインスタンスのNo Capacityによる停止がなくなりました。
    • 2024/03にAMD64のEC2 SpotインスタンスがNo Capacityで頻繁に停止する事象に遭遇しました。
    • 対策として一時的にOnDemandインスタンスを利用していましたが、ARM対応後はSpotインスタンスでも安定して利用できるようになりました。
    • お得に使えているのであまりブログでお知らせしたくはないのですが、ap-northeast-1のSpotインスタンスは現時点でARMの方が需要が少なく停止発生率が低いようです。(これは需給の問題なので今後変わる可能性があります)

まとめ

2024年1月から3月にかけて行なった、NewsPicksの共通バックエンドAPIサーバーのARM対応プロジェクトについて紹介しました。

活発に変更が行われるプロダクトでもAWSのコストを抑えながら段階的にリリースが行えました。

  • AMD/ARMの両方に対応したビルドスクリプトの作成
  • いつでもAMD64/ARM64を切り替えられるデプロイフロー
  • コスト最適化の観点からFargate Spotも併用したいために発生したAMD64/ARM64のハイブリッド運用
  • コスト制約から対応したコンテナイメージのマルチアーキテクチャの実現

などそれぞれの段階で発生した制約や要件にうまく対応できたのではないでと思っています。

今回の対応がこれからARM対応を行う開発者の参考になれば嬉しいです。

最後までお読みいただきありがとうございました!

*1:m6i系インスタンスとm7g系インスタンスの差分

*2:厳密には、1タスクで動くことが決まっている常駐ワーカー、定期実行ワーカーはFargateのためARMが利用可能です。共通バックエンドAPIサーバーと比べワーカーはコスト影響が小さいので、先に共通バックエンドAPIサーバーだけARMにしてコスト削減を行うことにしました。

*3:parallelはcodebuildのイメージに含まれていないコマンドです。利用するためにはカスタムイメージかpre_buildでのインストールが必要です。

*4:可読性上がるかな?とリダイレクトを先頭に書く変な書き方('>> parallel_docker_build.txt echo')をしています。あまり良くないアイデアだったかも?

Page top