koukiblog

たぶんweb系の話題

GitOps環境にExternal Secret Operatorを導入した

FluxなどGitOps環境でSecretリソースを安全に扱う方法を考え、External Secret Operatorを導入しました。

External Secret Operator ( https://external-secrets.io/latest/ ) は、GCPのSecret Managerなど外部のSecret管理ツールからデータを読み取って、kubernetesのsecretを生成してくれます。 external secretリソースで、SecretManagerのkeyを指定すると、SecretManagerに保存してあるデータでsecretを作成します。

具体的には、こういうマニフェストを作ると、exampleという名前のsecretが、SecretManagerに保存済みの"sm-key" の値で作成されます。

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: es-example
spec:
  secretStoreRef:
    kind: ClusterSecretStore
    name: gcp-cluster-secret-store
  target:
    name: example
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: sm-key

mozilla/sops のようにマニフェストを暗号化/複合化するアプローチの場合、GitOpsのツールが複合化できるように設定せなばならず、それが面倒ですが、External Secret Operatorの場合、External Secret ControllerがSecretの生成まで行ってくれるため、GitOpsのツールは暗号化を気にすることなく、Secretを利用できます。 最終的にはkuberentes標準のSecretリソースになるため、扱いやすいです。

インストール

External Secret Operatorのインストールは、Helmを利用するのが一般的なようです。今回は Fluxを導入済みのため、FluxでExternal Secret Operatorをインストールしました。

examplesにFluxを利用したインストール例が載っているため、これを参考にしてインストールしました。 https://external-secrets.io/v0.9.8/examples/gitops-using-fluxcd/

まず、レポジトリの定義を行います。GitRepostitoryのURLがhttpでかつmainブランチになっていなのですが、https にしたうえで、現時点で最新のv0.9.8 を指定します。

apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
  name: external-secrets
  namespace: flux-system
spec:
  interval: 10m
  url: https://charts.external-secrets.io
---
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: GitRepository
metadata:
  name: external-secrets
  namespace: flux-system
spec:
  interval: 10m
  ref:
    tag: v0.9.8
  url: https://github.com/external-secrets/external-secrets

次にCRDを定義します。CRDは差分が検出できないのか、毎回リソースの更新 ( CustomResourceDefinition/clustersecretstores.external-secrets.io configured というログがでる)が発生してしまいます。頻繁に更新が行われても特に問題は出なそうでしたが、気になったので、作成後にintervalを十分に長くすることで対応しました。

apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
  name: external-secrets-crds
  namespace: flux-system
spec:
  interval: 10m
  path: ./deploy/crds
  prune: true
  sourceRef:
    kind: GitRepository
    name: external-secrets

次に、Helmを利用してoperatorをインストールします。ここでも現時点で最新の0.9.8を指定しました。namespaceは別途手動で作成していたのでcreateNamespaceはfalseにします。

apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: external-secrets
  namespace: flux-system
spec:
  # Override Release name to avoid the pattern Namespace-Release
  # Ref: https://fluxcd.io/docs/components/helm/api/#helm.toolkit.fluxcd.io/v2beta1.HelmRelease
  releaseName: external-secrets
  targetNamespace: external-secrets
  interval: 10m
  chart:
    spec:
      chart: external-secrets
      version: 0.9.8
      sourceRef:
        kind: HelmRepository
        name: external-secrets
        namespace: flux-system
  values:
    installCRDs: false

  # Ref: https://fluxcd.io/docs/components/helm/api/#helm.toolkit.fluxcd.io/v2beta1.Install
  install:
    createNamespace: false

最後にClusterSecretStoreを定義します。このマニフェストはExternal Secrets OperatorのCRDが追加済みでないとエラーになるので、 dependsOnで依存関係を明示しておきます。こうすることで、Fluxが意図した順番に処理することができます。

apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
  name: external-secrets-crs
  namespace: flux-system
spec:
  dependsOn:
    - name: external-secrets-crds
  interval: 10m
  path: ./infrastructure/external-secrets/crs
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system

ClusterSecretStoreを定義するマニフェストは、例にしたがって /infrastructure/external-secrets/crs 配下に配置します。今回はGCPのSecretManagerを利用し、WorkloadIdentityで認証を行うので、サービスアカウントにアノテーションを追加します。WorkloadIdentityの設定については省略します。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-secrets
  namespace: external-secrets
  annotations:
    iam.gke.io/gcp-service-account: {serviceaccount-email}@{gcp-projectid}.iam.gserviceaccount.com
---
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: gcp-cluster-secret-store
spec:
  provider:
    gcpsm:
      projectID: {projectid}
      auth:
        workloadIdentity:
          clusterLocation: {cluster location}
          clusterName: {cluster name}
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

ここまで設定すると、ExternalSecretリソースを利用してSecretManagerに登録済みの値でSecretが作成できるようになります。マニフェストにはSecretManagerのkeyしか記載されないので、Gitレポジトリにcommitすることができ、GitOpsフローでSecretが管理できるようになりました。

GKEのSecret管理について調べると、Seald Secret、Berglas など出てきますが、どちらも現在は古いようなので、External Secret Operatorがよいんじゃないかと思います。 特にBerglasはSecretManagerリリース後は利用は推奨されていないようでした。

今までは、secretのマニフェスト自体を暗号化して、デプロイ直前に復号してapplyする方法が使われていて、これはGitOpsと相性悪くてどうしたものかと思っていたのですが、External Secret Operatiorで良い感じに解決できました

署名付きURLを作成してGCSに直接ファイルをアップロードする

署名付きURLを作成して、GCSに直接ファイルアップロードを試してみました。意外とドキュメントが少なかったので、残しておきます。

下記の記事にサンプル付きで紹介されているのですが、2019年の記事で一部Deprectedになっている箇所もありました(ServiceAccounts.SignBlob を利用している箇所)。 cloud.google.com

署名付きURLを発行するには、GCSバケットにオブジェクト作成権限のあるサービスアカウントで署名する必要があります。このサービスアカウントの権限でGCSにアクセスするできるURLを発行するので、サービスアカウントに付与する権限はオブジェクト作成権限のみなど、必要最低限にするのがよいはずです。 サービスアカウントに紐づく秘密鍵を作成して署名することもできますが、SignBlobという機能を使うと秘密鍵を作成することなく署名することができます。

URLを生成するプログラムを実行しているユーザーに、アップロード用サービスアカウントに対するroles/iam.serviceAccountTokenCreatorロールが必要です。

署名付きURLを作成する関数は以下のようになります。

import (
    "context"
    "time"

    credentials "cloud.google.com/go/iam/credentials/apiv1"
    "cloud.google.com/go/iam/credentials/apiv1/credentialspb"
    "cloud.google.com/go/storage"
)

var (
    serviceAccountName = "アップロード用サービスアカウント名@GCPプロジェクト名.iam.gserviceaccount.com"
    uploadableBucket   = "アップロード先バケット名"
)

func create_signed_url(filename string, content_type string) (string, error) {
    key := generate_key(file_name)
    url, err := storage.SignedURL(uploadableBucket, key, &storage.SignedURLOptions{
        GoogleAccessID: serviceAccountName,
        Method:         "PUT",
        Expires:        time.Now().Add(15 * time.Minute),
        ContentType:    content_type,
        SignBytes: func(b []byte) ([]byte, error) {
            resp, err := sign_blob(b)
            if err != nil {
                return nil, err
            }
            return resp.GetSignedBlob(), nil
        },
    })
    return url, err
}

基本的には、storage.SignedURL関数でURLを作成します。作成時に、GCS上に配置する際のパスとコンテンツタイプを設定します。

アップロードしようとしているファイルのファイル名と、拡張子からパスとコンテンツタイプを設定することになります。上記の例では、generate_key 関数でファイル名をGCSのパスに変換しています。特定のprefixをつけたり、UUIDでファイル名の衝突を避けたりするのがよいと思います。 SignBlobを使った署名は、SignedURLOptionsのSignBytes要素に関数を設定することによって行います。上記のサンプルではクロージャを設定しています。

sign_blob関数は以下のようになります。

func sign_blob(payload []byte) (*credentialspb.SignBlobResponse, error) {
    ctx := context.Background()
    c, err := credentials.NewIamCredentialsClient(ctx)
    if err != nil {
        return nil, err
    }
    defer c.Close()

    req := &credentialspb.SignBlobRequest{
        Name:    "projects/-/serviceAccounts/" + serviceAccountName,
        Payload: payload,
    }
    resp, err := c.SignBlob(ctx, req)

    if err != nil {
        return nil, err
        }

    return resp, nil
  }

SignBlobRequestで、サービスアカウント名を設定するときに、"projects/-/serviceAccounts/{ACCOUNT_EMAIL_OR_UNIQUEID}" というフォーマットにする必要があることに注意が必要です。

pkg.go.dev

生成された署名付きURLを利用してGCSにファイルアップロードするコードは以下のようになります。 この例ではGoでそのままアップロードしていますが、生成したURLをブラウザやアプリのようなクライアントに渡し、クライアントから直接アップロードすることもできます。 この例では、 "hello gcs" という文字列のファイルが "sample.txt"から生成されたパスでGCS上に作成されます。

file_name := "sample.txt"
payload := []byte("hello gcs")
content_type := "text/plain"

url, _ := create_signed_url(file_name, content_type)

req, _ := http.NewRequest("PUT", url, bytes.NewReader(payload))
req.Header.Add("Content-Type", content_type)
client := new(http.Client)
resp, _ := client.Do(req)

Signblob が何かわからず検索すると serviceAccounts.signBlobがヒットするのですが、これがdeprected なのが迷ったポイントでした。

cloud.google.com

GKE AutoPilotモードの制約

GKE の AutoPilotを利用する上で把握しておいた方がよい制約をメモしておきます。

AdmissionWebhook

Autopilotは、特定の条件でAdmissionWebhookの書き換え、拒否を行います。 https://cloud.google.com/kubernetes-engine/docs/concepts/autopilot-security?hl=ja#built-in-security

リソースリクエス

AutoPilotは、リソースリクエストを書き換えます。ワークロードのリソースリクエストを指定しない場合、RequestとLimitがデフォルト値で設定されます。 デフォルトでは小規模なワークロード向けの指定になるため、実運用にあたっては、ワークロードのリソースリクエストを全て指定する必要があります。 https://cloud.google.com/kubernetes-engine/docs/concepts/autopilot-resource-requests?hl=ja#defaults

Nodeの増減ができない

AutoPilotの場合 Nodeの増減はユーザーからはコントロールできません。そのため、必要なリソースが一気に増えた際には、Nodeの追加を伴う場合があり、この場合時間がかかります。具体的には helm install などでリソースを一気に追加した場合にタイムアウトすることがありました。

まとめ

Admission Webhookを利用してSidecarの注入などワークロードの書き換えを行うツールを導入する際は注意が必要かなと思います。 具体的にはtelepresenceを導入しようとしてかなりはまりました。 telepresenceは、Admisson Webhookを利用してtraffic-agent というコンテナをSidecarとして追加するのですが、そのときに以下のエラーが発生し、解決が困難でした。

 admission webhook "gkepolicy.common-webhooks.networking.gke.io" denied the request: GKE Warden rejected the request because it violates one or more constraints.
 Violations details: 
 {"[denied by autogke-pod-limit-constraints]":
    ["container 'traffic-agent' does not have resources/limits defined for all resources which required in Autopilot clusters."]}
    Requested by user:  'system:serviceaccount:kube-system:replicaset-controller', 
    groups: 'system:serviceaccounts,system:serviceaccounts:kube-system,system:authenticated'.

agentのresourceを指定できるオプション( https://www.getambassador.io/docs/telepresence/latest/reference/cluster-config#resources ) があり、指定してみたのですがエラーは解決しませんでした。 結局 AutoPilotは諦めて Standardクラスタを利用して解決しました。

おまけ

GKE Autopilotではなく、telepresenceの問題なのですが、 interceptを行うときにportの指定方法によっては NET_ADMIN特権が必要になり、エラーになる可能性があります。 以下のようなエラーがでます。

admission webhook "gkepolicy.common-webhooks.networking.gke.io" denied the request: 
GKE Warden rejected the request because it violates one or more constraints.
Violations details: {"[denied by autogke-default-linux-capabilities]":
["linux capability 'NET_ADMIN' on container 'tel-agent-init' not allowed; Autopilot only allows the capabilities:
 'AUDIT_WRITE,CHOWN,DAC_OVERRIDE,FOWNER,FSETID,KILL,MKNOD,NET_BIND_SERVICE,NET_RAW,SETFCAP,SETGID,SETPCAP,SETUID,SYS_CHROOT,SYS_PTRACE'."],
 "[denied by autogke-pod-limit-constraints]":
 ["container 'tel-agent-init' does not have resources/limits defined for all resources which required in Autopilot clusters.",
 "container 'traffic-agent' does not have resources/limits defined for all resources which required in Autopilot clusters."]} 
 Requested by user: 'system:serviceaccount:kube-system:replicaset-controller', 
 groups: 'system:serviceaccounts,system:serviceaccounts:kube-system,system:authenticated'.

これを回避するには、deploymentで公開するportに名前をつけ、serviceからはその名前を指定します。

deploymentで httpという名前をつける

ports:
    - name: http
      containerPort: 80
      protocol: TCP

serviceのtargetPortでは名前を指定する

  ports:
    - protocol: TCP
      port: 80
      targetPort: http

これはtelepresenceの公式サイトにも記述があります。

https://www.getambassador.io/docs/telepresence/latest/troubleshooting#injected-init-container-doesnt-function-properly

Djangoのユニットテスト内で発行されたクエリをキャプチャする

CaptureQueriesContextを利用すればクエリをキャプチャできたのでメモ。

こんな感じで使える

from django.db import connection
from django.test.utils import CaptureQueriesContext


with CaptureQueriesContext(connection) as ctx:
  # test method

  for q in ctx.captured_queries:
    print(f"time{q['time']}, sql:{q['sql']}")
    print(len(ctx.captured_queries))

HelmでCRDを利用するときの注意

HelmでCRDを扱う際にいくつか注意点があったので備忘のため残しておきます

helmで管理しているリソースのCRD、APIVersionが廃止されると、chart内のCRD,APIVersionをアップデートしてもhelm upgrade時にエラーになります。

たとえば、Kubernetes 1.16から下記のようなDeploymentのapps/v1beta1が削除されたり

apiVersion: apps/v1beta1
kind: Deployment

CertManagerのようなCRDを利用しているツールを導入している場合、バージョンアップに伴いCRDが廃止されたりします。CertManagerでは、1.7でv1beta1が廃止されています。

Release 1.7 - cert-manager Documentation

CRD, APIVersionの廃止までには移行期間があるので、この期間内にマニフェストのアップデートを行えば問題ありません。様々な事情でアップデートできないまま廃止されるとマニフェストを更新しても HelmによるUpgradeができなくなります。これはHelmのアーキテクチャによる制約です。

なぜ?

Helmはリソースの更新を行う場合、デプロイ済みのreleaseと、デプロイされる予定の次のreleaseの差分を確認して、それを反映しています。Helmは、Goのテンプレートを展開してkubectl applyしてるだけではないんです。

そのため、デプロイ済みのrelease内に廃止されたCRD, APIVersionが含まれているとそれがパースできなくなりエラーになります。

Helmのコード見ると、現在のリソースとデプロイ予定のリソースを比較しているコードが確認できます。 github.com

特にこのループでは、orignialにあってtargetに存在しないリソースを削除していて、これは kubectl apply とは異なる挙動です。 kubectl applyではリソースの削除は行われないため、ある程度の期間運用しているとリソース削除は問題になってきます(upgradeしていくだけでリソース削除できるのはHelmを利用するメリットの1つだと思います

   for _, info := range original.Difference(target) {
        c.Log("Deleting %s %q in namespace %s...", info.Mapping.GroupVersionKind.Kind, info.Name, info.Namespace)

        if err := info.Get(); err != nil {
            c.Log("Unable to get obj %q, err: %s", info.Name, err)
            continue
        }
        annotations, err := metadataAccessor.Annotations(info.Object)
        if err != nil {
            c.Log("Unable to get annotations on %q, err: %s", info.Name, err)
        }
        if annotations != nil && annotations[ResourcePolicyAnno] == KeepPolicy {
            c.Log("Skipping delete of %q due to annotation [%s=%s]", info.Name, ResourcePolicyAnno, KeepPolicy)
            continue
        }
        if err := deleteResource(info); err != nil {
            c.Log("Failed to delete %q, err: %s", info.ObjectName(), err)
            continue
        }
        res.Deleted = append(res.Deleted, info)
    }

対応

上記のような状態になると、releaseを更新するしかありません。以前はreleaseを手元にダウンロードして編集するしか方法がなかったのですが、現在はHelmが便利なpluginを提供してくれています。

mapkubeapis というHelm Pluginを利用することで簡単にreleaseを更新することができます。

GitHub - helm/helm-mapkubeapis: This is a Helm plugin which map deprecated or removed Kubernetes APIs in a release to supported APIs

たとえば、cert-manager.io/v1alpha2 のCertificateリソースを、cert-manager.io/v1 に置き換えたい場合

下記のようなyamlを作成してmap.yamlとして保存します。(depreactedInVersion, remvedInVersionは置き換えが発生するように適当に設定すればok)

mappings:
   -  deprecatedAPI: "apiVersion: cert-manager.io/v1alpha2\nkind: Certificate"
      newAPI: "apiVersion: cert-manager.io/v1\nkind: Certificate"
      deprecatedInVersion: "v1.9"
      removedInVersion: "v1.16"

作成したmap.yamlを利用してmapkubeapis を実行することでreleaseを更新することが可能です。

helm mapkubeapis #{release_name} --mapfile map.yaml

まとめ

廃止される前にマニフェストをアップデートしていくのが一番よいのですが、様々な事情でこのHelmの制約にハマってしまう方もいると思うので役に立つとうれしいです。

なお、公式のドキュメントではこのトピックについて、専用のページが設けられていて、releaseを直接編集する方法も紹介されています。

helm.sh

PreemptibleVMを利用しているGKEクラスタで "shutdown"ステータスのPodが残るようになった

PreemptibleVMを利用しているGKEクラスタで去年の夏ぐらいからshutdownステータスのPodが残るようになっていました。 ノードがシャットダウンの影響を受けたPodなんだろうなと思いつつ、調べる時間は取れてなかったので改めて時間をとって調べたのでその結果を残しておきます。

原因

KubernetesにGracefulNodeShutdownという機能が追加されており、この影響です。

https://kubernetes.io/blog/2021/04/21/graceful-node-shutdown-beta/

GKEでは、1.20.5 以降で有効化されています

https://cloud.google.com/kubernetes-engine/docs/release-notes#May_03_2021

1.20.5 以前では、PreemptibeVMがシャットダウンされた場合Podがそのまま削除されていたが、GracefulNodeShutdown機能が追加されたことにより、一定の猶予時間内にevictされるようになり、evictされたPodはshutdownステータスで残るようになる、ということのようです。

リリースされたのが2021/5/3なので、去年の夏頃から仕様が変わったという体感ともあっています。

shutdown Podへの対応

ガベージコレクション( https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-garbage-collection ) されるまではずっと残ります。 この閾値は、GKEユーザーからは変更することができないようです。かつそのデフォルト値は12500と非常に大きいです。 https://issuetracker.google.com/issues/172663707?pli=1

削除する場合、kubectl コマンドでまとめて削除することができます。GKEのドキュメント内にも記載があります。

1.21.3 以前:

kubectl get pods --all-namespaces | grep -i shutdown | awk '{print $1, $2}' | xargs -n2 kubectl delete pod -n

1.21.3 以降:

kubectl get pods --all-namespaces | grep -i NodeShutdown | awk '{print $1, $2}' | xargs -n2 kubectl delete pod -n
kubectl get pods --all-namespaces | grep -i Terminated | awk '{print $1, $2}' | xargs -n2 kubectl delete pod -n

https://cloud.google.com/kubernetes-engine/docs/how-to/preemptible-vms#graceful-shutdown

参考URL

https://stackoverflow.com/questions/68687716/k8s-pods-stuck-in-failed-shutdown-state-after-preemption-gke-v1-20

Kubernetes The Hard Wayをやった

前からずっとやろうと思った Kuberentes The Hard Way( GitHub - kelseyhightower/kubernetes-the-hard-way: Bootstrap Kubernetes the hard way on Google Cloud Platform. No scripts. ) をやっとやりました。

GCPアカウントがあったのでそこで進めたのですが、クラウドシェルで作業すべて完結できました。 GCPアカウントを新規作成していない場合は、無料枠内で出来ると思います。

途中詰まったところ

  • HOSTNAMEが取得できない

kubernetes-the-hard-way/09-bootstrapping-kubernetes-workers.md at master · kelseyhightower/kubernetes-the-hard-way · GitHub

9章で利用しているHOSTNAMEの取得方法が載っていなくてちょっと迷いました

HOSTNAME=$(curl -H Metadata-Flavor:Google http://metadata/computeMetadata/v1/instance/hostname | cut -d. -f1) で解決。

参考: google cloud platform - GCE metadata - get instance name - Server Fault

  • core-dns pod がずっとpending

手順をすべて終えたあとの動作確認で、core-dns podを作成するのですが、そこで作成したpodがずっとpendingのままという問題が起きました。Kubernetesマスターノードの運用はやったことないので、どのログ見ればいいのかもわからない状態で結構悩みました。

podをNodeに配置するところで止まってそうなので、kube-schedulerかなと思ってkube-schedulerのログを見ると、作業ミスで設定ファイルがなくて起動失敗していて、それが原因でした。

感想

GKEのようなマネージドサービスを使った運用しかしていなかったので、Masterノードがどういうコンポーネントで構成されているか少しでも体験できたのはよかったです。特にkube-schedulerの起動ミスのようなトラブルシューティングを経験できたのはよい経験になりました。

あとGCPクラウドシェルが思ってたより高機能で便利。Tmuxも使えました。普段の開発ももしかしたらクラウドシェル上で出来るかも。