CircleCI API v2で自由自在に業務ワークフローのタスクを実行する

ごきげんよう、CI日和ですね。技術部の谷脇です。

先日Jenkinsでテストを実行したり、Slack Botからトリガーして実行していた業務ワークフローの必要なタスクをえいやっとCircleCIに持っていったのでその話をします。

長いので要約すると

  • ヘビーにJenkins使ってたのを全部CircleCIに持っていきました。自動テストとSlack Botからトリガーされるジョブです
  • CircleCI 2.1の機能をつかって設定を書きました。いい感じです
  • テスト以外でBotからキックするようなジョブはそのままだと難しいので、プレビューリリースのCircleCI API v2を使いました。必見です

自前運用のJenkinsさんお疲れ様でした

テストを継続的に動かす環境といえばJenkinsです。カヤックでも多くのプロジェクトでテストや、その他様々なタスクを実行する環境として利用されてきました。

ぼくらの甲子園!ポケットもそういった例に漏れず、Jenkinsをヘビーに使っていました。

ただ、自分で運用するJenkinsの面倒を見続けるのも以下のようなデメリットが生じる状況になってきました。

  • Jenkinsおじさん後継者問題
    • JenkinsはJava製、カヤックはPerl/Go/Ruby/C#...
    • 技術スタックが違うのでなにか起こったときに対処がし辛い
    • スレーブを使うなど複雑な設定にするとさらに把握が大変
  • Jenkins自体がセキュリティリスクになる問題
    • サービス側で気をつけていても、認証をしくじったりしてJenkinsに侵入されるケース
    • ソースコードやマスタデータを取られたりするリスク
    • Jenkins自体にも脆弱性が度々見つかっているのでアップデートをこまめに、つまりこれはサービス運用だ
  • フルマネージドサービスは勝手に強くなるが、Jenkinsは勝手に強くならない問題
    • 勝手に機能が増えるみたいなことにはならない。プラグインを入れると強くなる。だがプラグインもまとめて面倒見るのは大変
    • 一方フルマネージドは勝手に機能が増えて強くなる

また、数あるCIサービスの中でもCircleCIを選択しました。すでに他のプロジェクトで導入済みで、そのときに会社単位でPerformance Planも契約済みだったからです。導入するハードルがたまたま一番低かったからとも言えます。他の会社さんではCircleCIが最適だとは限りません。

他の理由としては、フルマネージドのCI環境を提供するサービスの中では、並列でCPU時間を大量に使うような重いテストに比較的最適なサービスかと思います。

というわけで、移行を以下の手順で行いました。

  1. テストの実行をJenkinsからCircleCIへ移植
  2. Jenkinsで従来行っていた業務タスク実行をCircleCIへ移植
  3. ちゃんと全部CircleCIで動いているのを確認してJenkinsを撤収

そんなわけで、現在の構成と、やっているタスクを挙げてみます。

f:id:mackee_w:20191024184015p:plain

図にある通り、Jenkinsが2つあります。1つはテスト用のJenkinsで、EC2スポットインスタンスで強いインスタンスを用いたものです。

もう1つは、プロジェクト内の開発サーバに立てたJenkinsです。主にSlack botをトリガーにしてタスクを実行しています。このJenkinsでは、

  • Googleスプレッドシートからマスタデータの取り込み
  • 開発環境へデプロイ
  • アセットデータの生成

などなど、業務ワークフローに必要なタスクが行われています。CircleCIとJenkinsどちらも管理するのも面倒なので、こういったタスク類もCircleCIにお引越しします。

CircleCIでテストを実行する

テストを実行するのはCircleCIのもともとの用途なので、そこまで変なことはしていません。もともとJenkinsで実行されていたテストは以下の仕様でした。

  • Amazon LinuxベースのDockerコンテナイメージ上で実行
  • テスト対象はPerlアプリケーション。go-proveで並列実行
  • コンテナ内にMySQLを並列数分立ててテストに利用
  • 64vCPUを利用できるインスタンス1台で80並列実行

CircleCIもDockerを利用するほうが便利なのと、もともと並列数もべた書きではなく、コア数に応じてスケールするようになっていたため、変更せずにそのまま利用します。

なお、カヤックはすでに別のプロジェクトで使うために、CircleCIのPerformance Planを使用しています。この移行では8vCPUのxlargeを使用します。

ですが、8vCPUでももともとの64vCPUには満たないので、circleci tests splitを使った並列実行をします。

テストの並列実行 - CircleCI

テストを実行するタスクを記述していきます。以下に登場するYAMLファイルはリポジトリの.circleci/config.ymlに書いていきます。

設定に使うCircleCIのバージョンは、2.1を使っています。

以下の例は一部を抜粋している形です。完全なYAMLの例が見たい方は、公式ドキュメントの例を参照するか、以下の記事を参考してください。

CircleCI 2.1 の新機能を使って冗長な config.yml をすっきりさせよう!

executor

テストの実行環境を設定します。CircleCI 2.1では実行環境をexecutorsという形で記述し、jobから設定を呼び出せるようになりました。

CircleCIを設定する - executors

references:
  test_reporters: &test_reporters /tmp/test-reports
  working_directory: &working_directory /home/circleci/project

executors:
  test_runner:
    resource_class: xlarge
    docker:
      - image: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/circleci/project:latest
    environment:
      TEST_REPORTS: *test_reporters
    working_directory: *working_directory

以上の設定では、AWSで使えるDockerのプライベートレジストリである、Amazon ECRからDockerイメージをpullして使うようにしています。

*test_reportersおよび、*working_directoryはCircleCIの機能ではなくYAMLのアンカー/エイリアスの機能で定義しています。YAMLの他の場所からでも、同じ値を呼び出すためにこうしています。

Dockerイメージの置き先

テスト用のコンテナイメージのpullの話題が出たので、イメージの置き場所のついての工夫も述べます。

普段はap-northeast-1、つまり東京リージョンを使っているのですが、CircleCI上のイメージに関してはus-east-1を用いています。

ap-northeast-1に置いていたところ、us-east-1への転送料金がAWS側に発生していたのを抑えたいのでus-east-1に引っ越ししました。また、副次的な効果として、初期化時のイメージのpullも早くなりました。

以上のことから、ジョブは現在AWSのus-east-1で走っているようです。ただ、以下のポストにもあるように、リージョンは予告なく変更される可能性があります。

Where are the build servers located geographically?

commands

CircleCI 2.1ではcommandsという形で、jobで使う処理のまとまりに名前を付けれるようになりました。違うjobで一部だけ同じ処理をするときに使えます。

CircleCIを設定する - commands

commandsでは次の処理を定義しています。

プロジェクトリポジトリをcloneしたものをキャッシュからリストアする

CircleCIのキャッシュからリポジトリをリストアしています。

歴史の長いプロジェクトで、毎回GitHubからcloneを行うには時間を要してしまうためキャッシュに保存しています。キャッシュは過去に保存したもの中にブランチ名やコミットハッシュが同じものがあればそれが、なければキーに前方一致したものが使われます。

restore_repo:
  steps:
    - restore_cache:
        keys:
          - &repo_cache_key repo-v2-{{ .Branch }}-{{ .Revision }}
          - repo-v2-{{ .Branch }}-
          - repo-v2-

テスト対象のチェックアウト

かなりデカイリポジトリを必要な分だけ高速にcloneやcheckoutするために、いろいろごちゃごちゃ書いているので詳細には書きません。具体的には以下のようなことをしています。

  • clone済みでない場合
    • git initしてsparse checkoutの有効化
      • テストには使われなさそうなデカイファイルサイズのアセットが含まれるディレクトリを除外する目的で追加
      • そういったファイルのファイルサイズや命名規則を見るテストも含まれたため、有効活用はできず
    • git remote で originを設定
    • --depth 100でclone
      • デプロイ前にアセットのファイルを生成するスクリプトがある程度過去のcommit hashも参照するため
      • 上のテストを実際にやっているのでコケないためにある程度さかのぼったものも含めてcloneする
  • clone済みの場合
    • --depth 100git fetch
    • checkoutする
  • 最後に社内共通ライブラリをgit submodule update --initでcloneする
    • これのせいでcloneなどにdeployキーが使えないので、もともと使っていたBotアカウントをcloneに使用している
checkout_code:
  steps:
    run: <<省略>>

リポジトリキャッシュの保存

先述した「リポジトリキャッシュのリストア」に使われるキャッシュを保存するものです。ここでも同じエイリアスを使って、定義を共通にしています。

save_repo:
  steps:
    - save_cache:
        key: *repo_cache_key
        paths:
          - *working_directory

依存ライブラリのキャッシュ作成とリストア

CircleCIのキャッシュは本来こちらが想定されているようです。

Perlのプロジェクトなので、cpanfileを読んだ依存ライブラリのインストールと、保存をしています。{{ checksum <<filename>> }}で依存ライブラリの構成が変わったときにキャッシュを再作成するような設定をしています。

restore_cpan_local:
  steps:
    - restore_cache:
        keys:
          - &cpan_local_cache_key v1-project-cpanlocal-{{ checksum "cpanfile" }}
          - v1-project-cpanlocal-
install_cpanm:
  steps:
    - run:
        name: cpanm
        command: |
          export PATH=/home/circleci/bin:/home/circleci/local/perl/bin:$PATH
          cd /home/circleci/project
          carton install

テストの実行

テストを実行するスクリプト自体は、もともとDockerでテストを動かすために使われたシェルスクリプトが存在していたため、それを流用しています。なので、YAML上では以下の記述だけです。

run_test:
  steps:
    - run:
        command: |
          set -xu
          mkdir -p ${TEST_REPORTS}
          sh docker/circleci/app/docker_run.sh

このdocker_run.shには、テスト実行時の前準備などが書かれています。もともとJenkinsで使われていたスクリプトを流用したものです。テストを実行する部分は以下のようになっています。

export WORKER=`echo "$NCPU * 1.25" | bc | cut -d. -f1`
WRAPPER="ssmwrap -paths /dev -- carton exec --"
PLUGIN_OPT="-plugin harriet"
tests=$(circleci tests glob 't/**/*.t' | circleci tests split --split-by=timings)

$WRAPPER go-prove -j $WORKER -exec "$PERL" $PLUGIN_OPT -formatter junit $tests > ${TEST_REPORTS}/junit_01.xml

circleci tests splitで、テストジョブの分割を行っています。--split-by=timingsはテストの実行時間をもとにテストを分割します。

現在はxlargeで10コンテナ並列でテストを実行しています。circleci tests splitを使うと、コンテナごとにそれぞれ違うファイルが分配されて、分担してテストが実行されます。

jobs

以上に述べたexecutorscommandsをまとめてjobsに記述します。

jobs:
  app_test:
    executor:
      name: test_runner
    parallelism: 10
    steps:
      - restore_repo
      - checkout_code
      - restore_cpan_local
      - install_cpanm
      - run_test

これで、だいたいJenkinsのときとテスト実行時間と同じ程度の速度でテストを実行することが出来ました。

ただ気になる点として、全体のジョブの実行時間のうち、テスト実行よりもコンテナイメージのpullとリポジトリのキャッシュからの復旧のほうが時間がかかっています。このあたりをチューニングすればさらに早くなると思われます。

テスト以外を実行する

さて、これでテストの移植はできました。今度はそれ以外のジョブの実行です。

CircleCI 2.1を使っているので、上記のYAMLとは別にwokflowsも記述しています。

workflows:
  version: 2.1
  test:
    jobs:
      - app_test:
          name: "test for app"
          filters:
            branches:
              only:
                - release
                - master
                - /^feature\/.*/
                - /^fix\/.*/

これで、onlyに挙げられているブランチがpushされれば、テストジョブが起動するわけですが、従来の「マスタデータの取り込み」といったタスクはpushとは無関係に起動する必要があります。

Jenkinsの時代はSlack botが発言をトリガーにJenkinsのbuildWithParametersというエンドポイントを叩いていたわけですが、CircleCIでも相当のことが出来ないか検討してみます。

もちろん、CircleCIでもAPIでジョブをトリガーする方法はあります。以下のドキュメントを参照してください。

API を使用したジョブのトリガー

これで万事解決! といきたいところですが注意点があります。上記のドキュメントから引用します。

現在のところ、CircleCI 2.1 と Workflows を使用する場合には、単一のジョブをトリガーすることができません。

思いっきり条件に当てはまっています。APIドキュメントの方には、

Note Triggering a new job with a branch is not currently supported with configurations that specify version: 2.1.

とも書かれています。つまりこのAPIは使用できません。

さて、このドキュメントにはworkflowsを使うケースではこちらも使えるよという記述もあります。

Trigger a new Build by Project (preview)

ですがこのAPIにも難があります。

This endpoint does not yet support the build_parameters options that the job-triggering endpoint supports.

つまり特定のブランチがpushされたときにトリガーされるのと同じ挙動は実現できるものの、特別なジョブを実行したり、ジョブにパラメータをAPIから渡すことが出来ないと書かれています。これも要件に合いません。

詰んだか!? と思いましたが、ここでCircleCI API v2なるものを発見します。

CircleCI-Public/api-preview-docs

ドキュメントには記載されておらず、まだプレビューリリース段階のAPIです。予告なしで破壊的な変更されることもありうるのですが、ドキュメントを注意深く見ていくと、これは使えそうだとなりました。

POST /project/:project_slug/pipeline

CircleCI API v2では新たにpipeline parametersという概念が追加されています。CircleCI 2.1でも利用できたparametersですが、jobやcommands定義で使えるものの、workflowsでは使えませんでした。

CircleCI API v2では、APIリクエスト時にパラメータを指定できるようになってます。workflowsでは、APIで渡されたパラメータを埋め込むことができるようになりました。これがpipeline parametersです。pipeline parametersの仕様は別のリポジトリで公開されています。

CircleCI-Public/pipeline-preview-docs

pipeline parametersを使った例を挙げます。

version: 2.1

parameters:
  taskname:
    type: string
    default: ""

executors:
  task_runner:
    resource_class: xlarge
    docker:
      - image: << docker_image >>

jobs:
  do_task:
    executor:
      name: task_runner
    parameters:
      taskname:
        type: string
        default: ""
    steps:
      - run:
          command: make << parameters.taskname >>

workflows:
  version: 2.1
  run_task:
    jobs:
      - do_task:
          taskname: << pipeline.parameters.taskname >>

CircleCI 2.1のparametersも使っているのでわかりにくいですが、トップレベルのparametersで定義したものと、workflows<< pipeline.parameters.taskname >>と呼び出しているのが、pipeline parametersです。

pipeline parametersを埋め込んで、APIでトリガーさせてみます。

curl -u ${CIRCLECI_TOKEN}: -X POST --header "Content-Type: application/json" -d '{
  "branch": "special_nice_branch",
  "parameters": {
    "taskname": "great_awesome_task"
  }
}' https://circleci.com/api/v2/project/gh/kayac/something-repository/pipeline

すると<< pipeline.parameters.taskname >>great_awesome_taskに置き換わって実行されます。

ちなみにカヤックでは上記の例のmakeを使っている部分を、Daikuに置き換えて使用しています。リポジトリに置かれたDaikufileにPerlのコードでタスクを増やすと、CircleCIのconfigをいじらずにプロジェクト固有のタスクを実行できます。Rubyのrakeや、Node.jsのnpm runでも同じことが出来るかと思います。

走るworkflowを制御する

上記のYAMLに加えてテストのジョブもworkflowに設定している場合に問題が起こります。pushしてテストが走ると同時に、任意のタスク実行のためにmakeが走ってしまうのです。

業務ワークフローに必要なタスクをbotなどからトリガーする場合、タスク実行に使うブランチを限定する場合は容易に回避できます。workflowの設定に、filtersでブランチを列挙すれば、無駄にタスクが走るのを回避できます。

workflows
  version: 2.1
  test:
    jobs:
      - app_test:
          filters:
            branches:
              only:
                - master
                - develop
                - /^feature\/.*/
                - /^fix\/.*/
                - /^hotfix\/.*/
  run_task:
    jobs:
      - do_task:
          taskname: << pipeline.parameters.taskname >>
          filters:
            branches:
              only:
                - /workflow.*/

この例ではworkflowで始まるブランチが指定されたときのみ、ワークフロー用の業務タスクを実行します。

ですが、私のプロジェクトでは特定のブランチの情報で実行してほしいケースがあるため、上記の方法ではなく別の方法で走るworkflowを制御しています。

これもpreviewのドキュメント中に記載がある、Conditional Workflowsです。

Conditional Workflows

要約すると、先述したpipeline parametersでworkflowを実行するかどうかを決定するというものです。実際に使ってみましょう。

parameters:
  taskname:
    type: string
    default: ""
  kick_task:
    type: boolean
    default: false

workflows:
  version: 2.1
  test:
    unless: << pipeline.parameters.kick_task >>
    jobs:
      - app_test:
          filters:
            branches:
              only:
                - master
                - develop
                - /^feature\/.*/
                - /^fix\/.*/
                - /^hotfix\/.*/
  run_task:
    when: << pipeline.parameters.kick_task >>
    jobs:
      - do_task:
          taskname: << pipeline.parameters.taskname >>

kick_taskをtrueにして設定するかどうかで、run_taskが実行されるか、testが実行されるかを区別できます。例えば、

curl -u ${CIRCLECI_TOKEN}: -X POST --header "Content-Type: application/json" -d '{
  "branch": "special_nice_branch",
  "parameters": {
    "kick_task": true,
    "taskname": "great_awesome_task"
  }
}' https://circleci.com/api/v2/project/gh/kayac/something-repository/pipeline

とすれば、run_taskが起動し、

curl -u ${CIRCLECI_TOKEN}: -X POST --header "Content-Type: application/json" -d '{
  "branch": "special_nice_branch",
  "parameters": {
    "kick_task": false,
  }
}' https://circleci.com/api/v2/project/gh/kayac/something-repository/pipeline

とすれば通常のテストが起動します。

このように、Slack botから業務ワークフローのタスクを起動する際に、pipeline parametersをつけることで、テストのときと処理を分けています。

まとめ

他にも、違うブランチ(コミットハッシュ)だが、テスト済みのブランチとコード部分の内容が一緒だからテストをスキップする、などなど様々なことをやっていますが、長くなってきたのでこのへんで。

今後は、柔軟にプロジェクト依存のタスクが書けるのを生かして、本番環境への自動デプロイなどにチャレンジして見ようと思います。

最後にもう一度注意です。CircleCI API v2はまだプレビューリリースの段階で、この記事を書いた時点(2019年10月)ではこの情報で使えていますが、予告なしの破壊的変更が入って、そのままでは使えなくなったりする可能性があります。

なので、CircleCI上でしか実行できない、環境が作れないようなタスクを実行するのに使うのは避けましょう。壊れたときに泣きを見ます。

以上、CircleCIバリバリ使っているよ情報でした。