RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

Rust WebアプリのCI/CDパイプライン

こんばんは、ノバセルの渡邉です。いつもRustを書いています。Rust、良い言語ですよね。ビルドが遅いことを除いて。ビルドが遅いとデプロイも遅くなるので、デプロイを高速化するために実施した工夫を紹介します。

この記事はこんな方に役立つかもしれません。

  • Rust WebアプリのCI/CDパイプラインについて知りたい。
  • デプロイ速度を上げて、リードタイムを短縮したい。
  • デプロイ時間を短縮して、コストを削減したい。

前提として、私達は、デプロイにGitHub Actions(以後GHAと呼称)とAWS CodePipelineを使用しています。そして、Dockerイメージのビルド処理をAWS CodePipeline上の、CodeBuildにて行っています。また、ビルドしたDockerイメージをAWS Fargateにデプロイしています。

もし、0からパイプラインを構築するなら、GHAでDockerイメージをビルドすることをオススメします。理由は後述します。

この前提にもとづき、以下高速化のための工夫を解説します。

  1. ビルドの前にチェックすること〜不要な依存関係の削除〜
  2. GHAでの工夫〜並列化とキャッシュ〜
  3. CodePipelineでの工夫〜Dockerイメージのキャッシュ〜

上記工夫で、30分のデプロイ時間を17分に改善できました。

1. ビルドの前にチェックすること〜不要な依存関係の削除〜

プロジェクトで未使用なクレートが含まれていないかチェックします。不要な依存関係が含まれているとビルドが遅くなるからです。チェックにはcargo-udepsを使います。インストールして以下コマンドを実行してください。

cargo +nightly udeps

未使用なクレートを以下のように報告してくれます。

unused dependencies:
`app v0.1.0 (/Users/hoge/products/app)`
└─── dependencies
     └─── "sea-orm"
Note: These dependencies might be used by other targets.
      To find dependencies that are not used by any target, enable `--all-targets`.
Note: They might be false-positive.
      For example, `cargo-udeps` cannot detect usage of crates that are only used in doc-tests.
      To ignore some dependencies, write `package.metadata.cargo-udeps.ignore` in Cargo.toml.

2. GHAでの工夫〜並列化とキャッシュ〜

これは、私達が実際に使っているワークフローを本記事向けに改変したものです。工夫した点はジョブの並列化とキャッシュです。

name: Lint Test Build and Deploy

on:
  push:
    paths-ignore:
      - 'docker-compose*'
      - '**.md'
      - '.**'
      - '!.github/**'

permissions:
  id-token: write
  contents: read

env:
  CARGO_INCREMENTAL: 0 # CIではインクリメンタルビルドの変更差分追跡によるオーバーヘッドのほうが大きいためOFF
  RUSTFLAGS: "-D warnings" # 高速化のため全クレートの警告をOFF

jobs:
  lint:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@v1
        with:
          toolchain: 1.61.0
          components: clippy

      - uses: Swatinem/rust-cache@v2
        with:
          shared-key: "app-api" # 指定しないと異なるワークフロー、ジョブでキャッシュが効かない

      - name: Lint
        run: cargo clippy --bin app --all-features --tests

  test:
    runs-on: ubuntu-latest

    services:
      db:
        image: mysql:8.0
        ports:
          - 13306:3306
        env:
          MYSQL_ROOT_PASSWORD: pass
          MYSQL_DATABASE: app_test
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@v1
        with:
          toolchain: 1.61.0

      - uses: Swatinem/rust-cache@v2
        with:
          shared-key: "app-api"

      - name: Setup dot env
        run: cp .env{.template,}

      - name: Cache Diesel-cli
        id: cache-diesel-cli
        uses: actions/cache@v3
        with:
          path: /home/runner/.cargo/bin/diesel
          key: diesel-cli-cache-revision-1

      - name: Install Diesel-cli
        if: ${{ steps.cache-diesel-cli.outputs.cache-hit == false }}
        run: cargo install diesel_cli --no-default-features --features mysql

      - name: Setup Database
        run: diesel setup --database-url mysql://root:pass@127.0.0.1:13306/app_test

      - name: Test
        run: cargo test --no-fail-fast

  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@v1
        with:
          toolchain: 1.61.0

      - uses: Swatinem/rust-cache@v2
        with:
          shared-key: "app-api"

      - name: Build
        run: cargo build

  deploy:
    needs: [lint, test, build]
    runs-on: ubuntu-latest

    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1

      - name: Start CodePipeline for deploy to production
        run: aws codepipeline start-pipeline-execution --name app-api-production

2-1. ジョブを並列化する

上記の例では、lint, test, buildジョブを並列化しています。コスト改善には効果なしですが、デプロイ速度は向上します。testだけでなくbuildも実施しているのは、#[cfg(not(test))]によってビルド対象のコードが変わるためです。

並列化により大きな改善が見込めそうですが、実際にはtestジョブが大きなボトルネックになっており、それほどでもなかったです。直列に実行した場合11分で、並列だと7分です。(後述するキャッシュの効果を含みます)

2-2. Swatinem/rust-cacheアクションを使う

並列化とともにキャッシュは、高速化の定番です。プロジェクトの依存関係が変わらなければ高速化が見込めます。私達はSwatinem/rust-cacheアクションを導入しました。他にもsccacheを使った高速化も可能なようですが、このアクションの導入が圧倒的に楽だったので試していません。具体的に使っているのは以下の部分です。

      - uses: Swatinem/rust-cache@v2
        with:
          shared-key: "app-api" # 指定しないと異なるワークフロー、ジョブでキャッシュが効かない

Rust toolchainインストール後に上記アクションを実行するだけです。これだけで、ビルド時間を7分から3分30秒ほどに削減できました。

この他にもdiesel_cliをキャッシュしています。ORMにdieselを使っている方は参考にしていただければと思います。

3. CodePipelineでの工夫〜Dockerイメージのキャッシュ〜

以下はパイプラインで使っているCodeBuildのbuildspec.ymlと、Dockerfileです。工夫した点はDockerイメージをいかにキャッシュするかです。

buildspec.yml

version: 0.2
env:
  variables:
    DOCKER_BUILDKIT: "1"
phases:
  pre_build:
    commands:
      - IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - echo Logging in to Docker Hub...
      - docker login -u $DOCKER_HUB_USERNAME -p $DOCKER_HUB_PASSWORD
      - echo Logging in to Amazon ECR...
      - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
  build:
    commands:
      - echo Building the Docker image...
      - |
        docker build -t $IMAGE_REPO_NAME:latest \
          --cache-from=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:builder,$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:latest \
          --build-arg BUILDKIT_INLINE_CACHE=1 . \
          & docker pull $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$MIGRATION_REPO_NAME:latest \
          & wait
      - |
        docker build --target builder -t $IMAGE_REPO_NAME:builder --build-arg BUILDKIT_INLINE_CACHE=1 . \
          & docker run --mount type=bind,src=$(pwd)/migrations,dst=/migrations \
              $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$MIGRATION_REPO_NAME:latest \
              diesel migration run --migration-dir=/migrations --database-url=$DATABASE_URL \
          & wait
      - docker tag $IMAGE_REPO_NAME:builder $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:builder
      - docker tag $IMAGE_REPO_NAME:latest $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:latest
      - docker tag $IMAGE_REPO_NAME:latest $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
  post_build:
    commands:
      - echo Pushing the Docker images...
      - |
        docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:builder \
          & docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:latest \
          & docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG \
          & wait
      - printf '[{"name":"app","imageUri":"%s"}]' $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG > imagedefinitions.json
artifacts:
  files:
    - imagedefinitions.json

Dockerfile

FROM lukemathwalker/cargo-chef:latest-rust-1.61.0 as chef
WORKDIR /app
RUN apt update && apt install lld clang -y

FROM chef as planner
COPY . .
# recipe.json(プロジェクトの依存関係をまとめたファイル)を作る
RUN cargo chef prepare --recipe-path recipe.json

FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
# プロジェクトの依存関係を構築してキャッシュする
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .

# プロジェクトのビルド
RUN cargo build --release --bin app

FROM debian:bullseye-slim AS runtime
WORKDIR /app

RUN apt-get update -y && apt-get upgrade -y \
    && apt-get install -y --no-install-recommends openssl ca-certificates default-mysql-client \
    # Clean up
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/app .

CMD ["./app"]

3-1. CodeBuildでDocker BuildKitを使う

buildspec.ymlの環境変数DOCKER_BUILDKIT"1"を設定すると、Docker BuildKitが有効になります。これを有効にすることでDockerイメージをキャッシュできます。

env:
  variables:
    DOCKER_BUILDKIT: "1"

加えて、ビルド時にキャッシュとして使うDockerイメージを指定することで高速化できます。buildフェーズのcommandsを参照してください。--cache-fromにキャッシュとして使用するイメージを指定しています。

  build:
    commands:
      - echo Building the Docker image...
      - |
        docker build -t $IMAGE_REPO_NAME:latest \
          --cache-from=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:builder,$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:latest \
          --build-arg BUILDKIT_INLINE_CACHE=1 . \
          & docker pull $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$MIGRATION_REPO_NAME:latest \
          & wait
      - |
        docker build --target builder -t $IMAGE_REPO_NAME:builder --build-arg BUILDKIT_INLINE_CACHE=1 . \
          & docker run --mount type=bind,src=$(pwd)/migrations,dst=/migrations \
              $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$MIGRATION_REPO_NAME:latest \
              diesel migration run --migration-dir=/migrations --database-url=$DATABASE_URL \
          & wait

当然ですが、--cache-fromで指定するにはキャッシュとして指定するイメージをビルドして保存する必要があります。--build-arg BUILDKIT_INLINE_CACHE=1でキャッシュとして利用するためのメタ情報を埋め込みます。ビルドの前にこのメタ情報を元にキャッシュとして使えるか判定し、キャッシュとして使えるレイヤのみ自動でプルされます。

latest以外にbuilderもキャッシュとして利用するため、上記docker build --target builderでビルドしています。このビルドは、キャシュが効くためすぐに終わります。

その他、$MIGRATION_REPO_NAME:latestですが、これはdiesel-cliを実行するためのイメージです。DBマイグレーションに使います。

余談:GHAでDockerをビルドしたほうが良い理由

本記事の導入に、「0からパイプラインを構築するならDockerイメージビルドはGHAがオススメ」と書きました。その理由を説明します。結論、手軽なためです。

CodeBuildのDockerLayerCacheではキャッシュ時間が短いため、今回は上述した方法でキャッシュを行いました。 しかし、GHAではより簡単で高い効果の見込めるbuild-push-actionがあるため、こちらの利用をおすすめします。

3-2. cargo-chefを使う

cargo-chefは、Rustの依存関係をビルドしてDockerの中間レイヤに置いてくれます。他言語でもよく使うマルチステージビルドを活用した中間レイヤによるキャッシュ利用を、Rustで簡単に実施するためのDockerイメージです。

以下のように、ベースイメージにlukemathwalker/cargo-chef:latest-rust-<Rustのバージョン>を指定します。依存関係をビルドして中間レイヤにキャシュします。

FROM lukemathwalker/cargo-chef:latest-rust-1.61.0 as chef
WORKDIR /app
RUN apt update && apt install lld clang -y

FROM chef as planner
COPY . .
# recipe.json(プロジェクトの依存関係をまとめたファイル)を作る
RUN cargo chef prepare --recipe-path recipe.json

FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
# プロジェクトの依存関係を構築してキャッシュする
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .

# プロジェクトのビルド
RUN cargo build --release --bin app

以下ステージで実行環境を整えて、builderからコピーしています。

FROM debian:bullseye-slim AS runtime
WORKDIR /app

RUN apt-get update -y && apt-get upgrade -y \
    && apt-get install -y --no-install-recommends openssl ca-certificates default-mysql-client \
    # Clean up
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/app .

CMD ["./app"]

この工夫により、CodeBuildの実行時間は11分から4分30秒ほどに削減できました。

最後に

この記事が皆さんの開発リードタイム改善、時短によるコスト改善に貢献できたらうれしいです。ありがとうございました。