LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

CircleCI 2.0でbundle updateを定期実行してみた話

こんにちは、IESHILでエンジニアをしている須貝です。
今回は先月ローンチしたIESHIL CONNECT(イエシルコネクト)の運用で、CircleCI 2.0を使ってbundle updateを自動で実行するようにした話をします。CircleCI 1.0ではなく2.0というのがちょっとしたポイントです。

ところで、みなさんCircleCI 2.0を使っていますか? 2.0では純粋なDockerベースになっていてローカルでも実行可能、より柔軟に設定を記述できる、とにかく速いなど1.0と比較すると大幅に進化しています。どんどん使っていきましょう。

やろうとした背景

そもそも何でこれをやろうとしたの?という話なんですが、 IESHIL CONNECTはIESHILとは別に新規開発したRailsアプリです。せっかくの新規開発ですから、IESHIL本体の開発では手を回せていないことをやろう、というのが個人的にやりたいことでした。たとえばLint(rubocop, slim-lintなど)をCIで実行するなどです。

そのひとつが「bundle updateを定期的に実行する」ことでした。
依存しているライブラリの更新はこまめに実行したいところですが、こうした作業はどうしても忘れがち、後回しになりがちだと思います。 しかし、後でまとめてアップデートしようとすると、動作確認などのコストが非常に大きくなり負担となってしまいます。
ですので、リリースしたての今のうちにこの作業を自動化しよう、というのが今回の背景になります。

実現したいこと

やりたいことのおおまかな流れは下の図のとおりです。

今回の場合、4, 5の部分はすでに設定済みなので、図の背景が緑の部分を作っていきます。

手順

ここからは手順とポイントを簡単に解説していきたいと思います。

1. Circle CIを起動する

Circle CI 1.0の場合はCircle CIの公式ドキュメントにあるように、Parameterized Build APIを利用して、Web API経由で任意の環境変数がセットされている場合に通常のCI以外の処理を走らせる、という方法で実現することができます。
以下、1.0の場合の例(公式ドキュメントより引用)です。

test:
  post:
    - >
      if [ -n "${RUN_NIGHTLY_BUILD}" ]; then
        ./bin/run-functional-tests.sh ${FUNCTIONAL_TEST_TARGET};
      fi

ややトリッキーですね。

一方、2.0の場合はもっとシンプルです。Parameterized Builds APIを利用するのは同じですが、設定ファイルに通常のCIとは別の任意のjobを定義しておいて、そのjob名をCIRCLE_JOBというパラメータにセットしてAPIを呼び出すだけで簡単に実現できます。
以下、2.0の設定ファイル例です。

# .circleci/config.yml
version: 2
jobs:
  build: # デフォルトで実行されるjob
  ...
  hello: # 任意のjob
    docker:
      - image: circleci/ruby:2.4.2
    steps:
      - run:
          name: Hello World
          command: |
            echo 'Hello, World'

上記のような設定を.circleci/config.ymlに記述しておくと、下記のようにAPIをコールすることでCircle CI上で任意のjobを実行できるようになります。

curl \
  -X POST \
  --header "Content-Type: application/json" \
  --data '{ "build_parameters": { "CIRCLE_JOB": "hello" } }' \
  https://circleci.com/api/v1.1/project/github/:username/:project/tree/master?circle-token=:token

これをcron的なもので定期実行するわけですが、IESHIL CONNECTはHerokuを使っているのでHeroku Schedulerを使ってAPIを呼び出しています。

なお、Circle CIのAPIを利用するにはトークンが必要なので、プロジェクトのSettingsからAPI Permissionsに遷移してCreate Tokenからトークンを発行しておく必要があります。

2. Circle CI上でbundle updateを実行する

次にCircle CI上でbundle updateを実行できるようにしていきます。

# .circleci/config.yml
  bundle_update: # bundle update用のjob
    docker:
      - image: circleci/ruby:2.4.2
    ... # 中略
    steps:
      - checkout
      ... # 中略
      - run:
          name: Run bundle update
          command: |
            if [ "${CIRCLE_BRANCH}" = "master" ]; then
              bundle update
              ... # 中略
            fi

念のためmasterブランチかどうかを確認しています。

3-1. Gemfile.lockに変更があればGitHubにpushする

続いてbundle updateした結果、Gemfile.lockに変更があればGitHubにpushします。
GitHubとCircle CIを連携済みであれば簡単にpushできるのでは?と思うところですが、ここは少し設定が必要です。 なぜなら、GitHubに登録されているCircle CIのdeploy keyがread権限のみだとCircle CIからpushできないからです。
というわけでwrite権限のあるdeploy keyを新たに追加しましょう。GitHubで対象プロジェクトの SettingsからDeploy keysを選択し、Add deploy keyボタンでdeploy keyを新たに追加します。

その際に、Allow write accessにチェックを入れるのを忘れずに。
deploy keyを追加したら、公式ドキュメントを参考にconfig.ymlにwrite権限のあるdeploy keyを設定します。

# .circleci/config.yml
    steps:
      - checkout
      - add-ssh-keys:
          fingerprints:
            - "xx:xx:xx:xx:xx:xx:xx:xx"
      ...

ここで注意したいのが、- add-ssh-keys:の次行のfingerprints:のインデントです。

# NG
- add-ssh-keys:
  fingerprints:
    - "xx:xx:xx:xx:xx:xx:xx:xx"

# これだと評価した結果が下記のようになってしまう。
# => [{"add-ssh-keys"=>nil, "fingerprints"=>["xx:xx:xx:xx:xx:xx:xx:xx"]}]

# OK
- add-ssh-keys:
    fingerprints:
      - "xx:xx:xx:xx:xx:xx:xx:xx"

# => [{"add-ssh-keys"=>{"fingerprints"=>["xx:xx:xx:xx:xx:xx:xx:xx"]}}]

というわけでfingerprints:- add-ssh-keys:先頭の-から半角スペース4つ下げる必要があります。 私はややハマりました。

3-2. pushが成功したらPull Requestを作成する

最後にGitHubにプルリクエストを作ります。いろいろなやり方があると思いますが、今回はGitHubのWebAPIを利用することにしました。

curl \
  --header "Accept: application/vnd.github.v3+json" \
  --data "{\"title\": \"${BRANCH}\", \"head\": \"${CIRCLE_PROJECT_USERNAME}:${BRANCH}\", \"base\":\"${CIRCLE_BRANCH}\" }" \
  https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls?access_token=${GITHUB_ACCESS_TOKEN}

なお、GitHubのAPIアクセストークンが必要なので取得しておく必要があります。
トークンはCircleCIの設定画面で環境変数として設定しておくと良いでしょう。私はGITHUB_ACCESS_TOKENという名前で登録しました。

最終的にできあがったconfig.yml(一部抜粋)は以下になります。

version: 2
jobs:
  build:
  ... # 中略
  bundle_update:
    docker:
      - image: circleci/ruby:2.4.2
        environment:
          TZ: "/usr/share/zoneinfo/Asia/Tokyo"
        environment:
          - RAILS_ENV=development
          - RACK_ENV=development
    working_directory: ~/ieshil-connect-dev
    steps:
      - checkout
      - add-ssh-keys:
          fingerprints:
            - "xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx"
      - run:
          name: Run bundle update
          command: |
            if [ "${CIRCLE_BRANCH}" = "master" ]; then
              bundle update
              if [ -n "`git status -sb | grep Gemfile.lock`" ]; then
                BRANCH=bundle-update-`date -u "+%Y%m%d"`
                git config --global user.email ${BOT_EMAIL}
                git config --global user.name ${BOT_NAME}
                git checkout -b ${BRANCH}
                git add Gemfile.lock
                git commit -m "Bundle update `date -u '+%Y-%m-%d'`"
                if git push ${CIRCLE_REPOSITORY_URL} ${BRANCH}
                then
                  curl \
                    --header "Accept: application/vnd.github.v3+json" \
                    --data "{\"title\": \"${BRANCH}\", \"head\": \"${CIRCLE_PROJECT_USERNAME}:${BRANCH}\", \"base\":\"${CIRCLE_BRANCH}\" }" \
                    https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls?access_token=${GITHUB_ACCESS_TOKEN}
                fi
              fi
            fi

Circle CI側が用意している機能を利用すればもっとスッキリと書けそうな感じもするので、改善の余地がありそうです。

実際にやってみた感想

月並みですが、「やっぱり自動化しておくと楽だなあ」という一言に尽きます。
現在は日次でbundle updateの実行をしていて、一度に更新されるgemの数も少なくそこまで負担は感じていません。

もちろん、ライブラリのアップデートをしたことが原因で不具合が発生するリスクもあります。そうしたリスクを軽減するためにもまず「テストを書く」ことが大切なのかなと思いました。今回のような仕組みを導入できたのも、高いテストカバレッジを保てていることが前提にあります。きちんとテストを書いている開発陣に感謝。

改めて今回やってみたことを書き起こして見るとそこそこ手間がかかるなあという印象もありますが、一回作ってしまったら後は非常に楽なのでぜひ試してみてください!