背景
Amazon EKS(Elastic Kubernetes Service)には、IAM Roles for Service Accounts(IRSA)という便利な機能があります。IRSAを利用すると、KubernetesのPodはServiceAccountを通じてIAMロールを引き受け、AWSリソースへアクセスできます。これにより、Application Load Balancer(ALB)やElastic Block Store(EBS)などのAWSリソースを、Kubernetesのリソースと簡単に連携することができます。
普段の業務ではEKS上でIRSAを大いに活用しています。最初に使った時は「よくわからんけど、マニフェストをapplyしただけでALBとかNLBとかEBSとかEFSとか作られた!すごい!!」と感動しました。この便利な仕組みを知りたくて個人環境でも再現しようと思ったのですが、EKSを使うとなると最低でも月額70ドルの課金が必要です。お金に吝嗇な私は、IRSAのためにそんな大金を使う気にはなれません。
そこで、「EC2インスタンスにkubeadmでKubernetesクラスターを構築して、IRSAを使えるようにするといいのでは?」と思い立ちました。例えばt3a.smallのインスタンス一台でKubernetesクラスターを構築して必要な時だけ起動すれば、ストレージ代も含めて月額5ドル以下に抑えられそうです。
今回は、この「EKSなしでIRSA」の試みを実現するための最初の記事です。若干長い記事ですが、よろしければ最後まで読んでいただければと思います。
本記事の内容
本記事は、kubeadmで構築したKubernetesクラスターにおいて、OIDCメタデータの公開エンドポイントとJWKS(JSON Web Key Set)エンドポイントを公開する方法について説明します。これら2つのエンドポイントを公開すると、ServiceAccountをAWSのIAMロール等の外部リソースと関連づけることが可能になります。
参考情報
本記事に記載している内容は、以下の k8s-irsa-without-eks で実現可能です。
事前知識
今回実現したいことを理解するために、OIDCによるJWTの署名検証やServiceAccountトークンの知識が必要となります。まずはそれらに関する説明をします。
OIDCプロバイダーの公開エンドポイントについて
OIDCを提供しているサービスを「OIDCプロバイダー」と呼びます。例えばAuth0やKeycloak、Oktaはこれに該当します。
OIDCプロバイダーは、「メタデータ公開エンドポイント」と呼ばれるパスを用意しています。ここには、「ログインはここに行ってください」「トークンはここで受け取れます」「公開鍵はここに置いてあります」といった情報が、1か所にまとめて書かれています。
このエンドポイントは、以下のようにホスト名の後に /.well-known/openid-configuration が付与されたパスとなります。
https://oidc-provider.com/.well-known/openid-configuration
ここにアクセスすると、例えば以下のようなJSON形式のレスポンスが返ってきます。
{
"issuer": "https://oidc-provider.com",
"authorization_endpoint": "https://oidc-provider.com/authorize",
"token_endpoint": "https://oidc-provider.com/token",
"userinfo_endpoint": "https://oidc-provider.com/userinfo",
"jwks_uri": "https://oidc-provider.com/openid/v1/jwks",
"end_session_endpoint": "https://oidc-provider.com/logout",
"response_types_supported": [
"code",
"id_token",
"token id_token"
],
...
}
OIDCを使うクライアントは、まずこのエンドポイントにアクセスして、それに従って認証やトークン取得を進めます。そのため、細かいURLや仕様を事前に知らなくても、OIDCの標準仕様に従って安全に連携することが可能になります。
なお、OIDCプロバイダーを外部から利用するためには、
.well-known/openid-configuration に加えて /openid/v1/jwks パスの公開も必要になります。
/openid/v1/jwks は、kube-apiserverが発行するJWTの署名検証に使われる公開鍵(JWKS: JSON Web Key Set)を提供するエンドポイントです。外部サービスは、この公開鍵を用いて、OIDC プロバイダーが発行した JWT の署名を検証します。
kube-apiserverのOIDCエンドポイント
そして、KubernetesのAPIサーバー(kube-apiserver)にも、OIDCのメタデータ公開エンドポイントが存在します。つまりkube-apiserverは、OIDCプロバイダーとして振る舞う機能を持っています。
ここで「APIサーバーのOIDCエンドポイントは、ユーザーがkube-apiserverにログインするために使われるのでは?」と思われるかもしれませんが、これは正しくありません。この仕組みはユーザー認証のためではなく、Podで利用されるServiceAccountトークンを発行するために使われています。
ServiceAccountトークンは、Pod内では /run/secrets/kubernetes.io/serviceaccount/token に配置されますが、その中身を見るとJSON Web Token(JWT)の形式になっていることがわかります。 これは、kube-apiserverがOIDCの仕様に従ってトークンを発行しているためです。
このことから、
kube-apiserverはOIDCプロバイダーとしての機能を備えており、その役割はServiceAccountトークンの発行である
ことがわかります。
ServiceAccountトークンの利点
では、「ServiceAccountトークンがOIDCベースのJWT」だと、何が嬉しいのでしょうか。
OIDCの便利な点の一つは、「認証を自分で頑張らなくてよくなる」ことです。たとえば、ログインが必要なWebアプリを作る場合でも、OIDCプロバイダーと連携すれば、ユーザー管理や認証処理を一から実装する必要はありません。
KubernetesのServiceAccountトークンも、まさに同じ発想で設計されています。OIDCベースのJWTであるため、このトークンを使ってKubernetesの外にあるサービスへ「自分は誰か」を証明することができます。
代表的な例が、AWSのIAMロールとServiceAccountを関連付ける仕組みです。Pod自体がAWSの認証情報を保持していなくても、ServiceAccountトークンを用いることで、一時的なAWSの認証情報を取得し、各種AWSリソースを操作できます。
このように、ServiceAccountトークンをOIDCベースにすることで、Kubernetesと外部サービスとの安全かつ柔軟な連携が可能になります。
KubernetesにおけるServiceAccountトークンの署名検証
以上に説明した内容をもとに、Kubernetes版のJWTの署名検証を図にしてみました。この図では、外部サービスをAWSの各リソースにしていますが、他のサービスでも署名検証の流れは同じです。
kube-apiserverのOIDCエンドポイントを確認してみる
これまでの説明で、「ServiceAccountを外部サービスと連携するにはトークンの署名検証が必要で、外部サービスはkube-apiserverのOIDCエンドポイントへのアクセスが必要である」ことをおわかりいただけたかと思います。
実際にkubeadmで構築したKubernetesクラスターで、OIDCのメタデータエンドポイントにアクセスしてみましょう。今回は、クラスター内にPodを作って試してみます。
kube-apiserverは、クラスター内では以下の kubernetes というServiceリソースでサービス提供しています。
$ kubectl get svc -n default
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 95d
このため、クラスター内のネットワークでは kubernetes.default.svc.cluster.local というホスト名で名前解決が可能です。詳しくは公式ドキュメントを参照ください。
上記ホストに、curlを実行可能なPodを作ってアクセスしてみます。
kubectl run curl -it --rm --image=alpine/curl -- sh
curlのコンテナ内で、 /.well-known/openid-configuration へのアクセスを試みます。
/ # curl https://kubernetes.default.svc.cluster.local/.well-known/openid-configuration
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
More details here: https://curl.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
SSL/TLS証明書の検証でそもそも失敗しているようです。これを解決するには、kube-apiserverのCA証明書が必要です。CA証明書はPod内に格納されているので、そのパスを指定して再度アクセスしてみます。
/ # CACERT=/run/secrets/kubernetes.io/serviceaccount/ca.crt
/ # curl --cacert "$CACERT" \
https://kubernetes.default.svc.cluster.local/.well-known/openid-configuration
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "forbidden: User \"system:anonymous\" cannot get path \"/.well-known/\"",
"reason": "Forbidden",
"details": {},
"code": 403
}
証明書の検証は成功しましたが、次はクライアントにアクセス権がない旨のメッセージが表示されてしまいました。これを解決するには、PodにマウントされたServiceAccountのトークンを使って、自分が何者であるかを証明する必要があります。トークンを指定して、再度実行してみましょう。
/ # TOKEN=$(cat /run/secrets/kubernetes.io/serviceaccount/token)
/ # curl --cacert "$CACERT" \
-H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc.cluster.local/.well-known/openid-configuration
すると、以下のようなレスポンスが返ってきました。
{
"issuer": "https://kubernetes.default.svc.cluster.local",
"jwks_uri": "https://[control-plane-ip]:6443/openid/v1/jwks",
"response_types_supported": [
"id_token"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
]
}
"issuer" はクラスター内でのみ利用可能なホスト名になっており、 "jwks_uri" のホスト名はコントロールプレーンのIPアドレスになっています。このままでは、外部サービスがアクセスしても「アクセスしたホスト名と違う...」という理由で、JWTの署名検証に失敗してしまいます。そのため、この2つのURLに書かれたホスト名は、外部サービスがアクセスする際のホスト名と一致させる必要があります。
また、クラスター外のローカルネットワークからは、kube-apiserverに https://[control-plane-ip]:6443 で以下のようにアクセス可能です。
$ curl -k -I https://[control-plane-ip]:6443/ 2> /dev/null | head -n 1
HTTP/2 403
Control PlaneのIPアドレスは、例えば以下のコマンドで確認できます。
$ kubectl describe node -l node-role.kubernetes.io/control-plane | grep InternalIP
InternalIP: [control-plane-ip]
kube-apiserverにインターネットからアクセスするには、ロードバランサやリバースプロキシを使って、パブリックIPアドレスを[control-plane-ip]:6443に転送する必要がある点も注意です。
OIDCエンドポイント公開のために必要なことの整理
これまでにわかったことを踏まえて、OIDCのメタデータエンドポイントとJWKS(JSON Web Key Set)エンドポイントを公開するのに必要なことを整理してみます。
- (1) OIDCエンドポイントにインターネットからアクセスするための入口を作る
- パブリックIPの確保
- ホスト名(FQDN)でアクセス出来る状態にする
- (2) パブリックIPへのHTTPSリクエストをOIDCエンドポイントに転送する
- 今回は、Gateway API の Envoy Gateway を利用
- (3) OIDCエンドポイントの
"issuer"と"jwks_uri"のホスト名を修正する- (1)で決定したFQDNと同じホスト名にする
- kube-apiserverのマニフェストの編集が必要
手順
(1) OIDCエンドポイントにインターネットからアクセスするための入口を作る
外部サービスがJWTの署名検証を行う際には、OIDCのエンドポイントへアクセスできる必要があります。そのため、OIDCエンドポイントはインターネット経由で到達可能なことが必須となります。また、OIDCエンドポイントとの通信はHTTPSで行われるため、パブリックIPに紐づいたFQDNを用意し、信頼されたTLS証明書を設定しておく必要があります。
これらを実現する方法は、私の記事になりますが以下に掲載しました。
この記事の手順7までを進めると、準備は完了です。
作成したFQDNが名前解決するかを確認するには、例えばインターネットに接続可能なLinuxマシンで以下のコマンドを実行します。これでパブリックIPが返ってくれば成功です。
$ dig +short A [FQDN名]
11.22.33.44
TLS証明書でHTTPS通信できるかどうかの確認は後の手順で行います。
(2) パブリックIPへのHTTPSリクエストをOIDCエンドポイントに転送する
続いて、パブリックIPに届いたHTTPSのリクエストをOIDCエンドポイントに転送するためのリバースプロキシを用意します。今回はEnvoy Gatewayを使います。
(2-1) Envoy Gatewayのインストール
まずは、自前で用意したKubernetesクラスターにEnvoy Gatewayをインストールします。
今回は、以下のHelmfile形式のyamlファイルを使って、Envoy GatewayのHelmチャートをインストールすることで実現します。
releases:
- name: eg
chart: oci://docker.io/envoyproxy/gateway-helm
version: 1.6.2
createNamespace: true
namespace: envoy-gateway-system
values:
- config:
envoyGateway:
provider:
type: Kubernetes
kubernetes:
deploy:
type: GatewayNamespace # Envoy Proxyをnamespace指定でデプロイするのに必要
次のコマンドを実行します。
helmfile apply -f helmfile-envoy-gateway.yaml
Helmチャートのデプロイに成功したら、次のようなカスタムリソースが作成されていることを確認できます。
Gateway APIのカスタムリソース
$ kubectl api-resources --api-group=gateway.networking.k8s.io | head -n4
NAME SHORTNAMES APIVERSION NAMESPACED KIND
backendtlspolicies btlspolicy gateway.networking.k8s.io/v1 true BackendTLSPolicy
gatewayclasses gc gateway.networking.k8s.io/v1 false GatewayClass
gateways gtw gateway.networking.k8s.io/v1 true Gateway
Envoy Gatewayのカスタムリソース
$ kubectl api-resources --api-group=gateway.envoyproxy.io | head -n4
NAME SHORTNAMES APIVERSION NAMESPACED KIND
backends be gateway.envoyproxy.io/v1alpha1 true Backend
backendtrafficpolicies btp gateway.envoyproxy.io/v1alpha1 true BackendTrafficPolicy
clienttrafficpolicies ctp gateway.envoyproxy.io/v1alpha1 true ClientTrafficPolicy
また、Envoy Gatewayのコントローラーがインストールされていることも確認できます。
$ kubectl get pod -A -l app.kubernetes.io/instance=eg
NAMESPACE NAME READY STATUS RESTARTS AGE
envoy-gateway-system envoy-gateway-656884489f-62bgm 1/1 Running 0 11m
(2-2) Gateway API(Envoy Gateway)のカスタムリソースをデプロイ
続いて、Gateway APIのカスタムリソースをデプロイします。主要リソースだけで言うと、デプロイの順番は「GatewayClass → Gateway → HTTPRoute」となります。
まずは、以下のマニフェストでGatewayClassリソースをデプロイします。
GatewayClass
以下のマニフェストで、GatewayClassリソースをデプロイします。
# EnvoyProxyリソースの説明は後述
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: eg-hostnet
namespace: envoy-gateway-system
spec:
extraArgs:
- --use-dynamic-base-id
provider:
type: Kubernetes
kubernetes:
useListenerPortAsContainerPort: true
envoyDaemonSet:
patch:
type: StrategicMerge
value:
spec:
template:
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: envoy
securityContext:
allowPrivilegeEscalation: false
privileged: false
runAsUser: 0
runAsGroup: 0
runAsNonRoot: false
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
envoyService:
type: ClusterIP
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: GatewayClass
metadata:
name: envoy-gateway
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: eg-hostnet
namespace: envoy-gateway-system
EnvoyProxyはEnvoy Gatewayのカスタムリソースで、Envoy Gatewayが配備するEnvoyの動作設定(Podの属性やServiceの種類など)をカスタマイズするためのリソースです。今回はEnvoy Gatewayをリバースプロキシとして、ホストネットワーク上の443番で待ち受けさせたいので、EnvoyProxyにこの設定を記述しています。
このマニフェストをapplyします。
kubectl apply -f envoy.gatewayclass.yaml
GatewayClassリソースが作成されることを確認します。
$ kubectl get gatewayclass
NAME CONTROLLER ACCEPTED AGE
envoy-gateway gateway.envoyproxy.io/gatewayclass-controller True 13s
Gateway
続いて、Gatewayリソースを以下のマニフェストで作成します。
apiVersion: gateway.networking.k8s.io/v1beta1
kind: Gateway
metadata:
name: apiserver-gateway
namespace: default
spec:
gatewayClassName: envoy-gateway
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "[FQDN名]" # 手順(1)で用意したFQDNを記載
tls: # cert-managerで作成したTLS証明書を指定
mode: Terminate
certificateRefs:
- kind: Secret
name: cert-tls
このマニフェストをapplyします。
kubectl apply -f apiserver.gateway.yaml
すると、Gatewayリソースが作成されます。
$ kubectl get gateway -n default
NAME CLASS ADDRESS PROGRAMMED AGE
apiserver-gateway envoy-gateway 10.111.58.170 True 14s
上記出力で、PROGRAMMED が true になっているかを確認してください。
上手く行っていれば、以下のようなリバースプロキシ用のPod(Deployment)とServiceリソースが作成されているはずです。
$ kubectl get po -n default -l app.kubernetes.io/managed-by=envoy-gateway
NAME READY STATUS RESTARTS AGE
apiserver-gateway-nssng 2/2 Running 0 61s
$ kubectl get svc -n default -l app.kubernetes.io/managed-by=envoy-gateway
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
apiserver-gateway ClusterIP 10.111.58.170 <none> 443/TCP 2m41s
HTTPRoute
さらに、リバースプロキシの転送ルールを記載したHTTPRouteリソースを作成するために、以下のマニフェストを用意します。
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: apiserver-route
namespace: default
spec:
parentRefs:
- name: apiserver-gateway
hostnames:
- "[FQDN名]" # 手順(1)で用意したFQDNを記載
rules:
- backendRefs:
- name: kubernetes # 転送先はkube-apiserver
namespace: default
port: 443
matches: # OIDCのエンドポイントのみに転送を限定する
- path:
type: Exact
value: /.well-known/openid-configuration
- path:
type: Exact
value: /openid/v1/jwks
このマニフェストをapplyします。
kubectl apply -f apiserver.httproute.yaml
リソースが作成されたことを確認します。
$ kubectl get httproute -n default
NAME HOSTNAMES AGE
apiserver-route ["[FQDN名]"] 11s
その他の必要な対応
ここまで進めれば、ようやくOIDCのエンドポイントに接続できるようになります。
$ curl -I https://[FQDN名]/.well-known/openid-configuration 2>/dev/null | head -n 1
HTTP/2 400
しかしながら、400のステータスコードが返ってきてしまいます。
kubectl logs -n default ds/apiserver-gateway --tail=1 | jq
でEnvoy Gatewayのログを見ると、次のように出力されていました。
{
...
"response_code": 400,
"response_code_details": "via_upstream",
...
"upstream_cluster": "httproute/default/apiserver-route/rule/0",
"upstream_host": "[control-plane-ip]:6443",
...
}
この出力から「400のステータスコードはバックエンド、つまりkube-apiserverが返している」ことがわかります。理由は、kube-apiserverはHTTPSでlistenしているにも関わらず、Envoy GatewayはHTTPでプロキシしようとしているためです。
そこで、BackendTLSPolicyリソースを用意して、Gatewayからkube-apiserverの接続をHTTPSで実施するようにします。
以下がマニフェストです。
apiVersion: gateway.networking.k8s.io/v1
kind: BackendTLSPolicy
metadata:
name: apiserver-backend-tls
namespace: default
spec:
targetRefs:
- group: ""
kind: Service
name: kubernetes
validation:
hostname: kubernetes.default.svc
caCertificateRefs: # kube-apiserverのCA証明書を使う設定
- group: ""
kind: ConfigMap
name: kube-root-ca.crt
このマニフェストをapplyします。
kubectl apply -f apiserver.backendtlspolicy.yaml
すると、kube-apiserverからレスポンスが返るようになりました。
$ curl https://[FQDN名]/.well-known/openid-configuration
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "forbidden: User \"system:anonymous\" cannot get path \"/.well-known/openid-configuration\"",
"reason": "Forbidden",
"details": {},
"code": 403
}
しかし、既に説明したようにkube-apiserverのOIDCエンドポイントから情報を取得するにはJWT等を用いた認証認可が必要になるため、まだやりたいことを実現できません。
そこで、次のマニフェストで定義されるリソースを作成して、OIDCのエンドポイントのみは認証認可を不要とする設定にします。
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: allow-oidc-discovery-and-jwks
rules:
- nonResourceURLs:
- "/.well-known/openid-configuration"
- "/openid/v1/jwks"
verbs:
- "get"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: allow-oidc-discovery-and-jwks-to-anonymous
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: allow-oidc-discovery-and-jwks
subjects:
- kind: Group
name: system:unauthenticated
apiGroup: rbac.authorization.k8s.io
上記マニフェストを適用します。
kubectl apply -f allow-oidc-public.clusterrole.yaml
これでようやく、OIDCエンドポイントから必要な情報を取得できるのを、以下のように確認できます。
まずは、OIDCメタデータのエンドポイントの確認です。
curl https://[FQDN名]/.well-known/openid-configuration 2>/dev/null | jq
以下が出力です。
{
"issuer": "https://kubernetes.default.svc.cluster.local",
"jwks_uri": "https://[control-plane-ip]:6443/openid/v1/jwks",
"response_types_supported": [
"id_token"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
]
}
続いて、JWKSエンドポイントにアクセスしてみます。
curl https://[FQDN名]/openid/v1/jwks 2>/dev/null | jq
以下が出力です。
{
"keys": [
{
"use": "sig",
"kty": "RSA",
"kid": "awrVdy...",
"alg": "RS256",
"n": "qRcf8S...",
"e": "A..."
}
]
}
(3) OIDCエンドポイントの "issuer" と "jwks_uri" のホスト名を修正する
最後に、OIDCエンドポイントに記載されたホスト名を、外部サービスがアクセスする際のホスト名と同じになるように修正します。具体的には、kube-apiserverのマニフェスト(/etc/kubernetes/manifests/kube-apiserver.yaml)において、起動コマンド実行時の引数を以下のように変更します。
spec:
containers:
- command:
- kube-apiserver
...
- --requestheader-group-headers=X-Remote-Group
- --requestheader-username-headers=X-Remote-User
- --secure-port=6443
- - --service-account-issuer=https://kubernetes.default.svc.cluster.local
+ - --service-account-issuer=https://[FQDN名]
+ - --service-account-jwks-uri=https://[FQDN名]/openid/v1/jwks
- --service-account-key-file=/etc/kubernetes/pki/sa.pub
- --service-account-signing-key-file=/etc/kubernetes/pki/sa.key
- --service-cluster-ip-range=10.96.0.0/12
...
変更後、kube-apiserverが再起動してReadyになるまで待ちます。
- 注意点
-
kube-apiserver.yamlのマニフェストのバックアップを同じディレクトリに作ると、バックアップ側の内容が読まれてデプロイされてしまう可能性があります。 - 今回の変更により、いくつかのPod(CNIだとcilium-operator等)が起動しなくなってしまう可能性が高いです。その場合は該当PodのDeploymentやDaemonSetを再起動すると解決するはずです。
-
再度、curlでOIDCのメタデータエンドポイントにアクセスします。
curl https://[FQDN名]/.well-known/openid-configuration 2>/dev/null | jq
すると、次のようにホスト名が変わっているのを確認できます。
{
"issuer": "https://[FQDN名]",
"jwks_uri": "https://[FQDN名]/openid/v1/jwks",
"response_types_supported": [
"id_token"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
]
}
おわりに
「EKSなしでIRSA」を実現する第一歩として、kube-apiserverのOIDCのエンドポイントを公開することができました。次回記事では、AWSにOIDCプロバイダーやIAMロールを作成して、KubernetesのServiceAccountでAWSリソースにアクセスできることを確認します。

