CI/CD メモリ最適化のための努力
初めに
本記事はまだ変更に入る前の、概念的な部分を自分で悩んだことに対する記録である。
この内容通り適用することになると、結局CI/CD全般を切り替える作業になり、すぐはできない状態になるのでとりあえず概念だけ片付けている状態である。
この悩みは、どちらかというと、結局インスタンスのサイズを一回アップすることで解決はできる(もちろん長期的に見ると変更する方が費用的にメリットはある)感じであるので、とりあえずはインスタンスのサイズアップで処理する可能性が濃厚である。
なのでこの記事の内容はまだ検証はできていないということを心に留めて読んでいただけると助かる。
(もちろん変更できたら、新しく内容を適用する予定である)
といっても何とか今でもとりあえず動いてはいる状態だし、自分が作業するところも色々あるせいで、いつやるかは本当に未定である。ただこれをちょっと何とかして治したいと思うだけである。。
背景(と書いて、制約条件だと読む)
では今関わっているプロジェクトの状況を話してみよう。
- CI/CD用のインスタンスはt3.medium使用中。メモリーは4gb[1]
- gitlab CI/CD docker executor使用[2]
- gitlabは self-managed使用中
- 一つのEC2で6個のレポジトリのCICDを管理中
-> 現在活発に使っているプロジェクトのフロントエンド、バックエンドを除くと、残りのもののCI/CD使用率はそこまで高くはない
-> 今後プロジェクトが増えてもとりあえずは今のEC2インスタンスで管理を続ける予定である - 現在進んでいるプロジェクトCのバックエンドでCICD作業中に一部メモリーを強く使う作業がある(これも実はパフォーマンスを直接検証しないといけないなと思うのだが。。。とりあえず実際作業する時にやることにしよう。。)
- CI/CD過程で一回メモリー不足の問題発生 -> これは5が問題だったのか、それとも並列で実行される時のDockerが問題だったかはまだわからない。検証が必要である
まあ、正直にうちの会社がそこまでケチではないが、方法は知っておくと良いと思う。。
詳細状況
ではなにが問題だったか初めから見てみよう。
最初問題だったのはCI/CD上でのメモリー不足問題である。
まとまったログもない状態だったので(これも近いうちに設定しないと。。。)、単純にその時の状況を思い出してみると、
gitlab cicdをgitlab-runnerのconcurrent設定を3にした状態で同時に何個のCI/CDパイプラインが動いている状態だったと記憶している。[3]
であると、とりあえず問題は二つである。
- 同時実行しているパイプラインで使っている各DockerEngineのメモリーの総量が4gbを超えて、OOMが発生
- Cプロジェクトのバックエンドでの作業がメモリーを結構使って、他のパイプラインにも影響が及んだ
どちらもとりあえずDockerにメモリー制限をかけたら何とか解決ができる可能性はある。[4]
しかし2の場合になると、解決より途中で失敗してしまう可能性もある。
さあ、この状態で私が取れる戦略は何があるだろうか。
- gitlab-runnerでconcurrent1に戻して開発経験的には良くないがとりあえず問題なく動くように処理
- concurrentは3を維持してメモリー制限をかけてみる
-> メモリー使用率が高そうなバックエンド作業は、CI/CD環境で毎回行われることではない。特定トリガーがあるので、バックエンドの協力をもらわなければすぐ試してみるのも難しい - CI/CD過程でメモリーを減らしてみる方法を探してみる
ぐらいであると思う。
1番は短期的に既に適用している内容であり、2番はどっちかというとバックエンドがちょっと余裕ある時でしか確認できない(と言っても、多分大丈夫と言われると思うけど、とにかく)
なので、とりあえず3番を中心的にみようと思う。
バックエンドでメモリー負荷がかかる作業って?
現在今のプロジェクトの関連部分を絵で書いたら次のようになるのである。
これはtsv大容量ファイルアップロードのためのサーバレスAWS構造図である。
ここで問題になっているところはビルドステージ
である。
簡単に構造図を説明すると
- フロントでtsvをS3にアップロード
- s3アップロードを非同期でトリガー処理
- lambda実行
- ECS Fargateインスタンスでデータ処理
が目的で、
バックエンドではこの4番の実行のためのDockerイメージをビルドステージ
でビルドしてECRにpushする形になる。
正確には関連するCDK情報の変更があればイメージをビルドする時にメモリーを結構食う作業になり、CDK情報の変更がなければcache情報を使って普段問題がないということになる。
メモリー負荷がかかる時の区分はどうする?
最初に思い出したのはビルドステージの分離である。
つまり、その部分だけ分離してaws codebuildみたいなことで投げたりする方法を考えた。[5]
しかしその場合は今会社で使っているgitlabがself-managedになり、codebuildを使おうとするとcode commitを使うか、s3で管理をするかの方法しかなかった(githubなどを使うことに対しては許可が出ない雰囲気である)
code commitはもう支援終了になって、いつかサービス終了にもなる予定のもので、今使うのはできたが、ちょっと危ないなと思った。[6]
s3は逆に複雑性がすごくなる可能性があって、これも今の段階よりは本当に方法がない時に考えようと考えて後回しにした。
では結局はgitlab-cicd環境そのままで処理するしかないことである。
ではCDKの変更があるかどうかを比べてみるしかないが、これもできるのはできる。
パイプラインで以前コミットと現在コミットのdiffを比べて、その内容をアーティファクトで渡せば区別はできる[7]
例えばこのようにできると思う
if git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep -E "(Dockerfile|requirements\.txt|pyproject\.toml)"; then
echo "HEAVY_BUILD=true" >> build.env
echo "Heavy build detected - memory-intensive changes found"
else
echo "HEAVY_BUILD=false" >> build.env
echo "Light build detected - app code changes only"
fi
それで区別ができたらssm parameterみたいなことを利用してflag処理をするととりあえず区別はできると思う。[8]
それで区別をしたら?
ここからも実は問題になる。
区別できたら、その負荷がかかる作業を分離して他の作業をするべきなのだが、aws codebuildを使用し難しいというなら、他のCI/CDインスタンスを作って別途Runnerのタグをつけ、作業をするしかないと思う。
でもそれは結局費用がもっとかかることになり、メモリアップするとか、24時間動く別のインスタンスを使うことと同じ感じである。
では他の方法はないのか?実はあるのである。
Docker Autoscaler executor
gitlab cicdではDocker Autoscaler executorというものがある。[9]
元々はDocker machine executorというものだったみたいであるが、それはそろそろdeprecatedになり、Docker Autoscaler executorがおすすめされるみたいである。[10]
これは何かというと、多分autoscalerという名称で気付いたと思うが、ec2のautoscalerと同じものである。
簡単にいうと、gitlab-runnerがcicdのweb-hookをもらう時に追加のインスタンスが必要かを判断して新しいインスタンスを作ってその上でDockerを実行してBuildを行うことになる。
Instance Executorというものもあるが、レガシープロジェクトの対応も必要なのでDocker環境も入れる必要がある。[11]
というと、最初から新しいインスタンスを作ることになる。gitlab-runner config.tomlで設定したtagに合わせてtagを指定してciファイルを処理しておくと、gitlab-runner serviceが信号を受けてメモリー負荷がかかる作業をする時は大きいインスタンスを、それでなければ普通のものを使うことになる。
config.toml設定例
concurrent = 10
# 既存固定Runner
[[runners]]
tags = ["docker-fixed"]
executor = "docker"
[runners.docker]
memory = "3g"
# Auto Scaling Runner
[[runners]]
tags = ["docker-autoscaling"]
executor = "docker-autoscaler"
[runners.autoscaler]
plugin = "aws"
capacity_per_instance = 1
max_instances = 5
[runners.autoscaler.plugin_config]
instance_type = "t3.large"
region = "ap-northeast-1"
[[runners.autoscaler.policy]]
idle_count = 1
idle_time = "10m0s"
Docker Autoscaler executor 構造図
例えばこのようになるのである
stages:
- analyze
- build
- deploy
variables:
ECR_REPO: "123456789.dkr.ecr.ap-northeast-1.amazonaws.com/project"
IMAGE_TAG: "$ECR_REPO:$CI_COMMIT_SHA"
# 変更分析段階
analyze_changes:
stage: analyze
script:
# 変更されたファイル分析
- |
if git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep -E "(Dockerfile|requirements\.txt|pyproject\.toml)"; then
echo "HEAVY_BUILD=true" >> build.env
echo "Heavy build detected - memory-intensive changes found"
else
echo "HEAVY_BUILD=false" >> build.env
echo "Light build detected - app code changes only"
fi
# 分析結果をartifactsで渡し
- cat build.env
artifacts:
reports:
dotenv: build.env
expire_in: 1 hour
# Heavy Build: Auto Scaling Runner 使用
build_heavy:
stage: build
tags:
- docker-autoscaling # Auto Scaling Runner
rules:
- if: '$HEAVY_BUILD == "true"'
script:
- echo ":rocket: Starting heavy build with Auto Scaling Runner (t3.large)"
- aws ssm put-parameter --name "/project/deployment/status" --value "building" --overwrite
# メモリ余裕のあるビルド
- |
docker build \
--file Dockerfile.optimized \
-t $IMAGE_TAG .
- docker push $IMAGE_TAG
- aws ssm put-parameter --name "/project/deployment/image-tag" --value "$IMAGE_TAG" --overwrite
# Worker使用完了 - 自動削除
- echo ":white_check_mark: Heavy build completed, worker will auto-terminate"
dependencies:
- analyze_changes
# Light Build: 固定Runner使用
build_light:
stage: build
tags:
- docker-fixed # 既存固定Runner
rules:
- if: '$HEAVY_BUILD == "false"'
script:
- echo ":zap: Starting light build with fixed Runner (t3.medium)"
- aws ssm put-parameter --name "/project/deployment/status" --value "building" --overwrite
# Docker Cache活用で高速ビルド
- |
docker build \
--cache-from $ECR_REPO:latest \
-t $IMAGE_TAG .
- docker push $IMAGE_TAG
- aws ssm put-parameter --name "/project/deployment/image-tag" --value "$IMAGE_TAG" --overwrite
- echo ":white_check_mark: Light build completed using cache"
dependencies:
- analyze_changes
# 共通Deploy Stage
deploy:
stage: deploy
tags:
- docker-fixed # Deployは軽いので固定Runner使用
script:
- cd cdk
- npm ci
- cdk deploy --require-approval never
# ECS Task Definition更新
- |
aws ecs describe-task-definition \
--task-definition project-task \
--query taskDefinition > task-def.json
- |
jq --arg IMAGE "$IMAGE_TAG" \
'.containerDefinitions[0].image = $IMAGE' \
task-def.json > new-task-def.json
- |
aws ecs register-task-definition \
--cli-input-json file://new-task-def.json
# デプロイ完了状態設定
- aws ssm put-parameter --name "/project/deployment/status" --value "ready" --overwrite
- echo ":dart: Deploy completed with latest image: $IMAGE_TAG"
dependencies:
- analyze_changes
needs:
- job: build_heavy
optional: true
- job: build_light
optional: true
# ビルド統計収集 (オプション)
collect_metrics:
stage: deploy
tags:
- docker-fixed
script:
- |
if [ "$HEAVY_BUILD" == "true" ]; then
aws cloudwatch put-metric-data \
--namespace "TriPrice/CI" \
--metric-data MetricName=HeavyBuilds,Value=1,Unit=Count
echo ":bar_chart: Heavy build metric recorded"
else
aws cloudwatch put-metric-data \
--namespace "TriPrice/CI" \
--metric-data MetricName=LightBuilds,Value=1,Unit=Count
echo ":bar_chart: Light build metric recorded"
fi
dependencies:
- analyze_changes
when: always
allow_failure: true
他に顧慮するべきのどころ
もう一個考えてみるところがある。同時性の問題である。
例えばフロントでS3にアップロードしている途中にデプロイをしたり、デプロイ中にS3にアップロードされたりしたら?
実はこれはECS Fargateが解決してくれる。各タスクは隔離されるし、デプロイは基本ローリングデプロイになるから考えなくてもいい。[12](でもFargateがやってくれているというのは記憶しておくべきである)
まとめ
色々書いたが、正直に本当に簡単に考えるととりあえずは不便でもconcurrent1で処理しておくとか、それともインスタンスをt3.largeでアップしたら解決できるものである。ただ、最適化の方法を考えるとこんなこともできるのではないかなと思うので今後はやっておいたらどうかなと思っただけである。
この作業以外にも正直にDockerfileの最適化(multi-stage, レイヤーまとめ)、モニタリング設定を通じて測定を行う必要が先にあるので、この悩みはほんとに後回しになると思う。[13]
それじゃなくてももっと重要度高いものはあるので、なるべくそっちから優先するしかないよう状況である。。。
なので参考だけしてほしい。
-
AWS EC2 Instance Types - t3.medium specifications: https://aws.amazon.com/ec2/instance-types/t3/ ↩︎
-
GitLab Runner Docker executor documentation: https://docs.gitlab.com/runner/executors/docker.html ↩︎
-
GitLab Runner concurrent configuration: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section ↩︎
-
Docker memory limits configuration: https://docs.docker.com/config/containers/resource_constraints/ ↩︎
-
AWS CodeBuild documentation: https://docs.aws.amazon.com/codebuild/latest/userguide/welcome.html ↩︎
-
AWS CodeCommit service discontinuation announcement: https://aws.amazon.com/blogs/devops/how-to-migrate-your-aws-codecommit-repository-to-another-git-provider/ ↩︎
-
GitLab CI/CD variables and artifacts documentation: https://docs.gitlab.com/ee/ci/variables/ https://docs.gitlab.com/ee/ci/pipelines/job_artifacts.html ↩︎
-
AWS Systems Manager Parameter Store documentation: https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html ↩︎
-
GitLab Runner Docker Autoscaler executor documentation: https://docs.gitlab.com/runner/executors/docker_autoscaler.html ↩︎
-
GitLab Runner Instance executor documentation: https://docs.gitlab.com/runner/executors/instance.html ↩︎
-
Amazon ECS Fargate rolling deployment documentation: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-types.html ↩︎
-
Docker best practices for building images: https://docs.docker.com/develop/dev-best-practices/ ↩︎
Discussion