🔑

MinIOをKubernetesからアクセスキーなしで使いたい!

2023/12/31に公開

TL;DR

  • 同一クラスタ内 && minio-operatorを使う場合
    • 公式のexampleに従おう
    • (こっちだとかなり楽に設定できそう)
  • 外部のMinIO || minio-operator以外の手順でMinIOを構築している場合
    • → MinIOのOpenID Connect設定に追加して頑張る ←今回はこっちメインの話

はじめに

本当はAdvent Calendarのネタとして用意してたんですが、色々躓いてminioにPR送ったりしていたら遅くなりました。

自称Secret撲滅エンジニア、Tsuzu です。
今年NASを自作しTrueNAS Scaleをインストールするなど自宅サーバの拡張を進めています。
また、R86Sを購入したりフレッツ光クロスを引いて10G化を進めた1年でした。

そんな中で、S3互換オブジェクトストレージが欲しくなったので、NAS上でMinIO を立ててみました。MinIOはGoで書かれたオープンソースのS3互換のオブジェクトストレージです。

Kuberenetes上にインストールする場合はminio-operatorを利用したインストール が公式では推奨されていますが、沢山のテナントを立てるわけでもないのでoperatorは使わず1台だけ建てました。

NASの外部にあるKubernetesクラスタを自前で運用しており、ここから接続してMinIOを利用したくなりました。MinIOはS3と互換性を持っているため、接続にはAccess Key IDとSecret Access Keyを使うのが一般的です。

しかし、S3のためのAccess Keyをたくさん発行してSecret Storeに保存したりするのは、発行自体にコストがかかるだけでなく、流出時のリスクも高まり、更新時のコストも同様に発生します。そんなSecretは撲滅しましょう。

IAM Roles for Service Accounts(IRSA)について

AWSでEKS(on EC2)からAWSのAPIを叩く際には大きく分けて3つの方法があります。「愚直にAWS IAM Userを発行する方法」、「EC2 Instanceに紐づいたIAM Roleを使う方法」、そして「IAM Roles for Service Accounts(IRSA)」を使う方法です。

追記
...と思っていたら EKS Pod Identityというのが増えていたらしい...知らなかった。これから追います

「愚直にAWS IAM Userを発行する方法」については管理コストがかかり面倒だったり、「EC2 Instanceに紐づいたIAM Roleを使う方法」についてはノードごとに権限が付与されてしまうためPod単位の制御ができずセキュアでない、などの問題があります。それを解決するのが IAM Roles for Service Accounts を使う方法です。

この後につながるので簡単に仕組みについて説明します。すでに知っている方は読み飛ばしてください。

PodでService Accountを利用する際、kube-apiserverが信頼するOpenID Connect ProviderのID Tokenが付与されます。これはPod作成時に自動的にPodにProjected Volumeとしてmountされるようになっています。これはTokenRequestProjectionという仕組みを通じて実現されています。

$ k run --image busybox test -- sleep infinity 
pod/test created
$ k get po test -o yaml | yq .spec.volumes
- name: kube-api-access-97x8t
  projected:
    defaultMode: 420
    sources:
      - serviceAccountToken: # ←これ
          expirationSeconds: 3607
          path: token
      - configMap:
          items:
            - key: ca.crt
              path: ca.crt
          name: kube-root-ca.crt
      - downwardAPI:
          items:
            - fieldRef:
                apiVersion: v1
                fieldPath: metadata.namespace
              path: namespace

この機能はService Account以外の用途で使うこともできます。こんな感じでaudience(Client ID)を明示的に指定すると、そのaudience向けのID Tokenを発行することができます。

- name: aws-iam-token
  projected:
    sources:
    - serviceAccountToken:
        path: token
        expirationSeconds: 86400
        audience: sts.amazonaws.com

これによって発行されたID TokenのJWTを見てみるとaudに適切に指定したaudienceが入っています。

{
  "aud": [
    "sts.amazonaws.com"
  ],
  "exp": 1704041245,
  "iat": 1703954845,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "kubernetes.io": {
    "namespace": "default",
    "pod": {
      "name": "my-pod",
      "uid": "c0e21bfe-fd34-485b-bcc2-7d58c7d0fc8f"
    },
    "serviceaccount": {
      "name": "default",
      "uid": "c3dcebd8-d895-40f4-89d4-d8b1da80abb0"
    }
  },
  "nbf": 1703954845,
  "sub": "system:serviceaccount:default:default"
}

AWSのIAMにはAssume Role with Web IdentityというAPIがあり、外部のOpenID Connect Providerを信頼することができます。ID Tokenを付与してSTSのAssumeRoleWithWebIdentityを叩くと、一時的なcredentialが付与され、通常通りAWSのAPIが叩けるようになります。

詳細な動作については AWS公式の Diving into IAM Roles for Service Accounts が詳細に書いているのでおすすめです。

MinIOでもIAM Roles for Service Accounts

同一クラスタかつminio-operatorの場合

同じことがMinIOでもできるようになっています。同一クラスタ内に minio-operatorを利用している場合は公式ドキュメントの MinIO Operator STS: Native IAM Authentication for Kubernetesを参考に構築すれば動く(はず)です。ただ、私は試していないのでわかりません。

別クラスタ、もしくはminio-opratorを使わない場合

openid-configurationの公開

別クラスタを利用している、もしくはminio-operatorを利用せずに構築している場合は上記の手順が使えないので自力で頑張る必要があります。ここからはその話を書きます。
ドキュメント自体は Configure MinIO for Authentication using OpenID のページなどにあります。

まず、MinIOがID Tokenを検証するための情報を取得するエンドポイントを指定する必要があります。 kube-apiserverの /.well-known/openid-configuration をMinIOからアクセスできるようにすれば良いのですが、通常は認証済みユーザしかアクセスできません。本来は直接kube-apiserverを許可するよりはIngress等を介してあげた方がセキュリティ的には良いのですが、どうせインターネットからはアクセスできないのでkube-apiserverにanonymousでもアクセスできるようにしました。以下の ClusterRoleBinding をapplyして上げれば許可できます。ClusterRoleは 元々あるので作成不要です。

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: openid-configuration
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:service-account-issuer-discovery
subjects:
- kind: User
  name: system:anonymous
  apiGroup: rbac.authorization.k8s.io

kube-apiserverはデフォルトでanonymous-authが有効ですが、k3sでは無効化されているので有効化するのをお忘れなきよう...私はハマりました。

kube-apiserverのCA証明書をMinIOで信頼する

kube-apiserverが使用する証明書はクラスタ内の独自CA certificateであることが多いのでMinIOから接続する際に信頼できるようにする必要があります。適当なConfigMapを利用して /root/.minio/certs/CAs に配置するようにします。コンテナ以外で動かす場合は /root 以外になりうるのでいい感じにしてください。

MinIOでOpenID Connectの設定をする

Web UIからも出来ますがIaCがお好きなみなさんはYAMLで設定投入したいと思うのでそのやり方で書いておきます。以下を環境変数に突っ込みましょう。

MINIO_IDENTITY_OPENID_CONFIG_URL=https://{{ kube-apiserverのエンドポイント }}:6443/.well-known/openid-configuration
MINIO_IDENTITY_OPENID_CLIENT_ID=sts.amazonaws.com
MINIO_IDENTITY_OPENID_CLIENT_SECRET=dummy
MINIO_IDENTITY_OPENID_ROLE_POLICY=readwrite
MINIO_IDENTITY_OPENID_DISPLAY_NAME=k8s

OpenID Config URLは先ほどのURLです。
Client IDはAWSでの値に合わせます。合わせなくてもMinIO自体は問題ありませんが、AWS CLIを使うことを考えると合わせておいた方が楽です。Client SecretはWeb UIでのログインフローを使わないので適当で良いです。Role Policyは

$ mc admin policy ls truenas-scale
consoleAdmin
diagnostics
readonly
readwrite
writeonly

やUIで取得してきた好きなPolicyを指定しておきます。ただ、後々を考えると別途用意した方が良いとは思います。

この設定を適用してminioを起動すると起動時に以下のようなIAM Rolesが出力されているのでこれをメモっておきます。MinIOで利用する場合はAssumeするRoleがこの起動時に表示される1つのみになるようです。Roleを自由に作れないのはちょっと残念。

IAM Roles: arn:minio:iam:::role/oxnUw3DAuQF1uggjXGxI2AmW8IA

じゃあ権限をService Accountごとに制御できないのかというと一応PolicyのConditionで制御はできるらしいです。Policyは自前で別途用意した方が良い、というのはこれが理由です。
https://min.io/docs/minio/windows/administration/identity-access-management/oidc-access-management.html#minio-external-identity-management-openid-access-control

PodからMinIOに接続する

以上で設定はできたのでPodに設定を投入していきます。AWSでIAM Roles for Service Accountsを使う場合はMutation Webhookが勝手にこのあたりの設定を投入してくれますがMinIOの場合は自分で投入します。

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: amazon/aws-cli:latest
    command:
    - bash
    - -c
    - "sleep infinity"
    ports:
    - containerPort: 80
    env:
    - name: AWS_ROLE_ARN
      value: {{ 先ほどログから取得したARN }}
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token # この辺りもAWSの仕様に合わせています
    - name: AWS_ENDPOINT_URL_STS
      value: http://{{ MinIOのendpoint(consoleではなくAPIのため注意) }}
    - name: AWS_ENDPOINT_URL_S3
      value: http://{{ MinIOのendpoint(consoleではなくAPIのため注意) }}
    volumeMounts:
    - name: aws-iam-token
      readOnly: true
      mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
  volumes:
  - name: aws-iam-token
    projected:
      sources:
      - serviceAccountToken:
          path: token
          expirationSeconds: 86400
          audience: sts.amazonaws.com

以下のようにexecして接続できていればOKです。

$ pbpaste | k apply -f -
$ k exec -n default -ti my-pod -- bash
bash-4.2# aws s3api create-bucket --bucket test
{
    "Location": "/test"
}
bash-4.2# aws s3 ls
2023-12-31 12:41:09 test

注意点

2023/12/31現在、kube-apiserverのOpenID Connectの設定をMinIOに追加しているとMinIOのconsoleが見られないという問題があります。

minio consoleでAn error has occurredにより開けない様子

https://github.com/minio/console/pull/3168 のPRで修正されているのですが、まだリリースタグが打たれていないのでがリリースされるのを待った方が良いでしょう。OpenIDでログインできないだけでなく、ID/Passwordでもログインできません。

litestreamでMinIOのIRSAを使う

自宅クラスタでMySQLやPostgreSQLを用意するのは地味に面倒で、PV等を使わずサクッと作りたい時があります。そんな時はSQLiteを使ってlitestream でS3にreplicateし続けて、起動時にS3から復旧することでPVを利用せずDBを使うことができます。

そのlitestreamで今回のIRSA for MinIOを使う方法を紹介しておきます。こんな感じでlitestreamのconfigを用意します。

dbs:
  - path: /db/app.db
    replicas:
      - type: s3
        endpoint: {{ MinIOのS3 API endpoint (http://example.com:9000) }}
        bucket: app
        path: app.db

上記コンフィグをConfigMapとして読み込み、emptyDirのvolumeをinitContainerとsidecarでlitestreamを動かします。

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: app
  namespace: app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app
  serviceName: app  
  template:
    metadata:
      labels:
        app: app
    spec:
      initContainers:
      - name: litestream-restore
        image: litestream/litestream:0.3
        args: ['restore', '-if-db-not-exists', '-if-replica-exists', '-config', '/config/litestream.yml', '/db/app.db']
        env:
        - name: AWS_ROLE_ARN
          value: {{ 先ほどログから取得したARN }}
        - name: AWS_WEB_IDENTITY_TOKEN_FILE
          value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
        volumeMounts:
        - name: aws-iam-token
          readOnly: true
          mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
        - name: db
          mountPath: /db
        - name: litestream-config
          mountPath: /config
          readOnly: true
      containers:
      - name: litestream-replicate
        image: litestream/litestream:0.3
        args: ['replicate', '-config', '/config/litestream.yml']
        env:
        - name: AWS_ROLE_ARN
          value: {{ 先ほどログから取得したARN }}
        - name: AWS_WEB_IDENTITY_TOKEN_FILE
          value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
        volumeMounts:
        - name: aws-iam-token
          readOnly: true
          mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
        - name: db
          mountPath: /db
        - name: litestream-config
          mountPath: /config
          readOnly: true
      - name: app
        image: ... # 省略
      volumes:
      - name: aws-iam-token
        projected:
          sources:
          - serviceAccountToken:
              path: token
              expirationSeconds: 86400
              audience: sts.amazonaws.com
      - name: db
        emptyDir: {}
      - name: litestream-config
        configMap:
          name: litestream-config

参考: https://zenn.dev/mattn/articles/fef682a8b204ac

終わりに

以上が、AssumeRoleWithWebIdentityを使ってMinIOでSecretを使わずにIAM Roles for Service Accountsでアクセスする方法でした。ちなみにaws-sdk-goとかもLoadDefaultConfig()するだけで勝手にIRSAを使ってくれるので便利です。余裕があったらMutating Webhookも用意してもっと楽にしたい気持ちがあります。

私はSecretを無くしたかっただけなのに意外とやることが多くて大変でしたが皆さんも是非リスクの原因となるSecretはどんどん撲滅していきましょう。

それでは良いお年を!

GitHubで編集を提案

Discussion