DEVELOPER’s BLOG

技術ブログ

アジャイル開発を加速させるCircleCI×AWS ECSを活用した、テスト・デプロイの自動化

2023.10.19 Harumi Motono
AWS CI/CD SRE
アジャイル開発を加速させる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で行っています。

flow.png


ディレクトリ構成

.
├── .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 全体の流れ

  1. プロジェクトメンバーがGitHubにpushすると、CircleCIがトリガーされ、rspec jobのRSpecテストが実行されます。

  2. RSpecのテストが通ったらレビューを行います(人力)。これにパスしたら開発環境へのデプロイ用ブランチ ecs_deployにmergeします(人力)。

  3. ここで再度CircleCIが実行され、今度はrspec job実行後、build_image jobでAWS ECRにイメージが保存されます。

  4. イメージの保存が完了すると、最後にdeploy jobが実行され、ECRに保存されたイメージを利用してAWS ECSにアプリケーションがデプロイされます。

build image jobのPush Docker Imageで$CIRCLESHA1とlatest2つのタグをつけたイメージをpushしています。
latestタグがついたイメージをdeploy jobで使用しています。$CIRCLE
SHA1タグをつけるのはバージョン管理のためです。
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

関連記事

AWSのインフラコスト見積もりでの「この値なに?」をちょっと解説(AWS Fargate編)

目次 はじめに AWS Fargateの選択・入力項目 ・説明 ・ロケーションタイプを選択 ・リージョンを選択 ・オペレーティングシステム ・CPUアーキテクチャ ・タスクまたはポッドの数 ・平均期間 ・割り当てられたvCPU の量 ・割り当てたメモリ量 ・Amazon ECS に割り当てられたエフェメラルストレージの量 まとめ はじめに AWSのインフラコスト見積もりでおなじみのAWS Pricing Calculator、 リストから選択あるいは値を入

記事詳細
AWSのインフラコスト見積もりでの「この値なに?」をちょっと解説(AWS Fargate編)
AWS
分析は、会話でできる─AIエージェントで変わる顧客データの使い方

はじめに:顧客データ活用が進まない AIエージェントを利用した顧客データ活用環境(Amazon Bedrock) AIエージェントで顧客データを分析する まとめ:スモールスタートで始める顧客データ活用 1.はじめに:顧客データ活用が進まない 「顧客データをもっと活用したい」という声をよく耳にします。購買データ、会員データ、来店履歴、キャンペーンの反応率など、日々さまざまなデータが蓄積されていますが、それらを活用して顧客理解を深め、顧客体験

記事詳細
分析は、会話でできる─AIエージェントで変わる顧客データの使い方
AWS 生成AI
EC2・Fargateコスト最適化:Savings Plansとリザーブドインスタンスの使い分けガイド

はじめに Savings Plansとリザーブドインスタンスの基本 EC2とFargate(ECS/EKS)に対する割引プランの選び方 まとめ 1. はじめに EC2やFargate(ECS/EKS)を長期間運用する場合、Savings Plansやリザーブドインスタンスによる割引を適切に活用し、コストを抑えることが欠かせません。 本記事では、EC2・Fargate を対象に、Savings Plansとリザーブドインスタンスの基本を整理し、それぞれの特

記事詳細
EC2・Fargateコスト最適化:Savings Plansとリザーブドインスタンスの使い分けガイド
AWS
Fargate (ECS/EKS) × Savings Plansでコスト見積もりする方法

はじめに Fargate(ECS/EKS)のコスト最適化とSavings Plansの基本 Savings Plans適用後のFargate(ECS/EKS)の費用を計算する方法 まとめ 1. はじめに Fargate(ECS/EKS)のコストを最適化するには、長期運用を見据えてSavings Plansの割引を活用することが重要です。試算には多くの方が公式ツールのPricing Calculatorを利用していると思います。 しかし、Fargateにつ

記事詳細
Fargate (ECS/EKS) × Savings Plansでコスト見積もりする方法
AWS

お問い合わせはこちらから