839の日記

業務とは関係ない趣味の話を書くブログです。

Railsでk8sのRollingUpdate時のassets解決問題を考える

Railsassets:precompile したときに吐き出されるapplication.js/application.cssなどの解決方法についての検討です。
webpackerで吐き出されるファイルたちも範疇に入っています。

前提として、 assets:precomile をするとdigestが付与されたファイルたちをpublicに出力します。

public
└── assets
    ├── application-6788f0a7ffd28bf5fe4cc7c1c16c6c7fc009a2183e45f5bb230ee409843071f3.js
    ├── application-6788f0a7ffd28bf5fe4cc7c1c16c6c7fc009a2183e45f5bb230ee409843071f3.js.gz
    ├── application-d5effe4f291031ebf6419a81688641cbb134accdaece09f682f7e2c96a09d881.css
    └──  application-d5effe4f291031ebf6419a81688641cbb134accdaece09f682f7e2c96a09d881.css.gz

このファイルたちをimageの中に含めていたのですが、運用してみて以下のようなことに気づきました。

  • RollingUpdateなので同時に複数バージョンのpuma podが起動している瞬間が存在する
    • これ自体は想定していた
  • 古いpodにアクセスされた後にブラウザからのassetsリクエストを新しいpodで受けるとassetsが取れない
    • デプロイ時に挙動が変なケースがある、ということから気づいた

上記のような問題より、assetsを複数バージョンのimageで解決する方法について検討しました。
調べたり思いついたりしたのは以下の案です。

  • GCS bucketにassetsを配置する
  • firebase hostingにassetsを配置する
  • S3 bucketにassetsを配置する
  • ingress nginx controllerでassetsをキャッシュする

最終的にingress nginx controllerでassetsをキャッシュする方式を採用しました。
他の案がなぜ没になったか、なぜ採用したのかも書いておきます。

GCS bucketにassetsを配置する

GCSにassetsを置いて静的サイトとして公開する場合だとGCSのみではhttpsリクエストを受けられません*1
前段にLBを挟んでSSLはそこでterminateしてhttpリクエストをGCS bucketに流す必要があります。
LBを前段に挟むと費用がかさんでしまうので静的サイトとして公開するのは没になりました。

他の手段として、全てのオブジェクトを全てのユーザに公開するという手法もありました。
この場合だとGCSのAPIを経由してhttpsでアクセスを受けることができます。
リダイレクトさせるためには以下の設定をnginxに記載し、期待通りの動作をすることは確認できました。

  rewrite ^/assets(.*)$ https://storage.googleapis.com/assets-bucket/assets$1 permanent;

この方式にしてGCSにassetsを置く方法でも良いなとは思ったのですが、GCSに置くあたりのintegrationで悩みました*2
assets_sync gemを利用するか、自前で書くか、GitHub Actionsの中で解決するようにするか、あたりです。
最終的にはingress nginx controllerでのキャッシュが一番要件にあっていてシンプルだった、というのが見送った理由です。

firebase hostingにassetsを配置する

GCSで静的サイトを公開した場合にhttpsリクエストを受けられないと知った後に調査しました。
こちらが没になったのは以下のような理由です。

  • ドメインごとにfirebase hostingのprojectを作る必要がある
    • 管理が煩雑になるのでNG
  • firebase hostingにassetsを配置するのが手間がかかりそうだった
    • GitHub Actionsにfirebase cliを入れることも所要時間が増えるので微妙
    • golangで1からツールを作るのも不具合が出てしまうとメンテナンスコストが微妙
      • npmで入れるよりも速度が短縮できるのでは、という背景
  • firebaseはterraformで管理していなかったので手動オペレーションが増えることが微妙

S3 bucketにassetsを配置する

GCSと違ってbucketを静的サイトとして公開してもhttps通信が可能です。
ただ、複数のcloudを管理するのは個人趣味程度だと煩雑になりそう、というのが見送った経緯です。
AWSでフルに構成していたらこちらを採用したかもしれません。

ただ自分の要件だとingress nginx controllerキャッシュが(ry

ingress nginx controllerでassetsをキャッシュする

自分の構成の場合、全てのドメインは単独のingress nginx controller podを通してアクセスが来ている構成になっていました。
ingress nginx controllerから各サービスのnginxにreverse proxyするような構成です。
そして、各サービスのnginx podはサービスが更新されるとimageが変わる、アプリとのsidecarになっています。
そのため各サービス側のnginxでassetsをキャッシュするのは要件にあいませんでした。

ingress nginx controllerでキャッシュができると解決するなーと思いながら調べていたらできたのでそちらを採用しました。
具体的には以下のような修正を入れました。

stable/nginx-ingress chartを利用していたのでvalues.yamlに以下の部分を追加しました。
この設定を書いておくとingress nginx controller pod内の /tmp/nginx-cache に指定されたパスのレスポンスがキャッシュされます。

controller:
  config:
    http-snippet: |
      proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=static-cache:2m max_size=100m inactive=1d use_temp_path=off;
      proxy_cache_key "$scheme://$proxy_host$request_uri$is_args$args";
      proxy_cache_lock on;
      proxy_cache_use_stale updating;

次に各サービスのingressを以下のように変更しました。

before

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: before-ing
spec:
  tls:
  - secretName: example-tls
    hosts:
    - example.com
  rules:
  - host: example.com
    http:
      paths:
      - backend:
          serviceName: app
          servicePort: 80

after

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: after-ing
spec:
  tls:
  - secretName: example-tls
    hosts:
    - example.com
  rules:
  - host: example.com
    http:
      paths:
      - backend:
          serviceName: app
          servicePort: 80
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: after-ing-static
  annotations:
    nginx.ingress.kubernetes.io/proxy-buffering: "on"
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_cache static-cache;
      proxy_cache_valid 200 301 302 1d;
      proxy_cache_valid 404 10m;
      proxy_cache_use_stale error timeout updating http_404 http_500 http_502 http_503 http_504;
      proxy_cache_bypass $http_x_purge;
      add_header X-Cache-Status $upstream_cache_status;
      add_header Cache-Control "public, max-age=86400, s-maxage=86400";
spec:
  tls:
  - secretName: example-tls
    hosts:
    - example.com
  rules:
  - host: example.com
    http:
      paths:
      - path: /assets
        backend:
          serviceName: app
          servicePort: 80

ざっくりいうとassets用のingressを用意してassetsの場合はキャッシュするようにしました。
この対策を行うと、アプリに来る前にingress-nginx-controller内のキャッシュがあればそのまま返してくれるようになります。
そのため、railsでは特に問題ない部分ですがassetsにはuniqなdigestが付与されている必要があります。

キャッシュの期間はとりあえず1日程度にしているのですが、デプロイ時のRolling Updateの瞬間だけカバーされていれば良いので、実際はもっと短くて良いと思います。
1hぐらいで十分なのではないでしょうか。

assets/packsのようなファイルに関してはデプロイ前に誰かが参照しているであろう、という前提の元ですがこの対策を入れると複数バージョンを跨いだ場合でもassetsの状態を気にしなくて良くなりました。
独自ツールを作ったり新規bucketのterraform設定を書いたりもしなくて良いので、工数的には一番安く問題を解決できると感じ、この構成を採用しました。
複数のサービスを運用していると、サービスの方にこのような問題解決の方法を仕込みに行くのが手間なのでk8sレイヤで解決してアプリに改修は不要、という構成だったのも魅力のポイントです。

ただingress-nginx-controller podが複数いたりするとcacheの共有をしておく必要が出てきたりと考えることが増えてしまいます。
お金に余裕がある場合ならGCSの前段にLBを挟むとCloud CDNとかも併用できるようになるのでそちらを採用するのが良いと思います。

*1:正確にはSSL証明書がカスタムドメインに対応していない

*2:細かいですが、GCSリクエスト数で課金されるのもちょっと嫌でした