こんにちは。ima1zumiです。
私たちの使っていたCI用の物理サーバーがある日突然ハード障害で壊れてOSが起動しなくなり、OSのクリーンインストールをすることになりました。なんとか復旧しCIを実行していたJenkinsの設定をし直し使えるようにはなりましたが、ハード障害以外にもソフトな問題もありました。開発規模とチームが大きくなることにより、直列で実行しているCIの待ち時間が最大6時間程度になっていたのです。CI待ちで開発効率にも悪影響がありました。
そこでハードとソフトの問題を解決するため、AWS CodeBuildでRailsアプリのCIを実行できるようにしました。
この記事では、CodeBuildを使ってRailsアプリのCIを実行できるようにするための設定や、工夫した点を書きました。長くなってしまいましたが、何かの役に立てば幸いです。
- CI実行のための条件
- CodeBuildの選定理由
- 構成図
- CI環境で実行できるdocker-compose.ymlを作る
- CodeBuildから固定IPアドレスでGHESにアクセスできるようにする
- CodeBuildのプロジェクトを作る
- buildspec.ymlを作る
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のアカウントでログインする必要があります。
ローカル環境なら個人のアカウントで一度ダウンロードしてしまえば使えるので良いのですが、CodeBuildで使うためにはCodeBuild用の共有アカウントを準備する必要があります。
ローカル環境ではもともと手作業でビルドしたイメージを利用していること、なるべくアカウント管理をしたくなかったため今回は(2)の公式で配布されているDockerfileを元にビルドし、それを使うことにします。
OracleのDockerfileはこのリポジトリにまとまっています。
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_ID
と AWS_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 install
や bundle 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
-
Railsは
Encoding.default_internal
とEncoding.default_external
をUTF-8に設定しているため、localeが壊れていてもあまり影響がないと思われます。ref:↩ - スペックを1/10に下げても起動待ち時間が大きく変わるわけではないため、CPUのコア数の過多が効くような処理ではないと思われます。↩
- ボリュームマウントでホスト側のどのディレクトリに作成されるかというパスは分からない、または固定されないという認識です↩
- docker-images/OracleDatabase/SingleInstance/FAQ.md at main · oracle/docker-images · GitHub↩