GKE ユーザーが Kubernetes の新しいサービス アカウント トークンについて知っておかなければならないこと
Google Cloud Japan Team
※この投稿は米国時間 2022 年 7 月 2 日に、Google Cloud blog に投稿されたものの抄訳です。
Kubernetes にデプロイしたアプリケーションは、サービス アカウントとして実行されます。サービス アカウントとは、Kubernetes コントロール プレーンによって理解されるシステム ユーザーです。サービス アカウントは、アプリケーションにどのような操作を許可するかを構成するための基本ツールであり、単一マシン上のオペレーティング システム ユーザーの概念に似ています。Kubernetes クラスタ内でロールベースのアクセス制御を使用して、サービス アカウントに許可する操作(「すべての Namespace 内の Pod を一覧表示する」、「Namespace foo の Secret を読み取る」)を構成できます。Google Kubernetes Engine(GKE)で実行する場合は、GKE Workload Identity と Cloud IAM を使用してサービス アカウントに GCP リソースへのアクセス権を付与することもできます(「Cloud Storage バケット bar 内のすべてのオブジェクトを読み取る」)。
これはどのような仕組みで実現されているのでしょうか。Kubernetes API や Cloud Storage は、HTTP リクエストの送信元が組織 A のアプリケーションであり、組織 B のアプリケーションではないことをどのようにして知るのでしょうか。それを可能にしているのはトークン、もっと具体的に言うと Kubernetes サービス アカウント トークンです。アプリケーションは、Kubernetes クライアント ライブラリを使用して Kubernetes API を呼び出すとき、認証ヘッダーにトークンを添付します。サーバーはこのトークンを検証してアプリケーションのアイデンティティを確認します。
では、アプリケーションはこのトークンをどのようにして取得し、認証プロセスはどのように働くのでしょうか。この投稿では、Kubernetes 1.21 で導入された Kubernetes 認証を強化するいくつかの変更に注目してこのプロセスに深く分け入り、新たに追加されたセキュリティ機能を活用するためにアプリケーションを改良する方法を見ていきます。
レガシー トークン: Kubernetes 1.20 以前
まず、Pod をスピンアップして内部を覗いてみます。以下の手順は必ず 1.20(またはそれより前のバージョン)のクラスタで行ってください。
これらのファイルは何でしょうか。どこから来たのでしょうか。これらのファイルが Debian ベースイメージに最初から含まれているもののようではないのは確かです。
ca.crt は、このクラスタ内の Kubernetes API サーバーから提示された証明書の検証に必要なトラスト アンカーです。このファイルには通常、PEM でエンコードされた単一の証明書が含まれます。
namespace には、Pod が実行されている Namespace が含まれます。この例では
default
です。token にはサービス アカウント トークンが含まれます。これは API リクエストに添付できる署名なしトークンです。眼力の鋭い読者はお気づきかもしれませんが、これは
<base64>.<base64>.<base64>
という明らかに JSON ウェブトークン(JWT)だとわかる構造をしています。
余談ですが、セキュリティの観点から、これらのトークンをどこにでも送信することは避けてください。これは署名なしトークンです。つまり、このトークンを保持する人であれば誰でも、該当するアプリケーションのサービス アカウントとして認証を通過できます。
これらのファイルがどこから来たのかを理解するため、API サーバー上に存在するこの Pod オブジェクトについて調べます。
API サーバーは実に多くの情報を追加しています。しかし、その中で私たちに意味があるのは次の部分です。
この Pod がスケジュールされたとき、アドミッション コントローラは Pod 内の各コンテナに Secret ボリュームを注入しました。
この Secret には、先ほど Pod 内で見た各ファイルのキーとデータが含まれます。
次に、トークンについて詳しく見てみましょう。以下は、もはや存在しないクラスタから取得した実際のトークンの例です。
前述したように、これは JWT です。これを JWT インスペクタにかけると、このトークンには次のクレームが含まれていることがわかります。
各クレームの意味は次のとおりです。
iss(「issuer」)は標準の JWT クレームで、JWT の発行者を識別します。Kubernetes のレガシー トークンでは、これは常に「kubernetes/serviceaccount」という文字列にハードコードされています。これは RFC の定義に厳密に準拠していますが、特に有用なものではありません。
sub(「subject」)は標準の JWT クレームで、トークンの主題を識別します(この例ではサービス アカウント)。これはサービス アカウント名の標準的な文字列表現であり(RBAC ルールでサービス アカウントを参照するときにもこれが使用されます)、
system:serviceaccount:<namespace>:<name>
の形式をとります。注意すべき点は、これはグローバルに一意ではなく、発行者のスコープ内でも一意ではないため、RFC の定義に厳密に準拠していないということです。つまり、同じ Namespace と名前を持ち、互いに無関係な 2 つのクラスタに属する 2 つのサービス アカウントが、同じ issuer クレームと subject クレームを持つことができます。とはいえ、これは実際にはさほど大きな問題ではありません。kubernetes.io/serviceaccount/namespace は Kubernetes 固有のクレームで、サービス アカウントの Namespace が含まれます。
kubernetes.io/serviceaccount/secret.name は Kubernetes 固有のクレームで、トークンを保持する Kubernetes Secret の名前を示します。
kubernetes.io/serviceaccount/service-account.name は Kubernetes 固有のクレームで、サービス アカウントの名前を示します。
kubernetes.io/serviceaccount/service-account.uid は Kubernetes 固有のクレームで、サービス アカウントの UID が含まれます。トークンの検証時にこのクレームを使用することで、サービス アカウントがいったん削除されてから同じ名前で再作成されたことに気づくことができます。これは、時には重要となることがあります。
アプリケーションがクラスタ内の API サーバーとやり取りするとき、Kubernetes クライアント ライブラリがこの JWT をコンテナ ファイルシステムから読み込み、すべての API リクエストの認証ヘッダーに格納して送信します。API サーバーは受け取った JWT 署名を検証し、トークンのクレームを使用してアプリケーションのアイデンティティを確認します。
これは他のサービスに対する認証にも機能します。たとえば、よくあるパターンは、Hashicorp Vault を利用する際に、Vault がクラスタからのサービス アカウント トークンを使用して呼び出し元を認証できるようにすることです。証明書利用者(相手方を認証しようとしているサービス)のタスクを容易にするため、Kubernetes には TokenReview API が用意されています。証明書利用者がしなければならないのは、提供されたトークンを渡して TokenReview を呼び出すことだけです。戻り値は、そのトークンが有効かどうかを示します。有効な場合は、サービス アカウントのユーザー名も戻り値に含まれます(今回もユーザー名の形式は system:serviceaccount:<namespace>:<name> です)。
これはよくできた仕組みです。では、何が問題なのでしょうか。なぜこのセクションのタイトルは「レガシー」トークンという気になる表現になっているのでしょうか。レガシー トークンには次のような欠点があります。
レガシー トークンには有効期限がありません。そのため、トークンが盗まれた場合、ログファイルに記録された場合、GitHub に commit された場合、あるいは暗号化されていないバックアップにアーカイブされた場合は、永遠に(またはそのクラスタが存在しなくなるまで)危険な状態が続きます。
レガシー トークンにはオーディエンスという概念がありません。アプリケーションがサービス A にトークンを渡した場合、サービス A はサービス B にトークンを転送するだけでそのアプリケーションを装うことができます。たとえサービス A が現状信頼に足る正当なサービスであったとしても、項目 1 の理由から、サービス A に渡したトークンはいつまでも危険なままです。サービス A を信頼できなくなった場合は、クラスタのルート オブ トラストを変える以外に実際的な対処法はありません。
レガシー トークンは Kubernetes Secret オブジェクトを介して配布されます。このオブジェクトは厳格にアクセス制御されていない場合が多く、通常は保存時またはバックアップ内で暗号化されていません。
サードパーティ サービスをレガシー トークンと統合する際に余分な手間がかかります。通常は、カスタム トークン クレームへの対応と、TokenReview API でトークンを検証する必要性から、サードパーティ サービス側で Kubernetes のサポートを明示的に構築する必要があります。
こうした問題から、「バインド サービス アカウント トークン」と呼ばれる Kubernetes の新しいトークン形式を設計しようという動きが生まれました。
バインド トークン: Kubernetes 1.21 以降
Kubernetes 1.13 でリリースされ、1.21 でデフォルト形式になったバインド トークンは、レガシー トークンの制限された機能をすべて解決することに加えて、以下の特長を持ちます。
トークンが時間、オーディエンス、オブジェクトにバインドされ、トークン自体の盗難や不正使用がはるかに難しくなりました。
標準化形式の OpenID Connect(OIDC)が採用され、OIDC Discovery が完全にサポートされたため、サービス プロバイダによるトークンの受け入れが容易になりました。
新しい Kubelet 投影ボリューム タイプを使用して、より安全に Pod に配布されます。
これらの特性を一つずつ順に見ていきましょう。
上記の例を再び取り上げ、バインド トークンを細かく分析します。これは依然として JWT ではありますが、クレームの構造が変更されています。
時間バインディングは、exp(「expiration」)、iat(「issued at」)、nbf(「not before」)の各クレームによって実装されています。これらは標準化された JWT クレームです。外部サービスでは独自のクロックを使用してこれらのフィールドを評価し、期限切れのトークンを拒否できます。特に指定されていない場合、バインド トークンの有効期間はデフォルトで 1 時間に設定されます。Kubernetes TokenReview API は、トークンが有効かどうかを判定する前に、トークンの有効期限を自動的にチェックします。
オーディエンス バインディングは、aud(「audience」)クレームによって実装されています。これも標準化された JWT クレームです。オーディエンスは、トークンを特定の証明書利用者に強く関連付けます。たとえば、「service A」という文字列にオーディエンス バインドされたトークンをサービス A に送信した場合、サービス A はもはやこのトークンをサービス B に転送してトークン送信元になりすますことはできません。これを試みた場合、サービス B は、オーディエンスが「service B」ではないため、送られてきたトークンを拒否します。サービス プロバイダは Kubernetes TokenReview API を使用する際、トークンの検証時に受け入れるオーディエンスを指定できます。
オブジェクト バインディングは、kubernetes.io クレーム グループによって実装されています。レガシー トークンに含まれていたのはサービス アカウントに関する情報だけでしたが、バインド トークンには、トークンの発行先の Pod に関する情報も含まれます。上記の例では、トークンは Pod にバインドされています(トークンを Secret にバインドすることもできます)。このトークンが有効とみなされるのは、対象の Pod がまだ存在し、Kubernetes API サーバーに従って実行されている場合のみです。これは、expiration クレームの強化版のようなものと言えます。この種のバインディングを外部サービスがチェックするのは容易ではありません。というのは、外部サービスは条件のチェックに必要なクラスタへのアクセスレベルを持っていない(そして、サービス利用側では外部サービスにそのようなアクセス権を与えたくない)からです。幸いなことに、Kubernetes TokenReview API はこのようなクレームも検証します。
バインド サービス アカウント トークンは、有効な OpenID Connect(OIDC)ID トークンです。これはいくつかの意味合いを持ちますが、最も重要なものは iss(「issuer」)クレームの値に見ることができます。必ずしもすべての Kubernetes の実装がこのクレームを提供するわけではありませんが、このクレームを提供する実装(GKE を含む)は、クラスタによって発行されたトークンの有効な OIDC Discovery エンドポイントを指し示します。要するに、外部サービスは Kubernetes に対応していなくても、Kubernetes サービス アカウントを使用してクライアントを認証することができます。外部サービスに要求されるのは、OIDC と OIDC Discovery をサポートすることだけです。この種の統合を表す例として、OIDC Discovery エンドポイントは GKE Workload Identity の基盤を成しており、これを通じて Kubernetes と GCP アイデンティ システムが統合されています。
最後の改良点として、バインド サービス アカウント トークンはよりスケーラブルかつ安全な方法で Pod にデプロイされます。レガシー トークンは、サービス アカウントごとに一度生成され、Secret に保存され、Secret ボリュームを介して Pod にマウントされるのに対し、バインド トークンは Pod ごとにその場で生成され、新しい Kubelet serviceAccountToken ボリューム タイプを使用して Pod に注入されます。バインド トークンにアクセスするには、ボリューム仕様を Pod に追加し、トークンを必要とするコンテナにこれをマウントします。
トークンのオーディエンスは事前に選択する必要があります。また、トークンの有効期間を制御することもできます。このオーディエンスの要件は、Pod がやり取りする個々の外部利用者に対応するバインド トークンを複数まとめて 1 つの Pod にマウントすることがかなり一般的であることを意味します。
内部では、serviceAccountToken 投影ボリュームは Kubelet(Kubernetes のプライマリ ホスト エージェント)に直接実装されています。Kubelet は、kube-apiserver とやり取りして Pod が起動する前に適切なバインド トークンをリクエストし、トークンの有効期限が近づいたときに定期的にトークンを更新します。
以上の要点をまとめると、次のようになります。
バインド トークンは、時間、オーディエンス、およびオブジェクトへのバインディングと、より安全性の高い Pod への配布メカニズムの採用により、レガシー トークンに比べてセキュリティが大幅に向上しています。
OIDC に対応しているため、外部サービス プロバイダにとって反復処理がより簡単です。
ただし、バインド トークンではトークンとの統合方法が変わります。レガシー トークンはサービス アカウントごとに 1 つずつ存在し、常に /var/run/secrets/kubernetes.io/serviceaccount/token でアクセスできていましたが、バインド トークンは 1 つの Pod に複数注入できます。トークンには有効期限があり、Kubelet によって更新されるため、アプリケーションは定期的にファイルシステムからトークンを再読み込みする必要があります。
バインド トークンは Kubernetes 1.13 から提供されていましたが、Pod に対して発行されるデフォルト トークンは引き続きレガシー トークンであり、それに伴うセキュリティ上の欠点もすべてそのままでした。Kubernetes 1.21 でこれが変更され、バインド サービス アカウント トークンがデフォルト トークンになりました。この移行は Kubernetes 1.22 で終了し、バインド サービス アカウント トークンのデフォルト化は一般提供に昇格しました。
次のセクションでは、これらの変更が Kubernetes サービス アカウント トークンのユーザーに与える意味を、まずはクライアント、次にサービス プロバイダの観点から見ていきます。
クライアントに対する影響
Kubernetes 1.21 では、/var/run/secrets/kubernetes.io/serviceaccount/token に存在するデフォルト トークンがレガシー トークンからバインド サービス アカウント トークンに変わりました。このトークンをクライアントとして使用する場合、つまり署名なしトークンとして API に送信する場合は、アプリケーションの機能を維持するためにアプリケーションに変更を加えなければならないことがあります。
クライアントから見ると、新しいデフォルト トークンの主な変更箇所は次の 2 点です。
新しいデフォルト トークンはクラスタ固有のオーディエンスを持ち、これはクラスタの API サーバーを識別します。GKE では、このオーディエンスは URL
https://container.googleapis.com/v1/projects/プロジェクト/locations/ロケーション/clusters/名前です。
新しいデフォルト トークンは定期的に期限切れになり、ディスクから更新する必要があります。
これまで、公式の Kubernetes クライアント ライブラリの最新バージョン(たとえば、client-go と rest.InClusterConfig)を使用し、デフォルト トークンをアプリケーションがデプロイされているクラスタの Kubernetes API サーバーとの通信にのみ使用していた場合、アプリケーションに変更を加える必要はありません。API サーバーと通信するための適切なオーディエンスはデフォルト トークンによって伝えられ、ディスクからのトークンの更新はクライアント ライブラリが自動的に処理します。
アプリケーションが現在デフォルト トークンを外部サービスへの認証に使用している場合(たとえば、Hashicorp Vault のデプロイメントではこれが一般的です)、その外部サービスとクラスタとの統合の厳密な性質によっては、一部変更を加えなければならないことがあります。
まず、そのサービスがアクセス トークンで一意のオーディエンスを必須としている場合は、適切なオーディエンスを含む専用のバインド トークンを Pod にマウントし、サービスへの認証時にそのトークンを使用するようにアプリケーションを構成する必要があります。注意すべき点は、Kubernetes TokenReview API のデフォルトの動作はデフォルトの Kubernetes API サーバー オーディエンスを受け入れることなので、外部サービスが一意のオーディエンスを必須としていない場合でもデフォルト トークンが受け入れられる可能性があるということです。これは、セキュリティ面で理想的ではありません。オーディエンス クレームの目的は、外部サービスから盗まれた(または外部サービスによって不正に使用された)トークンを使用して他の外部サービスに対してトークン送信元のアプリケーションになりすますことができないようにすることにあります。
専用のオーディエンスを含むトークンをマウントする必要がある場合は、serviceAccountToken 投影ボリュームを作成し、このトークンを必要とする各コンテナの新しいパスにこれをマウントする必要があります。デフォルト トークンを置き換えようとしないでください。さらに、新しいパスからトークンを読み取るようにクライアント コードを修正します。
次に、アプリケーションが定期的にディスクからトークンを再読み込みするようにします。5 分ごとに変更をポーリングし、トークンが変更された場合に認証構成を更新するだけで十分です。クライアント ライブラリを提供しているサービスでは、このタスクは、特に何もしなくてもクライアント ライブラリで処理される場合があります。
具体的なシナリオ:
公式の Kubernetes クライアント ライブラリを使用してローカル クラスタ内の Kubernetes オブジェクトを読み書きするアプリケーションの場合: 使用しているクライアント ライブラリが最新のものであることを確認します。それ以上の変更は必要ありません。特に何もしなくても、デフォルト トークンが適切なオーディエンスを伝え、クライアント ライブラリがディスクからのトークンの再読み込みを自動的に処理します。
Google Cloud クライアント ライブラリと GKE Workload Identity を使用して Google Cloud APIs を呼び出すアプリケーションの場合: 変更は必要ありません。Kubernetes サービス アカウント トークンがバックグラウンドで要求されますが、必要なトークンの交換はすべて gke-metadata-server によって処理されます。
デフォルトの Kubernetes サービス アカウント トークンを使用して Vault への認証を行うアプリケーションの場合: 一部変更が必要です。Vault は、Kubernetes TokenReview API を呼び出すことでクラスタと統合しますが、発行者クレームに対する追加のチェックも行います。デフォルトでは、Vault はレガシー トークン発行者の kubernetes/serviceaccount
が届くものと想定しており、新しいデフォルト バインド トークンは拒否されます。Vault の構成を更新して新しい発行者を指定する必要があります。GKE では、発行者は https://container.googleapis.com/v1/projects/プロジェクト/locations/ロケーション/clusters/名前
のパターンに従います。
現時点で、Vault はトークンで一意のオーディエンスを必須としていないので、デフォルト トークンを必ず保護してください。デフォルト トークンが盗まれた場合、それを使用して Vault から秘密情報を取得できます。
デフォルトの Kubernetes サービス アカウント トークンを使用して外部サービスへの認証を行うアプリケーションの場合: 一般に、早急な対応として、アプリケーションが定期的にディスクからデフォルト トークンを読み込み直すようにする以上の変更は必要ありません。Kubernetes TokenReview API のデフォルトの動作により、移行の間も認証は機能し続けます。やがて外部サービスが更新されて、トークンで一意のオーディエンスが必須になった場合は、すでに説明したように専用のバインド トークンをマウントする必要があります。
サービスに対する影響
デフォルトのサービス アカウント トークンを使用してクライアントを認証するサービスは、クライアントが自身のクラスタを Kubernetes 1.21 にアップグレードした後も、Kubernetes TokenReview API のデフォルトの動作によって引き続き機能します。サービスに届くトークンは、デフォルト オーディエンスを含むバインド トークンに変わり、サービス側の TokenReview リクエストは、特に何もしなくてもこのデフォルト オーディエンスを検証します。ただし、バインド トークンは以下の 2 通りの新しい統合オプションを可能にします。
オプション 1: クライアントと協力して、サービスが受け入れるトークンで一意のオーディエンスを必須にします。これにより、盗まれたトークンの力が制限され、サービス プロバイダとクライアントの双方に次のようなメリットが生まれます。
クライアント側は、サービス プロバイダを信頼して、任意のサードパーティ(銀行や支払いゲートウェイなど)への認証に使用できるトークンを預ける必要がなくなります。
サービス プロバイダ側は、このような強力なトークンを保持することや、違反の責任を負う可能性について心配する必要がなくなります。その代わりに、受け入れたトークンを使用してサービスへの認証を行うだけで済みます。
そのためにはまず、提供するサービスのグローバルに一意のオーディエンス値を決定する必要があります。サービスが特定の DNS 名でアクセス可能な場合は、それを使用するのが適しています。それが駄目なら、常にランダムな UUID を生成して使用してもかまいません。大事なのは、サービス プロバイダとクライアントがその値に合意することです。
オーディエンスが決まったら、TokenReview の呼び出しを更新してオーディエンスの検証を開始します。クライアントに移行する時間を与えるため、移行は次のように段階的に行います。
TokenReview の呼び出しを更新し、
spec.audiences
リストに新しいオーディエンスとデフォルト オーディエンスの両方を指定します。デフォルト オーディエンスはクラスタごとに異なることに注意してください。そのため、クライアントからデフォルト オーディエンスを入手するか、クライアントから提供された kube-apiserver エンドポイントに基づいてデフォルト オーディエンスを推測する必要があります。覚書として記しておくと、GKE クラスタのデフォルト オーディエンスはhttps://container.googleapis.com/v1/projects/プロジェクト/locations/ロケーション/clusters/名前
です。この時点で、サービスは古いオーディエンスと新しいオーディエンスの両方を受け入れます。クライアントに、新しいオーディエンスを含むトークンの送信を開始してもらいます。クライアント側で必要な作業は、専用のバインド トークンを Pod にマウントし、そのトークンを使用するようにクライアント コードを構成することです。
TokenReview の呼び出しを更新し、
spec.audiences
リストに新しいオーディエンスのみを指定します。
オプション 2: サービス プロバイダ側に特定の要件がある場合は、OpenID Connect Discovery 規格を使用して Kubernetes と統合することを検討できます。サービス インスタンスを数千もの個々のクラスタと統合する場合、高い認証レートをサポートする必要がある場合、または Kubernetes 以外の多くの ID ソースとの連携を狙う場合は、Kubernetes TokenReview API ではなく OpenID Connect Discovery 規格を使用して Kubernetes と統合することを検討できます。
このアプローチにはメリットとデメリットがあります。メリットは次のとおりです。
各連携クラスタへの認証のためにサービスの Kubernetes 認証情報を管理しなくて済みます(一般に、OpenID Discovery ドキュメントが公開されます)。
連携クラスタの JWT 検証キーがキャッシュに保存されます。これにより、クラスタ内の kube-apiserver がダウンしている場合や過負荷状態にある場合でもクライアントを認証できます。
このキャッシュを使用することで、連携 kube-apiserver を認証のクリティカル パスから切り離し、クライアントからの増加する呼び出しレートを低いレイテンシで処理することもできます。
OpenID Connect をサポートすると、Kubernetes クラスタ以外の追加の ID プロバイダとも連携できます。
デメリットは次のとおりです。
すべての連携クラスタについて、JWT 検証キー用のキャッシュを運用する必要があります(キャッシュに保存されたキーの適切な失効など。クラスタは事前の警告なしに自身のキーを変更できます)。
TokenReview API が持つセキュリティ上のメリットの一部が失われます。特に、オブジェクト バインディング クレームを検証できなくなる可能性があります。
一般に、TokenReview API が目的のユースケースにうまく利用できる場合は、TokenReview API を使用することをおすすめします。その方が運用上はるかに簡単であり、OpenID Connect の証明書利用者として適切に振る舞うという見掛けによらず難しい問題も回避できます。
- ソフトウェア エンジニア Taahir Ahmed