Отже, маємо AWS EKS кластер, створений з AWS CDK та Python – AWS: CDK – створення EKS з Python та загальні враження від CDK та маємо уявлення, як працює IRSA – AWS: EKS, OpenID Connect та ServiceAccounts.
Наступним кроком після розгортання самого кластеру треба налаштувати OIDC Identity Provider в AWS IAM, та додати два контролери – ExternalDNS для роботи з Route53, то AWS ALB Controller для створення лоад-балансерів при створенні Ingress.
Для аутентифікації в AWS обидва контролери будуть використовувати модель IRSA – IAM Roles for ServiceAccounts, тобто в Kubernetes Pod з контролером підключаємо ServiceAccount, який дозволить використання IAM-ролі, до якої будуть підключені IAM Policy з необіхідними дозволами.
Пізніше окремо розглянемо питання контролеру для скейлінгу WorkerNodes: раніше я використовував Cluster AutoScaler, але цього разу хочу спробувати Karpenter, тож винесу це окремим постом.
Рішення, описані в цьому пості виглядають місцями дуже не гуд, і, може, є варіанти, як це зробити красивіше, але в мене вийшло так. “At least, it works” ¯\_(ツ)_/¯
“Так історично склалося” (с), що продовжуємо їсти кактус використовувати AWS CDK з Python. Ним будемо створювати і IAM-ресурси, і деплоїти Helm-чарти контролерів прямо з CloudFormation-стеку кластеру.
Я намагався винести деплой контролерів окремим стеком, але витратив годину-півтори намагаючись знайти, як у CDK передати значення з одного стеку в інший через CloudFormation Exports та Outputs, тож вреті-решт забив і зробив все в одному класі стеку.
Зміст
EKS cluster, VPC та IAM
Створення кластеру описано в одному з попередніх постів – AWS: CDK – створення EKS з Python та загальні враження від CDK.
Що ми маємо зараз?
Сам клас для створення стеку:
... class AtlasEksStack(Stack): def __init__(self, scope: Construct, construct_id: str, stage: str, region: str, **kwargs) -> None: super().__init__(scope, construct_id, **kwargs) # egt AWS_ACCOUNT aws_account = kwargs['env'].account # get AZs from the $region availability_zones = ['us-east-1a', 'us-east-1b'] ...
aws_account
передаємо з app.py
при створенні об’єкту класу AtlasEksStack()
:
... AWS_ACCOUNT = os.environ["AWS_ACCOUNT"] ... eks_stack = AtlasEksStack(app, f'eks-{EKS_STAGE}-1-26', env=cdk.Environment(account=AWS_ACCOUNT, region=AWS_REGION), stage=EKS_STAGE, region=AWS_REGION ) ...
І далі будемо використовувати для нашалтувань AWS IAM.
Також маємо окрему VPC:
... vpc = ec2.Vpc(self, 'Vpc', ip_addresses=ec2.IpAddresses.cidr("10.0.0.0/16"), vpc_name=f'eks-{stage}-1-26-vpc', enable_dns_hostnames=True, enable_dns_support=True, availability_zones=availability_zones, ... ) ...
Та сам кластер ЕКС:
... print(cluster_name) cluster = eks.Cluster( self, 'EKS-Cluster', cluster_name=cluster_name, version=eks.KubernetesVersion.V1_26, vpc=vpc, ... ) ...
Далі треба додати створення OIDC в IAM, та деплой Helm-чартів з контролерами.
Налаштування OIDC Provider в AWS IAM
Використовуємо boto3
(це одна з речей, яка в AWS CDK не дуже подобається – що багато чого доводиться робити не методами/конструктами самого CDK, а “костилями” у вигляді boto3
чи інших модулів/бібліотек).
Нам треба отримати OIDC Issuer URL, та отримати його thumbprint – тоді зможемо використати create_open_id_connect_provider
.
OIDC Provider URL отримаємо за допомогою boto3.client('eks')
:
... import boto3 ... ############ ### OIDC ### ############ eks_client = boto3.client('eks') # Retrieve the cluster's OIDC provider details response = eks_client.describe_cluster(name=cluster_name) # https://oidc.eks.us-east-1.amazonaws.com/id/2DC***124 oidc_provider_url = response['cluster']['identity']['oidc']['issuer'] ...
Далі, за допомогою бібліотек ssl
та hashlib
отримуємо thumbprint сертифікату ендпоінту oidc.eks.us-east-1.amazonaws.com:
... import ssl import hashlib ... # AWS EKS OIDC root URL eks_oidc_url = "oidc.eks.us-east-1.amazonaws.com" # Retrieve the SSL certificate from the URL cert = ssl.get_server_certificate((eks_oidc_url, 443)) der_cert = ssl.PEM_cert_to_DER_cert(cert) # Calculate the thumbprint for the create_open_id_connect_provider() oidc_provider_thumbprint = hashlib.sha1(der_cert).hexdigest() ...
І тепер з boto3.client('iam')
та create_open_id_connect_provider()
створюємо IAM OIDC Identity Provider:
... from botocore.exceptions import ClientError ... # Create IAM Identity Privder iam_client = boto3.client('iam') # to catch the "(EntityAlreadyExists) when calling the CreateOpenIDConnectProvider operation" try: response = iam_client.create_open_id_connect_provider( Url=oidc_provider_url, ThumbprintList=[oidc_provider_thumbprint], ClientIDList=["sts.amazonaws.com"] ) except ClientError as e: print(f"\n{e}") ...
Тут все загортаємо у костиль у вигляді try/except
, бо при подальших апдейтах стеку boto3.client('iam')
натикається на те, що Provider вже є, і падає з помилкою EntityAlreadyExists
.
Встановлення ExternalDNS
Першим додамо ExternalDNS – в нього досить проста IAM Policy, тож на ньому протестимо як взагалі CDK працює з Helm-чартами.
IRSA для ExternalDNS
Тут першим кроком нам треба створити IAM Role, яку зможе assume наш ServiceAccount для ExternalDNS, і яка дозволить ExternalDNS виконувати дії з доменною зоною у Route53, бо зараз ExternalDNS має ServiceAccount, але видає помилку:
msg=”records retrieval failed: failed to list hosted zones: WebIdentityErr: failed to retrieve credentials\ncaused by: AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity\n\tstatus code: 403
Trust relationships
У Trust relationships цієї ролі маємо вказати Principal у вигляді ARN створенного OIDC Provider, в Action – sts:AssumeRoleWithWebIdentity
, а в Condition – якщо запит приходить від ServiceAccount, який буде створений ExternalDNS Helm-чартом.
Створим пару змінних:
... # arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/2DC***124 oidc_provider_arn = f'arn:aws:iam::{aws_account}:oidc-provider/{oidc_provider_url.replace("https://", "")}' # deploy ExternalDNS to a namespace controllers_namespace = 'kube-system' ...
oidc_provider_arn
формуємо зі змінної oidc_provider_url
, яку отримали раніше у response = eks_client.describe_cluster(name=cluster_name)
.
Описуємо створення ролі за допомогою iam.Role()
:
... # Create an IAM Role to be assumed by ExternalDNS external_dns_role = iam.Role( self, 'EksExternalDnsRole', # for Role's Trust relationships assumed_by=iam.FederatedPrincipal( federated=oidc_provider_arn, conditions={ 'StringEquals': { f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{controllers_namespace}:external-dns' } }, assume_role_action='sts:AssumeRoleWithWebIdentity' ) ) ...
В результаті маємо отримати роль з таким Trust relationships:
Наступний крок – IAM Policy.
IAM Policy для ExternalDSN
msg=”records retrieval failed: failed to list hosted zones: AccessDenied: User: arn:aws:sts::492***148:assumed-role/eks-dev-1-26-EksExternalDnsRoleB9A571AF-7WM5HPF5CUYM/1689063807720305270 is not authorized to perform: route53:ListHostedZones because no identity-based policy allows the route53:ListHostedZones action\n\tstatus code: 403
Тож описуємо два iam.PolicyStatement()
– один для роботи з доменною зоною, другий – для доступу до route53:ListHostedZones
.
Робимо їх окремими, бо для route53:ChangeResourceRecordSets
у resources
хочеться мати обмеження тільки однією конкретною зоною, але для дозволу на route53:ListHostedZones
resources має бути у вигляді "*"
:
... # A Zone ID to create records in by ExternalDNS zone_id = "Z04***FJG" # to be used in domainFilters zone_name = example.co # Attach an IAM Policies to that Role so ExternalDNS can perform Route53 actions external_dns_policy = iam.PolicyStatement( actions=[ 'route53:ChangeResourceRecordSets', 'route53:ListResourceRecordSets' ], resources=[ f'arn:aws:route53:::hostedzone/{zone_id}', ] ) list_hosted_zones_policy = iam.PolicyStatement( actions=[ 'route53:ListHostedZones' ], resources=['*'] ) external_dns_role.add_to_policy(external_dns_policy) external_dns_role.add_to_policy(list_hosted_zones_policy) ...
Тепер можемо додати сам ExternalDNS Helm-чарт.
AWS CDK та ExternalDNS Helm-чарт
Тут використовуємо aws-cdk.aws-eks.add_helm_chart()
.
У values
вказуємо на створення serviceAccount
, і в його annotations
передаємо 'eks.amazonaws.com/role-arn': external_dns_role.role_arn
:
... # Install ExternalDNS Helm chart external_dns_chart = cluster.add_helm_chart('ExternalDNS', chart='external-dns', repository='https://charts.bitnami.com/bitnami', namespace=controllrs_namespace, release='external-dns', values={ 'provider': 'aws', 'aws': { 'region': region }, 'serviceAccount': { 'create': True, 'annotations': { 'eks.amazonaws.com/role-arn': external_dns_role.role_arn } }, 'domainFilters': [ f"{zone_name}" ], 'policy': 'upsert-only' } ) ...
Деплоїмо, і глянемо под ExternalDNS – бачимо і наш domain-filter
, і вже знайомі нам змінні оточення для роботи IRSA:
[simterm]
$ kubectl -n kube-system describe pod external-dns-85587d4b76-hdjj6 ... Args: --metrics-address=:7979 --log-level=info --log-format=text --domain-filter=test.example.co --policy=upsert-only --provider=aws ... Environment: AWS_DEFAULT_REGION: us-east-1 AWS_STS_REGIONAL_ENDPOINTS: regional AWS_ROLE_ARN: arn:aws:iam::492***148:role/eks-dev-1-26-EksExternalDnsRoleB9A571AF-7WM5HPF5CUYM AWS_WEB_IDENTITY_TOKEN_FILE: /var/run/secrets/eks.amazonaws.com/serviceaccount/token ...
[/simterm]
Перевіримо логи:
[simterm]
... time="2023-07-11T10:28:28Z" level=info msg="Applying provider record filter for domains: [example.co. .example.co.]" time="2023-07-11T10:28:28Z" level=info msg="All records are already up to date" ...
[/simterm]
І протестуємо його роботу.
Перевірка роботи ExternalDNS
Для перевірки – створимо простий Service з типом Loadbalancer, в annotations
додаємо external-dns.alpha.kubernetes.io/hostname
:
--- apiVersion: v1 kind: Service metadata: name: nginx-service annotations: external-dns.alpha.kubernetes.io/hostname: "nginx.test.example.co" spec: type: LoadBalancer selector: app: nginx ports: - name: nginx-http-svc-port protocol: TCP port: 80 targetPort: nginx-http --- apiVersion: v1 kind: Pod metadata: name: nginx-pod labels: app: nginx spec: containers: - name: nginx image: nginxdemos/hello ports: - containerPort: 80 name: nginx-http
Дивимось логи:
[simterm]
... time="2023-07-11T10:30:29Z" level=info msg="Applying provider record filter for domains: [example.co. .example.co.]" time="2023-07-11T10:30:29Z" level=info msg="Desired change: CREATE cname-nginx.test.example.co TXT [Id: /hostedzone/Z04***FJG]" time="2023-07-11T10:30:29Z" level=info msg="Desired change: CREATE nginx.test.example.co A [Id: /hostedzone/Z04***FJG]" time="2023-07-11T10:30:29Z" level=info msg="Desired change: CREATE nginx.test.example.co TXT [Id: /hostedzone/Z04***FJG]" time="2023-07-11T10:30:29Z" level=info msg="3 record(s) in zone example.co. [Id: /hostedzone/Z04***FJG] were successfully updated" ...
[/simterm]
Перевіряємо роботу домену:
[simterm]
$ curl -I nginx.test.example.co HTTP/1.1 200 OK
[/simterm]
“It works!” (c)
Весь код для OIDC та ExternalDNS
Весь код разом зараз виглядає так:
... ############ ### OIDC ### ############ eks_client = boto3.client('eks') # Retrieve the cluster's OIDC provider details response = eks_client.describe_cluster(name=cluster_name) # https://oidc.eks.us-east-1.amazonaws.com/id/2DC***124 oidc_provider_url = response['cluster']['identity']['oidc']['issuer'] # AWS EKS OIDC root URL eks_oidc_url = "oidc.eks.us-east-1.amazonaws.com" # Retrieve the SSL certificate from the URL cert = ssl.get_server_certificate((eks_oidc_url, 443)) der_cert = ssl.PEM_cert_to_DER_cert(cert) # Calculate the thumbprint for the create_open_id_connect_provider() oidc_provider_thumbprint = hashlib.sha1(der_cert).hexdigest() # Create IAM Identity Privder iam_client = boto3.client('iam') # to catch the "(EntityAlreadyExists) when calling the CreateOpenIDConnectProvider operation" try: response = iam_client.create_open_id_connect_provider( Url=oidc_provider_url, ThumbprintList=[oidc_provider_thumbprint], ClientIDList=["sts.amazonaws.com"] ) except ClientError as e: print(f"\n{e}") ################### ### Controllers ### ################### ### ExternalDNS ### # arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/2DC***124 oidc_provider_arn = f'arn:aws:iam::{aws_account}:oidc-provider/{oidc_provider_url.replace("https://", "")}' # deploy ExternalDNS to a namespace controllers_namespace = 'kube-system' # Create an IAM Role to be assumed by ExternalDNS external_dns_role = iam.Role( self, 'EksExternalDnsRole', # for Role's Trust relationships assumed_by=iam.FederatedPrincipal( federated=oidc_provider_arn, conditions={ 'StringEquals': { f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{controllers_namespace}:external-dns' } }, assume_role_action='sts:AssumeRoleWithWebIdentity' ) ) # A Zone ID to create records in by ExternalDNS zone_id = "Z04***FJG" # to be used in domainFilters zone_name = "example.co" # Attach an IAM Policies to that Role so ExternalDNS can perform Route53 actions external_dns_policy = iam.PolicyStatement( actions=[ 'route53:ChangeResourceRecordSets', 'route53:ListResourceRecordSets' ], resources=[ f'arn:aws:route53:::hostedzone/{zone_id}', ] ) list_hosted_zones_policy = iam.PolicyStatement( actions=[ 'route53:ListHostedZones' ], resources=['*'] ) external_dns_role.add_to_policy(external_dns_policy) external_dns_role.add_to_policy(list_hosted_zones_policy) # Install ExternalDNS Helm chart external_dns_chart = cluster.add_helm_chart('ExternalDNS', chart='external-dns', repository='https://charts.bitnami.com/bitnami', namespace=controllers_namespace, release='external-dns', values={ 'provider': 'aws', 'aws': { 'region': region }, 'serviceAccount': { 'create': True, 'annotations': { 'eks.amazonaws.com/role-arn': external_dns_role.role_arn } }, 'domainFilters': [ zone_name ], 'policy': 'upsert-only' } ) ...
Переходимо до ALB Controller.
Встановлення AWS ALB Controller
Тут, в принципі, все теж саме, єдине, з чим довелось повозитись – це IAM Policy, бо якщо для ExternalDNS маємо тільки два дозволи, і можемо описати їх прямо при створенні цієї Policy, то для ALB Controller політику треба взяти з GitHub, бо вона досить велика.
IAM Policy з GitHub URL
Тут використовуємо requests
(знов костилі):
... import requests ... alb_controller_version = "v2.5.3" url = f"https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/{alb_controller_version}/docs/install/iam_policy.json" response = requests.get(url) response.raise_for_status() # Check for any download errors # format as JSON policy_document = response.json() document = iam.PolicyDocument.from_json(policy_document) ...
Отримуємо файл політики, формуємо його в JSON, і потм з JSON формуємо вже сам policy document.
IAM Role для ALB Controller
Далі створюємо IAM Role з аналогічними до ExternalDNS Trust relationships, тільки міняємо conditions
– вказуємо ServiceAccount, який буде створено для AWS ALB Contoller:
... alb_controller_role = iam.Role( self, 'AwsAlbControllerRole', # for Role's Trust relationships assumed_by=iam.FederatedPrincipal( federated=oidc_provider_arn, conditions={ 'StringEquals': { f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{controllers_namespace}:aws-load-balancer-controller' } }, assume_role_action='sts:AssumeRoleWithWebIdentity' ) ) alb_controller_role.attach_inline_policy(iam.Policy(self, "AwsAlbControllerPolicy", document=document)) ...
AWS CDK та AWS ALB Controller Helm-чарт
І тепер встановлюємо сам чарт з потрібними values
– вказуємо на необхідність створення ServiceAccount, йому в annotations
передаємо ARM ролі, яку створили перед цим, та задаємо clusterName
:
... # Install AWS ALB Controller Helm chart alb_controller_chart = cluster.add_helm_chart('AwsAlbController', chart='aws-load-balancer-controller', repository='https://aws.github.io/eks-charts', namespace=controllers_namespace, release='aws-load-balancer-controller', values={ 'image': { 'tag': alb_controller_version }, 'serviceAccount': { 'name': 'aws-load-balancer-controller', 'create': True, 'annotations': { 'eks.amazonaws.com/role-arn': alb_controller_role.role_arn }, 'automountServiceAccountToken': True }, 'clusterName': cluster_name, 'replicaCount': 1 } ) ...
Перевірка роботи AWS ALB Controller
Створимо простий Pod, Service та до них – Ingress:
--- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx-ingress annotations: kubernetes.io/ingress.class: alb spec: rules: - http: paths: - path: / pathType: Prefix backend: service: name: nginx-http-svc-port port: number: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-service annotations: external-dns.alpha.kubernetes.io/hostname: "nginx.test.example.co" spec: selector: app: nginx ports: - name: nginx-http-svc-port protocol: TCP port: 80 targetPort: nginx-http --- apiVersion: v1 kind: Pod metadata: name: nginx-pod labels: app: nginx spec: containers: - name: nginx image: nginxdemos/hello ports: - containerPort: 80 name: nginx-http
Деплоїмо, і перевіряємо Ingress:
[simterm]
$ kubectl get ingress NAME CLASS HOSTS ADDRESS PORTS AGE nginx-ingress <none> * internal-k8s-default-nginxing-***-***.us-east-1.elb.amazonaws.com 80 34m
[/simterm]
Єдине тут, що спрацювало не з першого разу – це підключення aws-iam-token
: саме тому я в values
чарту явно передав 'automountServiceAccountToken': True
, хоча в нього і так дефолтне значення true
.
Але після декулькох редеплоїв з cdk deploy
– токен таки створився і підключився до поду:
... - name: AWS_ROLE_ARN value: arn:aws:iam::492***148:role/eks-dev-1-26-AwsAlbControllerRole4AC4054B-1QYCGEG2RZUD7 - name: AWS_WEB_IDENTITY_TOKEN_FILE value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token ...
В цілому, на цьому все.
Як завжди з CDK – це біль та страждання через відсутність нормальної документації та прикладів, але за допомогою ChatGPT та матюків – воно таки запрацювало.
Ще, мабуть, було б добре створення ресурсів винести хоча б у окремі функції, а не робити все з AtlasEksStack.__init__()
, але то може пізніше.
Далі за планом – запуск VictoriaMetrics в Kubernetes, а потім вже потицяємо Karpenter.