ぽよメモ

レガシーシステム考古学専攻

ArgoCDでリソースを消さずにrequire pruning状態を解消する

背景

ArgoCDでGitOpsしているとします。ここで誤ってkube-system namespaceリソースを一度管理下に入れてpushし、revertしたとしましょう。このようにArgoCDが同期しているリソースが同期元から消えたとき、設定によって以下の2パターンのどちらかの挙動となります。

  • prune: true のとき
    • 同期していたリソースの削除を自動的に試みる。リソースの削除が完了するとSyncedステータスになる。
  • prune: false のとき
    • 同期していたリソースは削除されず、該当リソースの削除待ち状態となりApplicationリソースは OutOfSyncステータスになる。
    • 手動で該当リソースを削除するとSyncedステータスになる。

まず前者の状態になるといきなり詰む可能性があります。基本的に使わない方がよいでしょう*1

後者の場合、kube-system namespaceは消したくありませんがOutOfSyncは解消したいところです。手動syncをしても(設定を変えなければ)以下の様にfailせずにずっとOutOfSyncなままの状態になります。今回はこれをどうにかする方法を紹介します。

kube-systemがrequire pruningになりOutOfSyncになっている

今回検証したのはArgoCD v2.14.11とv3.0.1の2つのバージョンです。

効果が無い方法

ignoreDifferenceに追加する

ArgoCDで差分を無視する有名な方法としてはArgoCDのsyncOptionで差分を無視するリソースを記述する方法です。GitHub Copilot Chatで聞いたところこれをおすすめされました。

argo-cd.readthedocs.io

しかし一度OutOfSyncとして認識されてしまうとこの方法は効果がありませんでした。

ArgoCDの機能でロールバックする

今回は一度変更をpush→revertしてなかったことにしたリソースでOutOfSyncにしてしまったため、過去のpush前の状態に戻すことは比較的安全であると考えられます。このとき上手くリセットされて管理下から外れないだろうかと考えたのですが効果はありませんでした。

argo-cd.readthedocs.io

正しい(?)方法

ArgoCDがリソーストラッキングに使っている情報を当該リソースから消すと、ArgoCDの管理下から外れます。そのリソースは既にArgoCDの同期対象ではないため、再度トラッキング情報が付与されることもありません。

ArgoCDがリソーストラッキングする方法が3つあり、自分のクラスタでどれを使っているかによって対処法が異なります。必ず設定を確認するようにしてください。

argo-cd.readthedocs.io

label の場合

v3.0.0以前のデフォルトのトラッキング手法です。ArgoCDは app.kubernetes.io/instance ラベルを使ってリソースを識別します

今回リソースを同期しているApplicationの名前を foo とすると、同期されているリソースには app.kubernetes.io/name: foo のようなラベルが付与されます。このラベルを削除すると管理かから外れたと認識され、OutOfSync状態からSyncedになります。

ドキュメントには書かれていませんが、自分の環境ではapp.kubernetes.io/part-of ラベルも付与されていたのでこれも消した方が良いでしょう。

# ラベル名の末尾に - を付けると削除するという意味になる
kubectl label ns kube-system app.kubernetes.io/instance-
kubectl label ns kube-system app.kubernetes.io/part-of-

annotation の場合

v3.0.0以降のデフォルトのトラッキング手法です。ArgoCDは対象リソースに argocd.argoproj.io/tracking-id アノテーションを付与します。 今回の場合以下のようなアノテーションが付与されていました。

❯ kubectl get ns kube-system -o yaml
apiVersion: v1
kind: Namespace
metadata:
  annotations:
    argocd.argoproj.io/tracking-id: require-pruning:/Namespace:require-pruning/kube-system
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"v1","kind":"Namespace","metadata":{"annotations":{"argocd.argoproj.io/tracking-id":"require-pruning:/Namespace:require-pruning/kube-system"},"name":"kube-system"}}
  creationTimestamp: "2025-05-18T02:15:00Z"
  labels:
    kubernetes.io/metadata.name: kube-system
  name: kube-system
  resourceVersion: "2095"
  uid: b4ed1a04-d5f3-4a15-bb19-bf2675660439
spec:
  finalizers:
  - kubernetes
status:
  phase: Active

このアノテーションを削除します。

# アノテーションも末尾に - を付けると削除するという意味になる
kubectl annotate ns kube-system argocd.argoproj.io/tracking-id-

annotation + label の場合

二つの方法の対応をどちらもやりましょう。

結果

Revertコミットに対してsyncedな状態になります。

syncedになった

手動syncするとどうなる?

設定を変更せずsyncするだけなら問題は生じません。 prune: false なApplicationではリソースは削除されないためです。

しかし、よくわからずオプションを有効にして手動syncすると問題が生じる可能性があります。仮にApplicationの設定で prune: false にしていても以下のように PRUNE を有効にしてsyncすると当該リソースを削除しようとします。

PRUNEにチェックを入れてsyncすると削除が走る

既に述べたようにkube-systemは保護されているため単にこの操作は失敗するのですが、何も保護が働いていないリソースに対して行うと削除は成功してしまいます。

手動syncするとkube-systemは削除出来ずsyncはfailする

forbiddenと言われている

まとめ

消したくないのに一度管理下に入れてしまったリソースを安全にArgoCDから見えないようにするには、付与されているリソーストラッキング情報を削除してやれば良いです(たぶん)。

またこのような事態が起き得ることを想定し、 prune: true な設定はできるだけ避けるべきでしょう。開発用で毎日のように消したり作ったりするリソースが含まれる様な環境以外では無効にしたいところです。

慌てて手動syncするのも危険です。仮にApplicationの設定で prune: false になっていても、手動syncでは無理矢理pruneできてしまうためです。


*1:kube-systemは保護されているため削除できませんが、同様に重要なNamespaceの削除はできないように保護を検討した方が良いでしょう。

Access KeyなしでMinIOを使う in Kubernetes with MinIO Operator

[2025-05-04 追記]
minio-operator v7.1.0より、ServiceAccount Tokenのaudienceに sts.min.io を指定しなければならなくなったので記載を修正しました。 github.com

はじめに

以下の記事を読んで、MinIOのSTSを使うとAccess Keyを毎回作って保存してとかしなくても済むことを知りました。

zenn.dev

ちょうど家にMinIO Operatorを使って構築したクラスタが居たので、上記の記事では検証されていない MinIO Operator を使ったSTSをやってみました。

STSとは

AWSに存在するSecurity Token Serviceの略です。 OpenID ConnectやSAMLなどの別のIdentity Providerへのフェデレーションを行い、 それらの認証トークンからAWSへアクセスするための短命なトークンを発行する方法が主流です。必要なときに都度リクエストして短命なトークンを生成するので、Access Keyなどを作って保存しておく必要がなくなります。

docs.aws.amazon.com

MinIO Operatorでは、KubernetesのService Account Tokenを使ってAssumeRoleWithWebIdentityを行います。Service AccountのトークンはKubernetesによって署名されており、operatorはその署名を検証してService Account Tokenの真正性を確認します。

事前にMinIO Operatorのカスタムリソースである PolicyBinding を作っておくことで、MinIOクラスタ内のアクセスポリシーとServiceAccountの紐付けを行います。operatorはService Account Tokenから得られたアカウント名に対応するPolicyBindingのアクセスポリシーを知り、MinioのAPIを使って一時的なトークンを発行します。

MinIO OperatorのSTSの流れ

そのため、Kubernetesクラスタ内でAccess KeyやAccess SecretをSecretに保存する必要はなくなります。MinioのAPIへのアクセスをするアプリケーションをデプロイする前に、対応するPolicyとPolicyBinding・ServiceAccountを用意してアプリケーションの環境変数として与えるだけでアクセスできるようになります。 正しいService Account以外からのリクエストに対してはトークンが発行されず、MinIOへのアクセスはできません。

準備

今回はkindで作ったクラスタで試します。STSのためにはTLSが必須となり、署名検証を無効にせずに使うためにはcert-managerおよびtrust-managerを用いるのがよいです。いずれもhelmで簡単にインストールできます。

k8sクラスタをセットアップ

kindで建てたクラスタにhelmを使ってcert-managerとtrust-managerをインストールします。trust-managerについて詳しくは過去記事をご覧下さい。 簡単に言うとクラスタ内にCA証明書をばらまく機能を持ったカスタムコントローラです。

poyo.hatenablog.jp

kind create cluster --name minio-sts
helm repo add jetstack https://charts.jetstack.io --force-update
helm upgrade cert-manager jetstack/cert-manager \
    --install \
    --namespace cert-manager \
    --create-namespace \
    --set crds.enabled=true \
    --wait
# テストのために secretTargets.authorizedSecretsAll=true を付けているが、
# 本来は secretTargets.authorizedSecrets で許可するsecretを指定した方が良い
helm upgrade trust-manager jetstack/trust-manager \
    --install \
    --namespace cert-manager \
    --wait
    --set secretTargets.enabled=true \
    --set secretTargets.authorizedSecretsAll=true

CAとClusterIssuerをセットアップ

このk8sクラスタ内で使えるCAをセットアップし、各種コンポーネントの証明書はこのCAを使って発行します。tls/issuer.yaml に記述します。

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsign
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-ca-2025
spec:
  isCA: true
  commonName: internal-ca-2025
  secretName: internal-ca-2025-secret
  duration: 87600h
  renewBefore: 336h # 14d
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsign
    kind: ClusterIssuer
    group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal
spec:
  ca:
    secretName: internal-ca-2025-secret
kubectl apply -f tls/issuer.yaml -n cert-manager

Operator用の証明書とCA証明書をセットアップ

先にMinIO OperatorのSTSエンドポイント用証明書を発行しておきます。このとき、secretNameは sts-tls とする必要があります*1minio-operator/cert.yaml として記述します。

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: sts-certmanager-cert
  namespace: minio-operator
spec:
  dnsNames:
    - sts
    - sts.minio-operator.svc
    - sts.minio-operator.svc.cluster.local
  secretName: sts-tls # MUST
  issuerRef:
    kind: ClusterIssuer
    name: internal
kubectl create ns minio-operator
kubectl apply -f minio-operator/cert.yaml

今回MinIO本体でもcert-managerが発行した証明書を使うのですが、operatorからMinIOへのリクエストでも署名検証できるようにするためにCA証明書をマウントします。Operatorには特定の名前パターンでsecretを作ると自動で検知してくれる仕組み*2があるのでこれを使います。

CA証明書はtrust-managerに配布させます。tls/bundle-for-minio.yaml として記述します。

---
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: operator-ca-tls-internal # operator-ca-tls-*という名前にする
  namespace: cert-manager
spec:
  sources:
  # 作成したクラスタ内CAの証明書を同梱する
  - secret:
      name: "internal-ca-2025-secret"
      key: "ca.crt"
  target:
    secret:
      key: "ca.crt"
    namespaceSelector:
      # minio-operatorのnamespaceだけにインストールする
      matchLabels:
        kubernetes.io/metadata.name: minio-operator
kubectl apply -f tls/bundle-for-minio.yaml

MinIO Operatorをセットアップ

MinIO Operatorはアップストリームの手順に従ってインストールします。具体的にはリポジトリアーカイブをダウンロードし、kustomizeでマニフェストをビルド・一部パッチを当ててクラスタにapplyします。

MINIO_OPERATOR_VERSION="7.1.1"
mkdir -p minio-operator/upstream
# minio-operatorのupstreamのマニフェストをダウンロードする
wget -O minio-operator/upstream/v${MINIO_OPERATOR_VERSION}.tar.gz \
    https://github.com/minio/operator/archive/refs/tags/v${MINIO_OPERATOR_VERSION}.tar.gz
cd "minio-operator/upstream"
tar xf "v${MINIO_OPERATOR_VERSION}.tar.gz"
# リポジトリ丸ごとは要らないので、一旦素のマニフェストをビルドして書き出しておく
kustomize build operator-${MINIO_OPERATOR_VERSION}/ > "operator.yaml"

# 不要なファイルを削除する
rm -r operator-${MINIO_OPERATOR_VERSION} v${MINIO_OPERATOR_VERSION}.tar.gz
cd ../../

通常MinIO Operatorは自己署名証明書を自動的に作成しますが、これはあらゆる署名検証を無効にする必要があり面倒です。そのため、自動作成する機能を切ってcert-managerで発行した証明書を使わせます。前のセクションで証明書は既に用意したので、ここでは自動作成する機能をオフにします。minio-operator/kustomization.yaml でパッチを当てます。

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: minio-operator

resources:
- upstream/operator.yaml
- cert.yaml

patches:
- patch: |-
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: minio-operator
      namespace: minio-operator
    spec:
      template:
        spec:
          containers:
            - name: minio-operator
              env:
                - name: OPERATOR_STS_AUTO_TLS_ENABLED
                  value: "off"
                - name: OPERATOR_STS_ENABLED
                  value: "on"
kustomize build minio-operator/ | kubectl apply -f -

MinIO用の証明書をセットアップ

MinIOのWebコンソールおよびS3互換APIのエンドポイントで利用される証明書を発行します。

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: minio-tenant-cert
  namespace: objectstorage
spec:
  dnsNames:
    - "minio.objectstorage"
    - "minio.objectstorage.svc"
    - "minio.objectstorage.svc.cluster.local"
    - "*.minio-hl.objectstorage.svc.cluster.local"
    - "*.objectstorage.svc.cluster.local"
    - "*.minio.objectstorage.svc.cluster.local"
  secretName: minio-tenant-tls
  issuerRef:
    kind: ClusterIssuer
    name: internal
kubectl create ns objectstorage
kubectl apply -f objectstorage/cert.yaml

MinIOをセットアップ

MinIO Operatorに対して、MinIO本体はTenantと呼ばれます。カスタムリソースであるTenantを作ることでOpertorによってMinIOがセットアップされます。 今回はテスト用にErasure Codingの設定を限界まで緩めているので冗長性がありません。本番環境でそのまま利用しないでください。

---
apiVersion: minio.min.io/v2
kind: Tenant
metadata:
  name: minio
  namespace: objectstorage
spec:
  image: 'minio/minio:RELEASE.2025-03-12T18-04-18Z'
  # Disable default tls certificates.
  requestAutoCert: false
  # Use certificates generated by cert-manager.
  externalCertSecret:
    # 先に作っておいた証明書
    - name: minio-tenant-tls
      type: cert-manager.io/v1
  configuration:
    # 後で作る
    name: storage-configuration
  pools:
    - servers: 1
      name: pool-0
      volumesPerServer: 1
      volumeClaimTemplate:
        apiVersion: v1
        kind: persistentvolumeclaims
        spec:
          accessModes:
            - ReadWriteOnce
          resources:
            requests:
              storage: 1Gi

いくつかの追加設定をsecretを使って設定します。 MINIO_STORAGE_CLASS_STANDARD でErasure Codingの設定をしています。本番環境では正しい設定をしてください*3。パスワードも同様にランダムかつ十分長い文字列を設定するようにし、リポジトリにはpushしないようにしてください。

---
apiVersion: v1
kind: Secret
metadata:
  name: storage-configuration
  namespace: objectstorage
stringData:
  config.env: |
    export MINIO_ROOT_USER="minio"
    export MINIO_ROOT_PASSWORD="password"
    export MINIO_STORAGE_CLASS_STANDARD="EC:0"
    export MINIO_BROWSER="on"

これらを適用します。

cat << 'EOF' > objectstorage/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: objectstorage
resources:
  - storage-configuration.yaml
  - tenant.yaml
  - tenant-cert.yaml
EOF
kustomize build objectstorage/ | kubectl apply -f -

最終的に以下の様にPodが立っていればOKです。

❯ kubectl get po -n objectstorage
NAME             READY   STATUS    RESTARTS   AGE
minio-pool-0-0   2/2     Running   0          6h24m

BucketとPolicyを作る

ポートフォワードしてコンソールにアクセスします。証明書のエラーが出ますが、これはクラスタ内CAの証明書がブラウザ側にはインストールされていないためです。本番環境ではコンソールを公開するためのIngressリソースなどを用意し、クラスタ外でもvalidな証明書を使いましょう。今回は単に警告を無視してアクセスします。

kubectl port-forward svc/minio-console -n objectstorage 9443:9443

https://localhost:9443 にアクセスすると設定したユーザ名・パスワードでログインできます。

MinIOのWebコンソール

Create Bucket してテスト用のbucketを作ります。

testバケットを作った様子

最後にこの test bucketのためのPolicyを作成します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::test/*"
            ]
        }
    ]
}

test-rw policyを追加する様子

MinIOのクライアント用CA証明書を配布する

MinIOの証明書の署名検証のため、CA証明書をクラスタ内の全namespaceに配布します。各Podはこれをマウントすることで正しく署名検証できます。

---
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: internal-ca-bundle
spec:
  sources:
  - useDefaultCAs: true
  - secret:
      name: "internal-ca-2025-secret"
      key: "tls.crt"
  target:
    configMap:
      key: "trust-bundle.pem"
kubectl apply -f tls/bundle.yaml

全namespaceで internal-ca-bundle というConfigMapが作られているはずです。

❯ kubectl get cm internal-ca-bundle
NAME                 DATA   AGE
internal-ca-bundle   1      6h4m

STSを使ってMinioにアクセスする

ようやく長いセットアップが終わったので実際にSTSを使ってMinIOのAPIを呼び出します。

PolicyBindingを作る

MinIO Operatorはこのカスタムリソースを元にService Accountとそれに許可するPolicyを識別します。このPolicy BindingはMinIO Tenantと同じnamespaceに配置する必要があります。今回はdefault namespaceの test というService Accountに test-rw Policyを許可します。

---
apiVersion: sts.min.io/v1alpha1
kind: PolicyBinding
metadata:
  name: default-test
  namespace: objectstorage
spec:
  application:
    namespace: default
    serviceaccount: test
  policies:
    - test-rw
kubectl apply -f objectstorage/policybindings.yaml

awscliで操作する

基本的には環境変数を使ってアクセス先や認証方法を設定します。awscliやAWS SDKはいずれも同じ環境変数で設定を変更できるようになっています。

docs.aws.amazon.com

awscliの入ったPodを立ち上げます。

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: test
---
apiVersion: v1
kind: Pod
metadata:
  name: bastion
spec:
  containers:
    - name: bastion
      # 古いバージョンでは環境変数からの設定変更をサポートしていないので注意
      image: amazon/aws-cli:latest
      command:
        - sleep
        - infinity
      volumeMounts:
        - name: internal-ca
          mountPath: /tls/internal
          readOnly: true
        # audienceを指定したトークンをマウント
        - name: minio-sts-token
          mountPath: /var/run/secrets/sts.min.io/serviceaccount
          readOnly: true
  volumes:
    - name: internal-ca
      configMap:
        name: internal-ca-bundle
    - name: minio-sts-token
      projected:
        sources:
          - serviceAccountToken:
              # audienceを指定しなければならない
              audience: "sts.min.io"
              expirationSeconds: 86400
              path: token
  serviceAccountName: test
kubectl apply -f default/bastion.yaml

kubectl exec を使って上記コンテナ内のbashを立ち上げます。

kubectl exec -it bastion -- bash

コンテナの中で以下の環境変数を設定します。

# マウントしたCA証明書のパス。STSとMinioのエンドポイントの署名検証に必要
export AWS_CA_BUNDLE=/tls/internal/trust-bundle.pem
# ServiceAccountのトークンを認証キーとして使う
export AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/sts.min.io/serviceaccount/token
# consoleではなくMinioのAPIエンドポイント
export AWS_ENDPOINT_URL_S3=https://minio.objectstorage.svc:443
# STSのエンドポイントはサービス名の末尾に `sts/テナントのnamesapce` を入れる
export AWS_ENDPOINT_URL_STS=https://sts.minio-operator.svc:4223/sts/objectstorage
# Minioでは使われないのだが、awscliはそれっぽい値を入れないとリクエストを蹴るので適当に入れる
export AWS_ROLE_ARN=arn:aws:iam::dummy:role/test

これだけでawscliを使ってMinIOのS3互換APIを利用できます。

bash-4.2# aws s3 ls s3://test/
bash-4.2# echo hello > hello.txt
bash-4.2# aws s3 cp hello.txt s3://test/
upload: ./hello.txt to s3://test/hello.txt
bash-4.2# aws s3 ls s3://test/
2025-03-21 06:14:38          6 hello.txt
bash-4.2#

AWS SDKから操作する

同じ環境変数を用いると、以下の様なコードでMinIOへの操作を記述できます。今回はGoのAWS SDK v2を使っています。

package main

import (
    "context"
    "flag"
    "fmt"
    "log/slog"
    "os"
    "os/signal"
    "syscall"

    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

var (
    bucketName string
    pathToList string
)

func listBucketItems() error {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
    defer cancel()

    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return fmt.Errorf("failed to load configuration, %w", err)
    }
    bucket := s3.NewFromConfig(cfg, func(o *s3.Options) {
        o.UsePathStyle = true
    })
    outputs, err := bucket.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
        Bucket: &bucketName,
        Prefix: &pathToList,
    })
    if err != nil {
        return fmt.Errorf("failed to list bucket items, %w", err)
    }
    for _, item := range outputs.Contents {
        slog.Info("get", "path", item.Key, "size", item.Size, "bucket", bucketName)
    }
    return nil
}

func main() {
    flag.StringVar(&bucketName, "bucket", "", "bucket name")
    flag.StringVar(&pathToList, "path", "", "path to list")
    flag.Parse()

    if err := listBucketItems(); err != nil {
        slog.Error("failed to list bucket items", "error", err)
        os.Exit(1)
    }
}

同じようにAWS SDKを使っているアプリケーションでは同様の環境変数の設定でアクセス出来るようになるため非常に便利です。

例えば筆者のクラスタにはmocoというMySQLのオペレータがインストールされており、これはAWS SDKを使ってMySQLクラスタのバックアップをS3にアップロードする仕組みを持っています。MySQLBackupというカスタムリソースでその設定を書くのですが、以下の様に書いて適切なBucket・Policy・PolicyBindingを用意するだけでバックアップのデータをMinIOにアップロードできます。

apiVersion: moco.cybozu.com/v1beta2
kind: BackupPolicy
metadata:
  name: daily
spec:
  schedule: "5 0 * * *"
  concurrencyPolicy: Forbid
  jobConfig:
    serviceAccountName: moco-mysql
    env:
    - name: AWS_CA_BUNDLE
      value: /tls/internal/trust-bundle.pem
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/sts.min.io/serviceaccount/token
    - name: AWS_ENDPOINT_URL_STS
      value: https://sts.minio-operator.svc:4223/sts/objectstorage
    # AWS_ROLE_ARN is required by the AWS SDK, but it is not mandatory in Minio.
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::dummy:role/test
    bucketConfig:
      bucketName: moco-mysql-backup
      region: us-east-1
      endpointURL: https://minio.objectstorage.svc:443
      usePathStyle: true
    workVolume:
      ephemeral:
        volumeClaimTemplate:
          spec:
            accessModes: [ "ReadWriteOnce" ]
            resources:
              requests:
                storage: 10Gi
    volumeMounts:
      - name: internal-ca
        mountPath: /tls/internal
        readOnly: true
      - name: minio-sts-token
        mountPath: /var/run/secrets/sts.min.io/serviceaccount
        readOnly: true
    volumes:
      - name: internal-ca
        configMap:
          name: internal-ca-bundle
      - name: minio-sts-token
        projected:
          sources:
            - serviceAccountToken:
                audience: "sts.min.io"
                expirationSeconds: 86400
                path: token

気になったところ

STSのエンドポイントが /sts/{{Tenantのnamespace}} となるのですが、 Tenant リソース自体は一つのnamespaceに複数作成できそうな気がします。 すると、あるPolicyBindingがどのTenantのものなのか判別できない気がします。実用的にこれで困ることがあるかは不明です。

今回は自分しか管理する人が居ないクラスタなのでよいのですが、複数の開発者が同じクラスタを共有するマルチテナント構成の場合、PolicyBindingリソースの操作権限をどう与えるか悩ましいなと思いました。 いくらでも作成・更新できてしまうと他チームのbucketを勝手に操作する権限を付与できてしまいますし、逆に全く作れないようにしてMinIO Tenantの管理チームだけに絞ってしまうと都度依頼対応が必要になったりして手間が増えそうです。

また、Policyを宣言的に管理する方法がないのもつらいところです。どのように権限分担をするのかが悩ましい仕組みだなと思いました。

まとめ

Kubernetesのin clusterなMinIOへのアクセスでは、MinIO OperatorのSTSを利用することで面倒なAccess Keyの管理なしにMinIOへのアクセス権を付与出来ます。

MinIO OperatorのSTSはリクエストに付与されたKubernetesのService Account Tokenを元に、PolicyBindingカスタムリソースを用いてMinIO上のPolicyを識別し、MinIOから短命なトークンを発行します。

awscliやAWS SDK環境変数経由で認証方法やアクセス先をカスタマイズでき、他者のアプリケーションであってもAWS SDKを使っていればSTSを使ってMinIOにアクセス出来るます(たぶん)。


Cilium 1.16.5への更新でnode-local-dnsが壊れた話

何が起きた?

私物のKubernetesクラスタ(kubesprayで構築)のCiliumを1.15.11から1.16.5へ更新したところ、クラスタ内の名前解決が一斉にタイムアウトするようになりました。

CoreDNSサービス自体は正常に応答を返しているようでした。デバッグした結果、各ノードのkubeletが配る resolv.conf にはnode-local-dnsがリッスンしているリンクローカルアドレスが書かれており、このアドレスへの接続に問題が発生したことが分かりました。

悲しいことにこれによってArgoCDが機能不能になり、Ciliumの簡単なロールバックはできない状態になったためどうにか現状で復旧するしかありませんでした*1。最終的にnode-local-dnsを無効にしてノードを順番にdrain→uncordonして復旧しました。

再現手順

node-local-dns自体のマニフェストkubernetesのドキュメントにも載っています。

kubernetes.io

しかしこれを適用するだけでは配られる resolv.conf にリンクローカルアドレスが記載される状態にはなりません。kubesprayでnode-local-dnsのアドオンを有効化するとこの状態を作るようになります。

このような構成は上記の公式ドキュメントでも推奨されているものであり、特段に不審な構成ではありません。

NodeLocal DNSキャッシュのローカルリッスン用のIPアドレスは、クラスター内の既存のIPと衝突しないことが保証できるものであれば、どのようなアドレスでもかまいません。例えば、IPv4のリンクローカル範囲169.254.0.0/16やIPv6のユニークローカルアドレス範囲fd00::/8から、ローカルスコープのアドレスを使用することが推奨されています。

1. VMを作る

kubesprayでkubernetesクラスタを構築するため、VMを用意します。ここではmultipassを使っていますが、何でも良いです。

OSはUbuntu 24.04を問題になったクラスタで利用していたため、ここでも採用しました。他のOSの挙動は検証していませんが、おそらく同じだと思います。

GITHUB_USER=pddg

# cloud-initのconfigを記述する
cat << EOF > cloud-config.yaml
allow_public_ssh_keys: true
ssh_import_id: ["gh:${GITHUB_USER}"]
EOF

# VMを起動する
multipass launch \
  --name k8s-master \
  --memory 2G \
  --disk 30G \
  --cloud-init cloud-config.yaml \
  24.04

multipass launch \
  --name k8s-worker \
  --memory 2G \
  --disk 30G \
  --cloud-init cloud-config.yaml \
  24.04

2. kubesprayでクラスタを構築する

作成したクラスタとホストOSは普通にIPアドレスで通信できるようになっているので、これを元にkubesprayで使うためのAnsible Inventoryファイルを用意します。ユーザが ubuntu であることに注意してください(一敗)。

mkdir -p inventory/multipass
MASTER_IPADDR=$(multipass info --format json| jq -r '.info | to_entries[] | select(.key == "k8s-master") | .value.ipv4[]')
WORKER_IPADDR=$(multipass info --format json| jq -r '.info | to_entries[] | select(.key == "k8s-worker") | .value.ipv4[]')

cat << EOF > inventory/multipass/hosts.yaml
all:
  hosts:
    k8s-master:
      ansible_user: ubuntu
      ansible_host: ${MASTER_IPADDR}
      ip: ${MASTER_IPADDR}
      access_ip: ${MASTER_IPADDR}
    k8s-worker:
      ansible_user: ubuntu
      ansible_host: ${WORKER_IPADDR}
      ip: ${WORKER_IPADDR}
      access_ip: ${WORKER_IPADDR}
  children:
    kube_control_plane:
      hosts:
        k8s-master:
    kube_node:
      hosts:
        k8s-master:
        k8s-worker:
    etcd:
      hosts:
        k8s-master:
    k8s_cluster:
      children:
        kube_control_plane:
        kube_node:
EOF

このファイルを使って対象のホストがアクセス可能であることを確かめます。ここでは簡単のためdockerコンテナを利用しています。

# pingが成功することを確認する
docker run --rm  -it \
    --mount type=tmpfs,dst=/.ansible \
    --mount type=bind,source=$(pwd)/inventory,dst=/kubeproxy/inventory \
    --mount type=bind,source=${HOME}/.ssh/id_ed25519,dst=/root/.ssh/id_rsa \
    quay.io/kubespray/kubespray:v2.26.0 \
    ansible -i /kubeproxy/inventory/multipass/hosts.yaml -m ping all

うまくいけば各ホストからpongが返ってくるはずです*2

❯ docker run --rm \
        --mount type=tmpfs,dst=/.ansible \
        --mount type=bind,source=$(pwd)/inventory,dst=/kubeproxy/inventory \
        --mount type=bind,source=${HOME}/.ssh/id_ed25519,dst=/root/.ssh/id_rsa \
        quay.io/kubespray/kubespray:v2.26.0 \
        ansible -i /kubeproxy/inventory/multipass/hosts.yaml -m ping --private-key /root/.ssh/id_rsa all
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
[WARNING]: Skipping callback plugin 'ara_default', unable to load
k8s-worker | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
k8s-master | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

kubesprayの設定ファイルを書き、クラスタを構築します。

mkdir -p inventory/multipass/group_vars
cat << EOF > inventory/multipass/group_vars/all.yaml
---
kube_version: v1.30.4
container_manager: containerd

# 後からciliumを自分で入れる
kube_network_plugin: cni

# kube-proxyは削除しておく
kube_proxy_remove: true

# 構築したクラスタへアクセスするためのKUBECONFIGをローカルに配置する
kubeconfig_localhost: true

dns_mode: coredns

# node-local-dnsを有効化
enable_nodelocaldns: true
# node-local-dnsがリッスンするアドレス(デフォルト)
nodelocaldns_ip: 169.254.25.10
EOF

docker run --rm \
    --mount type=tmpfs,dst=/.ansible \
    --mount type=bind,source=$(pwd)/inventory,dst=/kubeproxy/inventory \
    --mount type=bind,source=${HOME}/.ssh/id_ed25519,dst=/root/.ssh/id_rsa \
    quay.io/kubespray/kubespray:v2.26.0 \
    ansible-playbook -i /kubeproxy/inventory/multipass/hosts.yaml --become cluster.yml

しばらく待つと構築が完了するので、 .envrcKUBECONFIG の設定を書いてここで使うkubernetesクラスタの情報を設定します。

cat << 'EOF' > .envrc
export KUBECONFIG=$KUBECONFIG:inventory/multipass/artifacts/admin.conf
EOF

direnv allow .

いくつかのpodはpendingですが、Kubernetesクラスタが構築出来たことを確認します。

❯ kubectl get po -n kube-system
NAME                                 READY   STATUS    RESTARTS   AGE
coredns-776bb9db5d-jsnjw             0/1     Pending   0          2m36s
dns-autoscaler-6ffb84bd6-9xt7x       0/1     Pending   0          2m34s
kube-apiserver-k8s-master            1/1     Running   0          3m46s
kube-controller-manager-k8s-master   1/1     Running   1          3m45s
kube-scheduler-k8s-master            1/1     Running   1          3m45s
nginx-proxy-k8s-worker               1/1     Running   0          2m49s
nodelocaldns-75h97                   1/1     Running   0          2m33s
nodelocaldns-jhpmt                   1/1     Running   0          2m33s

3. helmでCilium 1.16.4をインストール

まずはCIlium 1.16.4をインストールします。実際に壊れた環境では1.15.12→1.16.5への更新で壊れたのですが、再現環境を作って試していると1.16.4まではこの構成でも普通に動作していました。

helm repo add cilium https://helm.cilium.io/

cat << EOF > cilium-values.yaml
# kube-proxyがない環境でmasterにアクセスできる必要がある
k8sServiceHost: ${MASTER_IPADDR}
k8sServicePort: 6443
# kube-proxyレスモードでインストールする
kubeProxyReplacement: true
ipam:
  mode: kubernetes
securityContext:
  privileged: true

# この設定を入れると再現する
bpf:
  masquerade: true
EOF

helm install cilium cilium/cilium \
  --version 1.16.4 \
  --values cilium-values.yaml \
  --namespace kube-system

# cilium agentがreadyになるまで待つ
kubectl wait --timeout=90s --for=condition=Ready -n kube-system \
  pods -l k8s-app=cilium


# ciliumインストール前から存在し、hostNetwork以外で動くpodをdeleteして作り直す
kubectl get pods \
  --all-namespaces \
  -o custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name,HOSTNETWORK:.spec.hostNetwork \
  --no-headers=true \
  | grep '<none>' \
  | awk '{print "-n "$1" "$2}' \
  | xargs -L 1 -r kubectl delete pod

4. 名前解決出来ることを確認する

公式ドキュメントの以下で紹介されているイメージを使うことにします。 kubernetes.io

cat << EOF > dnsutil.yaml
apiVersion: v1
kind: Pod
metadata:
  name: dnsutils
  namespace: default
spec:
  containers:
  - name: dnsutils
    image: registry.k8s.io/e2e-test-images/agnhost:2.39
    imagePullPolicy: IfNotPresent
  restartPolicy: Always
EOF

kubectl apply -f dnsutil.yaml

# PodがReadyになるまで待つ
kubectl wait --timeout=90s --for=condition=Ready pods/dnsutils

1.16.4ではこの状態で名前解決に成功します。

❯ kubectl exec -i -t dnsutils -- nslookup kubernetes.default
Server:     169.254.25.10
Address:    169.254.25.10#53

Name:   kubernetes.default.svc.cluster.local
Address: 10.233.0.1

resolv.confにはnode-local-dnsのリッスンするリンクローカルアドレスが書かれています。

❯ kubectl exec -i -t dnsutils -- cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local flets-east.jp iptvf.jp
nameserver 169.254.25.10
options ndots:5

kubernetesノードには以下の様なデバイスが生えるようです。

❯ ssh ubuntu@$WORKER_IPADDR ip a show dev nodelocaldns
3: nodelocaldns: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default
    link/ether 6e:a5:52:ac:8a:b2 brd ff:ff:ff:ff:ff:ff
    inet 169.254.25.10/32 scope global nodelocaldns
       valid_lft forever preferred_lft forever

hubbleを使って通信を観測してみます。

# 対象のpodが乗っているノードのcilium-agentを出力する便利スクリプト
wget -O k8s-get-cilium-pod.sh \
  https://raw.githubusercontent.com/cilium/cilium/refs/tags/v1.16.4/contrib/k8s/k8s-get-cilium-pod.sh
chmod +x k8s-get-cilium-pod.sh

kubectl exec -ti pod/$(./k8s-get-cilium-pod.sh dnsutils default) -n kube-system -c cilium-agent \
  -- hubble observe --since 1m --pod default/dnsutils

手元で実行した記録

❯ kubectl exec -ti pod/$(./k8s-get-cilium-pod.sh dnsutils default) -n kube-system -c cilium-agent \
  -- hubble observe --since 1m --pod default/dnsutils
Dec 22 07:51:41.852: 127.0.0.1:39065 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP)
Dec 22 07:51:41.852: default/dnsutils:50642 (ID:32060) -> 169.254.25.10:53 (world) to-stack FORWARDED (UDP)
Dec 22 07:51:41.853: default/dnsutils:50642 (ID:32060) <- 169.254.25.10:53 (world) to-endpoint FORWARDED (UDP)
Dec 22 07:51:41.853: 169.254.25.10:53 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP)
Dec 22 07:51:41.853: default/dnsutils:60221 (ID:32060) -> 169.254.25.10:53 (world) to-stack FORWARDED (UDP)
Dec 22 07:51:41.853: default/dnsutils:60221 (ID:32060) <- 169.254.25.10:53 (world) to-endpoint FORWARDED (UDP)
Dec 22 07:51:41.853: 169.254.25.10:53 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP)
Dec 22 07:51:41.855: default/dnsutils:51283 (ID:32060) -> 169.254.25.10:53 (world) to-stack FORWARDED (UDP)
Dec 22 07:51:41.856: default/dnsutils:51283 (ID:32060) <- 169.254.25.10:53 (world) to-endpoint FORWARDED (UDP)
Dec 22 07:51:41.856: 169.254.25.10:53 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP)

5. Cilium 1.16.5に更新する

helm upgrade cilium cilium/cilium \
  --version 1.16.5 \
  --namespace kube-system \
  --values cilium-values.yaml

# cilium agentがreadyになるまで待つ
kubectl wait --timeout=90s --for=condition=Ready -n kube-system \
  pods -l k8s-app=cilium

dnsutils Podでのnslookupがタイムアウトするようになります。

❯ kubectl exec -i -t dnsutils -- nslookup kubernetes.default
;; connection timed out; no servers could be reached


command terminated with exit code 1

corednsのClusterIPを指定すると通るので、node-local-dnsとの通信だけが途絶えている事が分かります。

❯ kubectl get svc -n kube-system
NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                  AGE
cilium-envoy   ClusterIP   None            <none>        9964/TCP                 6m56s
coredns        ClusterIP   10.233.0.3      <none>        53/UDP,53/TCP,9153/TCP   56m
hubble-peer    ClusterIP   10.233.15.182   <none>        443/TCP                  6m56s

❯ kubectl exec -i -t dnsutils -- nslookup kubernetes.default 10.233.0.3
Server:     10.233.0.3
Address:    10.233.0.3#53

Name:   kubernetes.default.svc.cluster.local
Address: 10.233.0.1

hubble observeすると虚空に消えて返って来ないようです。

❯ kubectl exec -ti pod/$(./k8s-get-cilium-pod.sh dnsutils default) -n kube-system -c cilium-agent \
  -- hubble observe --since 1m --pod default/dnsutils
Dec 22 07:45:18.830: 127.0.0.1:45439 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP)
Dec 22 07:45:18.830: default/dnsutils:55914 (ID:32060) -> 169.254.25.10:53 (world) to-network FORWARDED (UDP)
Dec 22 07:45:28.849: default/dnsutils:55914 (ID:32060) -> 169.254.25.10:53 (world) to-network FORWARDED (UDP)

試したこと

masqLinkLocal: false

bpf.masquerade: true にすると問題が発生することから、IPマスカレードの仕組みのどこかでおかしくなっているのではと考えました。

docs.cilium.io

In addition, if the masqLinkLocal is not set or set to false, then 169.254.0.0/16 is appended to the non-masquerade CIDRs list.

node-local-dnsのリンクローカルアドレスは169.254.25.10なので、なにかの設定変更でIPマスカレードの対象になりおかしくなったのかもしれません。 下記の設定を追加してみましたが効果はありませんでした。

ipMasqAgent:
  enabled: true
  masqLinkLocal: false

hostLegacyRouting: true

以下のissueは起きた現象としては似ているなと思ったので参考にし、この設定を入れてcilium agentを再起動したところ接続出来るようになりました。

github.com

メンテナの見解では、これはBPF Host Routingが正しく動作するようになった結果なので、このオプションを有効化するのが正しいとのこと(できればChangelogに書いて欲しかったなぁ)。

https://github.com/cilium/cilium/issues/36761#issuecomment-2559123347

Ciliumにおける正しい(?)node-local-dnsアーキテクチャ

node-local-dnsと言えばhost networkで動作させるものだと思っていました。しかしCiliumではpod networkで動作させ、 CiliumLocalRedirectPolicy リソースでCoreDNS Cluster IPあての通信をnode-local-dnsサービスにルーティングするという構成が想定されているようです。

docs.cilium.io

自分のクラスタはこの構成に移行することにし、kubesprayのaddonとしてのnode-local-dnsは無効にしました。

まとめ

stagingとかdevみたいなクラスタは用意しないポリシーだったんですが、やっぱりあった方が良いかもと言う気持ちになりました。

通常のKubernetesでのデファクトな構成と、自分が利用しているコンポーネントの想定する構成が一致するとは限りません。ちゃんとドキュメントは確認しましょう。


*1:ArgoCDのhelm integrationを使っていたため、手元には生のマニフェストが無い状態でした。脱helm integrationの決意を固くしました。

*2:応答が返ってこなくなる(no route hostになったりconnection refusedになったりする)ことが一度だけあったのですが、VMを作り直したら直ったので深く追求していません