DEVELOPER’s BLOG

技術ブログ

CircleCIでdocker-composeを使ってテストからAWS ECSへのデプロイまでやってみた

2023.10.19 Harumi Motono
AWS CI/CD SRE
CircleCIでdocker-composeを使ってテストから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

関連記事

生成AIで架空飲食チェーン店のVOC分析やってみた

ご挨拶 AWS全冠エンジニアの小澤です。 今年の目標はテニスで初中級の草トーナメントに優勝することです。よろしくお願いいたします。 本記事の目的 本記事では、生成AIでVOC分析を行うことで得られた知見を共有したいと思います。 昨今、生成AIの登場など機械学習の進歩は目覚ましいものがあります。一方、足元では自社データの利活用が進まず、世の中のトレンドと乖離していくことに課題感を持たれている方も多いかと思います。また、ガートナーの調査(2024年1月)による

記事詳細
生成AIで架空飲食チェーン店のVOC分析やってみた
AWS SRE 機械学習
通信をすべてNAT Gatewayを通していませんか?棚卸しによる70%のコスト削減に成功!

目次 背景 原因究明 解決策 結果 背景  AUCでは、SRE活動の一環として、AWSコストの適正化を行っています。 (技術ブログ『SRE:コスト抑制のための異常値検知機構の実装』) コスト適正化における課題は、大きく分けて下記の4つです。 ①コストは月末にチェックしており、月中でコストが急激に上昇した場合発見が遅れてしまう。 ② 不要なリソースが放置されていたり、新たなリリースによって生じたコストを確認していない。 ③ AWSが提供するベスト

記事詳細
通信をすべてNAT Gatewayを通していませんか?棚卸しによる70%のコスト削減に成功!
AWS SRE 利用事例
Amazon Connectを使って占い案内IVRをつくってみた

目次 挨拶 Amazon Connectとは? 目的と経緯 仕様 作業手順 作業内容 最後に 挨拶 こんにちは、システム部の長森です。 皆様は 自動音声応答システム(IVR) を利用したことはありますか? IVRとは Interactive Voice Response の略で、架電時に音声が流れ、番号をプッシュすると適切なオペレーターまたは次の音声に繋がるシステムのことを指します。 企業への電話問い合わせ、商品の電話注文などによく使われているシステムなの

記事詳細
Amazon Connectを使って占い案内IVRをつくってみた
AWS 体験記
SRE:コスト抑制のための異常値検知機構の実装

目次 実装前の課題 採用した技術と理由 実装した内容の紹介 改善したこと(抑制できたコスト) 実装前の課題  SRE(Site Reliability Engineering:サイト信頼性エンジニアリング)とは、Googleが提唱したシステム管理とサービス運用に対するアプローチです。システムの信頼性に焦点を置き、企業が保有する全てのシステムの管理、問題解決、運用タスクの自動化を行います。 弊社では2021年2月からSRE活動を行っており、セキュリ

記事詳細
SRE:コスト抑制のための異常値検知機構の実装
AWS SRE 利用事例

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