AWS Controllers for Kubernetes (ACK): để Helm chart của ứng dụng tự sở hữu tài nguyên AWS

ACK giúp Helm chart của mỗi ứng dụng tự sở hữu bucket, queue, database — và kro đưa những giá trị như DATABASE_URL vào Deployment một cách tự động, không copy-paste.

Read in English →

Đây là câu chuyện mà team platform nào cũng từng gặp. Một developer cần một S3 bucket và một IAM role cho service mới. Ứng dụng nằm trong Helm chart, deploy bằng Argo CD. Bucket thì nằm trong repo Terraform của một team khác. Thế là phải mở ticket, tạo pull request thứ hai, chạy terraform apply, rồi ai đó copy tay tên bucket và role ARN vào values-prod.yaml. Sáu tháng sau, không ai nhớ bucket nào thuộc service nào, và xoá service xong thì hạ tầng mồ côi vẫn nằm đó.

AWS Controllers for Kubernetes (ACK) lấp khoảng trống này. ACK cho phép khai báo tài nguyên AWS dưới dạng object Kubernetes, nên Helm chart của mỗi ứng dụng có thể tự sở hữu tài nguyên AWS mà ứng dụng đó cần. Chúng đi cùng chart, cùng pull request và cùng lần sync của Argo CD với đoạn code dùng chúng. Cấu hình cũng tự kết nối: tạo database trong chart, và DATABASE_URL tự xuất hiện trong biến môi trường của Deployment mà không ai phải copy endpoint. Thêm kro ở tầng trên, team platform có thể biến các tài nguyên thô đó thành một API nhỏ gọn, an toàn cho developer.

ACK là gì

ACK là dự án mã nguồn mở của AWS, gồm các Kubernetes controller, mỗi controller cho một dịch vụ AWS (s3-controller, iam-controller, rds-controller, eks-controller…), hỗ trợ hơn 50 dịch vụ. Mỗi controller được sinh tự động từ API model của AWS, nên custom resource của nó gần như khớp 1-1 với API thật.

Vòng reconcile của ACK giữa custom resource Bucket, s3-controller và S3 API
Controller theo dõi custom resource, gọi AWS API, rồi ghi trạng thái thật (ARN, conditions) ngược vào status.
apiVersion: s3.services.k8s.aws/v1alpha1
kind: Bucket
metadata:
  name: orders-api-data
spec:
  name: orders-prod-orders-api-data
  versioning:
    status: Enabled
  publicAccessBlock:
    blockPublicACLs: true
    blockPublicPolicy: true
    ignorePublicACLs: true
    restrictPublicBuckets: true

Sau kubectl apply, controller tạo bucket và điền status:

status:
  ackResourceMetadata:
    arn: arn:aws:s3:::orders-prod-orders-api-data
    ownerAccountID: "111122223333"
    region: eu-west-1
  conditions:
  - type: ACK.ResourceSynced
    status: "True"

Ba đặc điểm quan trọng khi dùng thực tế:

  • Reconcile liên tục. Đây không phải apply một lần là xong. Mặc định mỗi controller kiểm tra lại tài nguyên mỗi 10 giờ và sửa drift, ví dụ ai đó tắt versioning trên console. Chu kỳ này chỉnh được cho cả controller (reconcile.defaultResyncPeriod) hoặc cho từng loại resource (reconcile.resourceResyncPeriods) trong Helm values.
  • Status chính là API. ARN, endpoint, ID đều nằm trong .status, để công cụ khác đọc được. Các condition ACK.ResourceSynced, ACK.Recoverable, ACK.Terminal cho biết tài nguyên có khoẻ hay không.
  • Dùng được RBAC và audit của Kubernetes. Ai được tạo Bucket trong namespace nào giờ là chuyện của RBAC, và mọi thay đổi đều có trong audit log của API server.

Cài đặt

Controller được phát hành dưới dạng OCI Helm chart trên Amazon ECR Public:

export SERVICE=s3
export RELEASE_VERSION=$(curl -sL https://api.github.com/repos/aws-controllers-k8s/${SERVICE}-controller/releases/latest | jq -r '.tag_name | ltrimstr("v")')

helm install --create-namespace -n ack-system ack-$SERVICE-controller \
  oci://public.ecr.aws/aws-controllers-k8s/$SERVICE-chart \
  --version=$RELEASE_VERSION --set=aws.region=eu-west-1

Mỗi controller cần IAM permission riêng. Hãy cấp role cho nó qua EKS Pod Identity hoặc IRSA, và chỉ giới hạn quyền trong đúng dịch vụ đó.

Nếu không muốn tự vận hành controller, EKS Capabilities cung cấp ACK, kro và Argo CD dưới dạng tính năng do AWS quản lý. Chúng chạy trong EKS chứ không chiếm worker node của bạn, và AWS lo việc nâng cấp và scale.

Pattern 1: Helm chart của ứng dụng sở hữu tài nguyên AWS của chính nó

Đây là ý tưởng cốt lõi của bài. Helm chart của một ứng dụng nên mô tả mọi thứ ứng dụng đó cần: image và các object Kubernetes, và cả tài nguyên AWS của riêng nó, như bucket nó ghi vào, queue nó đọc, database nó sở hữu, IAM role nó dùng để chạy. Phần nền móng dùng chung vẫn thuộc về team platform.

Nằm trong chart của ứng dụng Thuộc nền móng của platform
S3 bucket, SQS queue, SNS topic, DynamoDB table VPC, subnet, NAT, Transit Gateway
Database RDS / Aurora riêng của service, ElastiCache EKS cluster và node pool
IAM role + Pod Identity association của app ACK controller và IAM role của chúng
CloudWatch alarm của app IAM cấp tổ chức, SCP, DNS zone
charts/orders-api/
├── Chart.yaml
├── values.yaml
├── values-dev.yaml
├── values-prod.yaml
└── templates/
    ├── _helpers.tpl
    ├── deployment.yaml
    ├── service.yaml
    ├── serviceaccount.yaml
    └── aws/
        ├── bucket.yaml
        ├── queue.yaml
        ├── database.yaml
        ├── role.yaml
        └── podidentityassociation.yaml
# templates/aws/bucket.yaml
{{- if .Values.aws.bucket.enabled }}
apiVersion: s3.services.k8s.aws/v1alpha1
kind: Bucket
metadata:
  name: {{ include "orders-api.fullname" . }}-data
  annotations:
    services.k8s.aws/deletion-policy: {{ .Values.aws.deletionPolicy }}
spec:
  name: {{ include "orders-api.bucketName" . }}
  versioning:
    status: Enabled
{{- end }}
# templates/aws/podidentityassociation.yaml
apiVersion: eks.services.k8s.aws/v1alpha1
kind: PodIdentityAssociation
metadata:
  name: {{ include "orders-api.fullname" . }}
spec:
  clusterName: {{ .Values.aws.clusterName }}
  namespace: {{ .Release.Namespace }}
  serviceAccount: {{ include "orders-api.serviceAccountName" . }}
  roleRef:
    from:
      name: {{ include "orders-api.fullname" . }}   # ACK Role trong cùng namespace
# values-prod.yaml
aws:
  region: eu-west-1
  accountId: "444444444444"
  clusterName: prod-eu-1
  deletionPolicy: retain   # không bao giờ xoá dữ liệu prod theo release
  bucket:
    enabled: true

Lợi ích:

  • Một pull request, một version. Thay đổi code, bucket mới và IAM permission được review và release cùng nhau, rollback cũng cùng nhau.
  • Vòng đời đi theo ứng dụng. Môi trường preview được tạo bằng helm install và xoá bằng helm uninstall, kèm luôn bucket và queue của nó (deletionPolicy: delete ở dev). Ở prod, retain giữ lại dữ liệu kể cả khi release bị xoá.
  • Values theo môi trường có sẵn. Dev dùng db.t4g.micro, không Multi-AZ. Prod dùng instance lớn hơn, có Multi-AZ, từ cùng một bộ template.
  • Ownership rõ ràng. Mỗi tài nguyên AWS thuộc về một Helm release trong một namespace. Chạy kubectl get buckets,queues,dbinstances -n orders-prod là biết service này sở hữu những gì.
  • Cấu hình tự kết nối, là phần ngay dưới đây.

Đưa output của AWS vào Deployment

Ứng dụng không quan tâm ARN. Nó cần giá trị trong biến môi trường: BUCKET_NAME, QUEUE_URL, DATABASE_URL. Các giá trị này chia làm hai loại:

Giá trị ứng dụng cần Ví dụ Biết được lúc Helm render? Cách đưa vào
Tên S3 bucket orders-prod-orders-api-data ✅ do bạn đặt template
URL của SQS queue https://sqs.eu-west-1.amazonaws.com/444444444444/orders-prod-events ✅ region + account + tên template
Tên DynamoDB table orders-prod-orders ✅ do bạn đặt template
ARN của IAM role arn:aws:iam::444444444444:role/orders-prod-orders-api ✅ account + tên template hoặc ACK roleRef
Endpoint RDS orders-prod-orders-api.c9xyz….rds.amazonaws.com ❌ AWS tự sinh kro → ConfigMap
Endpoint ElastiCache ….cache.amazonaws.com ❌ AWS tự sinh kro → ConfigMap

Giá trị do bạn đặt: định nghĩa tên một lần, dùng hai nơi. Đặt tên trong _helpers.tpl rồi dùng cho cả resource ACK lẫn env của Deployment. Nhờ vậy resource và cấu hình không bao giờ lệch nhau:

# templates/_helpers.tpl
{{- define "orders-api.bucketName" -}}
{{ .Release.Namespace }}-{{ include "orders-api.fullname" . }}-data
{{- end }}

{{- define "orders-api.queueName" -}}
{{ .Release.Namespace }}-{{ include "orders-api.fullname" . }}-events
{{- end }}
# templates/deployment.yaml (env của container)
env:
- name: BUCKET_NAME
  value: {{ include "orders-api.bucketName" . }}
- name: QUEUE_URL
  value: https://sqs.{{ .Values.aws.region }}.amazonaws.com/{{ .Values.aws.accountId }}/{{ include "orders-api.queueName" . }}

Giá trị do AWS sinh ra cần một chút “keo” lúc chạy. Endpoint RDS chưa tồn tại cho tới khi AWS tạo xong instance, tức là rất lâu sau khi Helm render xong. ACK từng có resource FieldExport cho việc này nhưng đã bị deprecate. “Keo” được khuyến nghị là kro, được trình bày chi tiết ở phần sau. Ví dụ dưới đây cho thấy toàn bộ luồng.

Ví dụ: tạo database và có DATABASE_URL trong Deployment

Mục tiêu: developer thêm một database vào chart của ứng dụng, và pod nhận được DATABASE_URL dùng được ngay. Không ai phải copy endpoint, chạy script hay restart gì bằng tay.

Helm chart của ứng dụng khai báo PostgresDatabase và Deployment; kro và ACK tạo RDS, kro ghi endpoint vào ConfigMap, và Deployment ghép DATABASE_URL từ ConfigMap và Secret mật khẩu
Chart khai báo ứng dụng cần gì. kro và ACK tạo ra nó và ghi thông tin kết nối vào ConfigMap. Kubernetes ghép DATABASE_URL khi pod khởi động.

Bước 1, làm một lần, do team platform: tạo API PostgresDatabase bằng kro. API này tạo DBInstance của ACK, và khi RDS đã có endpoint thì tạo một ConfigMap chứa thông tin kết nối:

apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
  name: postgresdatabase.kro.run
spec:
  schema:
    apiVersion: v1alpha1
    kind: PostgresDatabase
    spec:
      dbName: string
      username: string | default="app"
      passwordSecretName: string
      instanceClass: string | default="db.t4g.micro"
      allocatedStorage: integer | default=20
      engineVersion: string | default="16.10"
      multiAZ: boolean | default=false
      subnetGroupName: string
      securityGroupIDs: "[]string"
    status:
      host: ${dbinstance.status.endpoint.address}
      connectionConfigMap: ${connection.metadata.name}

  resources:
  - id: dbinstance
    readyWhen:
    - ${dbinstance.status.dbInstanceStatus == "available"}
    - ${has(dbinstance.status.endpoint)}
    template:
      apiVersion: rds.services.k8s.aws/v1alpha1
      kind: DBInstance
      metadata:
        name: ${schema.metadata.name}
        annotations:
          services.k8s.aws/deletion-policy: retain
      spec:
        dbInstanceIdentifier: ${schema.metadata.namespace}-${schema.metadata.name}
        engine: postgres
        engineVersion: ${schema.spec.engineVersion}
        dbInstanceClass: ${schema.spec.instanceClass}
        allocatedStorage: ${schema.spec.allocatedStorage}
        multiAZ: ${schema.spec.multiAZ}
        storageEncrypted: true
        backupRetentionPeriod: 7
        dbName: ${schema.spec.dbName}
        masterUsername: ${schema.spec.username}
        masterUserPassword:
          namespace: ${schema.metadata.namespace}
          name: ${schema.spec.passwordSecretName}
          key: password
        dbSubnetGroupName: ${schema.spec.subnetGroupName}
        vpcSecurityGroupIDs: ${schema.spec.securityGroupIDs}

  - id: connection
    template:
      apiVersion: v1
      kind: ConfigMap
      metadata:
        name: ${schema.metadata.name}-connection
      data:
        DB_HOST: ${dbinstance.status.endpoint.address}
        DB_PORT: "5432"
        DB_NAME: ${schema.spec.dbName}
        DB_USER: ${schema.spec.username}

Bước 2, trong chart của ứng dụng: khai báo database ngay cạnh Deployment dùng nó:

# templates/aws/database.yaml
apiVersion: kro.run/v1alpha1
kind: PostgresDatabase
metadata:
  name: {{ include "orders-api.fullname" . }}-db
spec:
  dbName: orders
  passwordSecretName: {{ .Values.db.passwordSecretName }}
  instanceClass: {{ .Values.db.instanceClass }}
  multiAZ: {{ .Values.db.multiAZ }}
  subnetGroupName: {{ .Values.db.subnetGroupName }}
  securityGroupIDs: {{ toJson .Values.db.securityGroupIDs }}
# templates/deployment.yaml (container)
envFrom:
- configMapRef:
    name: {{ include "orders-api.fullname" . }}-db-connection   # do kro ghi
env:
- name: DB_PASSWORD
  valueFrom:
    secretKeyRef:
      name: {{ .Values.db.passwordSecretName }}
      key: password
- name: DATABASE_URL
  value: "postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=require"
# values-dev.yaml               # values-prod.yaml
db:                             # db:
  instanceClass: db.t4g.micro   #   instanceClass: db.r6g.large
  multiAZ: false                #   multiAZ: true
  passwordSecretName: orders-api-db-password
  subnetGroupName: dev-private  #   subnetGroupName: prod-private
  securityGroupIDs: [sg-0dev…]  #   securityGroupIDs: [sg-0prod…]

Cú pháp $(DB_USER) là tính năng có sẵn của Kubernetes: một biến môi trường có thể tham chiếu tới biến đã khai báo trước nó trong cùng container, kể cả các biến nạp bằng envFrom. URL do kubelet ghép, không cần script entrypoint hay init container.

Bước 3: mật khẩu. Không để mật khẩu trong Git hay values.yaml. Tạo Secret bằng External Secrets Operator (lấy từ AWS Secrets Manager) hoặc Sealed Secrets. Để thử nhanh:

kubectl create secret generic orders-api-db-password -n orders-prod \
  --from-literal=password="$(openssl rand -hex 24)"   # hex = an toàn khi đặt trong URL

Nếu muốn RDS tự sinh và tự xoay vòng mật khẩu, ACK cũng hỗ trợ manageMasterUserPassword: true. Khi đó mật khẩu nằm trong AWS Secrets Manager (status.masterUserSecret.secretARN), và bạn đồng bộ nó vào cluster bằng External Secrets Operator.

Chuyện gì xảy ra ở lần deploy đầu tiên:

  1. Argo CD sync chart. PostgresDatabase, Deployment và Service được tạo cùng lúc.
  2. Pod ở trạng thái CreateContainerConfigError vì ConfigMap chưa có. Đây là điều bình thường, không phải lỗi cần sửa.
  3. ACK tạo RDS instance. Sau vài phút, RDS báo available kèm endpoint.
  4. kro ghi orders-api-db-connection, kubelet thử lại, và pod khởi động với DATABASE_URL đầy đủ. Không cần restart, không có bước thủ công nào.

Vận hành về sau cũng đơn giản như vậy. Đổi instanceClass trong values-prod.yaml, merge pull request, và ACK nâng cấp instance tại chỗ. Endpoint giữ nguyên nên ứng dụng không bị ảnh hưởng.

Helm thuần bắt đầu thấy giới hạn ở đâu

Ví dụ trên đã dùng kro cho đúng một việc mà Helm không làm được. Nói chung, Helm render template rồi apply tất cả cùng lúc, và không biết các resource phụ thuộc vào nhau:

  • Thứ tự tạo. Pod của Deployment có thể được tạo trước khi PodIdentityAssociation tồn tại. Pod Identity webhook chỉ inject credentials lúc tạo pod, nên các pod đó chạy mà không có AWS credentials cho tới khi được restart.
  • Output lúc render. Mọi thứ AWS tự sinh ra (endpoint, ID) đều chưa biết lúc Helm render. Bạn cần “keo” lúc chạy như PostgresDatabase ở trên.
  • helm --wait không hiểu ACK. Nó không biết ACK.ResourceSynced=True nghĩa là đã sẵn sàng.
  • Chuẩn chung bị copy-paste. Mỗi team có một bản bucket.yaml, role.yaml hơi khác nhau. Mã hoá, tag, chặn public access lệch dần giữa các team.

Pattern 2: compose bằng kro

kro (Kube Resource Orchestrator) là dự án thuộc Kubernetes SIG, được AWS, Google và Microsoft cùng phát triển. kro cho phép team platform định nghĩa một API mới, ví dụ WebService, bằng một ResourceGraphDefinition (RGD): gồm schema và tập template tài nguyên. Sau đó kro sẽ:

  1. tạo một CRD cho kind mới,
  2. đọc các tham chiếu CEL ${...} giữa các template để dựng đồ thị phụ thuộc (DAG),
  3. tạo tài nguyên theo đúng thứ tự phụ thuộc, chờ điều kiện readyWhen, và truyền giá trị (ARN, tên, endpoint) từ resource này sang resource khác,
  4. đưa các trường được chọn ra status của instance.
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
  name: webservice.kro.run
spec:
  schema:
    apiVersion: v1alpha1
    kind: WebService
    spec:
      image: string
      replicas: integer | default=2
      port: integer | default=8080
      clusterName: string
    status:
      bucketARN: ${bucket.status.ackResourceMetadata.arn}
      roleARN: ${role.status.ackResourceMetadata.arn}
      availableReplicas: ${deployment.status.availableReplicas}

  resources:
  - id: bucket
    readyWhen:
    - ${has(bucket.status.ackResourceMetadata.arn)}
    template:
      apiVersion: s3.services.k8s.aws/v1alpha1
      kind: Bucket
      metadata:
        name: ${schema.metadata.name}-data
        annotations:
          services.k8s.aws/deletion-policy: retain
      spec:
        name: ${schema.metadata.namespace}-${schema.metadata.name}-data
        versioning:
          status: Enabled
        publicAccessBlock:
          blockPublicACLs: true
          blockPublicPolicy: true
          ignorePublicACLs: true
          restrictPublicBuckets: true

  - id: role
    template:
      apiVersion: iam.services.k8s.aws/v1alpha1
      kind: Role
      metadata:
        name: ${schema.metadata.name}
      spec:
        name: ${schema.metadata.namespace}-${schema.metadata.name}
        assumeRolePolicyDocument: |
          {
            "Version": "2012-10-17",
            "Statement": [{
              "Effect": "Allow",
              "Principal": { "Service": "pods.eks.amazonaws.com" },
              "Action": ["sts:AssumeRole", "sts:TagSession"]
            }]
          }
        inlinePolicies:
          bucket-access: |
            {
              "Version": "2012-10-17",
              "Statement": [{
                "Effect": "Allow",
                "Action": ["s3:GetObject", "s3:PutObject"],
                "Resource": "${bucket.status.ackResourceMetadata.arn}/*"
              }]
            }

  - id: serviceaccount
    template:
      apiVersion: v1
      kind: ServiceAccount
      metadata:
        name: ${schema.metadata.name}

  - id: podidentityassociation
    template:
      apiVersion: eks.services.k8s.aws/v1alpha1
      kind: PodIdentityAssociation
      metadata:
        name: ${schema.metadata.name}
      spec:
        clusterName: ${schema.spec.clusterName}
        namespace: ${schema.metadata.namespace}
        serviceAccount: ${serviceaccount.metadata.name}
        roleARN: ${role.status.ackResourceMetadata.arn}

  - id: deployment
    readyWhen:
    - ${deployment.status.availableReplicas == schema.spec.replicas}
    template:
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: ${schema.metadata.name}
      spec:
        replicas: ${schema.spec.replicas}
        selector:
          matchLabels:
            app: ${schema.metadata.name}
        template:
          metadata:
            labels:
              app: ${schema.metadata.name}
            annotations:
              # tham chiếu tới association để biến nó thành dependency:
              # pod chỉ được tạo khi Pod Identity đã sẵn sàng
              example.com/pod-identity: ${podidentityassociation.status.ackResourceMetadata.arn}
          spec:
            serviceAccountName: ${serviceaccount.metadata.name}
            containers:
            - name: app
              image: ${schema.spec.image}
              ports:
              - containerPort: ${schema.spec.port}
              env:
              - name: BUCKET_NAME
                value: ${bucket.spec.name}
Đồ thị phụ thuộc kro dựng cho WebService: bucket và service account trước, rồi role, rồi Pod Identity association, cuối cùng là deployment
kro đọc các tham chiếu ${...} và tự suy ra thứ tự. Không ai phải viết sync wave hay init container.

Giờ developer chỉ cần viết đúng thế này:

apiVersion: kro.run/v1alpha1
kind: WebService
metadata:
  name: orders-api
  namespace: orders
spec:
  image: 111122223333.dkr.ecr.eu-west-1.amazonaws.com/orders-api:1.4.2
  replicas: 3
  clusterName: prod-eu-1
kubectl get webservice orders-api -n orders -o jsonpath='{.status.bucketARN}'
# arn:aws:s3:::orders-orders-api-data

Những gì thay đổi:

  • Thứ tự được giải quyết bằng dữ liệu, không phải bằng quy ước. Role chờ bucket ARN, association chờ role ARN, pod chờ association.
  • Chuẩn chung được viết một lần. Mã hoá, chặn public access, deletion policy, quy tắc đặt tên, tag đều nằm trong RGD. Mọi WebService đều tuân theo, và sửa RGD một lần là cả tổ chức được cập nhật.
  • API nhỏ hơn và an toàn hơn. Developer chỉ khai báo image và replicas, không bao giờ phải đụng vào IAM JSON.
  • Least privilege bằng RBAC. Developer chỉ cần quyền tạo object WebService. kro tạo Role, Bucket… bên dưới bằng service account của chính nó, nên team ứng dụng không bao giờ có quyền tạo IAM role tuỳ ý. Có quyền đó chính là một đường leo thang đặc quyền.

Pattern 3: Helm + kro + ACK cùng nhau

kro không thay thế Helm, vì hai công cụ giải quyết hai bài toán khác nhau. Helm lo đóng gói: chart có version, values theo môi trường, và vòng đời release mà Argo CD hiểu được. kro lo việc compose lúc chạy: phụ thuộc và output. ACK là cầu nối tới AWS API.

Trên thực tế, chart của ứng dụng chỉ còn phần riêng của app cộng thêm một WebService:

# charts/orders-api/templates/webservice.yaml
apiVersion: kro.run/v1alpha1
kind: WebService
metadata:
  name: {{ include "orders-api.fullname" . }}
spec:
  image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
  replicas: {{ .Values.replicas }}
  clusterName: {{ .Values.clusterName }}
Các lớp từ Git và Helm chart, qua Argo CD, kro và ACK controller, xuống tới tài nguyên AWS, kèm phân chia trách nhiệm giữa team ứng dụng và team platform
Team ứng dụng sở hữu chart và values. Team platform sở hữu RGD và ACK controller. Git vẫn là nguồn sự thật duy nhất.
Terraform + Helm (tách rời) Helm + ACK Helm + kro + ACK
Số pull request mỗi tính năng 2+ (thường 2 team) 1 1
Đưa output hạ tầng vào app Copy tay Chỉ qua ACK ref Mọi trường, qua CEL
Thứ tự tạo Thủ công / pipeline Không có (apply một lượt) DAG tự động
Sửa drift Lần terraform plan sau Liên tục (ACK) Liên tục (ACK + kro)
Chuẩn của tổ chức Module Template copy-paste Viết một lần trong RGD
Developer cần quyền IAM Thường có Có (tạo Role) Không (chỉ WebService)
Dọn dẹp khi xoá app Destroy riêng Theo release Theo release

Pattern 4: một control plane cho nhiều AWS account

Rất ít tổ chức chỉ dùng một AWS account. Phần lớn chạy AWS Organization với account riêng cho từng môi trường, team hay mảng kinh doanh. Cài ACK vào từng workload cluster nghĩa là N bản controller, N bộ quyền và N chỗ phải debug. Mô hình gọn hơn là hub-and-spoke: một EKS cluster làm control plane riêng, đặt trong account quản lý (platform account), chạy Argo CD, kro và các ACK controller, rồi từ đó quản lý tài nguyên AWS ở mọi account khác.

EKS cluster control plane trong management account ánh xạ các namespace qua IAMRoleSelector tới role ack-target ở account dev, staging và prod
Mỗi namespace trong control plane đại diện cho một account đích. IAMRoleSelector quyết định ACK sẽ assume role nào cho mọi resource trong namespace đó.

Các thành phần:

  1. ACK controller chạy với một danh tính duy nhất trong management account: role/ack-controller, được cấp qua Pod Identity.
  2. Mỗi account đích có một role/ack-target tin ack-controller, và chỉ có những quyền mà account đó cần.
  3. Mỗi namespace trong control plane đại diện cho một đích: label cho biết account nào, annotation cho biết region nào.
  4. Một IAMRoleSelector ánh xạ label đó tới role đích. Với mọi resource trong namespace, ACK assume role này và gọi AWS API ngay trong account đích.

Trước đây ACK làm việc này bằng CARM (ConfigMap ack-role-account-map cộng annotation services.k8s.aws/owner-account-id trên namespace). CARM đang bị deprecate. Hệ thống mới nên dùng IAMRoleSelector, và khi bật tính năng này thì CARM tự động bị tắt.

Bước 1: tạo target role ở mọi account

Mỗi account đích tin role của controller trong management account:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::111111111111:role/ack-controller" },
    "Action": "sts:AssumeRole"
  }]
}

Chỉ gắn quyền cho những dịch vụ mà ACK quản lý ở account đó, và để prod chặt hơn dev. Triển khai role này bằng CloudFormation StackSets hoặc Terraform trên cả organizational unit, để account mới nào cũng tự có. Đây là một phần của lớp nền móng nhỏ nằm ngoài ACK.

Bước 2: cho controller quyền assume các role đó

Role của controller trong management account chỉ được assume đúng những role này, không hơn:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "sts:AssumeRole",
    "Resource": [
      "arn:aws:iam::222222222222:role/ack-target",
      "arn:aws:iam::333333333333:role/ack-target",
      "arn:aws:iam::444444444444:role/ack-target"
    ]
  }]
}

Bước 3: bật feature gate trên mọi controller

for SERVICE in s3 iam rds eks sqs; do
  helm upgrade --install -n ack-system ack-$SERVICE-controller \
    oci://public.ecr.aws/aws-controllers-k8s/$SERVICE-chart \
    --version "<version đã ghim cho controller này>" \
    --set aws.region=eu-west-1 \
    --set featureGates.IAMRoleSelector=true
done

Bước 4: mỗi đích một namespace và một selector

apiVersion: v1
kind: Namespace
metadata:
  name: orders-prod
  labels:
    aws-account: prod
  annotations:
    services.k8s.aws/region: ap-southeast-1   # region mặc định cho resource ACK ở đây
    s3.services.k8s.aws/deletion-policy: retain
---
apiVersion: services.k8s.aws/v1alpha1
kind: IAMRoleSelector
metadata:
  name: account-prod
spec:
  arn: arn:aws:iam::444444444444:role/ack-target
  namespaceSelector:
    names: []            # bắt buộc phải có, kể cả khi chọn theo label
    labelSelector:
      matchLabels:
        aws-account: prod

Từ giờ, mọi resource ACK tạo trong orders-prod, kể cả do một WebService của kro tạo ra, đều nằm ở account 444444444444, region ap-southeast-1. Resource cũng cho biết nó đã dùng selector nào:

status:
  conditions:
  - type: ACK.IAMRoleSelected
    status: "True"
    message: "roleARN: arn:aws:iam::444444444444:role/ack-target, selectorName: account-prod, selectorResourceVersion: 89424719"
Luật chọn role: không khớp selector nào thì dùng role mặc định của controller, khớp đúng một thì assume role đó, khớp từ hai trở lên là xung đột
Phải khớp đúng một selector. Không khớp cái nào thì ACK dùng role của chính nó ở management account. Khớp từ hai trở lên thì resource báo ACK.Recoverable và không được reconcile.

Hai chi tiết dễ dính bẫy:

  • Chồng lấn là xung đột, không phải ưu tiên. Selector cụ thể hơn không thắng selector rộng hơn. Ví dụ, một selector dùng resourceTypeSelector cho Bucket và một selector khác cho cả namespace đều khớp với bucket, và đó là xung đột. Dùng một khoá label duy nhất với đúng một giá trị cho mỗi namespace (aws-account=…) thì không thể chồng lấn ngay từ thiết kế.
  • Không khớp sẽ rơi về management account. Namespace bị quên gắn label sẽ âm thầm tạo resource ngay trong account của control plane. Hãy chặn bằng policy Kyverno hoặc Gatekeeper từ chối resource ACK trong namespace không có label aws-account, và để role của chính controller gần như không có quyền gì.

Bước 5: để Argo CD nhân bản ra các môi trường

Khi đường ống đã xong, mỗi môi trường chỉ còn là một namespace. Một ApplicationSet của Argo CD render cùng một chart cho từng account:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: orders-infra
  namespace: argocd
spec:
  generators:
  - list:
      elements:
      - env: dev
      - env: staging
      - env: prod
  template:
    metadata:
      name: 'orders-infra-{{env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/example/platform.git
        targetRevision: main
        path: charts/orders-infra
        helm:
          valueFiles:
          - 'values-{{env}}.yaml'
      destination:
        server: https://kubernetes.default.svc   # chính cluster control plane
        namespace: 'orders-{{env}}'
      syncPolicy:
        automated: {}

Trong mô hình hub-and-spoke, hãy tách hai đích deploy:

  • Tài nguyên AWS (Bucket, Role, PodIdentityAssociation, RDS…) vào namespace trên control plane.
  • Workload (Deployment, Service) vào workload cluster ở account đích.

RGD kro cho mô hình này chính là WebService ở trên nhưng bỏ phần Deployment. PodIdentityAssociation.clusterName trỏ tới workload cluster trong cùng account, nên ACK EKS controller sẽ tạo association ở đó. Workload cluster vì thế rất gọn: chỉ chạy ứng dụng, không chạy controller hạ tầng.

Vận hành control plane

  • Coi nó là tier 0. Nó tạo được tài nguyên ở mọi account. Đặt nó trong account riêng, dùng private API endpoint, chỉ cho người dùng quyền RBAC read-only, và để Argo CD là bên duy nhất được ghi.
  • Sự cố được thiết kế để “nhàm chán”. Khi control plane sập, tài nguyên AWS hiện có vẫn chạy bình thường và workload cluster không bị ảnh hưởng. Chỉ có việc thay đổi và sửa drift tạm dừng cho tới khi nó hoạt động lại.
  • Tách hub nếu cần cô lập. Nhiều team chạy một control plane cho non-prod và một cái riêng cho prod, để lỗi trong công cụ dev không bao giờ chạm được tới credentials của production.
  • Audit ở account đích. CloudTrail trong từng account cho thấy lệnh AssumeRole từ ack-controller, tiếp theo là các API call thực hiện bằng session đó.
  • Thứ tự debug: kiểm tra condition ACK.IAMRoleSelected, rồi trust policy của target role, rồi quyền sts:AssumeRole của role controller, cuối cùng là log của controller.

Checklist cho production

  • Bảo vệ tài nguyên có dữ liệu. services.k8s.aws/deletion-policy: retain giữ lại tài nguyên AWS khi object Kubernetes bị xoá. Mặc định là delete. Có thể đặt cho từng resource, từng namespace, hoặc bằng cờ --deletion-policy của controller. Kết hợp thêm helm.sh/resource-policy: keep cho các object mà Helm không được xoá.
  • Adopt thay vì tạo lại. Với bucket hay database đã có sẵn, dùng annotation services.k8s.aws/adoption-policy (adopt hoặc adopt-or-create) cùng services.k8s.aws/adoption-fields. Tính năng này cần bật feature gate ResourceAdoption. CRD AdoptedResource kiểu cũ không còn được khuyến nghị.
  • Multi-account và multi-region. Dùng IAMRoleSelector (Pattern 4) cho nhiều account và annotation services.k8s.aws/region cho nhiều region. Đừng bắt đầu hệ thống mới bằng CARM, vì nó đã bị deprecate.
  • Dạy Argo CD hiểu trạng thái health. Thêm custom health check để resource ACK báo Progressing cho tới khi ACK.ResourceSynced là True, và báo Degraded khi gặp ACK.Terminal. Nếu không, Argo sẽ báo “Healthy” trong khi AWS vẫn đang tạo tài nguyên.
  • Guardrail. Dùng RBAC để chặn namespace ứng dụng tạo trực tiếp object iam.services.k8s.aws, và thêm policy Kyverno hoặc Gatekeeper để bắt buộc có tag và mã hoá. IAM là chỗ quan trọng nhất, vì tạo được Role nghĩa là tự cấp được quyền.
  • Bootstrap vẫn cần IaC. Cluster, VPC và IAM role của chính các controller phải có trước thì ACK mới chạy được. Giữ phần nền móng nhỏ này trong Terraform (hoặc trong một management cluster), còn mọi thứ ứng dụng cần thì để ACK và kro lo.
  • Ghim version. API của kro vẫn là v1alpha1, nên hãy ghim version và đọc release notes trước khi nâng cấp. Ghim cả version chart của ACK controller, hoặc dùng EKS Capabilities để AWS lo việc nâng cấp.

Khi nào ACK không phải lựa chọn đúng

  • Tổ chức multi-cloud và muốn một lớp trừu tượng chung cho mọi cloud. Crossplane, hoặc Terraform kết hợp GitOps controller, có thể hợp hơn.
  • Tài nguyên nền móng và dùng chung (VPC, transit gateway, IAM cấp tổ chức). Chúng ít thay đổi, sống lâu hơn bất kỳ ứng dụng nào, và nên nằm trong một pipeline riêng, chậm hơn.
  • Bạn không dùng Kubernetes làm control plane. Toàn bộ giá trị của ACK đến từ việc dùng Kubernetes API làm giao diện.

Kết luận

ACK biến tài nguyên AWS thành object Kubernetes, nghĩa là chúng có thể được đóng gói, đánh version, review và xoá cùng với ứng dụng cần chúng. Helm mang lại việc đóng gói đó và cấu hình theo từng môi trường. kro bổ sung những gì Helm còn thiếu: thứ tự phụ thuộc, truyền output giữa các tài nguyên, và một API nhỏ gọn mang sẵn chuẩn của platform. Kết hợp lại, “tôi cần một database” chỉ còn là vài dòng trong chính chart của ứng dụng, và DATABASE_URL tự có mặt trong biến môi trường của pod.

Nguồn

Bình luận