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}" というフォーマットにする必要があることに注意が必要です。
生成された署名付き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 なのが迷ったポイントでした。
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の公式サイトにも記述があります。
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を更新することができます。
たとえば、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を直接編集する方法も紹介されています。
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
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が取得できない
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も使えました。普段の開発ももしかしたらクラウドシェル上で出来るかも。