こんばんは、ノバセルの渡邉です。いつもRustを書いています。Rust、良い言語ですよね。ビルドが遅いことを除いて。ビルドが遅いとデプロイも遅くなるので、デプロイを高速化するために実施した工夫を紹介します。
この記事はこんな方に役立つかもしれません。
- Rust WebアプリのCI/CDパイプラインについて知りたい。
- デプロイ速度を上げて、リードタイムを短縮したい。
- デプロイ時間を短縮して、コストを削減したい。
前提として、私達は、デプロイにGitHub Actions(以後GHAと呼称)とAWS CodePipelineを使用しています。そして、Dockerイメージのビルド処理をAWS CodePipeline上の、CodeBuildにて行っています。また、ビルドしたDockerイメージをAWS Fargateにデプロイしています。
もし、0からパイプラインを構築するなら、GHAでDockerイメージをビルドすることをオススメします。理由は後述します。
この前提にもとづき、以下高速化のための工夫を解説します。
- ビルドの前にチェックすること〜不要な依存関係の削除〜
- GHAでの工夫〜並列化とキャッシュ〜
- 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秒ほどに削減できました。
最後に
この記事が皆さんの開発リードタイム改善、時短によるコスト改善に貢献できたらうれしいです。ありがとうございました。