🚅

デプロイ速度を約50%高速化した話

に公開

こんにちは。Dress Code株式会社でプロダクトエンジニアをしているぽこひで(@pokohide)です。

弊社の提供する「DRESS CODE」のBackend APIのデプロイパイプラインを見直し、デプロイ時間を40~50%ほど高速化した話をご紹介します。

この手のパーセントが大きいのは、そもそも既存のデプロイパイプラインが最適化されていないだけなことが往々にしてあるので数字の大きさは釣りと思っていただき、自社でも使えると思った最適化案や考えがあれば参考にしていただくくらいの気持ちで気軽に読んでいただければと思います。

今回入れた変更点は主に以下のとおりです。

  • ジョブの並列化
  • Dockerfileのレイヤーキャッシュ整理
  • productionイメージの最適化
  • ベースイメージと圧縮形式の見直し
  • SOCI IndexによるFargateコンテナのLazy Load化
  • ALBターゲットグループの最適化
  • アーキテクチャをARM64に統一

背景

弊社は2週間のリリースサイクルを回しており、本番リリース前にQA評価を挟んでおります。develop/staging環境ではこのQA確認のために1日に複数回マージ・デプロイすることもあり、デプロイが遅いとQA着手までのリードタイムが伸び、そのまま開発生産性に影響します。

リリースサイクルが2週間と長めなぶん、1回あたりの変更点も多くなりがちです。そのため障害発生時もビルドイメージのロールバックだけでは戻しきれず、hotfixリリースで対応するケースが多く、デプロイ時間がそのまま復旧速度(MTTR)にも影響します。

当時のデプロイパイプラインは、migrationとseedを伴うデプロイで1回30分強かかっていました。開発生産性とMTTRの両方に効いてくる課題で、改善しない理由はありません。

前提

弊社のBackend APIはNestJS + Prisma + PostgreSQLをECS Fargate上で動かしていて、CI/CDはGitHub Actions、インフラはCDKで管理しています。デプロイはworkflow_dispatchでワークフローを手動キックする形で、リリース担当者が環境を指定して実行します。

構成をざっくり図にすると次のとおりです。Backend API関連のECS Fargateタスク定義(メインAPI・Queueワーカー・Migration)はいずれもx86_64、Docker build / deployのRunnerはubuntu-latestで動かしていました。

社内全体で見ると、一部のBatchジョブやLambdaなどはすでにself-hosted ARM64ランナーやARM64タスク定義で動いていましたが、Backend APIのデプロイは上の図のとおりx86_64 + ubuntu-latestで完結していて、アーキテクチャが混在している状態でした。

また、当時のデプロイワークフローは次のようになっていました。

直列で構成されており、各ジョブの所要時間はおおよそ次の通りでした。

ジョブ 内容 所要時間
setup-env 環境変数の解決などの軽量ジョブ 約30秒
migration-and-seed Prisma migrationとseedの実行 約15分
build-image アプリケーションのDocker buildとECRへのpush 約9分
deploy-app / deploy-queue ECSタスク定義を更新してロールアウト 4〜7分

ボトルネックの特定

各ジョブのログを追って時間を使っている箇所を書き出していくと、ボトルネックは5つ特定できました。

  1. migration-and-seedbuild-imageの直列実行
    build-imageはDBに触らないのに、needsでmigrationの完了を待っていた。直列構成自体が無駄
  2. Dockerレイヤーキャッシュが効いていない
    DockerfileのCOPY順序が悪く、ソースのちょっとした変更でpnpm installのレイヤーが毎回invalidateされていた
  3. ECRからのpullとコンテナ起動が遅い
    イメージサイズが数百MB〜1GB級で、コンテナ起動時の全pull・展開に時間がかかる
  4. ECS Deployジョブの最悪値ばらつき
    ALBターゲットグループのHealthy判定(最大150秒)とDeregistration delay(300秒)がAWSデフォルトのまま
  5. アーキテクチャ混在
    他リソースはARM64で動いているのに、Backendだけx86_64。Graviton割引や高スペックなself-hosted ARM64ランナーの恩恵を取りこぼしていた

上から順に「変更が小さく、ロールバックが容易」なものから手をつけていきます。

やったこと

ジョブを並列化する

最初の打ち手はジョブ依存の整理です。

build-imageはDockerイメージのビルドとECRへのpushしかしていないので、DBの状態には依存しません。deploy-app / deploy-queue側でmigration-and-seedbuild-imageの両方を待てば、ロールアウト前に「新しいスキーマと新しいコードの両方が揃っている」ことは保証できます。

build-imageneedsからmigration-and-seedを外し、deploy-app / deploy-queueneedsに両方を入れる、というだけのDAG書き換えです。これでbuild-imagemigration-and-seedのうち長い方がクリティカルパスから消えます。

この並列化だけで全体の約39%短縮になりました。一番費用対効果が高かった打ち手です。

Dockerイメージのキャッシュと軽量化を見直す

Dockerfileのキャッシュやイメージサイズ周りは、Docker公式のBuilding best practicesに定番の指針がまとまっています。今回はこのページ(と、シークレットについては別ページのBuild secrets)を参考に、マルチステージビルド・レイヤーキャッシュ・不要ファイルの除外・シークレットの受け渡しといった基本に立ち返って直しました。

目的別にDockerfileを分ける

デプロイの大半はschema変更を伴わない通常リリースで、prisma migrate deployさえ動けば十分です。それまではseed用の重いDockerfileを使い回していたので、migration専用の軽量Dockerfileを切り出して、composite action側のフラグで切り替えるようにしました。

その後、それぞれを1ファイルのマルチターゲットDockerfileに集約して、docker buildx build --target <name>で切り替える構成に整理しています。共通の修正がbase / runtime-baseステージ1箇所で全ターゲットに反映されるようになりました。

レイヤーキャッシュを組み直す

一番効くのは依存パッケージのインストール(pnpm install)レイヤーをキャッシュヒットさせることです。依存定義以外のファイル変更でinvalidateされないように、COPY順を組み直しました。

  • 依存定義ファイル(pnpm-lock.yaml / package.json)を最初にCOPY → pnpm install
  • pnpm install --frozen-lockfile --prefer-offlineに統一
  • tsconfig*.jsonのようなinstall結果に影響しないファイルはpnpm installの後ろに置く

さらに、pnpmのstoreを--mount=type=cache,id=pnpm,target=/pnpm-storeでBuildKitのcache mountに乗せて、self-hosted runnerのBuildKit daemonでビルド間にpnpm storeが共有されるようにしています。lockに変更があってもダウンロード済みパッケージを再利用できます。あわせてpnpmをcorepackで固定して、package.jsonpackageManagerフィールドとビルド時バージョンを揃えるところまでやっています。

GitHub Tokenを--mount=type=secretに渡す

キャッシュ周りを整理していて気付いた副作用です。ARG / ENV経由で渡していたGitHub TokenがDockerレイヤーに焼き込まれていて、docker historyで覗くと見えてしまう状態でした。BuildKitの--mount=type=secretで渡すように直すと、ビルド時のみ参照可能でレイヤーには永続化されません。

RUN --mount=type=secret,id=github_token,env=AUTH_TOKEN_FOR_GITHUB_PKG \
    pnpm install --frozen-lockfile --prefer-offline

ここまでで、キャッシュヒット時のBuild & Pushが約56%短縮しました。

productionイメージから不要なものを外す

productionステージでアプリケーションのリポジトリを「ディレクトリ丸ごと」COPYしていため、起動に必要なファイルだけ選別する構成に書き換えました。あわせて.dockerignoreも整理して、ビルドコンテキストに余計なファイル(ローカル用ダンプ、E2Eシナリオ、開発スクリプトなど)が乗らないようにしています。

イメージサイズが下がるとECRへのpushとECSタスク起動時のpullの両方が速くなるので、Build & Push単体に加えて後段のDeployジョブの安定化にも効いてきます。

ベースイメージと圧縮形式を見直す

イメージそのものを軽くしたあとは、ベースイメージの取得元とイメージの圧縮形式も詰めました。

ベースイメージをpublic ECRに切り替える

DockerfileのベースイメージをDocker HubからAWSのpublic ECRミラーに切り替えました。DockerとAWSが公式提携してホストしているDocker Hub Official ImagesのミラーなのでコンテンツはDocker Hubのものと同一で、AWSインフラ内通信になるぶんpullが速くなります。

build cacheをzstd圧縮にする

当初は最終イメージpushとbuild cacheをzstd圧縮にしていました。

ただ、後段でSOCIを導入する段階でsoci-snapshotter(v0.13.0時点)がzstdをサポートしておらず、最終イメージのほうはgzipに戻しました。build cacheはFargateがpullするものではないのでzstdのまま維持していて、最終的に「Fargateに出るイメージはgzip、CI内で完結するbuild cacheはzstd」という構成で運用しています。

Fargateのコンテナ起動をlazy loadにする

ECS Fargateは新しいタスクを起動するときに、まずイメージを最後までpull・展開してからアプリを起動します。Backendのように数百MB級のイメージだと、この待ち時間が無視できなくなってきます。

ここをSOCI (Seekable OCI)でlazy load化しました。SOCI index manifest v2を紐づけておけば、Fargateが必要なファイルから順次ロードしてくれます(AWSの公称値ではpull/展開フェーズが50〜70%短縮)。

ただし、docker buildx単体にはSOCI生成機能がないので、push後にcrane + soci convertでSOCI対応イメージに変換するcomposite actionをdeploy workflowに挟む形にしました。CI時間は数十秒〜数分伸びますが、Fargate側のコンテナ起動で取り返せる、というトレードオフです。

ALBの設定を本番データで詰める

ECS rolling updateのDeployジョブは、新タスクの起動(プロビジョニング・ECRからのイメージpull・コンテナ起動)→ ALBのHealthy判定 → 旧タスクのdrain(Deregistration delay)、というフェーズで構成されます。今回はHealthy判定とDeregistration delayの2つがAWSデフォルトのまま使われていたので、本番実績ベースで詰め直しました。

設定 変更前 変更後
Deregistration delay (メインAPI) 300秒 120秒
Deregistration delay (Queue) 300秒 180秒
ヘルスチェック間隔 30秒 10秒
Healthy判定回数 5回 2回
Unhealthy判定回数 2回 3回

Deregistration delayを短くしすぎると処理中のリクエストがALBから強制切断されるので、ここは雰囲気で決めたくない領域です。本番直近1ヶ月のターゲットグループ別最大レスポンスタイムを洗い出して、「最悪値の何倍をマージンに取るか」で議論しました。

  • メインAPI: 最悪57.0秒 → 120秒で2.1倍のマージン
  • Queue処理: 最悪58.4秒 → 180秒で3.1倍のマージン

Unhealthy判定回数は2 → 3に「上げて」います。ヘルスチェック間隔を30秒 → 10秒に詰めたぶん、瞬間的なネットワークジッターでの誤検知を防ぐためです。異常検知速度は変更前60秒(2 × 30秒)から変更後30秒(3 × 10秒)に短くなっており、誤検知耐性と検知速度を両立しています。

合わせてECS Task Protectionの権限をタスクロールに足し、Queueワーカーが重いジョブを掴んでいる間はスケールインで終了されないようにしました。

結果としてDeployジョブのばらつきが落ち着き、特にDeploy Queueの最悪値(6分37秒 → 5分26秒、約18%短縮)が改善しました。

アーキテクチャをARM64に揃える

最後にアーキテクチャの統一です。BackendのメインAPI・Queue・Migrationはまだx86_64で動いていたので、ECS Fargateタスク定義、Lambda、Dockerベースイメージ、GitHub Actions RunnerをすべてARM64(AWS Graviton)に揃えました。

self-hosted ARM64ランナーはubuntu-latest(2 vCPU / 7 GB RAM)と比較してネイティブビルド(QEMU不要)と高スペックの両方が乗るので、ビルド時間自体も短縮できます。Fargate Gravitonによるコスト削減も同時に得られる構成です。

切替後はARM64ネイティブビルドが効いて、キャッシュなしのBuild & Pushが約9分 → 約6〜7分(約22〜33%短縮)まで落ちています。

結果

各項目を変更前後で比較したものが次のとおりです。直近2週間のワークフロー実行ログで集計した結果です。

項目 変更前 変更後(安定値) 削減幅
パイプライン全体(migration + seedあり) 約32分 約17分 約46%短縮
パイプライン全体(migrationなし) 約14.5分 約11分 約24%短縮
Build & Push(キャッシュヒット) 約9分 約4分 約56%短縮
Build & Push(ARM64ネイティブ) 約9分 約6〜7分 約22〜33%短縮

また、ベースイメージの変更とSOCIによるlazy load化を入れたあと、Fargateのタスク起動側(前提で触れたdeploy-appジョブ)でも次のような変化が見られました。

項目 変更前 変更後 削減幅
deploy-appジョブ(ECSタスク更新〜ロールアウト) 約5分 約4分半 約14%短縮
build-imageジョブ 約3分50秒 約4分強 SOCI変換ぶん10〜20秒のオーバーヘッド
最終イメージサイズ(gzip) 約670 MB 約655 MB わずかに減少(+ SOCI v2 index 約52 MB)

deploy-appジョブは「新タスクの起動(プロビジョニング + ECRイメージpull + コンテナ起動)→ ALBのHealthy判定 → 旧タスクのdrain」で構成されますが、Healthy判定とdrainは既にALBチューニングで詰めてあるので、今回の14%短縮はSOCIによる新タスク起動フェーズの改善が乗ってきていると見ています。build-image側にSOCI変換のオーバーヘッドが乗りますが、deploy-app側の短縮のほうが大きいので、パイプライン全体としても短縮側に振れています。

Before / After のアーキテクチャ

一連の変更を反映したあとの構成は、前提セクションの図に比べて次のようになりました。

要素ごとの差分も並べておきます。

観点 変更前 変更後
Runner ubuntu-latest (x86_64, 2 vCPU / 7 GB) self-hosted ARM64 (Graviton4)
ベースイメージ取得元 Docker Hub public ECR (Docker Hub mirror)
最終イメージ gzip gzip + SOCI v2 index
build cache 圧縮 gzip zstd
ECS タスク定義 x86_64 ARM64
Fargate でのイメージ展開 コンテナ起動時に全 pull・展開 SOCI による lazy load

学び

局所最適に陥らないように、まず大局を見る

ジョブ並列化だけで全体の約39%短縮になっています。個別ジョブのチューニング(依存パッケージのキャッシュなど)はもちろん有効なのですが、まずパイプライン全体のクリティカルパスを潰しておかないと、その内側の改善は費用対効果がぼやけてしまいます。

本番リクエストに影響する数字は、本番データで決める

ALBのDeregistration delay 300秒 → 120秒、Healthy判定5回 → 2回は、数字だけ見るとなかなか過激な変更です。短くしすぎると処理中のリクエストが切断されるので、雰囲気では決めたくない領域でもあります。

ここで効いたのが、直近1ヶ月のターゲットグループ別最大レスポンスタイムの実測値でした。最悪値57秒に対して120秒、最悪値58.4秒に対して180秒、というふうに「最悪値の何倍をマージンに取るか」という観点で議論ができ、感覚ではなく実測値に基づいた根拠ある形で話を進められたのはよかったです。

速度改善だけでなく、将来の打ち手を増やしておく

今回の取り組みは「いま動いているパイプラインを速くする」ことが直接の目的でしたが、もう一つ「将来の打ち手を増やしておく」という狙いもありました。

特にARM64統一は、それ自体のビルド時間短縮効果に加えて、コストパフォーマンスが上がります。コスパが良くなれば、必要なときに「お金で殴ってさらに速くする」(self-hostedランナーの台数を増やす、より高スペックなインスタンスに変える、など)という選択肢が取りやすくなります。

おわりに

ジョブの並列化、Dockerfileのキャッシュと軽量化、productionイメージのスリム化、ベースイメージと圧縮形式の見直し、Fargateコンテナ起動のlazy load化、ALBの本番データに基づく設定、ARM64統一を順に進めて、デプロイ時間を約46%短縮できました。直近で入れたSOCIによるlazy load化も、Fargateのタスク起動フェーズが中央値で約14%短くなっており、さらに乗ってきている形です。

Dress Codeでは、急成長する事業を支えるインフラ・アプリケーションを一緒に作る仲間を募集しています。興味を持っていただいた方は、ぜひ気軽に一度お話ししましょう。

https://herp.careers/v1/dresscode

DRESS CODE TECH BLOG

Discussion