DEVELOPER’s BLOG
技術ブログ
アジャイル開発を加速させるCircleCI×AWS ECSを活用した、テスト・デプロイの自動化
目次
- 挨拶
- CI/CD導入の背景
- 前提
- プロジェクト構成
- ディレクトリ構成
- .circleci/config.yml 全体の流れ
- 試行錯誤したところ
- 今後の展開
挨拶
CircleCIでdocker-composeを使ってテストからAWS ECSへのデプロイまでを自動化してみたので紹介します。
CI/CD導入の背景
参加しているプロジェクトではアジャイルを取り入れ、毎月リリースするようになりました。
毎月リリースがあるということは、それだけ機能改修、追加、削除があるということで、その分テストが必要になります。リリースの作業も毎月やってきます。時間がかかります。
そこで、CircleCIを導入しテスト工程とデプロイ工程、さらには2工程間の処理を自動化することで、工数を減らしつつ品質担保を実現しました。
前提
AWS ECSのサービス/クラスターの作成や、CircleCIの登録とSet up projectまで済んでいます。
AUCではVCSにGitHubを、CI/CDツールとしてCircleCIを利用しています。どちらもAUCが構築したAWS上で動いています。
技術ブログ『AWSを利用した弊社の開発環境』
プロジェクト構成
プロジェクトは、AWS ECS上に構築され、開発はDockerを用いてRuby on Railsで行っています。テストはRSpecで行っています。
ディレクトリ構成
.
├── .circleci
│ └── config.yml
├── Dockerfile
├── Dockerfile.ecs
├── docker-compose.ecs.yml
├── docker-compose.rspec.yml
├── docker-compose.yml
├── ecs-service.json
├── spec
docker-composeファイルは、開発用、CirlceCIのRSpec実行ジョブ用、CirlceCIのAWS デプロイジョブ用の3つに分けています。それぞれで無駄なイメージをプルしたり、不要なキャッシュを設けたりするのを省くためです。Dockerfileも同じように不要な設定を省く目的で2つに分けています。
全体の流れを紹介するために.circleci/config.ymlとデプロイに使うdocker-compose.ecs.ymlを載せています。docker-compose.rspec.ymlやDockerfileは長くなるので割愛しました。
.circleci/config.yml
version: 2.1
jobs:
rspec:
docker:
- image: cimg/python:3.11.5
working_directory: ~/repo
steps:
- checkout
- setup_remote_docker
- restore_cache:
keys:
- v1-dependencies-{{ checksum "Gemfile.lock" }}
- v1-dependencies-
- run:
name: Create Docker Network
command: docker network create --subnet=172.19.0.0/16 itid_group
- run:
name: Start Docker Compose services
command: docker-compose -f docker-compose.rspec.yml up --build -d
- run:
name: Create DB
command: docker exec -it app_web bundle exec rake db:create RAILS_ENV=test
- run:
name: Run RSpec tests
command: |
docker exec -it app_web bundle exec rspec
docker-compose down
- save_cache:
paths:
- ./vendor/bundle
key: v1-dependencies-{{ checksum "Gemfile.lock" }}
build_image:
docker:
- image: cimg/python:3.11.5
steps:
- checkout
- setup_remote_docker
- run:
name: Install AWS CLI
command: |
sudo pip install awscli
- run:
name: Build image
command: |
$(aws ecr get-login --no-include-email --region ap-northeast-1)
docker-compose -f docker-compose.ecs.yml build --build-arg RAILS_MASTER_KEY=${RAILS_MASTER_KEY} --build-arg RAILS_ENV=production
docker tag project_web ${ECR_DOMAIN}/app-web:$CIRCLE_SHA1
docker tag project_web ${ECR_DOMAIN}/app-web:latest
- run:
name: Push Docker Image
command: |
docker push ${ECR_DOMAIN}/app-web:$CIRCLE_SHA1
docker push ${ECR_DOMAIN}/app-web:latest
deploy:
docker:
- image: cimg/python:3.11.5
steps:
- run:
name: Install AWS CLI
command: |
sudo pip install awscli
- run:
name: Migration
command: |
aws ecs run-task \
--region ap-northeast-1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=["subnet-*****************", "subnet-*****************", "subnet-*****************", "subnet-*****************"],securityGroups=["********************"],assignPublicIp=ENABLED}" \
--cluster app-ecs-cluster --task-definition app-migrate
- run:
name: Deploy
command: |
aws ecs update-service --cluster app-ecs-cluster --service app-service --task-definition app --force-new-deployment
workflows:
version: 2
test:
jobs:
- rspec
- build_image:
filters:
branches:
only: ecs_deploy
- deploy:
requires:
- build_image
filters:
branches:
only: ecs_deploy
docker-compose.ecs.yml
version: "3.9"
services:
web: &web
container_name: 'app_web'
build:
context : .
dockerfile: Dockerfile.ecs
command: bash -c "yarn install &&
rm -f /app/tmp/pids/server.pid &&
freshclam &&
service clamav-daemon start &&
bundle exec rails s -p 3000 -b '0.0.0.0'"
ports:
- "3000:3000"
tty: true
stdin_open: true
environment:
REDIS_URL: 'redis://localhost:6379/1'
networks:
- app_group
- default
# 毎回ビルドする必要がないため、別個ECRにpushした
# redis:
# container_name: 'app_redis'
# image: redis:7-alpine
# ports:
# - "6379:6379"
# command: redis-server --appendonly yes
sidekiq:
<<: *web
container_name: 'app_sidekiq'
ports:
- "5001:3000"
command: bundle exec sidekiq -C config/sidekiq.yml
.circleci/config.yml 全体の流れ
プロジェクトメンバーがGitHubにpushすると、CircleCIがトリガーされ、rspec jobのRSpecテストが実行されます。
RSpecのテストが通ったらレビューを行います(人力)。これにパスしたら開発環境へのデプロイ用ブランチ ecs_deployにmergeします(人力)。
ここで再度CircleCIが実行され、今度はrspec job実行後、build_image jobでAWS ECRにイメージが保存されます。
イメージの保存が完了すると、最後にdeploy jobが実行され、ECRに保存されたイメージを利用してAWS ECSにアプリケーションがデプロイされます。
build image jobのPush Docker Imageで$CIRCLESHA1とlatest2つのタグをつけたイメージをpushしています。
latestタグがついたイメージをdeploy jobで使用しています。$CIRCLESHA1タグをつけるのはバージョン管理のためです。
CircleCI 定義済み環境変数
deploy jobでは、まずapp-web:latestイメージを用いてDBのマイグレーションを行っています。
その後、Deployで同じapp-web:latestイメージを用いてapp-deployタスクを実行し、app-ecs-clusterクラスターにアプリケーションをデプロイします。
試行錯誤したところ
docker-compose を利用したビルド
CircleCI導入当時、.circleci/configは直にrubyのイメージをプルして直に環境構築することを想定していましたが、開発環境はもともとDockerで構築していたため、CircleCIもdocker-composeを利用して構築することにしました。
管理するコードを減らし、ローカルとCircleCI環境の環境差がrspecテストに影響しないようにできました。
docker:
- image: cimg/ruby:3.1.2-browsers
steps:
- checkout
# ↓各jobのイメージはdocker-composeをインストール済みの仮想マシンを使用するよう変更
docker:
- image: cimg/python:3.11.5
steps:
- checkout
# -setup_remote_dockerでリモート Docker環境をアクティブ化。これでdockerコマンドが使えるようになる
- setup_remote_docker
RedisのECRイメージだけ別でpushした
ECRのイメージは本当はapp-web1つにまとめられると良かったのですが、docker-compose.ymlを利用したビルドではRedisはRedis単独のイメージとしてpushされておりapp-webイメージ、redisイメージの2つができていました。それに気づかずデプロイを進め、Redisが参照できないとエラーが出ていました。
生成されたredisイメージを使うことも考えましたが、プロジェクトで使用しているRedisは現在バージョン7で固定しており、毎回のイメージビルドは不要です。
そこで、最初に別個ECRへredis-7イメージをpushしてそれを使用するようにしました。
docker-compose.ecs.ymlでredisの部分をコメントアウトしているのはそのためです。Redisのバージョンアップがある際にはまたpushする予定です。
rspec jobのDB接続がうまくいかない
DBセットアップ前にmigrateしようとしてエラーが発生したため、migrateとseedはrspecコマンド実行時にspec/rails_helper.rb内で行われるようにしました。
エラーメッセージ
rails aborted!
ActiveRecord::NoDatabaseError: We could not find your database: app_test. Which can be found in the database configuration file located at config/database.yml.
spec/rails_helper.rb
# 一部抜粋
RSpec.configure do |config|
config.before(:suite) do
Rails.application.load_tasks
# migrateにはridgepole gemを使用
Rake.application['ridgepole:apply'].invoke
Rails.application.load_seed
end
end
rspec jobのsubdomain付きURLへのアクセスでNet::ReadTimeoutエラー
ローカルでのテストはパスしていたのですが、CircleCI上ではsubdomain付きURLへのアクセス箇所で失敗していました。
プロジェクトではRSpecのテストでWEBブラウザを操作するためにSeleniumを使用しています。
調べてみると、Webdriverが内部的にいろいろなドライバーと通信するのにHTTPを使用していて、その通信にはRubyの標準ライブラリであるNet::HTTPが使われているようです。このNet::HTTPのデフォルトタイムアウトが60秒になっていてそこでひっかかっているようでした。
Selenium ドキュメント
Failures:
1) 管理画面にログインする
Failure/Error: visit login_path
Net::ReadTimeout:
Net::ReadTimeout with #
Seleniumのドキュメントの通りread_timeoutを設定するとうまく通りました。 spec/support/capybara.rb
Capybara.register_driver :remote_chrome do |app|
# 一部抜粋
client = Selenium::WebDriver::Remote::Http::Default.new
client.read_timeout = 240
end
今後の展開
CI/CDを導入してテストからデプロイまで一気通貫で行えるようになりました。今後の展開として以下に上げる点を改善していきたいです。
latestタグ運用の廃止
deploy jobのDeploy、Migrationでlatestタグのついたイメージを使用しているのは、コミットハッシュのタグを使う運用に変更したいです。
具体的には、ECSのタスク定義でlatestタグのついたイメージを使用するようにしていますが、毎回コミットハッシュのタグ付きのイメージを使用するようタスク定義を書き換えるようにします。
そうすることで、障害発生時の切り戻しが迅速に行えるようになるのでベストプラクティスのようです。
デプロイの前に手動承認のひと手間を加える
CircleCIには、[Approval (承認)] ボタンがクリックされるまでジョブの実行を待つ手動承認の機能があります。
現在のconfigファイルではcommitしたらすぐにテストからデプロイまで走ってしまいますが、手動承認を実装してデプロイは実行者の承認のもと行うようにしたいです。
マイグレーション前のDBバックアップ
プロジェクトではDBにRDSを使用しています。マイグレーションの前にスナップショットを撮るようにして、バックアップするようにしたいです。
Net::ReadTimeoutエラーの解消方法
今回はread_timeout = 240として解消しましたが、240秒はちょっと長すぎるので、根本原因を探り改善したいです。
Image by Freepik