株式会社CyberOwl(以下、サイバーアウル)でエンジニアをしている佐藤です。
今回はCodeBuildとDocker buildxを組み合わせることでCI/CDを改善することができたのでその方法を紹介しようと思います。
サイバーアウルの従来のCI/CDのフローは以下のようになっています。
※buildをローカルで行っているのには理由があります。
CyberOwlのエンジニアチームは少数であり、CIシステムを導入することによる保守コストと、CIシステムのプロビジョニングやキャッシュ初期化などによるビルド実行の遅延から、このような部分的なCIを使用していました。
そして実行方法にはbuildの観点で以下のメリットがあります。
1についてはデプロイするローカルマシンが同じため、常にキャッシュを活用することが可能です。
2については、開発環境デプロイ時に役立つケースが多いです。全体通知時に他の人がcommitを含めてほしい場合に手元でbuildの中断が可能です。
最後に3については、サイバーアウルが提供しているPCのスペックが高いのが挙げられます。例えば私はMacBook Proの64GBが提供されており、他のメンバーも同等のPCを使っています。そのためクラウドで提供されているマシンを使わずとも高速にbuildできます。また手元でbuildできるのでお金がかからないのもメリットです。
このフローはマルチステージビルドやキャッシュも効いており、当初5分-10分ほどでbuild時間が完了するため大きな支障はありませんでした。
しかし、Appleがarm CPUのM1 Macをリリースしたことで問題が発生しました。
ご存知の通り、Docker ImageはCPUアーキテクチャごとにImageが異なります。またECSも同一のアーキテクチャのImageを使用しないとエラーが発生します。徐々にM1 MacへPCを変更するメンバーが増えており、早急に対応する必要がありました。
Dockerはplatformを指定することで、指定したplatformのCPUをエミュレートする機能があり、最初はこれで対応しました。
Dockerfile
FROM --platform=linux/amd64 node:18
また、サイバーアウルの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は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しないように配慮する必要がありました。
この問題を解決するためにデプロイ時はリモートビルドを使用せず、amd64のCodeBuild上でbuildする手法を取りました。
CodeBuildは同時に異なるbuildを行った場合も別々のインスタンスで並行に起動するため、複数のbuildがお互いに影響することはありません。これによりbuild待ちを解消することができます。
また、CodeBuildのyaml記法を覚えるには学習コストがかかるため、極力CodeBuildのbuildspec.yamlをシンプルに保ちつつ、既存のコマンドを活用するように意識しました。
最終的には以下のフローになりました。
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上で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
}
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に変更を加えないことを意識しました。既存のCodePipelineはECRへのpushをトリガーにしています。今回のCodebuildは常にECR pushするため従来の仕組みと何も変わりません。CodePipelineに組み込む場合は、CodePipelineごとにCodeBuildを作成する必要があり、導入コストが若干高くなるため採用しませんでした。
この記事ではCyberOwlのbuild環境の変遷とその改善手法について紹介してきました。
特にCodeBuildのsubmoduleについてはssh公開鍵を登録している方も多いと思います。是非参考になると嬉しいです!
※2023年11月9日時点