0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オンプレKubernetesのServiceAccountにS3+CloudFrontだけでAWS IAMロールを割り当てる(IRSA相当)

0
Posted at

はじめに

オンプレの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サーバーに問い合わせるわけではない」ことです。

  1. kube-apiserverは、ServiceAccountのトークン(JWT)を秘密鍵で署名して発行する
  2. 署名の検証に使う公開鍵(JWKS) と、その場所を示すdiscoveryドキュメントを、インターネットから取得できるURLに置いておく
  3. AWSのIAMに、そのURLをOIDC IdPとして登録する
  4. 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と完全に一致させます。

openid-configuration.json
{
  "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に入れている方は、ぜひ試してみてください。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?