【Skew Protection】Server Actionがクライアント-サーバー間のバージョン不一致でエラーになる問題をIstioのVirtual Serviceで解決した話

はじめに

みなさんこんにちは、株式会社ユーザベース エキスパートプロダクト開発チームの佐藤一徹です。

私たちのチームでは、 Speedaのエキスパート事業を支えるプロダクト群を開発しており、そのうちの一つとして社員がエキスパートを管理するための社内ツールを運用しています。

本記事では、Next.jsのServer Actionにおけるversion skew問題に対して、 Kubernetes + Istioのルーティングで「クライアントと同じバージョンのサーバーにリクエストをルーティングする」仕組みを実装した話を共有します!

問題

最近、ビジネスサイドの方からこんな問い合わせがありました。 「1週間に1回以上、システムを操作したタイミングでいきなりエラー画面になる」

画面を一緒に見せていただくと、検証ツールのConsoleタブにはUnrecognizedActionError: Server Action "..." was not found on the server.の文字が。*1

状況から、エンジニアがリリース作業をしたタイミングで起こるエラーのようでした。

第一の解決策

先ほどのエラーUnrecognizedActionError: Server Action "..." was not found on the server. で検索すると、早速Next.js公式の解決策らしきものがヒットします。

Failed to find Server Action | Next.js

これには以下のようなことが書いてあります。

  • Next.jsは、ビルド時に暗号化された非決定的なキーを発行し、クライアントがServer Actionを参照及び呼び出すために使われること
  • Next.jsアプリケーションを複数のサーバーでホスティングすると、キーの違いによる不整合が起こりうること
  • process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEYという環境変数をビルド時に渡すとキーを固定できること

実際に手元で調べてみると、以下のことがわかりました。

  • クライアントサイドからServer Actionを呼び出す時、POSTリクエストが送信され、その際にヘッダーにNext-Actionというフィールドがついています。このフィールドの値が、上述したServer Actionを参照するキーです。(例: 7fba82afe74a366c5fce8ea1fba3dda9a77878837a
  • 一方で、Next.jsの.nextフォルダに含まれるserver-reference-manifest.jsonを見ると、このキーとServer Actionを紐づけるような設定があります。
"7fba82afe74a366c5fce8ea1fba3dda9a77878837a": {
  "workers": ...,
  "layer": ...,
  "exportedName": "serverActionName",
  "filename": "serverActionFileName"
},

Server Actionは、内部的には識別子付きのRPCのような仕組みで動作していることがわかります。

NEXT_SERVER_ACTIONS_ENCRYPTION_KEYを指定することで、このキーを固定することができ、異なるビルド間であってもServer Actionを呼び出せるようになります。

これによって、確かにデプロイが走った時に古い画面からServer Actionを実行してもエラーにはならなくなりました。

しかし、今度は「画面がリロードされてしまう」、「フォームに入力した検索条件が消えてしまう」という問い合わせが来るようになりました。

第二の解決策

NEXT_SERVER_ACTIONS_ENCRYPTION_KEYを指定する方法は、異なるバージョン間でServer Actionを「呼び出す」ことはできるようになったものの

  • Actionの引数が変わる
  • 返却するReact Server Componentsの差分データ(RSC payload)が変わる

といった、バージョンが異なることによって起こりうる不整合について考えられていませんでした。

Next.jsのドキュメントには以下のような記述もあり、バージョンの違いを検知するとfull page reloadのような挙動を取るようです。

When the client detects a mismatch between its deployment ID and the server's (via the response header), it triggers a hard navigation (full page reload) instead of a client-side navigation. This ensures users always receive assets and Server Functions from a consistent deployment version.

https://nextjs.org/docs/app/api-reference/config/next-config-js/deploymentId#how-it-worksnextjs.org

上記はdeploymentIdを設定した場合の挙動の説明になりますが、今回の場合、Server ActionのレスポンスにはRSC payloadが含まれており、 このデータはビルドごとに構造が変わる可能性があるため、 クライアント側で整合性が取れず、full page reloadが発生したと思われます。

このように、相互に依存関係のある2つのシステムがデプロイされた時、バージョンの違いによってエラーが起こることをskew(歪み)という単語を使ってversion skewと呼びます。

Vercelでは、version skewの問題への対策として、 Skew Protection という手法をとっています。

これは、デプロイ後も古いバージョンのサーバーを何世代かホスティングし続け、クライアントからのリクエストと同じバージョンのサーバーにリクエストを届けるというものです。

skew protection | Vercel公式サイトより引用 | https://vercel.com/docs/skew-protection

そこで今回、私たちが普段利用しているKubernetesとIstioの基盤を利用して、以下のようにSkew Protectionのようなルーティングを実装しました。(revisionはコミットハッシュから生成した値のことです。)

ルーティングの略図

この図を表現しているのがVirtual Service(ルーティングルールを定義するIstioのリソース)で、yamlファイルには次のような設定が含まれます。

- match:
  - headers:
      sec-fetch-mode:
        exact: "navigate"
  route:
  - destination:
      host: {{ .Values.prefix }}-svc.{{ .Values.namespace }}.svc.cluster.local
      port:
        number: {{ .Values.port }}
      subset: {{ .Values.revision }}
- match:
  - headers:
      x-deployment-id:
        exact: {{ .Values.revision }}
  route:
  - destination:
      host: {{ .Values.prefix }}-svc.{{ .Values.namespace }}.svc.cluster.local
      port:
        number: {{ .Values.port }}
      subset: {{ .Values.revision }}
{{- if .Values.oldRevision }}
- match:
  - headers:
      x-deployment-id:
        exact: {{ .Values.oldRevision }}
  route:
  - destination:
      host: {{ .Values.prefix }}-svc.{{ .Values.namespace }}.svc.cluster.local
      port:
        number: {{ .Values.port }}
      subset: {{ .Values.oldRevision }}
{{- end }}
- route:
  - destination:
      host: {{ .Values.prefix }}-svc.{{ .Values.namespace }}.svc.cluster.local
      port:
        number: {{ .Values.port }}
      subset: {{ .Values.revision}}

ここで、x-deployment-idとsec-fetch-mode=navigateについて簡単に説明します。

x-deployment-id

Next.jsのビルド時に、NEXT_DEPLOYMENT_IDを渡すことで、クライアントサイドからのリクエストヘッダーに任意のx-deployment-idを付与することができます。これを利用して、現在のクライアントサイドのバージョン情報を知ることができます。

https://nextjs.org/docs/app/api-reference/config/next-config-js/deploymentIdnextjs.org

sec-fetch-mode

sec-fetch-modeは、ブラウザが付与するリクエストの種類を区別することができるヘッダーです。Server ActionやRSC データ取得などのリソース取得では値はcorsになり、画面のリロードやURL書き換えなどの文書間移動の際は値がnavigateになります。

これを設定しないと、ずっと同じバージョンに閉じ込められてしまいます。文書間移動のタイミングで最新のバージョンにルーティングすることで、安全に画面側を最新のものにできます。

上記のようなVirtual Serviceの設定をするために、デプロイ時に以下のようなフローを作成しました。 - デプロイ前に稼働系のrevisionを取得し、oldRevisionとする - 新しいrevisionをNEXT_DEPLOYMENT_IDに指定してNext.jsをビルド - oldRevisionと新しいrevisionを渡してKubernetesを更新

これによって、ユーザーはServer Actionの実行時にversion skew問題にぶつかることなく、意図的に画面を更新する行動をとった時にのみ最新のリソースが表示されるという挙動を実現できました!

補足

session storageを用いた特定のページのstate保護

今回問い合わせがあったのは、どうしてもReactのstateで保持する必要がある複雑な検索条件を入力するページで、画面が不意にリロードされてしまうことでした。 そのため、そのページだけでも、検索条件をsession storageに入れることで、画面がリロードされても入力内容を維持できるようにすることも解決策の一つでした。 しかしながら、今後も他の画面で同様の問題が発生する可能性がある中で、フロントエンドの実装を複雑にする選択はできれば取りたくありませんでした。

Virtual Serviceでは、レスポンスにSet-Cookieヘッダーを付与することができます。これによって、クライアントサイドに現在のバージョン情報をCookieに入れてリクエストを送らせることができます。

この方法ではデプロイパイプラインで古いrevisionを取らなくてよくなるため一見良い方法に見えますが、cookieはブラウザで共有されるため、複数タブで異なるバージョンを開いているときにversion skew問題が発生してしまいます。我々のシステムは複数タブで作業することが頻繁にあり、「一つの画面をリロードしてもう一つの画面の操作を続ける」ことは容易に想像できるため、採用しませんでした。

Cookieはオリジン全体、アプリケーションの状態はタブ単位で、スコープが違うということですね。

ずっと古い画面を開きっぱなしだとエラーになるのは避けられない

今回はVercelのSkew Protectionのようなことをやりましたが、BlueGreenデプロイの基盤を利用しているため1世代前のサーバーまでしか保持していません。ユーザーが画面を開いているうちに2回以上デプロイしてしまうとversion skewの問題は起こりえます。 そこに関しては、極論VercelのSkew Protectionでも起こりうる問題です。Vercelではデフォルトの有効期限をデプロイメント作成日から1日としており、バージョンずれを防ぎつつ古いデプロイメントへのアクセスを防止するバランスの取れた設定としています。今回対象となったサービスも、デプロイ頻度は1日1回程度ですし、ユースケース的にも1日以上同じページで作業を続けることは少ないので、version skew問題はひとまず解決としています。

まとめ

今回は、私たちが利用しているKubernetesとIstioの基盤で、Next.jsのバージョン不整合問題をVercelのSkew Protection的に解決した話を書きました。 デプロイパイプラインや、Kubernetes, Istioの知識、Next.jsの知識が複合的に必要な問題でしたが、この問題を通して理解を深めることができました。 Server Actionやskew protectionといったVercelの使いやすく抽象化された技術を、一歩踏み込んで理解できて楽しかったです!

*1:Next.jsのv15.3.8を利用しています。

Page top