`RUN --mount=type=cache`のキャッシュをGitHub Actionsのキャッシュとして保存しイメージビルドを速くする

背景

CI環境でのDockerイメージビルドの速度向上は、多くの開発者にとって切実な課題です。

簡単な対策は

しかしこれらのキャッシュはCI側で有効期限があり、キャッシュ保存量制限もあり、CircleCIの場合はコスト(料金)もかかります。

なので次によく取られる対策は、キャッシュをイメージレジストリに保存するやり方になります。
参考: https://shepherdmaster.hateblo.jp/entry/2022/06/11/123419

これによりCI環境でもレイヤーごとのキャッシュは可能になりました。 しかしパッケージが変更された場合(例えばpackage.jsonやgo.modやGemfileが変更になった場合)はパッケージのインストールはフルで実行されてしまいます。 ローカルで実行するのであれば追加/変更したパッケージだけインストールすればすむのにCI環境では少しでも変更があると毎回フルインストールが走ってビルド時間が延びるのが悩みです。

さて、このイメージビルド時のパッケージのインストールにキャッシュを効かせて速くする方法があります。
RUN --mount=type=cacheです。
https://docs.docker.com/build/guide/mounts/
これはローカルマシンでは効果がありますが、同一マシンじゃないとキャッシュが効かないため、マシンが毎回変わるCIでは効果がありません。またキャッシュ保存先がDockerが管理するディレクトリに保存されるためキャッシュが取り出しづらいという問題があります。

reproducible-containers/buildkit-cache-danceという救世主

RUN --mount=type=cacheのキャッシュをGitHub Actions上で保存できるようにするためのActionがreproducible-containers/buildkit-cache-danceです。
これを使えば、apt-getやパッケージ(npm, Goライブラリ, Gem等)のインストール結果をキャッシュすることができるようになります。
このActionやってることはシンプル(だが泥臭い)で、

という流れになっています。

apt-getのキャッシュの仕方

https://github.com/reproducible-containers/buildkit-cache-dance/tree/main#examples を参考

bundle installのキャッシュの仕方

Dockerfile:

WORKDIR /app/  
COPY Gemfile Gemfile.lock ./  
RUN bundle config set path .cache/bundle  
RUN --mount=type=cache,sharing=locked,target=/app/.cache/bundle \  
    bundle install && \  
    cp -ar .cache/bundle .bundle && \
    bundle config set path .bundle  

Action:

jobs:  
  build:  
    name: container build  
    runs-on: ubuntu-latest  
  
    steps:  
      - uses: actions/checkout@v3  
  
      - name: Set up Docker Buildx  
        uses: docker/setup-buildx-action@v3  
  
      - name: Cache bundle-install  
        uses: actions/cache@v3  
        with:  
          path: bundle-install-cache  
          key: bundle-cache-${{ hashFiles('Dockerfile') }}  
  
      - name: inject bundle-install-cache into docker  
        uses: reproducible-containers/buildkit-cache-dance@v2.1.3  
        with:  
          cache-source: bundle-install-cache  
          cache-target: /app/.cache/bundle  
  
      - uses: docker/build-push-action@v5  
        with:  
          context: .  
          tags: user/app:latest

その他

試してないですが、npmやGoライブラリなどのキャッシュも同様に出来るはずです。

注意点

CI上でのキャッシュのrestore、save、そしてイメージへのキャッシュのinject、イメージからのキャッシュのextractはキャッシュサイズが大きくなればなるほど時間がかかるようになります。 そのためRUN --mount=type=cacheのキャッシュが効くことによる削減時間よりも先述したオーバーヘッドのほうが大きくなる場合があるので注意が必要です。

まとめ

RUN --mount=type=cacheのキャッシュをGitHub Actionsのキャッシュとして保存し、イメージビルドを速くする方法を紹介しました。 キャッシュのサイズによりますがDependabotやRenovateでパッケージのアップデートを頻繁に行う場合でもイメージビルドが速くなるのは嬉しいですね。

Github Actionsのdocker/build-push-actionのcache-toにECRを指定する

TL;DR

- uses: docker/build-push-action@v3
  with:
    context: .
    tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.MY_REPOSITORY }}:${{ env.MY_TAG }},${{ steps.login-ecr.outputs.registry }}/${{ env.MY_REPOSITORY }}:${{ env.MY_CACHE_TAG }}
    push: true
    cache-from: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.MY_REPOSITORY }}:${{ env.MY_CACHE_TAG }}
    cache-to: type=inline,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.MY_REPOSITORY }}:${{ env.MY_CACHE_TAG }}
    outputs: type=registry

説明

まずdocker/build-push-actionではcache-toにtype=ghaを使うのが簡単で一般的だと思う。 しかしこれは内部でGithub Actions Cache APIを使っているのでキャッシュ先はactions/cacheと同様で、actions/cacheのキャッシュ制限事項が適用されると思われる。 そうだとすると、7日間アクセスがなければキャッシュが破棄され、またリポジトリごとの10G制限も発生する。actions/cacheのキャッシュサイズが大きい場合キャッシュが効きづらくなりそうだ。

そこで、cache-toにtype=registryを使いたい。 これならキャッシュ保存先がimageレジストリになるのでキャッシュ制限を考えなくてよくなる。 type=registry はimageとcache manifestを別々にレジストリにpushするが、ECRはcache manifestをサポートしていない(2022/06/11現在)
ただ2022/5/7にサポートを検討するコメントがついているのでしばらくしたらtype=registryが使えるようになりそう。

で、type=registryが使えないのでtype=inlineを使うことになる。 type=inlineはimage内にキャッシュ用メタデータを埋め込む方法。 type=inlineのデメリットとしてはmode=maxが使えないので、マルチステージビルドを使っている場合、中間レイヤーイメージがキャッシュされない。
もし中間レイヤーもキャッシュしたいのであれば「キャッシュのためにDockerビルドで中間イメージをタグ付けしレジストリにPushする - 🤖」を参考にするとよさそう。

また、docker/build-push-actionを使う際はtagsにメインのimageだけじゃなくcache用のimageも指定しないと、cache用のimageがpushされないので注意。

Podに指定したserviceAccountNameを消したいけど消せない件

Podのservice accountを指定する場合、マニフェストファイルのspec.serviceAccountNameに書きますが、service accountを使わなくなったのでファイルから行ごと消してapplyしたけど反映されない事象に遭遇しました。

調べると、以下が理由。 github.com

つまり、applyしたリソースには、下位互換性のためにserviceAccountNameだけではなくserviceAccountも同じ値が設定される。フィールドを削除する場合は、両方を明示的に空に設定する必要がある。serviceAccountNameだけを未設定にすることはできない。

解決策:
1, kubectl editでserviceAccountNameとserviceAccountの両方を消す
2, マニフェストファイルにserviceAccountNameとserviceAccountを指定してapply後、serviceAccountNameとserviceAccountの両方を消して再度apply

面倒ですね。。 以下のissueで対応されるかもです。 github.com

Kubernetesで動くSidekiqへのヘルスチェック方法

Kubernetesクラスタ上で動かすSidekiqに対してヘルスチェックを導入した話 | Money Forward Engineers' Blog を参考にさせていただきましたが、調べるとヘルスチェックスクリプトを自作しなくても済むことが分かりました。

TL;DR

livenessProbe:
  exec:
    command: ["sh", "-c", "REDIS_URL=$SIDEKIQ_REDIS_URI sidekiqmon processes | grep $HOSTNAME"]
startupProbe:
  exec:
    command: ["sh", "-c", "REDIS_URL=$SIDEKIQ_REDIS_URI sidekiqmon processes | grep $HOSTNAME"]

sidekiqmonとは

github.com

Sidekiq 6.0で導入されたモニタリング用のコマンドです。
sidekiq gemを入れていれば使えます。

ソースはこちら
sidekiq/sidekiqmon at main · mperham/sidekiq · GitHub

マネーフォワードさんのブログにあったスクリプト内でSidekiq::ProcessSetの中身を見てましたが、このsidekiqmonでもSidekiq::ProcessSetを見てます

またsidekiqmonversionoverviewprocessesqueuesなどの引数をとれます。念の為ヘルスチェック対象の情報であるprocessesを指定します。
またコンテナの$HOSTNAMEはpod名になるので sidekiqmon processes結果を$HOSTNAMEgrepしてあげます。

最終的には TL;DR に書いたようになります。

システムメンテナンスを事故なく終わらせるためのTips

システム運営をやっていると、システムメンテナンスをやらないといけない場面が出てきます。 ミドルウェアのバージョンアップ、データのマイグレーション、大きな機能リリースなどなど。 そこで事故なくメンテナンスを終わらせるためのTipsを書き残しておきます。

メンテナンス前

  • メンテナンス数週間前に関係各所(主に社内)に連絡をする
    • メンテナンス当日に大きな機能リリースやキャンペーンなどが無いかを確認する。あればメンテナンス日をずらす。
  • ユーザへのメンテナンス通知
    • アプリやWeb、SNS上でユーザに対しメンテナンスのお知らせを出す
  • メンテナンス手順書を作る
    • これが最も肝心。必ず作成すること。一度作成すれば次回のメンテナンス時にも手順書を流用できる。そして毎回手順書をブラッシュアップしていくことでどんどんミスが少ないメンテナンスができるようになる。
    • 必ず手順書はレビューしてもらう。
    • メンテナンス実施時には、手順をなぞるだけでメンテが完了するように、手順書は詳細かつ分かりやすく書く。特に深夜メンテナンス時は眠気と緊張で頭が普段の半分以下しか働かないものと想定する。
  • 手順書について
    • メンテ前に事前に行うことのチェックリストを設けておく(次回に使える)
      • たとえば関係各所への連絡、作成すべきプルリクエストや、止めておくバッチの確認など
    • 文章だけでは分かりにくい部分は画像で伝える。
    • サーバーに入ってなにかコマンドを実行する場合は具体的なコマンドを載せる
      • 例えば「Webサーバーを止める」という文章だけではなく止める際のコマンドを正確に書く。
    • メンテナンスの目的がきちんと達成されたかを確認する項目を設ける
      • メンテナンスを開けたあとに実はバージョンが上がってなかったとか、一部DBマイグレーションが失敗していたとかそういうことが稀に発生します。なので必ずメンテナンス中に目的が達成されたか確認をする。
    • 行うべき動作確認をリストアップしておくこと
      • メンテナンス後、システム影響(エラー等)が出ていないかを確認しますが、その際確認すべき項目をリストアップしておく。例えばよくあるのは会員登録からコンバージョンまでの一連のフロー。
    • 切り戻し手順を書いておく。切り戻しを行うデッドラインの時間を決めておく。
      • メンテナンス中に思わぬシステム影響が発生することを想定する
    • 細かいが重要なこと
      • メンテナンス中に止めるべきバッチ、メンテナンス後手動で再開すべきバッチを確認しておく
  • メンテナンスのリハーサルをする
    • これもかなり重要。一度リハーサルをしておくとメンテナンス当日の安心感が違う。
    • リハーサルをやると手順書通りにうまくいかないことが判明したりする。またステージング環境などできるだけ本番に近い環境でリハーサルを行っておく。この際、メンテナンスにかかった時間を測っておく。
  • メンテナンス当日に社内にリマインドをする
    • 忘れている人が多いので、今日この時間帯にメンテナンスをやりますと周知する。

メンテ時

  • Zoomをつなぎつつ、各作業開始/終了の報告はSlackで行う
    • 各作業というのは、「ユーザのアクセスを止める」とか「Webサーバーを止める」とか「DBのスナップショットを取得する」とか「動作確認をする」とかそういう細かい作業
    • これは結構個人的には重要だと思っている。各作業は平行して行うことが多いので自分の作業中に相手から口頭で各作業の開始終了を伝えられても覚えてられないんですよね。お互い、え、いまどこやってるんだっけ?みたいことになる。そうすると事故につながりかねない。
    • Slackで各作業開始/終了を書き残しておくと、メンテナンス作業に伴うシステム障害やメンテナンス時にうまくいかなかった場合の調査に役立つ。この作業とこの作業の間に問題が起きたからこの作業が影響しているのではみたいなことがすぐに分かる。これが口頭だと残らない(レコーディングしてても時間を割り出すのは面倒)。
  • 監視を止める
    • メンテナンス中にオンコールが発生してメンテナンス対応者以外のエンジニアが起こされることがないようにしましょう。自分は面倒なので全部の監視を止めてますがそれだと気づくべきアラートに気づけないという問題もあるので理想的には最低限の監視だけ止められるとよい。
  • SentryやRollbarを確認する
    • メンテナンス中に動作確認をすると思いますが、その際実は裏でエラーが発生していたということもあります。またメンテナンスを開けたあと、一般のユーザがアクセスしだした後も一応確認すると良い。リアルアクセスに勝る動作確認はない。
  • メンテが終わったらSlackで無事終わった報告をする
    • メンテナンス後他のエンジニアがメンテナンスは無事に終わったのか心配にならないように報告しておきましょう。
  • もし切り戻しをした場合や、なにか引き継ぎが必要な場合はSlackに細かく書いてきちんと引き継ぐこと。

メンテ後

  • メンテナンス振り返り(KPT)をする
    • 多くの場合、メンテナンスは改善の余地がたくさんあるものだと思います。手順書のブラッシュアップや一部作業の自動化など。
    • ポストモーテムと同じで振り返りで出たアクションアイテムは必ず対応すること

その他

  • インフラ/SRE以外のエンジニアにも時々参加してもらう
    • メンテナンス作業はインフラ/SREが行うことが多いと思いますが、インフラ/SRE以外のエンジニアに参加してもらうことで、システムの理解が深まったり、いざというときにメンテナンス時の知見が役立ったりします。

【解決編】EKS Cluster Autoscalerとログ保存用Daemonsetの組み合わせでスケールイン時にログが失われる問題

shepherdmaster.hateblo.jp の続き&解決編です。

add DaemonSet eviction option for empty nodes by yaroslava-serdiuk · Pull Request #3824 · kubernetes/autoscaler · GitHub がリリースされたので、--daemonset-eviction-for-empty-nodes=trueオプションをつけることで、アプリケーションpodのpreStop内のsleep時間を短くし、代わりにfluentd podのsleep時間を長くすることで、ログが正常に保存されることが可能になりました。

つまり流れを以下にできる。

  1. NodeのScale inが開始される
  2. アプリケーションpod と fluentd pod のターミネート処理が始まる
  3. fluentd podのpreStop内で長いsleepを実行
  4. 先にアプリケーションpodのターミネート処理が終わる
  5. fluentd podがログを保存する処理(flush)を実行(プロセスID 1にSIGTERMを送るとかで)
  6. fluentd podのターミネート処理が終わる アプリケーションPodとfluentd Podが終了したのでNodeインスタンスの終了処理が実行される

図にするとこんな感じ

f:id:shepherdMaster:20220223163245p:plain

以前と比べるとだいぶ自然な流れにできましたね。

KustomizeのComponent機能で環境に応じたmanifestファイルを生成する方法

KustomizeのComponent機能を使うと、特定の環境に対して一連のマニフェストを生成することができます。

github.com

今回はComponent内のmanifestファイルを環境に応じて変える方法を紹介します。

言葉では伝わりづらいかと思うのでファイルツリーを載せます。

├── base
│   └── kustomization.yaml
├── components
│   └── my-component
│       ├── base
│       │   ├── deployment.yaml
│       │   └── kustomization.yaml
│       └── overlays
│           ├── development
│           │   ├── deployment-patch.yaml
│           │   └── kustomization.yaml
│           └── production
│               └── kustomization.yaml
└── overlays
    ├── development
    │   └── kustomization.yaml
    └── production
        └── kustomization.yaml

このような構成にすることで、my-componentというComponentのmanifestファイルをdevelopmentとproductionで生成内容を変えることができます。

Manifestファイルの生成方法は、 普段と変わらず kustomize build overlays/developmentkustomize build overlays/production です。

具体的なサンプルは以下のリポジトリにあるので参考にしてみてください。 github.com

ポイントはcomponents/my-component/base/kustomization.yamlファイルにkind: Componentを記述しないことです。