はじめましての方ははじめまして。ピクシブで Scala エンジニアとして働いている Javakky です。 今回はビルド高速化 / デプロイの利便化のためビルド環境を Jenkins から GitLab に移行しましたので、そのレポートをお送りしていきたいと思います。
弊社のデプロイについて
まずは、弊チームリポジトリの動作環境とデプロイの流れがどうなっていたかを見ていきます。
弊チームの利用する環境としては、本番環境 (サーバー)、ステージング環境 (サーバー)、共用開発環境 (サーバー)、個人用開発環境 (ローカル) の 4種類が存在します。厳密にはバッチ処理をするサーバーも存在するのですが、デプロイ時には本番環境と全く同時にデプロイしているため、ここでは本番環境に含めることにします。
個人用開発環境でのプログラムの起動は sbt run
コマンドで行っているのですが、その他サーバー上でプログラムを実行するためにはデプロイを行う必要があります。
弊社では pploy という Git リポジトリをデプロイするツールが運用されており、それを利用してデプロイするのが通例となっています。
pploy からのデプロイ処理を実装するのは非常に簡単で、リポジトリ内に .deploy/bin/deploy
というファイルを作成し、デプロイ時に行いたい処理を記載するだけです。
この、入力欄にブランチ名を入れ、 Checkout
ボタンをクリックするだけでデプロイ用のサーバーで git checkout
され、 Deploy to production
ボタンを押せば以下のコマンドが実行されます。
ENV=production USER=javakky@pixiv.co.jp .deploy/bin/deploy
Jenkins でのビルド
そして、弊チームでは本番用のビルドに Jenkins を利用していました。
ビルドジョブでは 2分ごとに SCM poling を利用した origin/master
ブランチへの差分問い合せを行い、差分が検出された場合にはビルドスクリプトを実行するという手法を取っていました。呼び出されるビルドスクリプトは、以下の通りです。
# fetch された git リポジトリから docker イメージを生成する docker build -t build . docker network create network # docker から MySQL を起動する docker run \ --name=mysql \ --network=network \ -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ -d \ mysql --character-set-server=utf8mb4 --skip-character-set-client-handshake # mysql が起動されるまで待つ while ! docker exec mysql bash -c 'echo select 1 | mysql -uroot' > /dev/null 2>&1; do echo 'MySQL起動待ち...' sleep 1 done # ビルド用の docker を立ち上げ / テスト実行 / ビルド実行 docker run \ --name=building \ --network=network \ -e TZ=Asia/Tokyo \ -e DB_DEFAULT_HOST=mysql \ -v $PWD/.ivy2:/root/.ivy2 \ -v $PWD/.sbt:/root/.sbt \ -v $PWD/.node_modules:/node_modules \ build bash -c 'sbt test && sbt dist' # 成果物をローカルにコピー docker cp building:/target/universal/out.zip ./
しかしこの方法では、以下の要因で余分な時間がかかってしまい、 merge から ビルド完了まで 27分 も掛かってしまっていました。
- 最大2分の master merge 検知のラグ
- 直列で実行されるテスト
- docker イメージのビルド & 複数立ち上げ
- Jenkins に利用しているサーバーの性能
これらの問題を解決するため、ビルドジョブを Jenkins から GitLab CI へ移行しました。
GitLab CI を利用したビルド
ここからは実際に設定した build
タスクを見ながら設定について解説していきます。
まず、 GitLab CI に移行して一番便利だった点は docker 周りの構築、設定をほとんど勝手にやってくれるところです。
image
にベースとして利用したいコンテナを、 services
にその他必要なコマンドを追加してやるだけで各種環境が構築されます。
次に、テストの並列化についてです。 Jenkins + シェルスクリプトの構成でも頑張ればできるかもしれませんが、 GitLab CI では stage
を同じに設定するだけでそのジョブが並列実行されます。また、後のステージのジョブは前のステージのジョブの成否を元に起動ができるため安心です。
また、今回は after_script
で Slack Webhooks を利用した通知を行っています。
弊社では社用の Gmail アカウント、 Slack アカウントなどが同じニックネームで統一されているため、 $GITLAB_USER_EMAIL
から取得した Git メールアドレスのユーザー名でメンションするだけでビルドの完了を通知することができました。
もしここのIDが異なる場合でも、変換用の配列と case
などを組み合わせることで同じことが実現できそうです。
もう一つの工夫点として、これまでは master merge でしかデプロイが行えなかったところを MR の段階でビルド -> デプロイまで行えるようにしたことが挙げられます。
build
ジョブの rules
に、以下のような記述があります。
rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: manual allow_failure: true
これは、MRのブランチにプッシュなどがされた時にジョブが走る設定なのですが、 when: manual
をつけることによって自動では実行されず、 GitLab のページ上で ▶︎ ボタンを押した場合にのみ走らせることができます。
動作検証ビルドのためのテストスキップ
前項でお話しした通り、弊チームにはステージング環境や共用開発環境など検証用の環境がいくつかあります。
この環境はリモートサーバー上でしか検証できない挙動 (デプロイなど) の確認に利用しているのですが、動作確認のためのデプロイを行う場合、毎回テストを走らせると非常に時間がかかってしまいます。そこで、各テストの rules:if
に $CI_COMMIT_MESSAGE !~ /^wip: /
を追加しました。
これによって、コミットメッセージの最初に wip:
とつけることで全てのテストをスキップすることができるようになりました。
レビュー時に指摘が漏れると master merge 前のテストがスキップされてしまうという懸念はあったものの、 GitLab ページ上の表示が変わるのでテストスキップに気づきやすいこと、ビルド前に走るテストで最低限問題はキャッチできることからこの変更を導入することにしました。
.needs_sbt: image: mozilla/sbt services: - curlimages/curl - name: mysql alias: mysql command: ["mysqld", "--character-set-server=utf8mb4"] variables: IVY_HOME: .ivy/ SBT_BASE: .sbt/ SBT_OPTS: "-Dsbt.ivy.home=$IVY_HOME -Dsbt.global.base=$SBT_BASE" SLACK_HOOK: ${SLACK_HOOL_URL} # Configure mysql environment variables # @see: https://hub.docker.com/_/mysql/ MYSQL_ALLOW_EMPTY_PASSWORD: "yes" MYSQL_USER: ${USER_NAME} MYSQL_PASSWORD: ${PASSWORD} TZ: "Asia/Tokyo" DB_DEFAULT_HOST: "mysql" before_script: - curl -sL https://deb.nodesource.com/setup_x.x | bash - >/dev/null - apt-get update && apt-get install -y default-mysql-client nodejs - wait cache: key: ${KEY} paths: - $IVY_HOME - $SBT_BASE build: stage: deploy extends: .needs_sbt script: - sbt dist - mkdir artifacts/ - cp target/universal/out.zip artifacts/out.zip after_script: # Slack にビルド完了通知を行う - LAST_USER="$(echo $GITLAB_USER_EMAIL | cut -d "@" -f 1)" - if [ $CI_JOB_STATUS == 'success' ]; then BUILD_STATUS="が完了"; else BUILD_STATUS="に失敗"; fi - TEXT="${SLACK_MENTION} ビルド${BUILD_STATUS}しました <${CI_PIPELINE_URL}|#${CI_PIPELINE_IID}>"; - 'POST_DATA="{\"text\": \"$TEXT\", \"link_names\": 1}"' - 'curl -X POST -H "Content-type: application/json" --data "$POST_DATA" $SLACK_HOOK' artifacts: paths: - artifacts rules: # マスターへのプッシュで実行される - if: '$CI_COMMIT_BRANCH == "master" && $CI_PIPELINE_SOURCE == "push"' # MR の Pipeline で手動実行できる - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: manual allow_failure: true test_sub1: stage: test extends: .needs_sbt script: - sbt -mem 2048 clean sub1/test rules: - if: '$CI_COMMIT_BRANCH == "master" && $CI_PIPELINE_SOURCE == "push"' changes: - sub1/**/* - project/**/* - .gitlab-ci.yml - build.sbt - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_COMMIT_MESSAGE !~ /^wip: /' changes: - sub1/**/* - project/**/* - .gitlab-ci.yml - build.sbt test_sub2: stage: test extends: .needs_sbt script: - sbt -mem 2048 clean sub2/test rules: - if: '$CI_COMMIT_BRANCH == "master" && $CI_PIPELINE_SOURCE == "push"' changes: - sub2/**/* - project/**/* - .gitlab-ci.yml - build.sbt - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_COMMIT_MESSAGE !~ /^wip: /' changes: - sub2/**/* - project/**/* - .gitlab-ci.yml - build.sbt
デプロイスクリプト
ここまでで、 GitLab CI でビルドが行えるようになりました。 次に、デプロイを行う際に成果物をどのように取得しているかについて見ていきましょう。
まず、デプロイサーバーでは pploy によってデプロイするブランチが checkout
されているので、 rev-parse
コマンドで最新のコミットハッシュを取得してきます。
次に、 GitLab の Pipelines API を利用して同じコミット上で実行された Pipeline を全て取得します。ここで、失敗した Pipeline には成果物がないことが確定しているため、 jq コマンドで status
が ”success”
になっているものだけを取得しています。
そして、降順になっている各 Pipeline ID に対して Jobs API で全てのジョブを取得していきます。ここで、 build
ジョブが存在した場合には $JOB_ID
に記録し、なければ次の Pipeline ID を調査します。
なぜ、最新の Pipeline ID だけの調査でないかというと、スケジュールされた master ブランチのジョブが存在する場合に、コミットハッシュから取得した Pipeline ID の最新に build
ジョブが含まれなくなってしまうからです。
最後に、 Job Artifacts API を利用して成果物を取得します。残念ながら、このAPIで成果物の各ファイルを取得することはできなかったため、 unzip
コマンドで解凍して成果物を取り出す必要がありました。
HASH=$(git rev-parse HEAD) GITLAB_TOKEN=${GITLAB_TOKEN} # ハッシュにヒットする全てのパイプラインを取得する PIPELINE_ID_LIST="$(curl --header "Private-Token: $GITLAB_TOKEN" "https://example.com/api/v4/projects/${PROJECT_ID}/pipelines?sha=$HASH" | jq 'map(select(.status=="success")) | .[] | .id')" # 新しい pipeline から順にタスクを取得 while IFS= read -r PIPELINE_ID; do JOB_ID="$(curl --header "Private-Token: $GITLAB_TOKEN" "https://example.com/api/v4/projects/${PROJECT_ID}/pipelines/$PIPELINE_ID/jobs" | jq '.[] | select(.name=="build") | select(.status=="success") | .id')" # 成功した build タスクがあれば if [[ -n $JOB_ID ]] then # 終了 break fi done < <(echo "$PIPELINE_ID_LIST") curl -s -o $tmp_dir/artifacts.zip --header 'Private-Token: $GITLAB_TOKEN' 'https://example.com/api/v4/projects/${PROJECT_ID}/jobs/$JOB_ID/artifacts/' unzip -d $tmp_dir $tmp_dir/artifacts.zip > /dev/null
まとめ
以上の対応により、リポジトリの本番ビルドを Jenkins から GitLab CI に移行することができました。得られた恩恵は以下の通りです。
- デプロイ時間が 27分 → 16分まで短縮
- 動作検証用のビルドをMRから生成できる
- 動作検証時のテストスキップが可能
もちろん、「Jenkins が悪い!GitLab 最高」という話ではないのですが、このようにビルドを行う環境・ツールをチームにあった形に変更することで速度や利便性の向上を見込めることがあります。
みなさんもぜひデプロイ周りの調査、整備を行ってみてください!