フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

Jenkins × AWS CodeBuild × GitHubで複数コンテナを利用したビルドを試してみた

こんにちは、Cariot事業部の遠藤です。
Salesforceブログに続き、クラウドblogでも初投稿になります。

f:id:flect-endo:20170630235508p:plain

今回は、Jenkins × AWS CodeBuild × GitHubを組み合わせて、CodeBuild上で複数コンテナを使ったビルドを実行する仕組みを試してみたので、紹介します。
RDBMSやRedisなどの外部リソースを利用したテストを、CodeBuild上で走らせたい」「クリーンな実行環境でCI/CDを回したい」という方のヒントになれば幸いです。

それではどうぞ↓↓

CodeBuildについて

CodeBuildは、AWSが提供するフルマネージドなビルドサービスです。正式なサービス名は「AWS CodeBuild」ですが、本エントリでは省略してCodeBuildと書きます。

ビルドサービスと謳っているように、CodeBuildがカバーする領域はあくまでビルド処理(コンパイル、テスト、パッケージング)だけです。そのため、コードリポジトリやCI/CDパイプラインに関しては、別のものを使う必要があります。例えば、AWSのマネージドサービスで言えば、リポジトリにCodeCommit、パイプラインにCodePipelineがそれぞれ該当します。このあたりの関係は下記のスライドにわかりやすくまとまっています。

というわけで、CodeBuildはJenkinsをまるっと置き換えるようなものではないので、目的・制約などに合わせた組み合わせを考える必要があります。

今回の環境

今回はコードリポジトリGitHub、パイプラインにJenkins (on EC2)を利用しました。元々GitHubとJenkinsで組んでいたジョブがあった、といのが理由です。Jenkinsマスタ1台で運用していたのですが、1日数回のビルド時にだけCPUが100%近くに貼り付き、リソース効率が悪いので、マシンパワーを使うビルド処理の部分だけをCodeBuildにオフロードしたい、というのが動機です。

ビルド処理(Worker)としてCodeBuildを利用することで、次の利点が得られると期待しました。

  • Jenkinsマスターのような常時稼働型のインスタンスに、ビルド処理に必要十分なスペックを用意する必要がなくなるので、スケールダウンができる(コストメリット)
  • ジョブ同士が干渉することなく同時実行できるようになるので、待ちがなくなる
  • クリーンな環境でのビルドができるようになり、ビルドの安定化が見込める
  • データベースやJavaのバージョンなど、面倒なビルド環境の組み合わせテストが簡単に行える(こちらは、後述する複数コンテナを使ったビルドによって、実行環境のポータビリティが向上したことによるものです)

いいことずくめですね!

ちなみに、Workerとしてコンテナを使うのであれば、CodeBuildではなくECSを利用する、というのも一つの選択肢です。しかし今回は、ECSをJenkinsのスレーブとして使う場合の設定が面倒(煩雑)そうという理由で見送りました。まぁ、単純にCodeBuildを使いたかったから、という動機もなくはないですが…。

また、ビルド環境ですが、言語はJava、タスクランナーはGradle、データベースのマイグレーションにFlyway, テストにMySQL, Redisを利用しました。ただし、紹介する方法は、他の実行環境でも応用可能かな、と思います。

パイプラインの全体像

試行錯誤の結果、以下のような全体像に落ち着きました。

f:id:flect-endo:20170701011240p:plain

それぞれ見ていきます。

まず、ジョブ(パイプライン)については大きく2つに分けることにしました。

Register Runtime Image Pipeline
CodeBuildでビルドを行なうためのカスタムのDockerイメージ(便宜上ランタイムイメージと呼びます)を作成し、ECR(Dockerレジストリ)に登録するためのジョブ。
このランタイムイメージには、DBなどは含めず、docker-composeとJDKだけがインストールされた状態にしておきます。
Build Pipeline
ランタイムイメージ上で実行されるアプリケーションのビルド用ジョブ。メインのビルドはこちらです。
ビルドに必要なDBやRedisは、ビルドの前処理としてdocker-composeを使ってコンテナを起動します。

2つに分けた理由は、メインのビルドの実行時間の短縮のためです。アプリケーションコードやミドルウェアの更新頻度に比べ、ランタイムイメージは更新が少ないため、毎回のイメージ作成は無駄になることが多いからです。

メインのビルドパイプラインは Jenkinsfile で定義します。この中で CodeBuild のビルドを起動します。

実行環境に関しては、CodeBuildの場合、ECRに登録されているカスタムDockerイメージが選択できるので、あらかじめ登録しておいたランタイムイメージを使って、ビルドを行います。

ビルド処理のルールですが、CodeBuildでは、リポジトリ直下に置かれた buildspec.yml というYAMLファイルに定義することになっています *1

ビルド結果のレポートですが、CodeBuildでは、ビルドの結果はダッシュボード上は成否と実行時間しか分からないようです *2。なので、Jenkins上でテスト結果を見られるようにするには少し手間をかける必要があります。
CodeBuildではビルドの成果物をS3にアップロードすることができるので、JUnitのテスト結果レポートをアップロードさせてから、Jenkinsのワークスペースにダウンロードして対応します。

まとめると、

  • ランタイムイメージ作成のための Dockerfile
  • パイプライン定義のための Jenkinsfile
  • ビルド仕様定義のための buildspec.yml

が必要になります。実際にはそれ以外に build.gradledocker-compose.yml 等も必要ですが、説明は割愛します。

Dockerfileを使ってランタイムイメージの作成とECRに登録

ランタイムイメージの Dockerfile は大体以下のような感じです。

FROM jpetazzo/dind

RUN apt-get update -y
RUN apt-get install -y software-properties-common

# Install Oracle JDK 8
RUN add-apt-repository ppa:webupd8team/java -y
RUN apt-get update -y
RUN echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | /usr/bin/debconf-set-selections
RUN apt-get install -y oracle-java8-installer

# Install docker-compose
RUN add-apt-repository ppa:git-core/ppa -y
RUN apt-get update -y
RUN apt-get install -y build-essential python-pip git
RUN pip install docker-compose

ベースにするDockerイメージは jpetazzo/dind (Docker in Docker)にしました。Docker in Dockerについては、作者であるPetazzoniさんが「Using Docker-in-Docker for your CI or testing environment? Think twice.」という記事を書かれていて、「実際どうなのよ?」という疑問もありましたが、ひとまずよしとしました。

そもそもDocker in Dockerを使っている理由は、CodeBuildの実行環境(コンテナ)上でDB, Redisをコンテナとして起動するためです。ちょっと余談ですが、CodeBuildでDB, Redisを使いたい、という方法の検討にあたり、以下の3つのパターンで迷いました。

  • 『個別インストール(非コンテナ)』パターン
    • ビルド処理の前処理(具体的には buildspec.xml 中の install フェーズ)にて、個別にDB, Redisパッケージをインストールする。
  • 『全部入り1コンテナ』パターン
    • ランタイムとして、全てインストール済みのイメージを利用する。
  • 『複数コンテナ』パターン
    • buildspec.xml 中の install, pre_build フェーズにて docker-compose up -d する。

最終的に『複数コンテナ』パターンを選択しました。理由は、Dockerfile, docker-compose.yml, buildspec.yml が相対的に秘伝のタレ化しにくいということと、ローカルの開発環境の構築でも同じ docker-compose.yml が流用可能で無駄がないことです。

さて、話を本題に戻します。
Dockerfile を作ったら、これを使ってイメージを作成し、ECRにプッシュします。GradleのDockerプラグインを使い、イメージ作成処理自体もCodeBuildで行うのもよいですし、普通にAWS CLIで行ってもかまいません。AWS CLIを使う場合の手順については「イメージのプッシュ - Amazon ECR」を参照してください。

Jenkinsfile

まずはJenkinsにAWS CodeBuild Jenkins Pluginをインストールします。インストールはPlugin Managerから行えます。ちなみに、以前はソースコードからビルドしてインストールが必要だったみたいですね。なお、利用したバージョンは0.9です。

Jenkinsfile は以下のようにしました。

node {
  stage('checkout') {
    checkout scm
  }

  stage('build') {
    awsCodeBuild projectName: "${codeBuildProjectName}",
      sourceControlType: 'project',
      sourceVersion: "master",
      awsAccessKey: "${env.AWS_ACCESS_KEY_ID}",
      awsSecretKey: "${env.AWS_SECRET_ACCESS_KEY}",
      region: 'ap-northeast-1'
  }

  stage('report') {
    sh "aws configure set s3.signature_version s3v4 && aws s3 cp --region ap-northeast-1 ${codeBuildArtifactPath} . --recursive"
    junit '**/test-results/**/*.xml'
  }
}
  1. checkoutステージ
    コードをチェックアウトします *3
  2. buildステージ
    CodeBuildのビルドを開始します。
  3. reportステージ
    CodeBuildのビルドが完了して、アップロードされたS3上の成果物をダウンロードし、レポート出力します。

CodeBuildのプロジェクト設定に関わっている、プロジェクト名(codeBuildProjectName)、成果物のアップロード先(codeBuildArtifactPath)に関しては、ビルドパラメータとして渡し、 Jenkinsfile 上でのハードコードを避けています。

また、上記の例では、sourceVersionとしてmasterを指定しています。これは、リポジトリのmasterブランチをビルド対象とすることを意味します。こちらもビルドパラメータとして渡した方が融通が利くでしょう。
CodeBuildではビルドを開始する時のパラメータとして、ビルドの対象にするブランチやコミットIDを指定できます。これをSource Versionと呼んでいます。Source VersionはコミットIDやブランチ名の他、GitHubのプルリクエストであれば pr/{プルリクエストID} の形式であったり、ある程度柔軟に解釈をしてくれるようです。

buildspec.yml

構文についてはBuild Spec Syntaxを参照してください。

version: 0.2
phases:
  install:
    commands:
      - echo Install started on `date`
      - service docker start
      - docker-compose up -d
  pre_build:
    commands:
      - echo PreBuild started on `date`
      - ./gradlew flywayMigrate
  build:
    commands:
      - echo Build started on `date`
      - ./gradlew build
  post_build:
    commands:
      - echo Build completed on `date`
artifacts:
  files:
    - build/test-results/**/*
  discard-paths: no
  • installフェーズ
    • docker-compose up -d を実行して、必要なコンテナを起動します。このタイミングでDBやRedisが立ち上がります。
  • pre_buildフェーズ
  • buildフェーズ
    • テストとパッケージング(jarファイルの作成)を行います。
  • artifacts
    • テスト結果をS3に保存します(保存先はCodeBuildプロジェクトの設定で保存された箇所です)

成果物保存先のS3バケットと、CodeBuildプロジェクトの作成

上記の各種ファイルを用意し、GitHubリポジトリにpushしたら、あとは、成果物のアップロード先であるS3バケットと、CodeBuildプロジェクトを作成して仕込みは完了です。

CodeBuildのプロジェクトでは特に注意する点はありませんが、Environment(環境)のところは、「Specify a Docker image(Dockerイメージの指定)」、「Linux」、「Amazon ECR」から、作成したイメージを選択するようにします。

Jenkinsのジョブを実行!

いよいよ、ジョブを作って実行します。

うまくビルドが成功すれば、ジョブの画面からJenkins利用者におなじみのテスト結果が見られるはずです。テスト結果のRSSフィードやSlackNotificationPluginを利用して結果をSlackに連携するのもおすすめです。

また、AWS CodeBuild Jenkins Pluginを入れることで、「CodeBuild Dashboard」というメニューが追加され、Jenkins上からCodeBuildのビルド結果が確認できるようになります。

f:id:flect-endo:20170703095644p:plain

残念ながら失敗してしまった場合、 Jenkinsfilebuildspec.yml の記述に間違いがないかを確認し、Jenkinsビルドのコンソール出力やCodeBuildのログを元に原因を修正していってください。私が試した時は、ECRをpullする権限が不足していて、ビルドに失敗することがありました…。

おわりに

後日談というか、今回のオチ。

結果的にパフォーマンス効率性ってどうなった?かというと、ビルド時でもJenkinsマスターのCPU使用率は数%で収まるようになりました!

しかし一方で、ビルド時間については、約6分→約10分と、大幅に増えてしまいました…。コンパイルやテストの時間だけで見ると10%程度は短縮されたものの、git clone、CodeBuildで走らせるためのプロビジョニング、依存関係のあるライブラリのダウンロードを毎回行っているところが原因なようです。特に、依存ライブラリのダウンロードはビルド時間が増えた最大の要因なので、自前で事前に全部入りのtarballを作って、 /root/.gradle (Gradleのキャッシュディレクトリ)に展開する、みたいなお膳立てが必要になるかもしれません。

そんなわけで、若干の課題はあるものの、CodeBuildを試した感触ですが、ビルドに特化しており機能がシンプルな分、学習コストは低い印象でした。
リリースされた当初は弊社のブログエントリ「フレクトのクラウドblog(New): AWS CodeBuildに入門失敗した話」にもあるように、ビルド対象のブランチが指定できなかったり、色々癖はあったみたいですね。その頃から比べると大分よくなったと言えるでしょうし、これからもどんどん便利になっていくでしょう。東京リージョンにも来ていますし、実際にプロジェクトで導入しても問題なさそうな手応えを感じました。

一方で、今回紹介した内容はまだまだ発展途上なやり方かなーという気もしています。「もっとよいやり方あるんじゃね?」という方は、お気軽にコメントください。

*1:ちなみに、1つのリポジトリにbuildspec.ymlは1つしか持てない、ということではありません。
マネージメントコンソールや日本語のドキュメントには(まだ)記述がありませんが、APIとしては、ビルド時にbuildspec.ymlを指定する buildspecOverride というオプションが用意されています。

*2:各フェーズの成否をexit codeで判定しているだけっぽい…?

*3:ちょっとややこしいですが、JenkinsとCodeBuildでリポジトリの設定は別々であり、CodeBuild側でもコードのチェックアウトを行なうので、Jenkins側で必要なのは全コードではなくJenkinsfileだけです。ただ、Jenkins用とCodeBuild用でリポジトリを分けるのも不便なので、全体をチェックアウトするようにしています。