はじめに
オンプレのKubernetesから、Podにアクセスキーを持たせずにAWS(S3やSSM Parameter Storeなど)を使いたい場面があります。
EKSにはIRSA(IAM Roles for Service Accounts) があり、ServiceAccountにIAMロールを紐付けられます。実はこの仕組みはEKS専用ではなく、OIDCの公開ドキュメントをHTTPSで公開できれば、オンプレのkubeadmクラスタでも同じことができます。しかも公開用のサーバーは不要で、S3(とCloudFront)に静的ファイルを2つ置くだけです。
この記事では、自宅のkubeadmクラスタで実際に動かしている構成をもとに、仕組みと手順を紹介します。
仕組み
ポイントは「AWSがKubernetesのAPIサーバーに問い合わせるわけではない」ことです。
- kube-apiserverは、ServiceAccountのトークン(JWT)を秘密鍵で署名して発行する
- 署名の検証に使う公開鍵(JWKS) と、その場所を示すdiscoveryドキュメントを、インターネットから取得できるURLに置いておく
- AWSのIAMに、そのURLをOIDC IdPとして登録する
- Podはトークンを添えてSTSの
AssumeRoleWithWebIdentityを呼ぶ。STSはIdPのJWKSでトークンの署名を検証し、ロールのtrust policyの条件(sub・aud)に合えば一時クレデンシャルを返す
つまりAWSが必要とするのは静的なJSON 2つだけで、クラスタ自体はインターネットに公開しなくて済みます。
全体像
| 要素 | 役割 |
|---|---|
| S3バケット(private) |
.well-known/openid-configurationとopenid/v1/jwksを置く |
| CloudFront(OAC) | S3をHTTPSで公開する。独自ドメインとACM証明書を付ける |
| IAM OIDC provider | 上記URLをIdPとして登録する |
| IAM role | trust policyでnamespace:serviceaccountを限定する |
| kube-apiserver |
service-account-issuerを上記URLにする |
| Pod |
audience: sts.amazonaws.comのprojected tokenを使う |
S3を直接公開せず、バケットはprivateのままCloudFront(OAC)経由にしています。IAM OIDC providerはHTTPSの有効な証明書が前提なので、独自ドメインとACM証明書をCloudFrontに付けるほうが扱いやすいためです(CloudFrontに付けるACM証明書だけus-east-1に作る必要があります)。
手順
以降のoidc.example.comは自分のドメインに読み替えてください。AWS側はTerraformで書きますが、手でも同じです。
1. discoveryとJWKSを用意する
discoveryドキュメントは次のとおりです。issuerは後でkube-apiserverに指定するissuerと完全に一致させます。
{
"issuer": "https://oidc.example.com",
"jwks_uri": "https://oidc.example.com/openid/v1/jwks",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
JWKSは、クラスタが自分で出している公開鍵をそのまま保存します。コントロールプレーンで次を実行します。
kubectl get --raw /openid/v1/jwks > jwks.json
公開鍵なので、Gitに置いても問題ありません。
2. S3 + CloudFrontで公開する
バケットはブロックパブリックアクセスを有効にしたまま、CloudFrontのディストリビューションからだけ読めるようにします。
resource "aws_s3_object" "k8s_oidc_discovery" {
bucket = aws_s3_bucket.oidc.id
key = ".well-known/openid-configuration"
source = "${path.module}/oidc/openid-configuration.json"
etag = filemd5("${path.module}/oidc/openid-configuration.json")
content_type = "application/json"
}
resource "aws_s3_object" "k8s_oidc_jwks" {
bucket = aws_s3_bucket.oidc.id
key = "openid/v1/jwks"
source = "${path.module}/oidc/jwks.json"
etag = filemd5("${path.module}/oidc/jwks.json")
content_type = "application/json"
}
resource "aws_cloudfront_origin_access_control" "k8s_oidc" {
name = "oidc"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
data "aws_iam_policy_document" "oidc_cloudfront" {
statement {
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.oidc.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.k8s_oidc.arn]
}
}
}
ディストリビューション・ACM証明書(us-east-1、DNS検証)・Route 53のエイリアスは通常のCloudFront+S3の構成と同じなので省略します。デプロイ後、次が取得できれば準備完了です。
curl -sf https://oidc.example.com/.well-known/openid-configuration
curl -sf https://oidc.example.com/openid/v1/jwks
3. IAM OIDC providerとロールを作る
resource "aws_iam_openid_connect_provider" "k8s" {
url = "https://oidc.example.com"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_policy_document" "trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.k8s.arn]
}
condition {
test = "StringEquals"
variable = "oidc.example.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "oidc.example.com:sub"
values = ["system:serviceaccount:my-namespace:my-sa"]
}
}
}
resource "aws_iam_role" "my_app" {
name = "k8s-my-app"
assume_role_policy = data.aws_iam_policy_document.trust.json
}
subでnamespaceとServiceAccount名まで絞るのが重要です。ここを緩めると、クラスタ内の別のServiceAccountからもこのロールを使えてしまいます。
4. kube-apiserverのissuerを切り替える
kubeadmならClusterConfigurationのapiServer.extraArgsに指定します。
apiServer:
extraArgs:
- name: service-account-issuer
value: https://oidc.example.com
- name: service-account-issuer
value: https://kubernetes.default.svc.cluster.local
- name: service-account-jwks-uri
value: https://oidc.example.com/openid/v1/jwks
service-account-issuerは複数指定でき、先頭が署名に使われ、2つ目以降は検証だけに使われます。 既定のissuerを残さないと、すでに発行済みのトークン(CNIやArgo CDなどが使っているもの)が無効になるので注意してください。
反映します。
kubeadm init phase control-plane apiserver --config cluster-configuration.yaml
kubeadm init phase upload-config kubeadm --config cluster-configuration.yaml
kubeadm-configにも反映しておかないと、kubeadm upgradeでフラグが消えます。単一コントロールプレーン構成だと、kube-apiserverの再起動中は数十秒APIが止まります(動作中のPodには影響しません)。
切り替わったかは次で確認できます。
kubectl get --raw /.well-known/openid-configuration
issuerとjwks_uriが、S3に置いたものと一致していればOKです。
5. Podにトークンを渡す
EKSでは「Pod Identity Webhook」がPodに自動で注入してくれますが、オンプレでは自分で書きます。と言っても、projected tokenのaudienceをsts.amazonaws.comにして、AWS SDKが読む環境変数を指定するだけです。
spec:
serviceAccountName: my-sa
containers:
- name: app
env:
- {name: AWS_ROLE_ARN, value: "arn:aws:iam::123456789012:role/k8s-my-app"}
- {name: AWS_WEB_IDENTITY_TOKEN_FILE, value: /var/run/secrets/aws/token}
- {name: AWS_REGION, value: ap-northeast-1}
- {name: AWS_STS_REGIONAL_ENDPOINTS, value: regional}
volumeMounts:
- {name: aws-token, mountPath: /var/run/secrets/aws, readOnly: true}
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken: {audience: sts.amazonaws.com, expirationSeconds: 86400, path: token}
AWS SDK(やaws CLI)はAWS_ROLE_ARNとAWS_WEB_IDENTITY_TOKEN_FILEがあれば自動でWeb Identityのクレデンシャルを使うので、アプリ側のコード変更は不要です。トークンはkubeletが期限前に更新します。
動作確認は、Pod内で次を実行します。
aws sts get-caller-identity
assumed-role/k8s-my-app/...が返ればOKです。
運用上のポイント
-
ロールの追加はTerraformに1エントリ足すだけにしておくと楽です。
namespace・service_account・ポリシーのmapをfor_eachでロールとtrust policyに展開しています。 -
ServiceAccountの署名鍵(
sa.pub)を入れ替えたら、JWKSの更新が必要です。kubectl get --raw /openid/v1/jwksの出力でS3上のファイルを置き換えます。CloudFrontのキャッシュで反映が遅れる場合はcreate-invalidationで消します。 -
Podから
sts.<region>.amazonaws.com:443に出られる必要があります。閉域のネットワークだと見落としがちです。 - アクセスキーを使わないので、ローテーションやSecretへの格納が不要になり、漏洩リスクも下がります。クレデンシャルは短期(既定1時間)です。
うまくいかないとき
| 症状 | 確認すること |
|---|---|
InvalidIdentityToken: No OpenIDConnect provider found |
トークンのissと、IAM OIDC providerのURLが完全一致しているか(末尾スラッシュ含む) |
InvalidIdentityToken で署名検証に失敗 |
S3上のJWKSがkubectl get --raw /openid/v1/jwksと一致しているか。CloudFrontのキャッシュが古くないか |
AccessDenied(AssumeRoleWithWebIdentity) |
trust policyのaudとsub。subはsystem:serviceaccount:<namespace>:<sa名>
|
| 既存のPodが認証エラーになる | 旧issuerをservice-account-issuerの2つ目以降に残したか |
トークンの中身は、payloadをbase64デコードするとiss・sub・audが見られます。
cut -d. -f2 /var/run/secrets/aws/token | base64 -d 2>/dev/null; echo
おわりに
オンプレKubernetesでも、公開された2つのJSONとIAM OIDC providerだけで、EKSのIRSAと同じ「アクセスキーなしのIAMロール割り当て」 が実現できます。サーバーを立てる必要がなく、S3とCloudFrontの静的配信で済むのが気に入っています。
実際に、External Secrets OperatorがSSM Parameter Storeを読むロールや、S3にバックアップを書くCronJobのロールなど、ServiceAccountごとに最小権限のロールを割り当てて運用しています。
オンプレのクラスタからAWSを使うときに、IAMユーザーのアクセスキーをSecretに入れている方は、ぜひ試してみてください。