きり丸の技術日記

技術検証したり、資格等をここに残していきます。

ビルド時間短縮のために途中ステージをpushする

始めに

弊社のシステムではECSを使用しているのですが、ここ最近Dockerイメージのビルド時間が大幅に延長されてしまっていました。そのうち、大幅な時間を占めているのがライブラリのインストール時間で、CPUの使用率が高くなって応答が非常に遅くなっていました。

uv.lock等のロックファイルが取り扱われている環境であればインストールでは常に同じライブラリが使用されるものですし、ライブラリインストールが完了した状態のイメージをRepositoryにアップロードすることで短縮することを目指しました。

今回の記事では、Dockerのマルチステージビルドを扱って処理時間を短縮することを目指します。

環境

  • Docker
    • 21以降
  • GitHub Actions

実装

Repositoryの作成

ライブラリをインストールしたイメージをアップロードするためのRepositoryを使用します。

dev-dependenciesありのイメージと、なしのイメージをアップロードしたかったので、2つ用意しました。

マルチステージビルドができるDockerファイルの作成

マルチステージビルドができるDockerファイルを用意します。全体の内容については今回の解説には不要なので省きます。

今回の場合、次のステージを用意しました。

  1. base
    1. 一番基準になるステージです。本番とベースイメージを分けたい場合はdev_base, prod_baseが生まれます
  2. dev_runtime
    1. 開発用のイメージを作るためのステージ。4. testで流用する。
  3. dev
    1. ローカル用ステージ
  4. test
    1. 2.のdev_runtimeを使用してCIを高速で処理させる
  5. prod_runtime
    1. 本番用イメージ。dev-dependenciesを抜いているだけ
  6. prod
    1. 本番用ステージ
ARG RUNTIME_TAG=latest

# ベースイメージ
FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_PROJECT_ENVIRONMENT="/usr/local/"

WORKDIR /app

COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

# 1. 開発用ランタイムのビルド
FROM base AS dev_runtime

COPY src /app/src
COPY README.md pyproject.toml .python-version uv.lock ./
COPY tests /app/tests
COPY alembic /app/alembic
COPY alembic.ini .env ./
RUN uv sync --frozen --no-cache --dev

## ローカルはあんまりここを分けるメリットがない
## 2. 開発用ランタイムを使用して起動
FROM dev_runtime AS dev

COPY src /app/src

CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--reload"]

## 3. test 用
FROM kirimaru/fastapi-practice_dev-runtime:${RUNTIME_TAG} AS test
ARG RUNTIME_TAG

# NOTE: compose ファイルでマウントするなら不要
COPY src /app/src

# 4. 本番用ランタイムのビルド
FROM base AS prod_runtime

COPY README.md pyproject.toml .python-version uv.lock ./

RUN uv sync --frozen --no-cache

# 5. 本番用ランタイムを使用して起動
FROM kirimaru/fastapi-practice_prod-runtime:${RUNTIME_TAG} AS prod
ARG RUNTIME_TAG

COPY src /app/src
COPY .env ./

CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0"]

ロックファイルのハッシュ取得

ロックファイルのハッシュを取得し、それをDockerのタグにします。そうすることで、ライブラリの更新がされたかどうかをチェックできます。また、念のためDockerのイメージ名と混ざらないようにpython-等のprefixも付与しています。

export LOCK_HASH=python-$(sha1sum < uv.lock | cut -d' ' -f1)
# Docker側でわかりやすい変数に変更
export RUNTIME_TAG=$LOCK_HASH

Dockerイメージのpull

過去に作成したイメージがあればそれを取得します。

docker pull kirimaru/fastapi-practice_dev-runtime:$RUNTIME_TAG

Dockerイメージのbuild and push

pullしたイメージが存在しない場合、イメージをビルドします。作成したイメージをRepositoryにpushします。

if [ $? -ne 0 ]; then
  docker buildx build --target dev_runtime -t kirimaru/fastapi-practice_prod-runtime:$RUNTIME_TAG .
  docker push kirimaru/fastapi-practice_dev-runtime:$RUNTIME_TAG
fi

pullしたイメージを使用する

開発用ビルドイメージをもとにテストしたいので、compose.ymlのtargetに定義したtestを指定します。また、タグをロックファイルのハッシュにしているので、パラメータとして渡してあげます。

services:
  api:
    build:
      context: .
      target: ${BUILD_TARGET:-dev}
      args:
        - RUNTIME_TAG=${RUNTIME_TAG}
export BUILD_TARGET=test
docker compose up -d

ソースコード

終わりに

1回あたり全体で20分くらいかかっていたのを10分弱まで省略できました。特にlintするだけのCIでは、イメージのpullがなくなったことで10分弱かかっていたのを2分程度で完了するほど高速処理になっています。

CIが遅くて悩んでいる方はぜひ参考にしてみてください。