entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

環境を GitLab CI に移行してビルドを高速化した話

はじめましての方ははじめまして。ピクシブで Scala エンジニアとして働いている Javakky です。 今回はビルド高速化 / デプロイの利便化のためビルド環境を Jenkins から GitLab に移行しましたので、そのレポートをお送りしていきたいと思います。

弊社のデプロイについて

まずは、弊チームリポジトリの動作環境とデプロイの流れがどうなっていたかを見ていきます。

弊チームの利用する環境としては、本番環境 (サーバー)、ステージング環境 (サーバー)、共用開発環境 (サーバー)、個人用開発環境 (ローカル) の 4種類が存在します。厳密にはバッチ処理をするサーバーも存在するのですが、デプロイ時には本番環境と全く同時にデプロイしているため、ここでは本番環境に含めることにします。

個人用開発環境でのプログラムの起動は sbt run コマンドで行っているのですが、その他サーバー上でプログラムを実行するためにはデプロイを行う必要があります。 弊社では pploy という Git リポジトリをデプロイするツールが運用されており、それを利用してデプロイするのが通例となっています。

devpixiv.hatenablog.com

inside.pixiv.blog

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_scriptSlack 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 最高」という話ではないのですが、このようにビルドを行う環境・ツールをチームにあった形に変更することで速度や利便性の向上を見込めることがあります。

みなさんもぜひデプロイ周りの調査、整備を行ってみてください!

icon
javakky
決済周りの改善を中心に働いている2021年入社エンジニア。その名の通りJavaが好きなことで有名(?)で、最近はScalaを使える部署へ入ったらしい。