ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

AWS CodeBuildでRailsアプリのCIを実行する

こんにちは。ima1zumiです。

私たちの使っていたCI用の物理サーバーがある日突然ハード障害で壊れてOSが起動しなくなり、OSのクリーンインストールをすることになりました。なんとか復旧しCIを実行していたJenkinsの設定をし直し使えるようにはなりましたが、ハード障害以外にもソフトな問題もありました。開発規模とチームが大きくなることにより、直列で実行しているCIの待ち時間が最大6時間程度になっていたのです。CI待ちで開発効率にも悪影響がありました。

そこでハードとソフトの問題を解決するため、AWS CodeBuildでRailsアプリのCIを実行できるようにしました。

この記事では、CodeBuildを使ってRailsアプリのCIを実行できるようにするための設定や、工夫した点を書きました。長くなってしまいましたが、何かの役に立てば幸いです。

CI実行のための条件

私たちの開発しているRailsアプリは10年近く開発が続けられており、10,000ケース近くのテストが実行されています。また、E2Eテストが多いという特徴があります。メモリ128GB、CPU18コアのマシンで全テストを16並列で実行するのに1時間10分ほどかかっていました。

テストに必要なソフトウェアとしては、大きく

  • Ruby on Rails
  • OracleDatabase
  • Solr
  • Redis
  • Chrome

があります。

私たちのアプリケーションは開発環境とCIではDockerを使ってきました。CodeBuildでも同様にDockerを利用できるようにします。

CodeBuildの選定理由

セキュリティ要件上選択しやすかったということと、求めるスペックが満たせそうという2点から選択しました。

なお、AWSの他のCodeシリーズのサービスは利用していません。CIのみの利用です。

ソースコードのホスティングサービスはGitHub Enterprise Server(以下GHES)を利用しています。

構成図

このような構成で構築しました。

CI環境で実行できるdocker-compose.ymlを作る

基本的には開発環境で使っているdocker-compose.ymlからCI実行に必要な箇所だけ抜き出してきました。

私たちのアプリケーションを表す名前として、ここでは texas というコードネームをつけています。

docker-compose-ci.yml

version: "3.9"

services:
  redis:
    container_name: redis
    image: redis:latest
    ports:
      - target: 6379
        published: 6379
        mode: host
  app:
    container_name: app
    image: *.dkr.ecr.ap-northeast-1.amazonaws.com/texas:app
    shm_size: 8gb # メモリ不足でテストが落ちるため追加
    volumes:
      - .:/texas
      - ./vendor/bundle:/usr/local/bundle
    ports:
      - target: 3000 # rails server
        published: 3000
        mode: host
      - target: 8983 # solr
        published: 8983
        mode: host
    environment:
      - ORACLE_CONNECT_IDENTIFIER=//oracle:1521/PDB1
      - REDIS_CONNECT_IP=redis
      - CI=true
      - RAILS_ENV=test
    depends_on:
      - redis
      - db
  db:
    container_name: oracle
    image: *.dkr.ecr.ap-northeast-1.amazonaws.com/texas:oracle
    volumes:
      - ./oracle_data:/opt/oracle/oradata
    ports:
      - 8080:8080
      - 1521:1521
    shm_size: 4g

Docker imageをECRに置いていることと、いくつかのボリュームをバインドマウントでホスト側に配置していることがポイントです。

意図的にバインドマウントを利用しています。これは後ほど説明します。

Docker imageを作る

appのimageとOracleDatabaseのimageを作ります。

app image

ほとんど開発環境と同じDockerfileを使いました。

Node.js, Ruby on Rails, Solr, Chrome を実行できる環境を作ります。これらはパッケージをインストールすればよいだけですが、ハマった点を2点紹介します。

LANGは正しく設定する

LANGを正しく ja_JP.UTF-8 に設定するためには対応するパッケージをインストールする必要があります。

パッケージをインストールせずに LANG ja_JP.UTF-8 のみを設定するとlocaleが正しく設定できないためRubyのスクリプトエンコーディングがUTF-8にできなくなります。私の環境では、US-ASCIIになりました。

localeが設定できているかどうかはlocaleコマンドの結果でわかります。

$ locale
locale: Cannot set LC_CTYPE to default locale: No such file or directory
locale: Cannot set LC_MESSAGES to default locale: No such file or directory
locale: Cannot set LC_ALL to default locale: No such file or directory
(snip)

そのため、日本語を含むRubyスクリプトや日本語を含むファイルを読み込むようなRubyスクリプトをRailsを介さずに実行すると、RubyのスクリプトエンコーディングがUS-ASCIIとなり、US-ASCIIとして不正なバイト列が含まれるため例外が発生して失敗します。1

また、私たちのアプリケーションではLANGを C.UTF-8 にするとRailsのi18nが英語になり、エラーメッセージが英語化されテストに失敗しました。

Chromeはappのimageに入れた

selenium の配布するimageを使っている場合、ポート番号が解決できず並列でテスト実行ができなかったためappのimageに入れました。

dockerfile

最終的にDockerfileは以下のようになりました。

FROM node:16.14.0-slim AS node

WORKDIR /texas

COPY package.json yarn.lock /texas/
RUN yarn

FROM ruby:3.0.4-slim AS app

RUN apt-get update -qq && \
  apt-get install -y --no-install-recommends build-essential curl git gnupg imagemagick libaio1 tzdata unzip wget vim && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/*

# LANGを正しく設定しないとRubyのスクリプトエンコーディングが設定できない、i18nのlocaleが英語になるなどの影響があるため正しく日本語環境に設定する
RUN apt-get update && \
  apt-get install -y locales && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/* && \
  localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8

ENV TZ Asia/Tokyo
ENV LANG ja_JP.UTF-8

# Install Chrome
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add && \
  echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \
  apt-get update -qq && \
  apt-get install -y google-chrome-stable libnss3 libgconf-2-4 fonts-ipafont-gothic && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/*

# Install ChromeDriver
RUN CHROMEDRIVER_VERSION=`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE` && \
  curl -sS -o /tmp/chromedriver_linux64.zip http://chromedriver.storage.googleapis.com/$CHROMEDRIVER_VERSION/chromedriver_linux64.zip && \
  unzip /tmp/chromedriver_linux64.zip && \
  mv chromedriver /usr/local/bin/

# Install Oracle JDK for Solr
# ref: https://docs.datastax.com/ja/dse/5.1/dse-dev/datastax_enterprise/install/installJdkDeb.html
WORKDIR /usr/lib/jvm
ADD docker/jdk/jdk-*-linux-x64.tar.gz /usr/lib/jvm
RUN JAVA_PATH="/usr/lib/jvm/$(ls /usr/lib/jvm)/bin/java" && \
  update-alternatives --install "/usr/bin/java" "java" "${JAVA_PATH}" 1 && \
  update-alternatives --config java

# Setup for ruby-oci8
# ref: https://github.com/kubo/ruby-oci8/blob/master/docs/install-instant-client.md
WORKDIR /opt/oracle
COPY docker/instantclient/*.zip /opt/oracle/
RUN unzip -qq '*.zip' && \
  rm /opt/oracle/*.zip && \
  mv `ls /opt/oracle/ | grep instantclient` instantclient && \
  ln -s /opt/oracle/instantclient/sqlplus /usr/local/bin/sqlplus
ENV LD_LIBRARY_PATH /opt/oracle/instantclient

WORKDIR /texas

# Copy node, yarn binary files and installed npm packages.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /opt/yarn*/ /opt/yarn/
RUN ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn
COPY --from=node /texas/node_modules/ /texas/node_modules/

RUN gem install --no-document bundler:2.3.4

ENV TEXAS_DOCKER 1

OracleDatabase image

OracleDatabaseをDockerで動かすには2つの方法があります。

  • (1) 公式で配布されているイメージを使う
  • (2) 公式で配布されているDockerfileを元にビルドする

(1) の場合、ビルドしなくてよいので楽なのですが配布されているイメージをpullするためにOracleのアカウントでログインする必要があります。

Oracle Container Registry

ローカル環境なら個人のアカウントで一度ダウンロードしてしまえば使えるので良いのですが、CodeBuildで使うためにはCodeBuild用の共有アカウントを準備する必要があります。

ローカル環境ではもともと手作業でビルドしたイメージを利用していること、なるべくアカウント管理をしたくなかったため今回は(2)の公式で配布されているDockerfileを元にビルドし、それを使うことにします。

OracleのDockerfileはこのリポジトリにまとまっています。

oracle/docker-images: Official source of container configurations, images, and examples for Oracle products and projects

OracleDatabaseはここにあります。READMEにビルド方法が、FAQ.mdによくあるQAが書いてあります。

docker-images/OracleDatabase/SingleInstance at main · oracle/docker-images · GitHub

ECRにimageをpushできるようにする

プライベートリポジトリを作成する - Amazon ECR の手順に従いECRにプライベートリポジトリを設定します。今回はtexasという名前のリポジトリとしました。

また、ECRにimageをpushするために必要なIAM ポリシーを付与したIAM ユーザーを作成しました。Identity Centerを利用できるとよかったのですが私たちの環境では利用できず、ECRにpush, pullする専用のIAM ユーザーを利用してポリシーをつけました。ここには本番用のimageは入っておらず開発用のみのため、そこまで厳密に管理しなくても良いと判断しています。

Docker イメージをプッシュする - Amazon ECR

ローカルではIAM ユーザーのアクセスキーとシークレットアクセスキーをできるだけ安全に保管したいためaws-vaultを使ってアクセスキーとシークレットアクセスキーを保存しました。

CI向けにはAWS Systems ManagerのParameter Storeに保存し、CodeBuildのサービスロールにSSMへアクセスするポリシーを付けて取得できるようにしました。この記事を書いていて気づいたのですが、ECRのプライベートリポジトリポリシーでアクセス制御を設定したほうが良かったかもしれません。 プライベートリポジトリポリシー - Amazon ECR

これでCI上で動かしたいdocker-compose-ci.ymlはできました。

CodeBuildから固定IPアドレスでGHESにアクセスできるようにする

私たちの利用しているGHESはアクセスできるIPアドレスを制限しています。

CodeBuildからソースコードを取得できるよう、CodeBuildからGHESにアクセスする際に固定IPアドレスを付与する必要があります。CodeBuildで固定IPアドレスを利用するためには、NATゲートウェイかNATインスタンスを利用します。ここではCodeBuildで都度利用したいため、NATゲートウェイにElastic IPを割り当ててそれをCodeBuildのprivate subnetから利用します。VPC, Subnet, Security groupsは必要に応じ設定します。

Amazon Virtual Private Cloud での AWS CodeBuild の使用 - AWS CodeBuild

ちなみに、VPCを使う場合はドキュメントにある通りCodeBuildのローカルキャッシュは利用できません。ローカルキャッシュを設定しても警告などはでず、ただキャッシュが使えない状態になります。原因に気づきにくいので注意してください。

CodeBuildのプロジェクトを作る

ではCodeBuildの設定をしていきましょう!

Project configuration

CodeBuildのプロジェクト名を入力します。この名前は後から変更できません。

Source

ソースコードプロバイダを設定します。

GitHub Enterpriseの場合はPATを使って認証する必要があります。必要な権限をつけたPATを作成しましょう。個人のアカウントでPATを生成すると他の人が変更できなくなるため、共有のアカウントで生成したほうがよいと思います。

Primary source webhook events

ここにチェックを入れるとpushするたびにCodeBuildが実行されます。

オープンソースなどパブリックなリポジトリでは注意してください。

Environment

実行環境を設定します。今回はdocker-compose上でテストを実行するため、docker-composeが使えれば何でも良いです。UbuntuのStandard:7.0を選びます。

Privilegedは設定しないとdocker-composeが動かないのでチェックを入れます。

Service Roleは必要に応じ設定します。

Additional configuration

Timeout, GitHub Enterprise サーバーの公開鍵、VPC、Compute typeなどを設定します。

Certificate

GHESの公開鍵を入れたS3のbacketとObject keyを指定します。

VPC

先程設定したVPC, Subnet, Security groupsを設定します。

Compute type

ここで設定したスペックでCodeBuildが実行されます。

Buildspec

リポジトリ管理するため後で作成します。

Batch configuration

バッチ実行したい場合は設定しますが、今回は設定しません。

Artifacts

今回はCIを実行するだけで成果物はないため、設定しません。

Logs

CloudWatch logsに保存しておきます。

ここまで設定できれば、Create build projectを押すとCodeBuildのプロジェクトが作成されます!

ここからは実際にCIを実行するために、CIで実行したいコマンドをbuildspec.ymlという設定ファイルに書いていきます。

buildspec.ymlを作る

最終的にこんなbuildspec.ymlを作りました。1つずつ見ていきましょう。

version: 0.2
env:
  parameter-store:
    AWS_ACCESS_KEY_ID: /texas/development/ecr_access_key_id
    AWS_SECRET_ACCESS_KEY: /texas/development/ecr_secret_access_key

phases:
  pre_build:
    commands:
      - git show --no-patch
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin *.dkr.ecr.ap-northeast-1.amazonaws.com
      - docker-compose -f docker-compose-ci.yml pull --quiet
      # NOTE: ./oracle_dataディレクトリがない場合に作成します。
      # oracle_dataはS3キャッシュの対象のため、S3キャッシュが存在する場合はCodeBuildにより自動で作成されます。
      # S3キャッシュが消えた場合、このコマンドを実行しないとoracle_dataは存在せずchmodに失敗し、テストが失敗します。
      # そのため以下の`if [ ! -d ./oracle_data ]; then mkdir oracle_data; fi`は消さないでください。
      - if [ ! -d ./oracle_data ]; then mkdir oracle_data; fi
      # NOTE: ホストのrootが作成したoracle_dataディレクトリと配下のファイルをOracleコンテナ内のoracleユーザが読み書きできるよう、権限を追加します。
      - chmod -R a=rwx oracle_data
      # NOTE: S3キャッシュの対象で自動で作成されますが、S3キャッシュが消えた場合に再作成します。
      - touch ./tmp/parallel_runtime_rspec.log
  build:
    commands:
      - docker-compose -f docker-compose-ci.yml run app bin/ci
    finally:
      # NOTE: runしたままCodeBuildを終了するとOracleのredoファイルが壊れるため、downで落とします。
      - docker-compose -f docker-compose-ci.yml down
      - git show --no-patch
      - bin/ci_show_failed_examples

cache:
  paths:
    - './tmp/parallel_runtime_rspec.log'
    - './vendor/bundle/**/*'
    - './oracle_data/**/*'

env

SSMからECRにアクセスするためのアクセスキーとシークレットアクセスキーを取得しています。

env:
  parameter-store:
    AWS_ACCESS_KEY_ID: /texas/development/ecr_access_key_id
    AWS_SECRET_ACCESS_KEY: /texas/development/ecr_secret_access_key

前述したように、ECRの プライベートリポジトリポリシー - Amazon ECR でアクセス制御を設定したほうがよかったかもしれません。

cache

ここの設定が高速化に最も寄与しました。

どのファイル、ディレクトリをキャッシュしたいかを設定します。ローカルキャッシュ、S3キャッシュなどどうキャッシュするかはCodeBuildそのもののconfigurationで設定します。繰り返しになりますが、VPC利用時はローカルキャッシュは利用できません。

ここではVPCを利用しているためS3キャッシュを設定しています。

paths 以下に書いたファイル、ディレクトリをS3にアップロード、ダウンロードしてキャッシュしています。

cache:
  paths:
    - './tmp/parallel_runtime_rspec.log'
    - './vendor/bundle/**/*'
    - './oracle_data/**/*'

キャッシュを設定する前に、CodeBuildを設定してCIを実行する上で速度上問題になったことが2点ありました。

Oracleのデータ部のキャッシュ

1つは、Oracleの初回起動に時間がかかるということです。私たちのプロジェクトではCompute typeを145 GB memory, 72 vCPUsに設定していますがそれでもOracleの初回起動には10〜20分ほどかかります。2 Oracleが起動しないとテストが実行できないため、ボトルネックになっていました。

それを解消するためにOracleのデータ部 /opt/oracle/oradata をキャッシュしました。キャッシュするためにいくつか手を入れています。

rootが作成したディレクトリをOracleユーザが触れるようにする

CodeBuildは基本的にrootユーザが操作しています。キャッシュディレクトリもrootが作成します。ですがOracleDatabaseのimageの中ではOracleユーザが使われているため、権限不足でrootが作成したディレクトリが読み書きできませんでした。docker-composeでバインドマウント以外の方法、例えばボリュームマウントを使えば権限の問題は解消するようなのですが、buildspec.ymlにはcacheとしてホスト側の相対パスを書きたいため、ホスト側のパスとコンテナ側のパスを結び付けられるバインドマウントを使う必要がありました。3

rootが作成したディレクトリをホスト側には存在しないDocker内のユーザで読み書きできるようにする場合、2つ方法があります。

1つはDocker内のOracleユーザのuid, gidを調べてそのuid, gidに対象のディレクトリを読み書きする権限を与える方法です。これはOracleDatabaseのFAQにも記載があります。4 私の場合は少し試してみてうまくいかなかったため別の方法を取りました。

もう1つはすべてのユーザーに対象のディレクトリを読み書きする権限を与える方法です。乱暴な解決方法ですがコマンド1回で実行できることと、CodeBuild環境は独立しており他のユーザが存在しないためこの方法を取りました。chmod -R a=rwx path/to/dir で実行できます。このため、buildspec.yml で chmod -R a=rwx oracle_data でホスト側の oracle_data にすべてのユーザーに読み書きできる権限を与えています。

ただ、このコマンドはディレクトリが存在していないと失敗します。buildspec.ymlのcacheは指定がある場合にはbuildspec.ymlが実行される前にディレクトリを作成してくれますが、cacheの指定がない場合にはディレクトリは存在せず、このコマンドは失敗します。buildspec.ymlはexit codeが1以上になるとその場で失敗します。何らかの理由でcacheを削除した場合に chmod -R a=rwx oracle_data で必ず失敗してCIが途中で落ちてしまうようになります。これを避けるため、if [ ! -d ./oracle_data ]; then mkdir oracle_data; fi でホストの oracle_data が存在しない場合は mkdir で作成して、 cacheがなくても chmod -R a=rwx oracle_data が失敗しないようにしています。

docker-compose downでOracleDatabaseを停止する

キャッシュはCodeBuildの実行が終わった後にS3にアップロードされますが、その際にOracleDatabaseが起動したままだと壊れたRedoファイルがアップロードされます。次に実行したCIが壊れたRedoファイルを利用して実行されてOracleDatabaseの起動に必ず失敗し、CIが実行できなくなります。

OracleDatabaseを正しく終了してからキャッシュをアップロードできるよう、buildspec.ymlのfinallyでCIが成功しても失敗しても docker-compose down を実行しています。

これらの対策を行うことで、OracleDatabaseの初回起動時間10〜20分を1秒に削減できました。

parallel_testsの各並列の実行時間にばらつきがある

parallel_testsは並列でテストを実行してくれるgemですが、最初にすべてのテストケースをケース数ベースで割り振ってから実行するため、偏りが生じます。たとえばあるプロセスではmodel specばかりだったので3分でテストが終わったが別のプロセスではE2Eテストが多かったので30分かかったというようなことが発生します。

偏らないようにするため、./tmp/parallel_runtime_rspec.logをキャッシュしてこれを利用して実行時間ベースで分割して並列テストを実行するようにしました。平均で20分程度になり、偏りによるテスト時間の増加が抑えられました。

pre_build

git show --no-patch

どのcommitでCodeBuildを実行したか分かりやすくするために実行しています。

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin *.dkr.ecr.ap-northeast-1.amazonaws.com

CLIでECRにログインします。ログインするための認証情報は AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY から取得します。

docker-compose -f docker-compose-ci.yml pull --quiet

必要なimageをpullします。 --quiet をつけないとログがプログレスバーでいっぱいになってしまうため、つけています。

キャッシュの手当

上述したように、oracle_dataのディレクトリの存在チェックと権限付与を行っています。

また、./tmp/parallel_runtime_rspec.logが存在しないと並列テスト実行前に失敗するため./tmp/parallel_runtime_rspec.logが存在しないときには作成するようにしています。

build

docker-compose -f docker-compose-ci.yml run app bin/ci

bin/ci というCI用のシェルスクリプトを実行しています。bundle installbundle exec rubocop など。ここでは割愛しますが、parallel_tests gemを使って20並列でテストを実行しています。

finally

buildが成功しても失敗しても実行されるフェーズです。

docker-compose -f docker-compose-ci.yml down

上で説明した通り、Redoファイルが壊れないようにdownでOracleDatabaseを停止させています。

git show --no-patch

どのcommitで実行したかログの末尾からでも分かりやすいようにしています。

bin/ci_show_failed_examples

並列テストのどのテストが失敗したかをまとめてレポートしています。

#!/bin/bash

# bin/ci で paralell_tests を実行時に作成されるログファイル
FAILING_SPECS_LOG='tmp/failing_specs.log'

if [ -e $FAILING_SPECS_LOG ] && grep -q 'rspec ./' $FAILING_SPECS_LOG; then
  echo -e '\n=== Failed examples list ===\n'
  grep 'rspec ./' $FAILING_SPECS_LOG | sed -e 's/rspec //' -e 's/ # .*//'

  echo -e '\n=== Executable command for failed examples ===\n'
  grep 'rspec ./' $FAILING_SPECS_LOG | sed -e 's/rspec //' -e 's/ # .*//' | xargs echo 'bin/rspec'
fi

rails/railties/lib/rails.rb at 628cdf9e9cf54145cca716eb2f80dc04ef66f2f2 · rails/rails · GitHub


  1. Railsは Encoding.default_internalEncoding.default_external をUTF-8に設定しているため、localeが壊れていてもあまり影響がないと思われます。ref:
  2. スペックを1/10に下げても起動待ち時間が大きく変わるわけではないため、CPUのコア数の過多が効くような処理ではないと思われます。
  3. ボリュームマウントでホスト側のどのディレクトリに作成されるかというパスは分からない、または固定されないという認識です
  4. docker-images/OracleDatabase/SingleInstance/FAQ.md at main · oracle/docker-images · GitHub