CodeBuildとDocker buildxでCI/CDを改善してきた話

CodeBuildとDocker buildxでCI/CDを改善してきた話

株式会社CyberOwlでエンジニアをしている佐藤です。
今回はCodeBuildとDocker buildxを組み合わせることでCI/CDを改善することができたのでその方法を紹介しようと思います。

  1. 従来のCI/CDフロー
  2. マルチアーキテクチャ問題
    1. 対処法① platform指定でCPUエミューレート
    2. 対処法② buildxを使ったリモートビルド
  3. リモートビルド待ち問題
    1. 対処法 AWS CodeBuild上でbuild自動化
    2. 工夫した点
  4. まとめ

従来のCI/CDフロー

CyberOwlの従来のCI/CDのフローは以下のようになっています。

  1. 開発チャンネルでどの環境をデプロイするのか全体通知
  2. 登録されたaliasを使ってデプロイコマンドを実行
  3. ローカルでdocker buildとdocker image pushを実行 (git差分のvalidationもあり)
  4. ECRをトリガーにしたAWS CodePipelineが起動し、ECSへデプロイ

※buildをローカルで行っているのには理由があります。
CyberOwlのエンジニアチームは少数であり、CIシステムを導入することによる保守コストと、CIシステムのプロビジョニングやキャッシュ初期化などによるビルド実行の遅延から、このような部分的なCIを使用していました。

そして実行方法にはbuildの観点で以下のメリットがあります。

  1. ローカルのDocker Layer Cacheが効く
  2. Docker Build中のキャンセルが容易
  3. ローカルマシンのスペックを最大限に活用することが可能

1についてはデプロイするローカルマシンが同じため、常にキャッシュを活用することが可能です。

2については、開発環境デプロイ時に役立つケースが多いです。全体通知時に他の人がcommitを含めてほしい場合に手元でbuildの中断が可能です。

最後に3については、CyberOwlが提供しているPCのスペックが高いのが挙げられます。例えば私はMacBook Proの64GBが提供されており、他のメンバーも同等のPCを使っています。そのためクラウドで提供されているマシンを使わずとも高速にbuildできます。また手元でbuildできるのでお金がかからないのもメリットです。

このフローはマルチステージビルドやキャッシュも効いており、当初5分-10分ほどでbuild時間が完了するため大きな支障はありませんでした。

マルチアーキテクチャ問題

しかし、Appleがarm CPUのM1 Macをリリースしたことで問題が発生しました。
ご存知の通り、Docker ImageはCPUアーキテクチャごとにImageが異なります。またECSも同一のアーキテクチャのImageを使用しないとエラーが発生します。徐々にM1 MacへPCを変更するメンバーが増えており、早急に対応する必要がありました。

対処法① platform指定でCPUエミューレート

Dockerはplatformを指定することで、指定したplatformのCPUをエミュレートする機能があり、最初はこれで対応しました。

Dockerfile

FROM --platform=linux/amd64 node:18


また、CyberOwlのproduction用Dockerfileは、パッケージインストールなどをした独自Imageをマルチステージビルドのbuilderにするケースが多いです。この独自イメージは基本的にローカルで使用するImageになっています。

FROM <local用の独自イメージ> AS builder
...
FROM <production image>

COPY --from=builder ... 

そしてホストマシンが所持できるDocker ImageとImageのアーキテクチャは1:1の関係があります。
(例: node:14のイメージ1つに対して、arm64/amd64の両方はローカルにpullできない)

そのため、production Dockerfileのbuilderステージで使用されているローカル用Imageはデプロイ環境に合わせたamd64のアーキテクチャである必要がありました。

この方法で、M1のPCはamd64のCPUエミュレートすることでマルチアーキテクチャ問題は一旦解決しましたが、別の問題も発生しました。

それはエミュレータによる処理速度低下です。
具体的にはローカル環境でDBクエリやbuildの処理速度が低下しました。その結果、build時間が30分ほどになってしまいました。それだけでなく、通常のDBアクセスなども1リクエストが数秒かかるほどに遅延が発生してしまい、開発環境としては放置できない状態になってしまいました。

対処法② buildxを使ったリモートビルド

buildxはDockerが提供しているBuildKitの拡張です。buildxを活用することでマルチアーキテクチャイメージの作成とリモートビルドが可能です。

リモートビルドする場所はdocker context createでnodeを作成することで指定可能です。そして、docker buildx createでbuilderを作成することで、どのplatformでimageを作成するのか指定できます。

arm64用aliases.sh

function create-builder-for-m1-user() {
  # dockerオプションでec2をビルドマシンに指定
  # 事前にssh接続可能にする必要があります
  docker context create node-amd64 --description "amd64 on EC2" --docker "host=ssh://<ec2-hostname>"
  docker buildx create --name amd64-builder node-amd64 --driver docker-container --platform linux/amd64

  docker buildx create --name dbi-builder desktop-linux --driver docker-container --platform linux/arm64
  docker buildx create --append --name dbi-builder node-amd64 --driver docker-container --platform linux/amd64
}

上のコードでは2つのbuilderを作成しています。
amd64-builderはamd64 CPUでImage作成用です。デプロイ環境はamd64 CPUで動いているので、デプロイ用Imageをbuildする際に使用します。
dbi-builderはローカルで使用するamd64/arm64対応のマルチアーキテクチャImageをbuildする際に使用します。nodeに指定したdesktop-linuxはデフォルトで存在していて、ローカルマシンをbuild場所に指定しています。

逆にamd64マシンでマルチアーキテクチャ対応Imageを作成したい場合は、arm64 CPUのEC2にリモートビルドするaliasが用意されています。

amd64用aliases.sh

function create-builder-for-amd64-user() {
  docker context create node-arm64 --description "arm64 on EC2" --docker "host=ssh://<ec2-hostname>"
  docker buildx create --name amd64-builder desktop-linux --driver docker-container --platform linux/amd64

  docker buildx create --name dbi-builder desktop-linux --driver docker-container --platform linux/amd64
  docker buildx create --append --name dbi-builder node-arm64 --driver docker-container --platform linux/arm64
}

build実行コマンドは、作成したbuilderとdocker buildx bakeを組み合わせて実行してます。bakeコマンドはdocker-compose.yamlのx-bakeタグをオプションとして取り込むことが可能です。

docker-compose.yaml

version: "3.7"
services:
  prd:
    container_name: prd-container
    image: prd-container:latest
    build:
      context: .
      dockerfile: prd.Dockerfile
      args:
        - GIT_HASH=${GIT_HASH}
      x-bake:
        tags:
          - <ECR_URL>/prd-container:${GIT_HASH}
          - <ECR_URL>/prd-container:latest
        cache-from:
          - type=local,src=/tmp/.prd-build-cache
        cache-to:
          - type=local,dest=/tmp/.prd-build-cache
        platforms:
          - linux/amd64

prdのデプロイコマンド

docker buildx bake --builder amd64-builder prd --push

buildxのおかげて、簡単にマルチアーキテクチャを作成できるようになりました。ローカルの独自イメージもホストマシンと同じアーキテクチャでpull・buildできます。
また、M1ユーザーがデプロイする際はリモートビルドが走るのでエミュレートの必要がなくなりました。
これによりマルチアーキテクチャによる処理速度低下問題は解消することができ、デプロイ時間も元の5-10分ほどになりました。

リモートビルド待ち問題

これでローカル環境は改善されましたが、デプロイ時に課題がありました。
デプロイ先の複数のECSは全てamd64で構成されています。リモートビルド用のamd64インスタンスのEC2は1台稼働のみでした。複数のM1ユーザーが同時にデプロイするとbuildの処理待ちやEC2のメモリオーバーフローが発生するなどの問題がありました。そのため当初は極力同じタイミングでbuildしないように配慮する必要がありました。

対処法 AWS CodeBuild上でbuild自動化

この問題を解決するためにデプロイ時はリモートビルドを使用せず、amd64のCodeBuild上でbuildする手法を取りました。CodeBuildは同時に異なるbuildを行った場合も別々のインスタンスで並行に起動するため、複数のbuildがお互いに影響することはありません。これによりbuild待ちを解消することができます。

また、CodeBuildのyaml記法を覚えるには学習コストがかかるため、極力CodeBuildのbuildspec.yamlをシンプルに保ちつつ、既存のコマンドを活用するように意識しました。
最終的には以下のフローになりました。

  1. ローカルでgit archiveコマンドを実行し、commit済みのコードをzipファイルにまとめる
  2. zipファイルをS3にアップロード
  3. aws cliからcodebuildを起動
  4. codebuild上でdocker buildxでimage buildとpushを実行
  5. ECRにimage push後、既存のECRをトリガーにしたAWS CodePipelineが起動

buildspec.yamlも非常にシンプルです。

buildspec.yaml

version: 0.2
env:
  shell: bash
  variables:
    # Write value to alias as it will overwrite
    GIT_HASH: ""
    COMPOSE_SERVICE_NAME: ""
    REPOSITORY: ""
    SLACK_URL: ""
phases:
  pre_build:
    commands:
      - docker-buildx create --name amd64-builder default --driver docker-container --platform linux/amd64
    commands:
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <ECR_URL>
      - docker image pull <ECR_URL>/<IMAGE>/<TAG>:latest
      - GIT_HASH=$GIT_HASH docker-buildx bake
        --builder amd64-builder ${COMPOSE_SERVICE_NAME}
        --push
        --set ${COMPOSE_SERVICE_NAME}.cache-from=type=local,src=/mount/.prd-build-cache
        --set ${COMPOSE_SERVICE_NAME}.cache-to=type=local,dest=/mount/.prd-build-cache

マシンサイズを15GBに設定し、キャッシュなどを効かせた結果、安定して8分以内にbuildを完了することができました。
マシンサイズが大きい理由は、jsファイルのbundle時にメモリエラーが発生したためです。node.jsのヒープメモリを調整すれば、より小さいマシンで可能ですが、調査工数と実際の費用を天秤にかけ、マシンパワーで解決することにしました。
また、aws-cliで設定の上書きができるので、リポジトリごとに適切なマシンサイズにすることは可能です。

工夫した点

今回の実装で工夫した点などを紹介します。

CodeBuildのソースにGitHubを使用しなかった

CodeBuildとGitHubは連携可能です。しかしCodeBuild上でgit submoduleのcloneを簡単にできなかったため採用しませんでした。もしやるとしたらGitHubに登録したsshキーをCodeBuildに環境変数で渡すなどの処理が必要になったため、よりシンプルな仕組みで解決することにしました。

そこで、CodeBuildのソースをS3にし、ローカルでソースを作成・S3アップロードする仕組みを採用しました。
ソースの作成はgit archiveコマンドでcommit済みのコードのみをzipファイルにまとめました。submoduleもgit submodule foreachでsubmodule内部でgit archiveを実行し、1つのディレクトリに全てのコードを集約することが可能です。

function upload-source-zip() {
  local compose_service_name=$1
  local main_repo_name=$(basename "$PWD")
  local main_repo_path=$(pwd)
  mkdir code_zips merge_unzip
  git archive --format=zip --output="./code_zips/${main_repo_name}" HEAD &&\
  git submodule foreach 'repo_name=$(basename "$PWD") && parent_dir=$(dirname "$(pwd)") && git archive --format=zip --output="${parent_dir}/code_zips/${repo_name}" HEAD'

  unzip "code_zips/${main_repo_name}" -d "./merge_unzip/${main_repo_name}"
  for zip_path in code_zips/*; do
    repo_name="${zip_path#code_zips/}"
    if [ "$main_repo_name" != "$repo_name" ]; then
      # always overwrite directroy when submodule repo
      unzip -o ${zip_path} -d "./merge_unzip/${main_repo_name}/${repo_name}"
    fi
  done
  GIT_HASH=\$(git rev-parse --short HEAD)
  cd merge_unzip/${main_repo_name}
  zip -rA "${main_repo_path}/${GIT_HASH}" .
  cd ${main_repo_path}
  aws s3 cp ${GIT_HASH} "s3://<codebuild-source-s3>/${main_repo_name}/${compose_service_name}/${GIT_HASH}"
  rm -rf code_zips merge_unzip ${GIT_HASH}
}

また、同様のbuildフローを別プロジェクトに導入する際は、新たにCodeBuildやS3を作成する必要はありません。上記のzipソース作成コマンドと下記のcodebuild起動コマンドをコピペするだけなので簡単です!

function codebuild-deploy() {
  local COMPOSE_SERVICE_NAME=$1
  local REPO_NAME=$(basename "$PWD")

  upload-source-zip $COMPOSE_SERVICE_NAME &&\
  aws codebuild start-build --no-cli-pager \
    --project <CODEBUILD_PROJECT> \
    --source-location-override <S3_SOURCE_BUCKET>/${REPO_NAME}/${COMPOSE_SERVICE_NAME}/${GIT_HASH} \
    --environment-variables-override name=GIT_HASH,value=${GIT_HASH},type=PLAINTEXT \
      name=COMPOSE_SERVICE_NAME,value=${COMPOSE_SERVICE_NAME},type=PLAINTEXT \
      name=REPOSITORY,value=${REPO_NAME},type=PLAINTEXT \
      name=SLACK_URL,value=<SLACK_WEBHOOK_URL>,type=PLAINTEXT
}

DockerのCacheについて

CacheはAWS EFSとCodeBuildのローカルキャッシュ(Docker Layer Cache)を併用しました。
AWS EFSを採用した理由としては、buildxが出力するcache-to/cache-fromのキャッシュファイルをマウントしたかったからです。こちらはS3キャッシュのインポートを試しましたがうまくいきませんでした。
また、ローカルキャッシュはマシンが変わるとキャッシュが有効になりませんが、開発環境は頻繁にデプロイされるため有効であると判断し導入しました。

CodeBuildでAWS EFSをマウントする際は、CodeBuildのbuildインスタンスに存在しないディレクトリか、空のディレクトリである必要があります。ローカルではtmpディレクトリをcache出力場所にしてますが、tmpはすでにインスタンス上で使用されています。なのでbuildx bakeのsetオプションから出力先を上書きして実行しています。

docker-buildx bake
        --builder amd64-builder ${COMPOSE_SERVICE_NAME}
        --push
        --set ${COMPOSE_SERVICE_NAME}.cache-from=type=local,src=/mount/.prd-build-cache
        --set ${COMPOSE_SERVICE_NAME}.cache-to=type=local,dest=/mount/.prd-build-cache

また通常tmpディレクトリは、再起動すると空になります。しかしEFSにはそのような機能がありません。そのため1週間ごとのcronを設定したCodeBuildを別で用意し、キャッシュファイルの削除処理を行うことでキャッシュ肥大化を防いでいます。

CodePipelineにCodeBuildを組み込まなかった理由

既存のCodePipelineに変更を加えないことを意識しました。既存のCodePipelineはECRへのpushをトリガーにしています。今回のCodebuildは常にECR pushするため従来の仕組みと何も変わりません。CodePipelineに組み込む場合は、CodePipelineごとにCodeBuildを作成する必要があり、導入コストが若干高くなるため採用しませんでした。

まとめ

この記事ではCyberOwlのbuild環境の変遷とその改善手法について紹介してきました。
特にCodeBuildのsubmoduleについてはssh公開鍵を登録している方も多いと思います。是非参考になると嬉しいです!

Techブログ 新着記事一覧