Workload Identity trên GKE: mỗi pod một danh tính Google riêng

Hướng dẫn thực tế Workload Identity Federation for GKE: cấp quyền trực tiếp cho principal hay liên kết service account, triển khai bằng gcloud và Terraform, kiểm tra, truy cập cross-project, các cái bẫy, và quản lý bằng YAML với Config Connector.

Read in English →

Có hai cách phổ biến khiến một pod trên GKE gọi được Google API, và cả hai đều tệ:

  • Service account key (key.json) nằm trong Kubernetes Secret. Key không bao giờ hết hạn, bị copy khắp nơi, và dùng được từ bất kỳ đâu.
  • Service account của node. Mọi pod trên node âm thầm nhận chung một bộ quyền. Ở cluster cũ, đó thường là Compute Engine default service account, vốn có role Editor trên toàn project.

Workload Identity Federation for GKE giải quyết cả hai. Mỗi Kubernetes ServiceAccount (KSA) trở thành một danh tính Google riêng. Pod nhận token ngắn hạn từ một metadata server trên chính node của nó. Không có key, và không workload nào dùng chung quyền với workload nào.

Đây là bài “bản địa GCP” trong loạt bài: EKS Pod Identity vs IRSA, AWS → GCP, AWS → Azure.

Cơ chế hoạt động

Khi bật Workload Identity, GKE chạy một GKE metadata server trên mỗi node. Thư viện client của Google vốn đã hỏi credentials ở 169.254.169.254, và giờ server này trả lời thay cho KSA của pod chứ không phải cho node. Code hiện có chạy được mà không cần sửa.

Mọi cluster trong project shop-prod đều thuộc workload identity pool shop-prod.svc.id.goog. Trong pool đó, KSA orders-api ở namespace orders là principal sau:

principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/shop-prod.svc.id.goog/subject/ns/orders/sa/orders-api

Có hai cách cấp quyền cho danh tính đó:

Pod GKE hỏi GKE metadata server, server cấp token cho principal của Kubernetes ServiceAccount (được cấp role trực tiếp) hoặc cho một IAM service account được liên kết mà pod impersonate
A: cấp role thẳng cho danh tính Kubernetes. B: đặt một IAM service account ở giữa để KSA impersonate.
A: cấp quyền trực tiếp cho principal (khuyến nghị) B: liên kết IAM service account
Cấp role cho ai principal://…/ns/NS/sa/KSA một IAM service account (GSA)
Object phải tạo thêm không GSA + roles/iam.workloadIdentityUser + annotation trên KSA
Audit log hiển thị principal của KSA GSA
Dùng khi API hỗ trợ principal identifier (phần lớn đều hỗ trợ) API chưa hỗ trợ, hoặc cần email của GSA (ví dụ ký signed URL)

Google khuyến nghị dùng A khi sản phẩm hỗ trợ, và chỉ dùng B để lấp những chỗ chưa hỗ trợ.

Bước 1: bật trên cluster

# Cluster Standard (Autopilot bật sẵn)
gcloud container clusters update prod-eu-1 \
  --location=europe-west1 \
  --workload-pool=shop-prod.svc.id.goog

# node pool hiện có phải chuyển sang GKE metadata server
gcloud container node-pools update default-pool \
  --cluster=prod-eu-1 --location=europe-west1 \
  --workload-metadata=GKE_METADATA

Cập nhật node pool sẽ tạo lại các node, hãy lên kế hoạch như một lần nâng cấp node bình thường.

Nhân tiện, đừng cho node một danh tính quá mạnh. Tạo một service account riêng cho node, chỉ có role roles/container.defaultNodeServiceAccount. Khi Workload Identity đã bật, pod không chạm được tới nó, nên nó chỉ cần đủ quyền để vận hành node.

Bước 2a: cấp quyền trực tiếp cho principal

Tạo một KSA bình thường, không cần annotation:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-api
  namespace: orders

Cấp role trên tài nguyên cho principal của KSA:

PRINCIPAL="principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/shop-prod.svc.id.goog/subject/ns/orders/sa/orders-api"

gcloud storage buckets add-iam-policy-binding gs://shop-prod-orders-reports \
  --role=roles/storage.objectViewer --member="$PRINCIPAL"

gcloud pubsub subscriptions add-iam-policy-binding orders-events \
  --project=shop-prod --role=roles/pubsub.subscriber --member="$PRINCIPAL"

Muốn cấp rộng hơn một KSA, dùng principalSet:

# mọi KSA trong một namespace
principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/shop-prod.svc.id.goog/namespace/orders

# mọi KSA trong một cluster
principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/shop-prod.svc.id.goog/kubernetes.cluster/https://container.googleapis.com/v1/projects/shop-prod/locations/europe-west1/clusters/prod-eu-1

Bước 2b: liên kết IAM service account (khi cần)

gcloud iam service-accounts create orders-api --project=shop-prod

gcloud iam service-accounts add-iam-policy-binding \
  orders-api@shop-prod.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="serviceAccount:shop-prod.svc.id.goog[orders/orders-api]"

gcloud storage buckets add-iam-policy-binding gs://shop-prod-orders-reports \
  --role=roles/storage.objectViewer \
  --member="serviceAccount:orders-api@shop-prod.iam.gserviceaccount.com"
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-api
  namespace: orders
  annotations:
    iam.gke.io/gcp-service-account: orders-api@shop-prod.iam.gserviceaccount.com

Cần cả hai: IAM binding và annotation. Thiếu một trong hai là lý do phổ biến nhất khiến mô hình này “không chạy”.

Bước 3: dùng trong Deployment

Pod spec không có gì riêng cho GCP, chỉ cần chỉ định KSA:

spec:
  template:
    spec:
      serviceAccountName: orders-api
      containers:
      - name: app
        image: europe-docker.pkg.dev/shop-prod/apps/orders-api:1.8.0

Thư viện client tự lấy credentials qua Application Default Credentials. Không đặt GOOGLE_APPLICATION_CREDENTIALS, không mount key.

Bước 4: kiểm tra từ bên trong cluster

kubectl run wi-test -n orders -it --rm --image=google/cloud-sdk:slim \
  --overrides='{"spec":{"serviceAccountName":"orders-api"}}' -- bash

# bên trong pod
curl -s -H "Metadata-Flavor: Google" \
  http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email
# A → shop-prod.svc.id.goog        B → orders-api@shop-prod.iam.gserviceaccount.com

gcloud storage ls gs://shop-prod-orders-reports

Nếu email trả về là service account của node, nghĩa là node pool chưa dùng GKE_METADATA.

Terraform

data "google_project" "this" {
  project_id = "shop-prod"
}

resource "google_container_cluster" "prod" {
  name     = "prod-eu-1"
  location = "europe-west1"
  # …
  workload_identity_config {
    workload_pool = "shop-prod.svc.id.goog"
  }
}

resource "google_container_node_pool" "default" {
  cluster = google_container_cluster.prod.name
  # …
  node_config {
    service_account = google_service_account.nodes.email   # SA tối giản cho node
    workload_metadata_config {
      mode = "GKE_METADATA"
    }
  }
}

locals {
  orders_api = "principal://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/shop-prod.svc.id.goog/subject/ns/orders/sa/orders-api"
}

resource "google_storage_bucket_iam_member" "orders_reports" {
  bucket = google_storage_bucket.orders_reports.name
  role   = "roles/storage.objectViewer"
  member = local.orders_api
}

Truy cập cross-project

Danh tính nằm trong pool của project chứa cluster, nhưng có thể được cấp role ở bất kỳ đâu. Muốn orders-api (cluster ở shop-prod) đọc dataset trong analytics-prod, chỉ cần thêm binding ở analytics-prod với đúng member principal://…/shop-prod.svc.id.goog/…. Phía cluster không phải thay đổi gì.

Quản lý bằng YAML với Config Connector

Làm bằng gcloud hay một repo Terraform riêng thì quyền của ứng dụng nằm rất xa ứng dụng. Config Connector là phiên bản GCP của AWS Controllers for Kubernetes. Nó biến tài nguyên Google, kể cả IAM binding, thành object Kubernetes, nhờ đó Helm chart của ứng dụng tự sở hữu danh tính và quyền của chính nó.

Helm chart của ứng dụng chứa ServiceAccount, Deployment, StorageBucket và IAMPolicyMember; Config Connector tạo bucket và IAM binding cho principal của KSA, và pod đọc bucket qua GKE metadata server
Bucket, quyền truy cập và workload đi chung một chart, một pull request. Config Connector biến YAML thành tài nguyên GCP thật.

Bật Config Connector dưới dạng add-on của GKE (chính nó cũng cần Workload Identity), cấp cho controller một danh tính Google, rồi chỉ định project cần quản lý:

gcloud container clusters update prod-eu-1 --location=europe-west1 \
  --update-addons ConfigConnector=ENABLED

gcloud iam service-accounts create kcc-controller --project=shop-prod
gcloud projects add-iam-policy-binding shop-prod \
  --member="serviceAccount:kcc-controller@shop-prod.iam.gserviceaccount.com" \
  --role="roles/editor"   # bắt đầu từ đây, thu hẹp lại cho production
gcloud iam service-accounts add-iam-policy-binding \
  kcc-controller@shop-prod.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="serviceAccount:shop-prod.svc.id.goog[cnrm-system/cnrm-controller-manager]"
apiVersion: core.cnrm.cloud.google.com/v1beta1
kind: ConfigConnector
metadata:
  name: configconnector.core.cnrm.cloud.google.com
spec:
  mode: cluster
  googleServiceAccount: kcc-controller@shop-prod.iam.gserviceaccount.com
  stateIntoSpec: Absent
---
apiVersion: v1
kind: Namespace
metadata:
  name: orders
  annotations:
    cnrm.cloud.google.com/project-id: shop-prod

Giờ chart của ứng dụng có thể khai báo bucket và quyền ngay cạnh Deployment:

# templates/gcp/bucket.yaml
apiVersion: storage.cnrm.cloud.google.com/v1beta1
kind: StorageBucket
metadata:
  name: shop-prod-orders-reports           # = tên bucket
  annotations:
    cnrm.cloud.google.com/deletion-policy: abandon   # giữ dữ liệu nếu release bị xoá
spec:
  location: EU
  uniformBucketLevelAccess: true
  versioning:
    enabled: true
---
# templates/gcp/iam.yaml
apiVersion: iam.cnrm.cloud.google.com/v1beta1
kind: IAMPolicyMember
metadata:
  name: orders-api-reports-reader
spec:
  member: {{ include "orders-api.wiPrincipal" . }}
  role: roles/storage.objectViewer
  resourceRef:
    kind: StorageBucket
    name: shop-prod-orders-reports
# templates/_helpers.tpl — principal của KSA, định nghĩa một lần
{{- define "orders-api.wiPrincipal" -}}
principal://iam.googleapis.com/projects/{{ .Values.gcp.projectNumber }}/locations/global/workloadIdentityPools/{{ .Values.gcp.projectId }}.svc.id.goog/subject/ns/{{ .Release.Namespace }}/sa/{{ include "orders-api.serviceAccountName" . }}
{{- end }}

Với mô hình B, chart thêm một IAMServiceAccount và liên kết KSA vào đó. memberFrom.serviceAccountRef cho phép object này tham chiếu tới object kia, nên không ai phải gõ email:

apiVersion: iam.cnrm.cloud.google.com/v1beta1
kind: IAMServiceAccount
metadata:
  name: orders-api            # account ID → orders-api@shop-prod.iam.gserviceaccount.com
spec:
  displayName: orders-api (GKE)
---
apiVersion: iam.cnrm.cloud.google.com/v1beta1
kind: IAMPolicyMember
metadata:
  name: orders-api-wi-user
spec:
  member: serviceAccount:shop-prod.svc.id.goog[orders/orders-api]
  role: roles/iam.workloadIdentityUser
  resourceRef:
    kind: IAMServiceAccount
    name: orders-api
---
apiVersion: iam.cnrm.cloud.google.com/v1beta1
kind: IAMPolicyMember
metadata:
  name: orders-api-reports-reader
spec:
  memberFrom:
    serviceAccountRef:
      name: orders-api
  role: roles/storage.objectViewer
  resourceRef:
    kind: StorageBucket
    name: shop-prod-orders-reports

Nhiều team, nhiều project: namespaced mode

Nếu một danh tính controller duy nhất có quyền Editor trên mọi project, thì namespace nào cũng đụng được vào project nào. Namespaced mode cho mỗi namespace một controller Config Connector riêng với danh tính Google riêng:

Mỗi namespace có ConfigConnectorContext với Google service account và annotation project riêng, nên orders quản lý shop-prod còn analytics quản lý data-prod, mỗi bên chỉ với quyền của mình
Một sai sót trong namespace orders chỉ có thể chạm tới shop-prod, và chỉ trong phạm vi role mà kcc-orders đang có.
apiVersion: core.cnrm.cloud.google.com/v1beta1
kind: ConfigConnector
metadata:
  name: configconnector.core.cnrm.cloud.google.com
spec:
  mode: namespaced
---
apiVersion: core.cnrm.cloud.google.com/v1beta1
kind: ConfigConnectorContext
metadata:
  name: configconnectorcontext.core.cnrm.cloud.google.com
  namespace: orders
spec:
  googleServiceAccount: kcc-orders@shop-prod.iam.gserviceaccount.com

Controller riêng của namespace chạy dưới KSA cnrm-system/cnrm-controller-manager-orders, nên đó là KSA được cấp roles/iam.workloadIdentityUser trên kcc-orders. Chỉ cấp cho kcc-orders các role admin của những tài nguyên team sở hữu, không cấp resourcemanager.projectIamAdmin. Thêm luật Kyverno hoặc Gatekeeper để IAMPolicyMember trong orders chỉ được nêu principal thuộc …/ns/orders/….

Những cái bẫy của Config Connector

  • IAMPolicyMember không sửa được. Đổi member hay role nghĩa là xoá object rồi tạo lại. Trong Argo CD, đặt Replace=true cho các resource này.
  • Đừng bao giờ dùng IAMPolicy cùng IAMPolicyMember trên cùng một tài nguyên. IAMPolicy mang tính áp đặt toàn bộ (authoritative) và sẽ xoá các binding bạn không khai báo. Làm vậy trên project có thể khoá tất cả mọi người ra ngoài.
  • Xoá object là xoá luôn tài nguyên trên cloud, trừ khi có annotation cnrm.cloud.google.com/deletion-policy: abandon. Hãy gắn nó cho mọi bucket và database.
  • BigQueryDataset không dùng được với IAMPolicyMember. Quản lý quyền truy cập dataset qua spec.access của chính dataset.
  • Tài nguyên được reconcile khoảng mỗi 10 phút, nên thay đổi bằng tay trên console sẽ bị ghi đè. Đó chính là mục đích, nhưng hãy báo cho team biết.

Những cái bẫy (của chính Workload Identity)

  • Identity sameness. Pool tính theo project, không theo cluster. orders/orders-api ở bất kỳ cluster nào trong shop-prod cũng là cùng một principal. Đừng đặt các cluster có mức tin cậy khác nhau (dev và prod, hay một cluster CI dùng chung) trong cùng project. Nếu buộc phải làm vậy, hãy cấp quyền cho principalSet kubernetes.cluster/….
  • Pod hostNetwork: true không dùng được Workload Identity, vì chúng nói chuyện thẳng với metadata của node.
  • Race lúc khởi động. GKE metadata server cần một chút thời gian sau khi pod khởi động. Ứng dụng lấy credentials ngay trong giây đầu tiên nên có cơ chế thử lại, và mọi thư viện client của Google đều đã làm sẵn việc này.
  • IAM có độ trễ. Binding mới có thể mất vài phút mới có hiệu lực. Ngay sau khi deploy có thể gặp vài lỗi 403 thoáng qua, đừng coi đó là thất bại.
  • Chưa phải API nào cũng nhận principal://. Gặp API từ chối thì chuyển riêng workload đó sang mô hình B, các workload khác giữ nguyên.
  • Service account của node. Giữ nó tối giản. Workload Identity bảo vệ pod khỏi nó, nhưng pod hostNetwork và chính node vẫn dùng nó.

Kết luận

Trên GKE, Workload Identity chỉ là một thiết lập ở cluster cộng một IAM binding. Mặc định hãy cấp role thẳng cho principal của Kubernetes ServiceAccount, và chỉ dùng service account liên kết ở những chỗ API đòi hỏi. Sau đó đưa các binding ra khỏi việc bấm tay: với Config Connector, Helm chart của ứng dụng tự sở hữu bucket, danh tính và quyền của nó, được review trong cùng pull request với code dùng chúng.

Nguồn

Bình luận