くりにっき

フルスタックキュアエンジニアです

GitLab CIとCircleCIのキャッシュ戦略の違い

仕事でCI全般のお悩み相談されることが多くて .circleci/config.yml.gitlab-ci.ymlリファクタリングすることがよくあるのですが、その時に一番意識してるキャッシュ戦略について長年自分の中の暗黙知になっていて明文化できてなかったので書きます。

前置き

用語の定義

GitLab CIとCircleCIで用語が違っているので説明しやすくするために最初に定義します

  • キャッシュ:CIのビルドの実行を高速化するために保存しておくもの
  • ジョブ : CIで実行するスクリプト群の最小単位
  • ワークフロー:複数のジョブの集合で1コミットで実行される最小単位
    • GitLab CIだとpipeline、CirclrCIだとworkflowにあたる

その他

  • あくまで僕のポリシーなので異論は認めます

GitLab CIとCircleCIの両方に共通すること

キャッシュを過度に使いすぎない

複数のジョブ間でファイルを受け渡すためにキャッシュを利用することがよくあると思うのですが、個人的にこれはアンチパターンだとおもています。

理由はジョブ間が密に依存しているとCIの設定ファイル全体が複雑になり、リファクタリングの障壁になるためです。僕がCIの設定ファイルのリファクタリングをする時もまずはじめにこの手のキャッシュ依存をなくし、各ジョブをステートレスにしてワークフロー全体をシンプルにしています。

許容できるケース

CircleCIの persist_to_workspaceattach_workspace です。

https://circleci.com/docs/ja/2.0/configuration-reference/#persist_to_workspace

これは同一ワークフロー内でジョブ間でファイルを受け渡すための機能なので無問題。

特に persist_to_workspace を複数回読んだ後に attach_workspace を呼び出すとそれまでの persist_to_workspace で保存した結果全てが取り出せて便利です。

GitLab CIだとキャッシュのKeyに $CI_PIPELINE_ID *1を入れることで persist_to_workspaceattach_workspace に近いことはできますが、GitLab CIだと1つのジョブで1種類のキャッシュしか使えない関係で前述のように複数のキャッシュをマージして受け渡せないという問題があり、キャッシュに依存しすぎてると詰むことになります。

GitLab CIだとArtifactsでファイルの受け渡しをするのであれば可。

11/6追記

GitLab CIでもArtifactsでジョブ間でファイルのやり取りできたのを失念してたので修正。

ないと困るものはキャッシュにしない

キャッシュはあくまでもビルドを速くするために存在するので、キャッシュがあればラッキー、キャッシュがなくてもビルドが多少遅くなるだけでビルド自体は成功するって形の方がハマりが少ないです。

10/30 12:00追記

ここで言っているキャッシュというのは、例えばジョブの前段でライブラリをインストールして後続のジョブでそのライブラリがインストールされていることを前提にしているようなケースで引き回しているキャッシュのことを言っていて、ビルドの最終成果物であるArtifactsとは異なります。

ライブラリのインストールに時間にもよるんだけど基本的にはジョブわけない方がシンプルでメンテしやすいです。

persist_to_workspace 使うかどうかは好みによります。(個人的にはこの程度なら使わなくてもいいのでは派)

キャッシュをバージョニングする

キャッシュを互換性のないレベルで仕様変更するために古いキャッシュが使われないようにしたいことがあると思います。

GitLab CIだとリポジトリ単位でCIのキャッシュの全クリアができるのですが、CircleCIだとそういう機能がありません。

僕はよく v1-xxxx のような感じに先頭にバージョン情報を付与していて、必要に応じてインクリメントしています。

複数のブランチでキャッシュを共有できるようにする

キャッシュのkeyにブランチ名が入っているとその異なるブランチ間でキャッシュの共有ができません。

そうすると初めてブランチをpushした時に必ずキャッシュが存在しない状態でフルで bundle installnpm install されるような状態になるので、CI全体の時間で見ると遅くなります。

複数ブランチでキャッシュが共有されるとライブラリのインストールやバージョンアップが複数ブランチで同時進行しているとインストール合戦になってキャッシュがうまく使われなくなるデメリットがありますが、そういうのはレアケースなので複数ブランチでキャッシュが共有されて普段のビルドが速くなる方がメリットの方が多いと思ってます。

GitLab CI固有の話

GitLab CIのキャッシュの仕様

  • 同一のkeyに対して上書きができる
  • キャッシュのkeyは完全一致
    • keyに一致したキャッシュが存在しなければ取得されない
    • GitLab CIのキャッシュの実態はzipファイルなので、キャッシュのkey名でzipファイルが作られていると考えればイメージしやすいでしょう

CircleCI固有の話

CircleCIのキャッシュの仕様

CircleCIのキャッシュはGitLab CIに比べてトリッキーなので注意が必要です。

同一のkeyで一度キャッシュが作られたら上書きができない

save_cache で同名keyでキャッシュが保存できなくても「そのkey名で既にキャッシュを作成してるから作らなかったよ」ってログが出るだけでエラーにはならないため気づきづらいです。

https://circleci.com/docs/ja/2.0/configuration-reference/#save_cache

そのためCircleCIでは myapp-{{ checksum "package-lock.json" }} のようにキャッシュ全体のdigestをキャッシュのkeyに付与する必要があります。

キャッシュのkeyは前方一致

割と勘違いされがちなのですが、CircleCIのキャッシュのkeyは完全一致ではなく前方一致です。(完全一致するkeyがなければ前方一致するkeyのうち最新のものが返る)

またCircleCIの restore_cache では下記のようにキャッシュのkeyを複数設定でき、上から順番に評価されます。

- restore_cache:
    keys:
      - v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "Gemfile.lock" }}-{{ .Branch }}
      - v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "Gemfile.lock" }}
      - v1-{{ .Environment.CIRCLE_JOB }}
      - v1

この場合のキャッシュがヒットするルールはこんな感じです

  1. 同一ジョブ・同一Gemfile.lock・同一ブランチでキャッシュが存在すれば復元される
  2. 1でキャッシュが復元できなかった場合、同一ジョブ・同一Gemfile.lock・別ブランチの一番新しいキャッシュが復元される
    • 大抵の場合masterブランチで保存したキャッシュが復元される
  3. 2でキャッシュが復元できなかった場合、同一ジョブ・異なるGemfile.lock・別ブランチの最新のキャッシュが復元される
  4. 3でキャッシュが復元できなかった場合、同一バージョン(v1)のキャッシュが復元される
  5. 4でキャッシュが復元できなかった場合、キャッシュは復元されない

https://circleci.com/docs/ja/2.0/configuration-reference/#restore_cache

どうしても最新の状態でキャッシュを保存し続けたい時

同一key名だとキャッシュを上書きすることができないのですが、下記のように save_cacheepoch (エポックタイム)を含んだkeyのキャッシュを作ることで、 restore_cache で最新のキャッシュ復元できるようになります。

- restore_cache:
    keys:
      - v1-

- run: # キャッシュを作成する処理

- save_cache:
    key: v1-{{ epoch }}
    paths:
      - /caches/image.tar

せやかて

CircleCIのキャッシュの仕様を完全理解した状態で設定を書くのは無理ゲーだし設定ファイルも複雑になりがちなので他の人が作ったorbを使うのがいいでしょう。

Rubyリポジトリであれば下記orbに僕のCircleCIキャッシュの知見が全て詰まっているので何も考えなくてもいい感じにキャッシュを活用することができます。

https://circleci.com/orbs/registry/orb/sue445/ruby-orbs

詳しくは下記を参照

sue445.hatenablog.com

vagantでgem名とrequireする名前が異なるgemを使う時には注意が必要

タイトルが全て

経緯

https://hub.docker.com/r/sue445/vagrant-aws/ っていうDockerイメージをメンテしてて*1Vagrant 2.2.6が出たタイミングでDockerイメージのビルドがコケるようになりました

https://app.circleci.com/jobs/github/sue445/dockerfile-vagrant-aws/494

Installing the 'vagrant-serverspec' plugin. This can take a few minutes...
Vagrant failed to properly resolve required dependencies. These
errors can commonly be caused by misconfigured plugin installations
or transient network issues. The reported error is:

activesupport requires Ruby version >= 2.5.0.
The command '/bin/sh -c vagrant plugin install vagrant-aws  && vagrant plugin install vagrant-serverspec' returned a non-zero code: 1
Exited with code 1

ビルドコケてる原因はいたってシンプル*2でそれ自体は一瞬で直せました。

github.com

+# FIXME: Remove following when vagrant embed ruby is updated to 2.5+
+#        (vagrant embed ruby is 2.4.9, but activesupport 6.0+ requires ruby 2.5+)
+RUN vagrant plugin install activesupport --plugin-version 5.2.3
+
 RUN vagrant plugin install vagrant-aws \
  && vagrant plugin install vagrant-serverspec

誰だってそーする、俺もそーする。

docker buildも成功してvagrantのコマンドもCI上で一通り叩けることも確認してたんですが*3、実際に使おうとすると下記のようなエラーになってちょっとハマってました

root@b9657e1e1d4a:/# vagrant up
Vagrant failed to initialize at a very early stage:

The plugins failed to load properly. The error message given is
shown below.

cannot load such file -- activesupport

原因

vagrantのソース内の実装までは見つけられてないですが、vagrant plugin install でインストールしたgemは vagrant up などのコマンド実行時に自動的にgem名でrequireされているのが原因でした。(activesupportrequire する時は active_support になるのでエラーになってた)

requireする名前を明示的に指定する方法がないかなと思って探したら vagrant plugin install--entry-point があって、それを使うことで解決できました。

github.com

 # FIXME: Remove following when vagrant embed ruby is updated to 2.5+
 #        (vagrant embed ruby is 2.4.9, but activesupport 6.0+ requires ruby 2.5+)
-RUN vagrant plugin install activesupport --plugin-version 5.2.3
+RUN vagrant plugin install activesupport --plugin-version 5.2.3 --entry-point active_support

*1:vagrant-awsのインストールに数分かかるのでCI用にDockerイメージを作ってる

*2:Dockerイメージにvagrant-awsと一緒についでにvagrant-serverspecをインストールしていて、vagrant-serverspecの依存にactivesupportがいて、activesupport v6がリリースされたタイミングで最新版がRuby 2.5以降必須(vagrantに添付されてるRubyのバージョンが2.4.9)になったのが原因

*3:https://github.com/sue445/dockerfile-vagrant-aws/blob/2.2.6/.circleci/config.yml#L58-L61

Slackからのリクエストを処理するwebhookを1回だけ実行したい

最近そういうことを社内で何回か書いたのでメモ

前提

Slackには特定のイベントが発生した時に任意のwebhookにリクエストを飛ばすことができます。

f:id:sue445:20191017143638p:plain

具体的には下記のような使い方ができます

sue445.hatenablog.com

しかし普通にやると1回しかemojiを登録してないのにSlackに何回も投稿されて困ることがありました

なぜ重複投稿されるか?

公式ドキュメントを見つけきれてないので推測ですが、そういう仕様な気がしてます。

(Herokuだとdyno起動に時間がかかる時に重複リクエストになりやすい傾向があったけど、GASやLambdaでも同じことになる)

重複実行対策

Heroku + sinatra + puma構成の場合下記を行いました

1. 実行内容を一定時間キャッシュする

前述のemoy_webhookだとSlackの投稿内容を5分間redisにキャッシュしてたけど、Slackからのリクエストに含まれているevent_idを使った方がよかったかもしれないです

参考:

teppay.hatenablog.com

2. 排他ロックをする

redis-objectsにredisで排他ロックする機能があるのでそれを使ってました。(ruby以外にも同じようなライブラリがあるかは不明)

https://github.com/nateware/redis-objects#locks

3. サーバ側での並列処理を捨てる

1と2だけでは不完全で、完全同時にリクエストが飛んでくるとどうしようもなかったので多重実行されてしまいました。。。

そのためHerokuのdynoを1にしつつ、pumaのスレッド数を1にしてサーバ側で絶対に並列でリクエストを処理できない状態にしたら多重実行問題は解決しました。(普通のウェブアプリだと完全にアンチパターンなんだけどwebhookが叩かれる頻度は少ないので許容できる範囲内)

【CVE 2019-16892】rubyzip v1.3.0にアプデする時は絶対validate_entry_sizesを設定してください

tl;dr;

タイトルが全てです

前置き

GitHubのSecurity Advisoryで下記のようなPRがきていると思います

f:id:sue445:20191001205324p:plain

このPRをマージすればリポジトリのアラートも消えて問題なさそうに見えるのですが、実際にはgemのアップデートだけでは不十分です

CVE 2019-16892について

zipのヘッダを改ざんして展開時にクソデカメモリを喰わせる、いわゆるzip bombです(雑説明)

なんでgemのupdateだけでは不十分なのか?

zip bomb対策には Zip.validate_entry_sizes = true が必要なのですが、 v1.3.0の時点ではデフォルト値が false になっていて有効になっていないためです

詳しくはこのPRを見てください

github.com

ではどうすればいいか?

明示的に Zip.validate_entry_sizes = true してください。

Railsであれば config/initializers/CVE-2019-16892-rubyzip-patch.rb のようなファイルを置いておけばいいでしょう

v2.0.0ではデフォルトでONになっていてこのパッチは不要になるので、v2.0.0で使おうとしたらエラーになるようにしています。(モンキーパッチの賞味期限)

FAQ

Q. だったら最初から2.0.0にアプデすればいいのでは?

依存gemのdependency~> 1.3 と書かれていて2系に素直に上げられないケースも多々あるので、rubyzipを直接使ってない限りいきなり2.0.0に上げるのは難しいのではと思います。

circleci-bundle-update-prをGitHub Actionsで動かせるようにした

投げたPRはこちら

github.com

動機

とある事情でCI用途のパーソナルアクセストークンを撲滅したかった件の一環です。

解説

GitHub Actionsはジョブ実行時に自動でアクセストークンをセットしてくれて便利なんですが、API実行の許可は付与されていても git push に対する許可が与えられていませんでした。

具体的には下記がGitHub Actionsだとエラーになります。

https://github.com/masutaka/circleci-bundle-update-pr/blob/v1.16.1/lib/circleci/bundle/update/pr.rb#L104-L111

そこでシステムの gitgit commitgit push してる部分をAPIで置き換えた感じです。

circleci-bundle-update-prをGitHub Actionsで動かす方法

https://github.com/masutaka/circleci-bundle-update-pr#run-on-github-actions にも書いたようにGitHub Actionsの変数をCircleCIの変数でexportしなおしてあげるだけでGitHub Actionsで動くようになります。

実際にGitHub Actionsで動かした図がこちら

f:id:sue445:20190928003300p:plain

補足

執筆時点では

steps:
  - name: Set up Ruby
    uses: actions/setup-ruby@v1
    with:
      ruby-version: v2.6.4

のように書いてもRuby 2.6.4をインストールできないため *1Gemfileruby "2.6.4" を書いているとかでどうしてもRuby 2.6.4を使いたい場合には

container:
  image: ruby:2.6.4

のようにDockerイメージを使うのがいいと思います。

circleci-bundle-update-prをGitHub Actionsで動かすメリット

  • パーソナルアクセストークンの発行が不要になる
  • CircleCIがorg単位での並列数に対してGitHub Actionsがリポジトリ単位での並列数なので、複数リポジトリで同時にジョブが実行されても詰まらない

circleci-bundle-update-prをGitHub Actionsで動かすデメリット

  • GitHub Actionsにはキャッシュがないため、毎回フルでbundle installが実行されて遅い
    • schedulerで定期的に動かすジョブなので少々遅いのは個人的には許容できる
    • Betaなのでキャッシュがないと割り切っています(楽観)

余談1

gitlabci-bundle-update-mr で同じ方針でやったので余裕かと思ってたら、GitLabだとAPI 1回で済んだことがGitHubだとAPI 7回もかかって意外に手ごわかった、、、

余談2

dependabotでええやんっていうのもごもっともなんですが、dependabotだと1 bundle update 1 PRになっている関係でPRが大量にきた時にCIが詰まるという問題があるので個人的には circleci-bundle-update-pr くらいの粒度が好きです。

あと、circleci-bundle-update-prだと下記のようにすることで週替りでbundle update当番を割り当てることができるんですが、dependabotだと同じことができない(assigneesが固定値しか渡せない)ためcircleci-bundle-update-prを使い続けてるというのもあります。

circleci-bundle-update-pr --assignees $(ruby -rtime -e 'a=%w[foo bar baz]; print a[Time.now.to_date.cweek% a.size]') 

【CVE-2019-10429】 Jenkins GitLab Logo Plugin v1.0.4をリリースした

作ったきり特にメンテしていなかった Jenkins GitLab Logo Plugin ですが、Jenkinsチームから脆弱性の報告を受けたので修正しました。

jenkins.io

https://jenkins.io/security/advisory/2019-09-25/#SECURITY-1575 にも書いてますが、GitLabのprivate tokenを暗号化せずにそのまま保存していたので暗号化して保存するようにしています

バージョンアップ時の注意点

Global configをsaveするだけで暗号化されて保存されます

日本語の関連記事

blog.trendmicro.co.jp

その他

CVEは採番されてないですが Jenkins ChatWork plugin も同様の問題を抱えていたためv1.0.9で修正しています

GitLab CIでTerraformを動かす

GitLab CIでTerraformをいくつか動かしてるのですがGitLab CI独自の機能をうまく使うといい感じにCIができるようになったのでメモ

前提

  • AWSの各アカウントをTerraformで管理している
  • アカウントごとにTerraformのリポジトリを作成
  • masterブランチ以外では terraform plan を実行し、masterブランチで terraform apply を実行
    • terraform plan があまり信用できないから別アカウントへの terraform apply のパターンもあるかもしれないですが、 terraform plan の方が基本形かなと思ってます

ベースになるジョブをincludeで別リポジトリから読み込むと便利

includeとはGitLab 11.4にcoreに入った機能で、外部のCI設定を .gitlab-ci.yml に取り込む機能です。

https://docs.gitlab.com/ee/ci/yaml/#include

他のCIサービスでいうとCircleCIのorbsが一番近いです。

include導入前

includeを使う前は各リポジトリに下記のような .gitlab-ci.yml が点在していました。

.terraform: &terraform
  image:
    name: hashicorp/terraform:0.12.5
    entrypoint: [""]

  before_script:
    - terraform init -input=false

  tags:
    - terraform

stages:
  - test
  - deploy

cache:
  key: "${CI_JOB_NAME}"
  untracked: true
  paths:
    - .terraform/

tflint:
  <<: *terraform

  stage: test

  variables:
    TFLINT_VERSION: "0.9.2"

  script:
    # tflintのDockerイメージを使うとterraform initできなくてmoduleが読めないためバイナリをDLする
    - cd /tmp
    - wget https://github.com/wata727/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip
    - unzip tflint_linux_amd64.zip

    - ./tflint

  tags:
    - docker

terraform-plan:
  <<: *terraform

  stage: test

  script:
    - terraform plan -input=false

  except:
    - master

terraform-apply:
  <<: *terraform

  stage: deploy

  script:
    - terraform apply -input=false -auto-approve

  only:
    - master

terraform-apply-manual:
  <<: *terraform

  stage: deploy

  script:
    - terraform apply -input=false -auto-approve

  except:
    - master

  when: manual

2箇所くらいならまだ許せたのですが4つ超えたあたりで厳しい感じになりました

include導入後

先日会社のGitLabをバージョンアップしたので includeを使ってリファクタリングしました。

まずは下記のようにテンプレートファイルのみを用意したリポジトリを用意

variables:
  TERRAFORM_VERSION: ""
  TFLINT_VERSION:    ""

.terraform:
  image:
    name: hashicorp/terraform:${TERRAFORM_VERSION}
    entrypoint: [""]

  before_script:
    - terraform init -input=false

  tags:
    - terraform

stages:
  - test
  - deploy

cache:
  key: "${CI_JOB_NAME}"
  untracked: true
  paths:
    - .terraform/

tflint:
  extends: .terraform

  stage: test

  script:
    # tflintのDockerイメージを使うとterraform initできなくてmoduleが読めないためバイナリをDLする
    - cd /tmp
    - wget https://github.com/wata727/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip
    - unzip tflint_linux_amd64.zip

    - ./tflint

  except:
    - master

  tags:
    - docker

terraform-plan:
  extends: .terraform

  stage: test

  script:
    - terraform plan -input=false

  except:
    - master

terraform-apply:
  extends: .terraform

  stage: deploy

  script:
    - terraform apply -input=false -auto-approve

  only:
    - master

terraform-apply-manual:
  extends: .terraform

  stage: deploy

  script:
    - terraform apply -input=false -auto-approve

  except:
    - master

  when: manual

Terraformリポジトリでは下記のように別リポジトリのtemplateを呼び出すだけで使えるようになります

include:
  - project: "group/gitlabci-terraform-template"
    file:    "/template.yml"

variables:
  TERRAFORM_VERSION: "0.12.5"
  TFLINT_VERSION:    "0.9.2"

メリット

  • CIの設定を一箇所に集約したことにより、利用側ではTerraformとtflintのバージョンだけ渡すだけで使えるようになる

上記templateでやってること

masterブランチ以外では terraform plantflint を自動実行しつつ、必要なら terraform apply を手動実行

f:id:sue445:20190920235810p:plain

masterブランチでは terraform apply を自動実行

f:id:sue445:20190920235819p:plain

Terraform実行専用のRunnerを作る

前述の設定で tagsterraform を書いてたやつです。

このTerraform専用のRunnerですが、下記のようにlimitを1にしたdocker executorを作っています。

[[runners]]
  name = "terraform"
  executor = "docker"
  limit = 1

https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section

limit = 1 にする理由

AWSでTerraformを使う場合にはbackendをs3にしつつ、DynamoDBで排他ロックをかけるのがデファクトになってます。

www.terraform.io

しかしこれだと同時に2つのブランチをpushした場合に後から実行された方が排他ロックエラーになってジョブが失敗します。

Terraformを触る人数が多いとこの手のエラーはしょっちゅう起こるし、1人しか触ってない場合でもトピックブランチをpush後にミスに気づいてforce pushしたような場合にもエラーになります。

それを防ぐためにRunnerを limit = 1 にする(つまり複数のジョブの同時実行をさせない)ことでジョブが確実に1つずつ実行されるようになり、さっきのような排他ロックエラーを回避できます。(planの時だけロックかけないってのも頑張ればできそうだけど怖いのでやったことはない)

ちなみにジョブの並列実行数を意図的に制限するというのは僕が知る限りGitLab CIでしか実現できない機能です。(少なくともTravis CI, CircleCI, Wercker, GitHub Actionsには存在しないです)

このTerraform専用Runnerはここ1年くらいずっと使っていますが、CircleCIでTerraformを動かしてた時によく起こってた排他ロックエラーが一切発生しなくなったのでおすすめです。