Продовжимо про AWS CDK та Python. Пишу не тому, що подобається, а тому, що в інтернеті прикладів ну якось зовсім мало, тож нехай будуть хоча б тут.
Отже, маємо кластер, маємо пару контролерів. Наче все готово – почав встановлювати чарт VictoriaMetrics, і все завелося окрім поду з VMSingle, який завис в статусі Pending.
“VolumeBinding”: binding volumes: timed out waiting for the condition
Перевіряємо Events цього поду:
[simterm]
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 10m default-scheduler running PreBind plugin "VolumeBinding": binding volumes: timed out waiting for the condition
Тож сьогодні глянемо, як з AWS CDK додати аддони до кластеру.
Тут, в принципі, все досить просто, єдине що довелося погуглити як жеж саме використовувати CfnAddon, але цього разу документація знайшлась швидко, і навіть з прикладами на Python, а не TypeScript.
Для драйверу теж використовуємо IRSA – описуємо ServiceAccount, політику беремо вже готову – AWS Managed Policy, підключаємо через виклик iam.ManagedPolicy.from_aws_managed_policy_name():
...
# Create an IAM Role to be assumed by ExternalDNS
ebs_csi_addon_role = iam.Role(
self,
'EbsCsiAddonRole',
# for Role's Trust relationships
assumed_by=iam.FederatedPrincipal(
federated=oidc_provider_arn,
conditions={
'StringEquals': {
f'{oidc_provider_url.replace("https://", "")}:sub': 'system:serviceaccount:kube-system:ebs-csi-controller-sa'
}
},
assume_role_action='sts:AssumeRoleWithWebIdentity'
)
)
ebs_csi_addon_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AmazonEBSCSIDriverPolicy"))
...
У from_aws_managed_policy_name вказуємо ім’я як “service-role/ManagedPolicyName“.
CfnAddon для EBS CSI driver
Знаходимо актувальну версію дайверу, вказавши версію кластеру – у нас 1.26, бо CDK досі не підтримує 1.27:
$ kk -n kube-system get pod | grep csi
ebs-csi-controller-896d87c6b-7rv9z 6/6 Running 0 9m59s
ebs-csi-controller-896d87c6b-v7xg7 6/6 Running 0 9m59s
ebs-csi-node-2zwnr 3/3 Running 0 9m59s
ebs-csi-node-pt5zs 3/3 Running 0 9m59s
[/simterm]
І тепер маємо PVC для VictoriaMetrcis в статусі Bound:
[simterm]
$ kk -n dev-monitoring-ns get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
vmsingle-victoria-metrics-k8s-stack Bound pvc-151a631b-f6de-4567-8baa-97adb4e04a87 20Gi RWO gp2 91m
Наступним кроком після розгортання самого кластеру треба налаштувати 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, тож вреті-решт забив і зробив все в одному класі стеку.
Далі треба додати створення OIDC в IAM, та деплой Helm-чартів з контролерами.
Налаштування OIDC Provider в AWS IAM
Використовуємо boto3 (це одна з речей, яка в AWS CDK не дуже подобається – що багато чого доводиться робити не методами/конструктами самого CDK, а “костилями” у вигляді boto3 чи інших модулів/бібліотек).
...
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).
...
# 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
Якщо задеплоїти стек зараз, то 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)
...
...
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:
...
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:
І тепер встановлюємо сам чарт з потрібними values – вказуємо на необхідність створення ServiceAccount, йому в annotations передаємо ARM ролі, яку створили перед цим, та задаємо clusterName:
$ 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 – токен таки створився і підключився до поду:
Як завжди з CDK – це біль та страждання через відсутність нормальної документації та прикладів, але за допомогою ChatGPT та матюків – воно таки запрацювало.
Ще, мабуть, було б добре створення ресурсів винести хоча б у окремі функції, а не робити все з AtlasEksStack.__init__(), але то може пізніше.
Далі за планом – запуск VictoriaMetrics в Kubernetes, а потім вже потицяємо Karpenter.
Зараз сетаплю новий ЕКС кластер, і серед інших компонентів запускаю в ньому ExternalDNS, який використовує Kubernetes ServiceAccount для аутентифікації в AWS, щоб мати змогу вносити зміни до доменної зони в Route53.
Однак забув налаштувати Identity Provider в AWS IAM, і ExternalDNS видав помилку:
level=error msg=”records retrieval failed: failed to list hosted zones: WebIdentityErr: failed to retrieve credentials\ncaused by: InvalidIdentityToken: No OpenIDConnect provider found in your account for https://oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F\n\tstatus code: 400
Тож почав згадувати за OIDC, потім взагалі про аутентифікацію в Kubernetes, и вирішив ще раз копнути в те, як воно все працює, бо в останніх версіях EKS/Kubernetes були досить цікаві зміни.
Що таке OpenID Connect та Identity Provider
OpenID Connect (OIDC) це протокол, який дозволяє сервісам виконувати аутентифікацію іншого сервісу або користувача на основі Identity Tokens, які являють собою JSON Web Tokens (JWT).
Сам JWT підписується Identity Provider (IDP), і містить в собі інформацю про юзера або сервіс.
В нашому випадку, AWS Elastic Kubernetes Service – це Identity Provider, а AWS – це Service Provider. Тобто, EKS аутентифікує юзерів, і каже Амазону, що цьому юзеру можна довіряти виконувати якісь дії в AWS.
Тож головне, що треба усвідомлювати, коли ви налаштовуєте Identity Providers в AWS IAM, це те, що ви не налаштовуєте якийсь окремий AWS Service під назвою “Identity Providers“, а налаштовуєте AWS IAM, якому кажете – “Хей, довіряй чуваку з оцим URL”, тобто налаштовуєте Trust relations між вашим Identity Provider (EKS, GitHub, GitLab, Google тощо) та Service Provider (AWS).
Якщо провести аналогію, то це якби ви в аеропорту на паспортному контролі десь в Амстердамі прийшли зі своїм українським паспортом, і вам там повірили, що ви – то саме ви, бо прикордонна служба Нідерландів (Service Provider) довіряє уряду України (Identity Provider), який вам видав цей паспорт (JWT).
AWS EKS та IAM Role
Окей, тож як Kubernetes Pod у EKS отримує доступ до AIM-ролі?
ми створюємо ServiceAccount для Kubernetes Pod, в annotations цього ServiceAccount вказуємо ARN IAM-ролі, яку цей Pod має використовувати для аутентифікації в AWS (авторизація, тобто перевірка які саме дії ви можете в AWS виконувати, буде виконуватись на рівні самого AWS в IAM за допомогою IAM Policy, яка підключена до вашої IAM Role)
EKS генерує JWT-токен, в якому вказано, що “подавач” цього токену дійсно є валідним EKS-юзером, і EKS це підтвержує своїм сертифікатом
процес із поду за допомогою цього токену проходить аутентифікацію в AWS IAM і виконує AssumeRole
і вже від імені цїєї ролі виконує необхідні дії з AWS API
Тобто, в процесі приймаються участь Kubernetes ServiceAccount, AWS AIM, та JWT-токени.
Якщо раніше при створенні ServiceAccount створювався статичний Kubernetes Secret, який в собі мав три поля – namespace, ca.crt та власне token, то тепер це все генерується динамічно для кожного поду та ServiceAccount.
Давайте переглянемо, що ми зараз маємо в поді з ExternalDNS:
Отже, в volumeMounts ми бачимо два volumes – kube-api-access-qdgjr та aws-iam-token. До aws-iam-token повернемось пізніше, а поки давайте розглянемо volumes.projected для kube-api-access-qdgjr.
ServiceAccount Tokens
Починаючи з версії 1.22, Kubernetes має два типи токені – Long Live та Time Bound.
Long Live вже вважається deprecated, і не має використовуватись, хоча його можливо зробити зо допомогою Secret – це той самий тип токенів, які використовувались для ServiceAccounts раніше:
Time Bound токени генеруються Kubernetes TokenRequest API, мають обмежений час життя, валідні тільки для конкретного Pod та ServiceAccount, і підключаються до поду за допомогою Projected Volumes та serviceAccountToken.
Kubernetes API JWT authentification
Глянемо в самому поді зміст каталогу /var/run/secrets/kubernetes.io/serviceaccount:
Тут маємо три файли, які створені з Projected Volumes, в яких ми бачили три source, кожний з власним path:
serviceAccountToken: містить токен, отриманий від kube-apiserver за допомогою TokenRequest API, і використовується подом для аутентифікації на Kubernetes API. Має обмежений час життя, і валідний тільки для цього конкретного поду та його ServiceAccount
підключається у path: token
configMap: бере зміст kube-root-ca.crt ConfigMap, використовується подом, щоб впевнитись, що він підключається саме до потрібного Kubernetes API
підключається у path: ca.crt
downwardAPI: отримує від API інформацію про metadata.namespace
підключається у path: namespace
Давайте глянемо, що в самому токені – він теж змінився.
aud (audience): для кого цей токен призначений – отримувач має ідентифікувати себе з цим ім’ям, інакше токен має бути відхилений
exp (expiration time): “термін придатності” цього токену – після його закінчення, токен має бути відхилений
iat (issued at): час створення токену, від якого буде рахуватись exp
iss (issuer): OIDC Issuer URL нашого кластеру – той самий Identity Provider URL, який потім будемо використовувати при налаштувані AWS IAM
kubernetes.io: тут бачимо UID самого пода та ServiceAccount – саме тому якщо под або його ServiceAccount буде перестворено, то цей токен стане невалідним, бо зміняться UID
sub (subject): “ім’я користувача” цього токену – буде перевірятись у AWS IAM для авторизації дій з AWS API
Використовуючи це токен – ми з поду можемо аутентифікуватись на API нашого Kubernetes-кластеру.
Описуємо под з cURL:
---
apiVersion: v1
kind: Pod
metadata:
name: test-pod
spec:
containers:
- name: curl
image: curlimages/curl
command: ['sleep', '36000']
restartPolicy: Never
Створюємо його:
[simterm]
$ kubectl apply -f test-pod.yaml
pod/test-pod created
Хоча ви все ще можете використовувати підхід з ACCESS/SECRET через змінні оточення, або підключати необхідну роль до EC2 WorkerNode як EC2 IAM Instance Role, робота через IRSA дозволяє вам видавати права на роботу з AWS для конкретного поду, а не всіх подів на цьому ЕС2-інстансі.
У випадку ж з ACCESS/SECRET для поду – ключі у вас статичні, і по-перше – можуть бути скомпрометовані (вкрадені), по-друге – вам необхідно їх десь тримати та передавати у Deployment/StatefulSet, etc під час створення вашого workload, тоді як IRSA використовує динамічні дані доступу (credentials), яки створються під час запиту поду до IAM-ролі, і вам не потрібно їх ані зберігати, ані хвилюватись через їх витік.
Assume Role з AWS CLI
Отже, Kubernetes Pod буде виконувати AssumeRole для отримання ролі, тож давайте згадаємо, як AssumeRole працює з AWS CLI – тоді будемо краще уявляти собі, як це працює в EKS з його подами.
Описуємо IAM Policy, яка дозволяє доступ до S3-бакетів:
STS перевіряє, чи може користувач (який у ~/.aws/credentials має ACCESS/SECRET ключі юзеру в AWS) виконувати API-запит sts:AssumeRole (а так як ми в Trust Policy цієї ролі вказали Principal "arn:aws:iam::492***148:root" – то може)
якщо перевірку пройдено, то STS створює тимчасові дані доступу для цієї ролі – AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY та AWS_SESSION_TOKEN і повертає їх до AWS CLI
Далі, використовуючи ці дані, ми можемо виконувати дії від імені ціьєї IAM-ролі:
Спочатку налаштуємо Identity Provider в IAM, створимо ServiceAccount та IAM Role, яку будемо використовувати, перевіримо, і потім глянемо як саме воно працює.
Ми вже розбирали що знаходиться в /var/run/secrets/kubernetes.io/serviceaccount/token, який створюється з Projected Volume kube-api-access-frc4n – теперь глянемо на /var/run/secrets/eks.amazonaws.com/serviceaccount/token.
Для нього використовується той самий тип serviceAccountToken, якому передається audience: sts.amazonaws.com. В результаті маємо JWT-токен для аутентифікації в AWS:
aud: має співпадати з audience нашого Identity Provider в AWS AIM (інакше отримаємо помилку “An error occurred (InvalidIdentityToken) when calling the AssumeRoleWithWebIdentity operation: Incorrect token audience” – я с першого разу помилився, коли додавав IDP – в Audience вказав sts.amazon.com замість sts.amazonaws.com)
iss: IAM буде перевіряти, від кого прийшов токен, і чи може він довіряти цьому джерелу
sub: буде використовуватись у IAM Role Trusted Policy – згадайте Condition.StringEquals у файлі irsa-trust.json
Тобто, з цим токеном ми можемо звернутись до AWS STS, і отримати temporary crdentials, за якими зможемо виконати запит на sts:AssumeRoleWithWebIdentity.
при створенні пода з ServiceAccount, якому вказано IAM Role, Amazon EKS Pod Identity webhook створює змінні оточення AWS_ROLE_ARN та AWS_WEB_IDENTITY_TOKEN_FILE, і додає aws-iam-token projected volume, в якому генерує JWT
при роботі процесу всередині поду – цей процес (AWS CLI, CDK, SDK, whatever) використовує змінні оточення:
AWS_ROLE_ARN – щоб знати, Assume якої ролі робити
та AWS_WEB_IDENTITY_TOKEN_FILE – що знати, звідки йому взяти токен для аутентифікації в AWS
Тобто, коли ми викликали aws s3 ls і не передавали йому ніяких параметрів – він просто взяв їх з оточення:
Terraform то чудово, але поки що вирішили перші кластера AWS EKS створювати за допомогою AWS CDK, бо по-преше – він вже є на проекті, по-друге – самому цікаво спробувати новий інструмент.
Тож сьогодні розглянемо що з цього вийшло, та як створювався кластер і необхідні ресурси.
Забігаючи наперед – особисто я, дуже м’яко кажучи, не в захваті від CDK:
ніякого тобі KISS – Keep It Simple Stupid, ніякого тобі “явне краще неявного”
місцями незрозуміла документація з приклами на TypeScript навіть в репозиторії PyPi
купа окремих бібліотек та модулів, іноді геморой з їхніми імпортами
загальна перенавантаженість коду CDK/Python – Terraform з його HCL або Pulumi з Python виглядає набагато простішим для розуміння загальної картини інфрастуктури, котора цим кодом описана
перенавантаженість самого CloudFormation стеку, створенного за допомогою CDK – купа IAM-ролей, якісь Lambda-функції і таке інше – коли воно зламається, то доведеться дуже довго шукати де і що саме “пішло не так”
питати Google на тему “AWS CDK Python create something” – майже марна справа, бо результатів або не буде взагалі, але будуть на TypeScript
Хоча пост планувався в стилі “як зробити”, але в результаті вийшло “Як вистрілити собі в ногу, запроваджуючи на проекті AWS CDK”.
AWS CDK vs Terraform
Знову-таки, хоча сам пост не про це, але кілька слів після роботи з CDK та його порівняння з Terraform.
Time To Create: AWS CDK vs Terraform
Перше, що хочеться прям на початку показати – це швидкість роботи AWS CDK vs Terraform:
Тест, звісно, достаточно штучний, але дуже гарно показав різницю в роботі.
Я спецільно не створював NAT Gateways, бо їхнє створення займає більше хвилини просто на запуск самих NAT-інстансів, тоді як на створення VPC/Subnets/etc час не витрачається, тож бачимо саме швидкість роботи CDK/CloudFormation versus Terraform.
Пізніше ще заміряв створення VPC+EKS з CDK та Terraform:
CDK
create: 18m54.643s
destroy: 26m4.509s
Terraform:
create: 12m56.801s
destroy: 5m32.329s
AWS CDK workflow
Та й в цілому процес роботи CDK виглядає занадто ускладненим:
пишемо код на Python
який траснлюється до бекенду CDK на NodeJS
генерує CloudFormation Template та ChangeSets
CDK для своєї роботи створює пачку Lambda-функцій
і тільки потім створюються ресурси
Плюс в самому CloudFormation стеку для EKS створюється ціла купа AIM ролей та Lambda-функцій з неясним та неявним призначенням.
AWS CDK та нові “фічі” AWS
Ще з насправді досить очікуваного – CDK не має всіх нових “плюшок” AWS. Я с цим зіткнувся ще кілька років тому, коли потрібно було у CloudFormation створити cross-region VPC Peering, а CloudFormation це не підтримував, хоча у Terraform це вже було реалізовано.
Щоб мати якусь точку для старту – спитав ChatGPT. В цілому, ідею від подав, хоча з застарілими імпортами та деякими атрибутами, які довелось переписувати:
Поїхали.
Python virtualevn
Створюємо Python virtualevn:
[simterm]
$ python -m venv .venv
$ ls -l .venv/
total 16
drwxr-xr-x 2 setevoy setevoy 4096 Jun 20 11:18 bin
drwxr-xr-x 3 setevoy setevoy 4096 Jun 20 11:18 include
drwxr-xr-x 3 setevoy setevoy 4096 Jun 20 11:18 lib
lrwxrwxrwx 1 setevoy setevoy 3 Jun 20 11:18 lib64 -> lib
-rw-r--r-- 1 setevoy setevoy 176 Jun 20 11:18 pyvenv.cfg
Повертаємось до ChatGPT, що він там далі рекомендує:
Нам тут цікаві тільки імпорти (з якими він не вгадав), та сам ресурс cluster = eks.Cluster(), якому він пропонує версію 1.21, бо сам ChatGPT, як ми знаємо, має базу до 2021 року.
CDK: AttributeError: type object ‘KubernetesVersion’ has no attribute ‘V1_27’
Щодо AWS CDK та версії EKS, писав про це на початку – виглядала помилка так:
AttributeError: type object ‘KubernetesVersion’ has no attribute ‘V1_27’
Окей – давайте поки 1.26, там подивимось, як з цим жити.
from aws_cdk import (
aws_eks as eks,
Stack
)
from constructs import Construct
class AtlasEksStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
cluster = eks.Cluster(
self, 'EKS-Cluster',
cluster_name='my-eks-cluster',
version=eks.KubernetesVersion.V1_26,
)
Перевіряємо з cdk synth:
[simterm]
$ cdk synth
[Warning at /AtlasEksStack/EKS-Cluster] You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property. This may cause failures as the kubectl version provided with aws-cdk-lib is 1.20, which is only guaranteed to be compatible with Kubernetes versions 1.19-1.21. Please provide a kubectlLayer from @aws-cdk/lambda-layer-kubectl-v26.
Resources:
EKSClusterDefaultVpc01B29049:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
InstanceTenancy: default
Tags:
- Key: Name
Value: AtlasEksStack/EKS-Cluster/DefaultVpc
Metadata:
aws:cdk:path: AtlasEksStack/EKS-Cluster/DefaultVpc/Resource
...
[/simterm]
CDK сам тсворить VPC та subnets і все інше для мережі, та IAM ролі. Це, в принципі, зручно, хоча там є свої питання.
Ми далі будемо створювати власну VPC.
Warning: You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property
На початку cdk synth каже щось про kubectlLayer:
[Warning at /AtlasEksStack/EKS-Cluster] You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property. This may cause failures as the kubectl version provided with aws-cdk-lib is 1.20, which is only guaranteed to be compatible with Kubernetes versions 1.19-1.21. Please provide a kubectlLayer from @aws-cdk/lambda-layer-kubectl-v26.
З імені можно припустити, що CDK створить Lambda-функцію, в якій буде викликати kubectl для виконання якихось задач в самоу Kubernetes.
В документації KubectlLayer сказано, що “An AWS Lambda layer that includes kubectl and helm.”
Дуже дякую – все відразу стало зрозуміло. Де воно використовується, для чого?
Ну, ок… Давайте спробуємо позбутися цього варнінгу.
Знову спитаємо ChatGP:
Пробуємо встановити aws-lambda-layer-kubectl-v26:
[simterm]
$ pip install aws-cdk.aws-lambda-layer-kubectl-v26
ERROR: Could not find a version that satisfies the requirement aws-cdk.aws-lambda-layer-kubectl-v26 (from versions: none)
ERROR: No matching distribution found for aws-cdk.aws-lambda-layer-kubectl-v26
Пробуємо pip search – спочатку перевіримо, що search в PiP взагалі є, бо давно ним не користувався:
[simterm]
$ pip search --help
Usage:
pip search [options] <query>
Description:
Search for PyPI packages whose name or summary contains <query>.
Search Options:
-i, --index <url> Base URL of Python Package Index (default https://pypi.org/pypi)
...
[/simterm]
Окей – шукаємо:
[simterm]
$ pip search aws-lambda-layer-kubectl
ERROR: XMLRPC request failed [code: -32500]
RuntimeError: PyPI no longer supports 'pip search' (or XML-RPC search). Please use https://pypi.org/search (via a browser) instead. See https://warehouse.pypa.io/api-reference/xml-rpc.html#deprecated-methods for more information.
[/simterm]
WHAAAAT?!?
Тобто, просто з консолі з PiP знайти пакет неможливо? Це як так? Трохи “розрив шаблону”.
Окей – поки лишимо, як є, хоча далі з цим знову зустрінемось, і таки доведеться фіксити.
Змінні в CDK Stack
Що тепер хочеться, це додати змінну для Environment – Dev/Stage/Prod, і потім використовати її в іменах ресурсів та тегах.
Додамо до app.py змінну $EKS_STAGE, а до створення AtlasEksStack() – передаємо її другим агрументом, щоб використати як ім’я стеку, і додаємо параметр stage, що потім використовувати всередені класу:
Кастомну VPC хочеться, бо по-дефолту CDK створить по Subnet-у у кожній AvailabilityZone, тобто три мережі, плюс до кожної буде свій NAT Gateway. Але по-перше – мені більше подобається самому контролювати розбивку мережі, по-друге – кожен NAT Gateway коштує грошей, а нам поки що fault-tolerance аж на три AvailabilityZone не потрібен, краще зекономити трохи грошей.
Тут як на мене ще один не найкращий нюанс CDK: так, це зручно, що він має багато викорорівневих ресурсів, коли тобі достатньо просто вказати subnet_type=ec2.SubnetType.PUBLIC, а CDK сам створить все необхідне, але особисто мені декларативний підхід Terraform та його HCL виглядає привабливішим, бо навіть якщо використовувати модуль VPC, а не описувати все вручну – набагато простіше зайти в код того модулю і подивитись, що він має “під капотом”, ніж копатись у бібліотеці CDK. Але це чисто особисте “Я так бачу“.
Крім того, в документації не сказано, що PRIVATE_WITH_NAT вже deprecated, побачив це тільки коли перевіряв створення ресурсів:
[simterm]
$ cdk synth
[WARNING] aws-cdk-lib.aws_ec2.VpcProps#cidr is deprecated.
Use ipAddresses instead
This API will be removed in the next major release.
[WARNING] aws-cdk-lib.aws_ec2.SubnetType#PRIVATE_WITH_NAT is deprecated.
use `PRIVATE_WITH_EGRESS`
This API will be removed in the next major release.
[WARNING] aws-cdk-lib.aws_ec2.SubnetType#PRIVATE_WITH_NAT is deprecated.
use `PRIVATE_WITH_EGRESS`
This API will be removed in the next major release.
...
[/simterm]
Окей.
Додаємо availability_zones, в яких хочемо створювати subnets, і описуємо subnet_configuration.
В subnet_configuration описуємо subnet group – одну Public, та одну Private – CDK створить subnet кожного типу в кожній Availability Zone.
На майбутнє – відразу створимо S3 Endpoint, бо в кластері планується Grafana Loki, яка буде ходити в S3 бакети.
До ресурсу eks.Cluster() додаємо параметр vpc.
Весь файл тепер виглядає так:
from aws_cdk import (
aws_eks as eks,
aws_ec2 as ec2,
Stack
)
from constructs import Construct
class AtlasEksStack(Stack):
def __init__(self, scope: Construct, construct_id: str, stage: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
availability_zones = ['us-east-1a', 'us-east-1b']
# Create a new 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,
subnet_configuration=[
ec2.SubnetConfiguration(
name=f'eks-{stage}-1-26-subnet-public',
subnet_type=ec2.SubnetType.PUBLIC,
cidr_mask=24
),
ec2.SubnetConfiguration(
name=f'eks-{stage}-1-26-subnet-private',
subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidr_mask=24
)
]
)
# Add an S3 VPC endpoint
vpc.add_gateway_endpoint('S3Endpoint',
service=ec2.GatewayVpcEndpointAwsService.S3)
cluster = eks.Cluster(
self, 'EKS-Cluster',
cluster_name=f'eks-{stage}-1-26-cluster',
version=eks.KubernetesVersion.V1_26,
vpc=vpc
)
Деплоїмо (Total time: 182.67s просто на додавання тегів на ресурси), та перевіряємо теги:
Все є.
Створення NodeGroup
Взагалі скоріш за все будемо використовувати Karpenter замість “класичного” Cluster Autoscaler, бо про Karpenter чув багато гарних відгуків і хочеться його спробувати у ділі, і тоді ноди треба буде переробити, але поки що створимо звичайну Managed NodeGroup за допомогою add_nodegroup_capacity().
До файлу atlas_eks_stack.py додаємо cluster.add_nodegroup_capacity() з Amazon Linux AMI :
...
# Create the EC2 node group
nodegroup = cluster.add_nodegroup_capacity(
'Nodegroup',
instance_types=[ec2.InstanceType('t3.medium')],
desired_size=1,
min_size=1,
max_size=3,
ami_type=eks.NodegroupAmiType.AL2_X86_64
)
Необхідні IAM-ролі CDK має створити сам – подивимось.
У ресурсі eks.Cluster() вказуємо default_capacity=0, щоб СDK не створював власну дефолтну групу:
Error: b’configmap/aws-auth configured\nerror: error retrieving RESTMappings to prune: invalid resource batch/v1beta1, Kind=CronJob, Namespaced=true: no matches for kind “CronJob” in version “batch/v1beta1″\n’
Зараз стек вже задеплоєно, запускаємо cdk deploy, щоб оновити – і…
[simterm]
eks-dev-1-26: creating CloudFormation changeset...
1:26:35 PM | UPDATE_FAILED | Custom::AWSCDK-EKS-KubernetesResource | EKSClusterAwsAuthmanifest5D430CCD
Received response status [FAILED] from custom resource. Message returned: Error: b'configmap/aws-auth configured\nerror: error retrieving RESTMappings to prune: invalid resource batch/v1beta1, Kind=CronJob, Namespaced=true: no matches for kind "CronJob" in version "bat
ch/v1beta1"\n'
[/simterm]
Шта? Якого біса?
aws-auth ConfigMap, Kind=CronJob? Звідки це?
Тобто, мабуть, CDK намагається оновити aws-auth ConfigMap, щоб додати NodeGroup AIM роль, але… Але – що?
При чьому проявляється це тільки під час оновлення стеку. Якщо створювати його заново – то все працює. Але тут варто згадати про швидкість роботи CDK/CloudFormation, бо видалення і створення займає хвилин 30-40.
KubectlV26Layer
Ну, все ж довелося фіксити цю проблему.
Добре… Шукаємо просто в браузері – aws-cdk.lambda-layer-kubectl-v26. Є така ліба. Але навіть у PyPi репозиторії приклади на TypeScript – щиро дякую:
Це взагалі проблема при роботі з AWS CDK на Python – дуже багато прикладів все одно на TS.
Окей, ладно – лібу знайшли, вона називається aws-cdk.lambda-layer-kubectl-v26, встановлюємо:
...
from aws_cdk.lambda_layer_kubectl_v26 import KubectlV26Layer
...
# to fix warning "You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property"
kubectl_layer = KubectlV26Layer(self, 'KubectlV26Layer')
...
cluster = eks.Cluster(
self, 'EKS-Cluster',
cluster_name=f'eks-{stage}-1-26-cluster',
version=eks.KubernetesVersion.V1_26,
vpc=vpc,
default_capacity=0,
kubectl_layer=kubectl_layer
)
...
Повторюємо деплой для апдейту вже існуючого стеку, і…
CloudFormation UPDATE_ROLLBACK_FAILED
І маємо іншу помилку, бо після “Error: b’configmap/aws-auth configured\nerror” стек лишився у статусі UPDATE_ROLLBACK_FAILED:
[simterm]
...
eks-dev-1-26: deploying... [1/1]
eks-dev-1-26: creating CloudFormation changeset...
❌ eks-dev-1-26 failed: Error [ValidationError]: Stack:arn:aws:cloudformation:us-east-1:492***148:stack/eks-dev-1-26/9c7daa50-10f4-11ee-b64a-0a9b7e76090b is in UPDATE_ROLLBACK_FAILED state and can not be updated.
...
Тож видаляємо, і йдемо на Фейсбук дивитись котиків, поки воно буде перестворюватись.
Cannot replace cluster “since it has an explicit physical name
На цьому місці ще ловив “Cannot replace cluster “eks-dev-1-26-cluster” since it has an explicit physical name.“, виглядало це так:
[simterm]
...
2:30:45 PM | UPDATE_FAILED | Custom::AWSCDK-EKS-Cluster | EKSCluster676AE7D7
Received response status [FAILED] from custom resource. Message returned: Cannot replace cluster "eks-dev-1-26-cluster" since it has an explicit physical name. Either rename the cluster or remove the "name" configuration
...
[/simterm]
Але на цей раз не зарепродьюсилось, хоча треба мати на увазі, бо точно вилізе ще колись.
Добре, отже тепер вже маємо VPC, EKS Cluster та NodeGroup – час подумати про IAM.
IAM Role та aws-auth ConfigMap
Що треба зробити наступним – це створити IAM-роль, яку можна буде assume для отримання доступу до кластеру.
Поки що без всяких RBAC та юзер-груп – просто роль, щоб потім виконати aws eks update-kubeconfig.
from aws_cdk import (
...
aws_iam as iam,
...
)
...
# Create an IAM Role to be assumed by admins
masters_role = iam.Role(
self,
'EksMastersRole',
assumed_by=iam.AccountRootPrincipal()
)
# Attach an IAM Policy to that Role so users can access the Cluster
masters_role_policy = iam.PolicyStatement(
actions=['eks:DescribeCluster'],
resources=['*'], # Adjust the resource ARN if needed
)
masters_role.add_to_policy(masters_role_policy)
cluster.aws_auth.add_masters_role(masters_role)
# Add the user to the cluster's admins
admin_user = iam.User.from_user_arn(self, "AdminUser", user_arn="arn:aws:iam::492***148:user/arseny")
cluster.aws_auth.add_user_mapping(admin_user, groups=["system:masters"])
masters_role – роль, яку можна буде assume будь-ким з AWS-аккаунту, а admin_user – мій IAM юзер для “прямого” доступу до кластеру.
CfnOutput
Outputs CloudFormation-стеку. Наскільки пам’ятаю, може використовуватись для cross-stack передачі values, але нам більше треба для отримання ARN-у masters_role:
from aws_cdk import (
...
Stack, CfnOutput
)
...
# Output the EKS cluster name
CfnOutput(
self,
'ClusterNameOutput',
value=cluster.cluster_name,
)
# Output the EKS master role ARN
CfnOutput(
self,
'ClusterMasterRoleOutput',
value=masters_role.role_arn
)
По результату можу сказати одне – особисто я не готовий брати відповідальність за роботу такого стеку у production.
Можливо, якщо з CDK попрацювати ще, і знати основні його підводні камені, та згадати всі “особливості” CloudFormation – то з ними можна жити. Але поки що – ні, взагалі не хочеться.
Давно і багато чув про VictoriaMetrics, і нарешті настав час, коли її можна спробувати.
Отже, в двох словах – VictoriaMetrics це “Prometheus на стероідах”, і повністю з ним сумісна – може використовувати його файли конфігурації, експортери, PromQL тощо.
Тож як для людини, яка завжди користувалась Prometheus, перше питання – в чьому різниця? Єдине, що пам’ятаю, це те, що VictoriaMetrics начебто вміє в anomaly detection, чого не вистачало в Prometheus – давно хотілось додати.
В Google порівнянь не так багато, але знайшлись такі:
Отже, сьогодні глянемо на архітектуру і компоненти, запустимо VictoriaMetrics з Docker Compose, налаштуємо збір метрик с Prometheus exporters, глянемо як там з алертами, і підключимо Grafana.
Prometheus з Alertmanager, пачка експортерів та Grafana вже є, наразі запущені просто через Docker Compose на AWS EC2, туди ж додамо інстанс VictoriaMetrics. Тобто основна ідея – замінити Prometheus на VictoriaMetrics.
З того, що побачив поки запускав VictoriaMetrics – виглядає прям дуже цікаво. Більше можливостей по функціям, по шаблонам алертів, сам UI дає більше можливостей для роботи з метриками. Спробуємо його використати замість Prometehus в нашому проекті, подивимось, як воно буде. Правда, якихось прикладів в тому ж Гуглі небагато, проте ChatGPT може допомогти.
Архітектура VictoriaMetrics
VictoriaMetrics має cluster version та single-node version. Для невеликих проектів до мільйона метрик в секунду рекомендується використовувати single node, але у кластер-версії гарно описана загальна архітектура.
Основні сервіси та компоненти VictoriaMetrics:
vmstorage: відповідає за зберігання даних та відповіді на запит даних клієнтами (vmselect)
vmselect: відповідає за обробку вхідних запитів на вибірку даних та збор даних з нод vmstorage
vminsert: відповідає за прийом метрик та розподіл даних по нодам vmstorage у відповідності до імен та лейбл цих метрик
vmui: Web UI для доступу к даним і параметрам конфігурацї
vmalert: обробляє алерти з файлу конфігурації та відправляє їх до Alertmanager
vmagent: займається збором метрик з різних джерел, таких як експортери Prometheus, їхнє фільтрування та релейбл, і зберігання у сховищі даних (самій VictoriaMetrics або через remote_write протокол Prometheus)
vmanomaly: VictoriaMetrics Anomaly Detection – сервіс, який постійно сканує дані у VictoriaMetrics і за допомогою механізмів machine learning виявляє несподівані зміни, які можна використовувати у алертах
vmauth: простий auth proxy, роутер та лоад-балансер для VictoriaMetrics.
Запуск VictoriaMetrics з Docker Compose
Отже, як ми можемо використати VictoriaMetrics у випадку, якщо вже є Prometheus та його експортери?
можемо налаштувати Prometheus слати метрики у VictoriaMetrics, див. Prometheus setup (майте на увазі, що remote_write на Prometheus-інстансі збільшить споживання ресурсів ЦПУ та диску на 25%) – не бачу сенсу в нашому випадку, але можливо буде корисним у разі використання якогось KubePrometehusStack
можемо налаштувати VictoriaMetrics на збір даних з експортерів Prometheus, див. How to scrape Prometheus exporters such as node-exporter, тобто як раз зробити те, що хочеться зараз – замінити Prometheus на VictoriaMetrics з мінімальними змінами у конфігурації Prometheus
Для victoriametrics поки що закоментуємо --vmalert.proxyURL, додамо його згодом.
До vmagent підключаємо каталог ./prometheus – в ньому маємо файл prometheus.yaml з конфігурацією srape_jobs, та файли параметрів експортерів (наприклад – ./prometheus/blackbox.yml та /prometheus/blackbox-targets/targets.yaml для Blackbox Exporter).
У --remoteWrite.url вказуємо, куди будемо писати отримані метрики – до інстансу VictoriaMetrics.
Запускаємо:
[simterm]
# docker compose up
[/simterm]
Якщо перейти без URI, тобто просто на domain.com/ – то видасть всі доступні шляхи, дуже прям зручно:
field evaluation_interval not found in type promscrape.GlobalConfig
Але vmagent не запустився:
[simterm]
2023-06-05T09:38:31.376Z fatal VictoriaMetrics/lib/promscrape/scraper.go:117 cannot read "/etc/prometheus/prometheus.yml": cannot parse Prometheus config from "/etc/prometheus/prometheus.yml": cannot unmarshal data: yaml: unmarshal errors:
line 4: field evaluation_interval not found in type promscrape.GlobalConfig
line 13: field rule_files not found in type promscrape.Config
line 19: field alerting not found in type promscrape.Config; pass -promscrape.config.strictParse=false command-line flag for ignoring unknown fields in yaml config
Перезапускаємо сервіси, та заглянемо на порт 8429, vmagent – теж є лінки :
Перевіряємо таргети – вони є, тобто vmagent зчитав файл prometheus.yaml, але не всі працюють, наприклад – Sentry експортер є, YACE є, а от blackbox, node_exporter та cAdvisor не бачить:
А чому?
Ага… Не бачить тих, у кого sd_configs, тобто динамічний сервіс-діскавері:
... - job_name: 'cadvisor'
# Override the global default and scrape targets from this job every 5 seconds.
scrape_interval: 5s
dns_sd_configs:
- names:
- 'tasks.cadvisor'
type: 'A'
port: 8080
...
error in A lookup for “tasks.cadvisor”: lookup tasks.cadvisor on 127.0.0.11:53: no such host
А логи кажуть, що контейнер з vmagent не може отримати A-запис з DNS:
[simterm]
...
vmagent | 2023-06-05T10:04:10.818Z error VictoriaMetrics/lib/promscrape/discovery/dns/dns.go:163 error in A lookup for "tasks.cadvisor": lookup tasks.cadvisor on 127.0.0.11:53: no such host
vmagent | 2023-06-05T10:04:10.821Z error VictoriaMetrics/lib/promscrape/discovery/dns/dns.go:163 error in A lookup for "tasks.node-exporter": lookup tasks.node-exporter on 127.0.0.11:53: no such host
...
[/simterm]
Читаємо документацію по dns_sd_configs, де говориться про “# names must contain a list of DNS names to query“, але в мене зараз job описана з names = tasks.container_name, див. Container discovery.
Спробуємо вказати просто ім’я, тобто cadvisor замість tasks.cadvisor:
Отже, зара маємо запущений Alertmanager та Prometheus.
У prometheus.yaml маємо вказаний файл з алертами:
...
rule_files:
- 'alert.rules'
...
Що нам треба – це запустити vmalert, якому вкажемо “бекенд” у вигляді Alertmanager, якому він буде слати алерти, та сам файл з алертами у форматі Prometheus.
Як і сама VictoriaMetrics, vmalert має дещо ширші можливості, ніж Prometheus, наприклад – зберігає статус алертів, тож рестарт контейнеру не сбиває silenced алерти. Ще є зручна змінна $for для шаблонів, в якій передається значення for з алерту, і можемо мати щось таке:
...
for: 5m
annotations:
description: |-
{{ if $value }} *Current latency*: `{{ $value | humanize }}` milliseconds {{ end }} during `{{ $for }}` minutes
...
Також є підримка httpAuth, є можливість виконати запит алерту з query та багато іншого, див. Template functions.
Тут у datasource.url вказуємо, звідки брати метрики для перевірки у алертах, remoteRead.url та remoteWrite.url – де зберігати стан алертів.
У notifier.url – куди будемо слати алерти (а вже Alertmanager через свій конфіг відправить їх у Slack/Opsgenie/etc). І у rule вказуємо сам файл з алертами, який підключаємо у volumes.
Перезапускаємо контейнери з docker compose restart, и заходимо на порт 8880:
Окей, є алерт-рули.
Спробуємо тригернути тестовий алерт – і маємо новий алерт у vmalert Alerts:
Та повідомлення в Slack від Alertmanager:
Все працює.
Тепер можна відключати контейнер з Prometheus, тільки оновити depends_on у Grafana – замість prometheus вказати victoriametrics, і замінити data sources у дашбордах.
Bonus: Alertmanager Slack template
І приклад шаблону для нотіфікацій в Slack. Він ще буде перероблюватись, поки що вся система більше в стані proof of concept, але в цілому буде якось так.
Файл alertmanager/notifications.tmpl з шаблоном:
{{/* Title of the Slack alert */}}
{{ define "slack.title" -}}
{{ if eq .Status "firing" }} :scream: {{- else -}} :relaxed: {{- end -}}
[{{ .Status | toUpper -}} {{- if eq .Status "firing" -}}:{{ .Alerts.Firing | len }} {{- end }}] {{ (index .Alerts 0).Annotations.summary }}
{{ end }}
{{ define "slack.text" -}}
{{ range .Alerts }}
{{- if .Annotations.description -}}
*Description*: {{ .Annotations.description }}
{{- end }}
{{- end }}
{{- end }}
Темплейт для алерту – підключається в контейнер vmalerts, див. Reusable templates:
{{ define "grafana.filter" -}}
{{- $labels := .arg0 -}}
{{- range $name, $label := . -}}
{{- if (ne $name "arg0") -}}
{{- ( or (index $labels $label) "All" ) | printf "&var-%s=%s" $label -}}
{{- end -}}
{{- end -}}
{{- end -}}
І сам алерт:
- record: aws:apigateway_integration_latency_average_sum
expr: sum(aws_apigateway_integration_latency_average) by (dimension_ApiName, tag_environment)
- alert: APIGatewayLatencyBackendProdTEST2
expr: aws:apigateway_integration_latency_average_sum{tag_environment="prod"} > 100
for: 1s
labels:
severity: info
component: backend
environment: test
annotations:
summary: "API Gateway latency too high"
description: |-
The time between when API Gateway relays a request to the backend and when it receives a response from the backend
*Environment*: `{{ $labels.tag_environment }}`
*API Gateway name*: `{{ $labels.dimension_ApiName }}`
{{ if $value }} *Current latency*: `{{ $value | humanize }}` milliseconds {{ end }}
grafana_url: '{{ $externalURL }}/d/overview/overview?orgId=1{{ template "grafana.filter" (args .Labels "environment" "component") }}'
А $externalURL отримується vmalerts з параметру --external.url=http://100.***.***.197:3000".
Прийшла досить цікава задачка – побудувати в Grafana дашборду, в якій би відображався статус процессу розробки, а саме – перформанс, тобто ефективність наших DevOps-процесів.
Потрібно це тому, что ми намагаємось побудувати “true continuous deployment”, щоб код автоматично потрапляв у Production, і нам важливо бачити як саме проходить процес розробки.
Загалом для оцінки ефективності процессу розробки ми придумали 5 метрик:
Deployment Frequency: як часто виконуються деплої
Lead Time for Changes: скільки часу займає доставка фічі до Production, тобто час між її першим коммітом в репозиторій до моменту, коли вона потрапляє в Production
PR Lead Time: час, котрий фіча “вісить” у статусі Pull Request
Change Failure Rate: процент деплоїв, які викликали проблеми у Production
Time to Restore Service: час на відновлення системи у випадку її краху
Почати вирішили з метрики для PR Lead Time – будемо міряти час від створення Pull Request до його мержу в master-гілку, і виводити його на Grafana-дашборді.
Далі, спробуємо отримати інформацю про пул-реквест, а саме – час його створення та закриття.
Тут, щоб спростити та пришвидшити розробку експортеру і його тестування, будемо використовувати тільки один репозиторій і будемо вибирати закриті пул-реквести тільки за останній тиждень, а потім вже повернемо цикл, в якому будемо перебирати всі репозиторії та пул-реквести в них:
...
# get infro about a repository
repository = github_instance.get_repo("OrgName/repo-name")
# get all PRs in a given repository
pull_requests = repository.get_pulls(state='closed')
# to get PRs closed during last N days
days_ago = datetime.now() - timedelta(days=7)
for pull_request in pull_requests:
merged_at = pull_request.closed_at
created_at = pull_request.merged_at
if created_at >= days_ago and created_at and merged_at:
print(f"Pull Request: {pull_request.number} Created at: {pull_request.created_at} Merged at: {pull_request.merged_at}")
Тут у циклі для кожного PR отримуємо його атрибути merged_at та created_at, див. List pull requests – у Response schema є список всіх атрибутів, які ми можемо побачити для кожного PR.
У days_ago = datetime.now() - timedelta(days=7) отримуємо день 7 днів тому, щоб вибрати пул-реквести, створені після цієї дати, а потім для перевірки виводимо на екран інформацію про дату створення PR та дату, коли його змержили в master.
Тепер можемо починати думати про метрику для Prometheus.
Prometheus Client та метрики
Встановлюємо бібліотеку:
[simterm]
$ pip install prometheus_client
[/simterm]
Щоб мати більше уяви про те, що саме ми хочимо побудувати – можна почитати How to Read Lead Time Distribution, де є приклад такого графіку:
Тобто в нашому випадку будуть:
x-axis (горизонталь): час (години на закриття PR)
y-axis (вертикаль): кількість PR закриті за Х-годин
Тут я досить багато часу витратив, намагюсь зробити це з використанням різних типів метрик для Prometheus, і спочатку пробував Histogram, бо наче ж виглядає логічно – в бакети гістрограми вносити значення, по типу такого:
buckets = [1, 2, 5, 10, 20, 100, 1000]
gh_repo_lead_time = Histogram('gh_repo_lead_time', 'Time in hours between PR open and merge', buckets=buckets, labelnames=['gh_repo_name'])
Проте, з Histogram не вийшло, бо в бакет 1000 потрапляють всі значення меньше 1000, в бакет 100 – всі менше ста, і так далі, а нам потрібно в бакет 100 включати тільки дані про пул-реквести, які були закриті між 50 годин та 100 годин.
Але врешті-решт все вийшло з використанням типу Counter та лейбл repo_name та time_interval.
Спочатку створимо Python dictionary з “бакетами” – це години, на протязі яких були закриті пул-реквести:
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]
Далі будемо отримувати кількість годин на закриття у кожному PR, перевіряти в який саме “бакет” цей PR попадає, і потім заносити дані у метрику – додавати лейблу time_interval зі значенням з бакету, в який це PR попав, та інкрементити значення каунтеру.
Створємо саму метрику pull_request_duration_count та функцію calculate_pull_request_duration(), в яку будемо передавати пул-реквест для перевірки:
...
# buckets for PRs closed during {interval}
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000] # 1 hour, 2 hours, 5 hours
# prometheus metric to count PRs in each {interval}
pull_request_duration_count = Counter('pull_request_duration_count',
'Count of Pull Requests within a time interval',
labelnames=['repo_name', 'time_interval'])
def calculate_pull_request_duration(repository, pr):
created_at = pr.created_at
merged_at = pr.merged_at
if created_at >= days_ago and created_at and merged_at:
duration = (merged_at - created_at).total_seconds() / 3600
# Increment the histogram for each time interval
for interval in time_intervals:
if duration <= interval:
print(f"PR ID: {pr.number} Duration: {duration} Interval: {interval}")
pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
break
...
Тут у calculate_pull_request_duration():
отримуємо час створення та мержу пул-реквеста
перевіряємо, що PR молодший за $days_ago і має атрібути created_at та merged_at, тобто він вже змержений
рахуємо, скільки часу він провів до моменту його мержу в мастер-гілку, та переводимо в години – duration = (merged_at - created_at).total_seconds() / 3600
у циклі проходимось по “бакетах” з нашого time_intervals dictionary – шукаємо, в який з них попадає цей PR
і в кінці створюємо метрику pull_request_duration_count, в labels якої вносимо ім’я репозиторію та “бакет”, в який попав цей пул-реквест, і інкриментимо значення каунтера на +1: pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
Далі, описуємо функцію main() та ї виклик:
...
def main():
# connect to Gihub
github_instance = Github(github_token)
organization_name = 'OrgName'
# read org
organization = github_instance.get_organization(organization_name)
# get repos list
repositories = organization.get_repos()
for repository in repositories:
# to set in labels
repository_name = repository.full_name.split('/')[1]
pull_requests = repository.get_pulls(state='closed')
if pull_requests.totalCount > 0:
print(f"Checking repository: {repository_name}")
for pr in pull_requests:
calculate_pull_request_duration(repository_name, pr)
else:
print(f"Sckipping repository: {repository_name}")
# Start Prometheus HTTP server
start_http_server(8000)
print("HTTP server started")
while True:
time.sleep(15)
pass
if __name__ == '__main__':
main()
Тут ми:
створємо об’єкт Github
отримуємо список репозиторіїв організацї
для кожного репозиторія викликаємо get_pulls(state='closed')
перевіряємо, що в репозиторії були пул-реквести, і по черзі відправляємо їх до функції calculate_pull_request_duration()
запускаємо HTTP-сервер на порту 8000, де будемо отримувати метрики
Повний код Prometheus-експортеру
Все разом тепер виходить так:
#!/usr/bin/env python
from datetime import datetime, timedelta
import time
from prometheus_client import start_http_server, Counter
from github import Github
# TODO: move to env vars
github_token = "ghp_ys9***ilr"
# to get PRs closed during last N days
days_ago = datetime.now() - timedelta(days=7)
# buckets for PRs closed during {interval}
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000] # 1 hour, 2 hours, 5 hours
# prometheus metric to count PRs in each {interval}
pull_request_duration_count = Counter('pull_request_duration_count',
'Count of Pull Requests within a time interval',
labelnames=['repo_name', 'time_interval'])
def calculate_pull_request_duration(repository, pr):
created_at = pr.created_at
merged_at = pr.merged_at
if created_at >= days_ago and created_at and merged_at:
duration = (merged_at - created_at).total_seconds() / 3600
# Increment the Counter for each time interval
for interval in time_intervals:
if duration <= interval:
print(f"PR ID: {pr.number} Duration: {duration} Interval: {interval}")
pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
break
def main():
# connect to Gihub
github_instance = Github(github_token)
organization_name = 'OrgNameg'
# read org
organization = github_instance.get_organization(organization_name)
# get repos list
repositories = organization.get_repos()
for repository in repositories:
# to set in labels
repository_name = repository.full_name.split('/')[1]
pull_requests = repository.get_pulls(state='closed')
if pull_requests.totalCount > 0:
print(f"Checking repository: {repository_name}")
for pr in pull_requests:
calculate_pull_request_duration(repository_name, pr)
else:
print(f"Skipping repository: {repository_name}")
# Start Prometheus HTTP server
start_http_server(8000)
print("HTTP server started")
while True:
time.sleep(15)
pass
if __name__ == '__main__':
main()
Чекаємо, поки будуть перевірені всі репозиторії і запуститься http_server(), та перевіряємо метрики з curl:
[simterm]
$ curl localhost:8000
...
# HELP pull_request_duration_count_total Count of Pull Requests within a time interval
# TYPE pull_request_duration_count_total counter
pull_request_duration_count_total{repo_name="***-ios",time_interval="10"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="1"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="50"} 2.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="100"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="20"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="1000"} 1.0
...
[/simterm]
Гуд! Працює.
GitHub API rate limits
Майте на увазі, що GitHub обмежує кількість запитів до API – 5,000 на годину зі звичайним юзерським токеном, та 15.000, якщо у вас Enterprise ліцензія. Див. Rate limits for requests from personal accounts.
Якщо його перевищити – отримаєте 403:
[simterm]
...
File "/usr/local/lib/python3.11/site-packages/github/Requester.py", line 423, in __check
raise self.__createException(status, responseHeaders, output)
github.GithubException.RateLimitExceededException: 403 {"message": "API rate limit exceeded for user ID 132904972.", "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}
[/simterm]
Prometheus Server та отримання метрик
Залишилось почати збирати метрики у Prometheus, та створити Grafana dashboard.
Запуск Prometheus Exporter
Створюємо Dockerfile:
FROM python:latest
COPY github-exporter.py ./
RUN pip install prometheus_client PyGithub
CMD [ "python", "./github-exporter.py"]
Збираємо образ:
[simterm]
$ docker build -t gh-exporter .
[/simterm]
У нас Prometheus/Grafana поки що в простому Docker Compose – додаємо запуск нашого нового експортеру:
Запускаємо, і за хвилину перевіряємо метрики в Prometheus:
Найс!
Grafana dashboard
Останнім робимо саму борду.
Додамо змінну, щоб мати змогу відобразити дані по конкретному репозіторію/ях:
Для візуалізації я використав тип Bar gauge і такий query:
sum(pull_request_duration_count_total{repo_name=~"$repository"}) by (time_interval)
У Overrides задаємо колір для кожної колонки.
Єдине, що тут не дуже – це сортування колонок: сам Prometheus це не вміє і не хоче (див. Added sort_by_label function for sorting by label values), а Grafana сортує по першим цифрам у отриманих з label значеннях, тобто 1, 2, 5, не враховуючи кількість 0 після цифри.
Але то вже деталі – може, таки візьмемо Victoria Metrics з її sort_by_label, або в Grafana просто створимо кілька графіків, і в кожному будемо виводити дані по конкретному “бакету” та кількості пул-реквестів в ньому.
Збирати логи у Grafana Loki з Kubernetes дуже просто – запускаємо Promtail у DaemonSet, йому вказуємо читати всі дані з /var/logs – і готово (насправді взагалі нічого не вказуємо – з Helm-чарту все працює з коробки).
А от як бути з CloudWatch Logs? На новому проекті маємо купу AWS Lambda, API Gateways і т.д, і всі вони пишуть логи у CloudWatch.
Ще є варіант встановити CloudWatch як data source у Grafana, і просто користуватись логами з інтерфейсу Grafana та мабуть навіть мати алерти Grafana з цих логів, але рано чи пізно все одно з’явиться Kubernetes або просто ЕС2 інстанси, і треба буде збирати з них логи, тож будемо відразу робити все з Loki, тим більш в неї чудовий LogQL і набагато більше гнучкості у створенні лейбл та алертів.
В такому випадку можемо використати Lambda Promtail від самої Grafana, а працювати воно буде наступним чином:
якась Lambda-функція (наприклад) пише лог у CloudWatch Log Group
у Log Group будемо мати Subscription filter, який буде слати логи на іншу Lambda-функцію – власне у Lambda Promtail
а Lambda Promtail буде пересилати їх до інстансу Loki
Отже, сьогодні створимо тестову Lambda-функцію, яка буде писати логи, і запустимо Lambda Promtail, яка буде слати логи в Grafana Loki, яка вже є.
На що треба звернути увагу, так це на кількість даних, які будуть писатись, бо як завжди з AWS – попасти на гроші досить легко, тому добре мати налаштований AWS Budgets, щоб отримати алерт в разі неочікуваних витрат.
Також треба мати на увазі, що до Loki потрібно буде відкривати доступ на порт 3100, тож Lambda Promtail краще мати в тій самій VPC, де запущена сама Grafana та/або мати якийсь NGINX з HTTP-аутентифікацією.
Тестова Lambda для створення логів
Створюємо функцію, нехай буде на Python:
У коді функції додамо кілька print(), щоб створити запис в лог:
Тиснемо Test, щоб створити тестовий евент, дані в полі Event JSON нам не важливі, просто вказуємо ім’я евенту, та зберігаємо його:
Тиснемо Test ще раз – функція виконалась, Function Logs пішли:
Переходимо у Monitor > Logs, а звідти у CloudWatch Logs:
І перевіряємо, що Log events є:
Все – тепер можемо переходити до Lambda Promtail.
Запуск Lambda Promtail
Взагалі є готовий Terraform проект і навіть Cloudformation темплейт, тож можна скористатись ними. Єдине, що у Terrafrom треба пофіксити створення resource "aws_iam_role_policy_attachment" "lambda_sqs_execution" у файлі sqs.tf, бо там йде виклик ролі role = aws_iam_role.iam_for_lambda.name, а у main.tf вона називається resource "aws_iam_role" "this".
В усьому іншому Terraform працює – задаємо значення для змінних у variabels.tf – write_address, log_group_names та lambda_promtail_image, і можна створювати ресурси.
Проте я все ж вважаю за краще на перший раз створити все руками, щоб краще розуміти що і як буде працювати.
Docker образ та Elastic Container Service
Спочатку підготуємо Docker-образ, бо запустити AWS Lambda з публічного ECR Grafana чомусь неможливо, хоча ніде в документації такого обмеження не знайшов.
Вибираємо функцію, в яку будемо стрімити логи, та при потребі у Configure log format and filters можемо вказати фільтр, через який буде вибиратись що саме пересилати у Lambda, щоб не слати зовсім всі строки.
Зараз нам це не потрібно, тож у Log format ставимо Other, а Subscription filter pattern лишаємо пустим.
У Subscription filter name вказуємо ім’я самого фільтру:
Зберігаємо – Start streaming, повертаємось до Lambda write-logs, і кілька раз тиснемо Test, щоб створити ще записів у CloudWatch Log Group, які мають стригерити функцію lambda-promtail-testing і передати їй дані, які вона відправить у Loki.
Перевіряємо у функції lambda-promtail-testing – у Monitoring мають бути виклики:
У випадку Errors – на вкладці Logs є посилання на CloudWatch Log цієї функції, де буде описана помилка.
Якщо ж все Success – то в Loki вже маємо побачити нову лейблу, і по ній можемо вибрати логи з функції write-logs:
Готово.
На сторінці документації Grafana ще пишуть, що “Or, have lambda-promtail write to Promtail and use pipeline stages.“, але я так і не знайшов можливості в Promtail писати дані по gRPC або HTTP, хоча така ідея була ще у 2020 році, але вона досі в Draft – Promtail Push API.
Треба запланувати використання Terraform у новому проекті, а це включає в себе і планування структри файлів для проекті, і як створити бекенд (тобто bootstrap) і інші потрібні для початку роботи ресурси, і подумати на тему роботи з кількома оточеннями і AWS-аккаунтами.
Взагалі, цей пост спочатку писався чисто про створення AWS SES, але я почав додавати багато деталей по тому, як жеж саме створити та планувати новий проект з Terraform, тож вирішив винести окремим постом. Про SES пізніше теж допишу, бо там досить цікаво саме по самому SES та пошті в цілому.
бекенд – AWS S3, як робити корзину для першого запуску?
гарно б DynamoDB для State Locking, але то іншим разом
dev/prod оточення та aws multi account – як робитимо?
Структура файлів Terraform
У проекті для AWS SES спочатку зробив все у одному файлі, але давайте зробимо “як треба”, див. наприклад How to Structure Your Terraform Projects (там ще багато чього).
Отже, як організуємо:
main.tf – виклики модулів
terraform.tf – параметри backend-у, провайдери, версії
providers.tf – тут сам провайдер AWS, аутентифікаця, регіон
variables.tf – тут декларуємо змінні
terraform.tfvars – значення для змінних
У проекті SES ще будуть окремі файли ses.tf та route53.tf для всього, що пов’язано з ними.
Multiple environments з Terraform
Окей, а що маємо по роботі з декількома оточеннями типу Dev/Prod, або взагалі різними AWS-аккаунтами?
Як на мене, для невеликого проекту найбільш привабливим виглядає варіант з використанням декільких калалогів для оточень і Terraform modules для ресурсів.
Спробуємо, щоб побачити, як воно все виглядатиме та працюватиме.
$ tree
.
|-- environments
| |-- dev
| `-- prod
`-- modules
`-- vpc
[/simterm]
Тут у нас environments/dev/ та prod/ будуть незалежними проектами з власними параметрами та будуть використовувати загальні модулі з каталогу modules. Таким чином процес розробки чогось нового для інфрастуктури можна спочатку протестити у окремому файлі в каталозі environments/dev, потім перенести його до modules, додати до dev вже у вигляді модулю, і після повторного тестування там – додати до production.
Крім того, так як будемо мати власні файли параметрів для AWS, то зможемо використовувати окремі AWS-аккаунти.
Поки що руками створимо корзину для стейтів – дійдемо до цього пізніше, як будемо говорити про Bootstrap:
Переходимо до каталога modules/vpc/ і у файлі main.tf описуємо VPC (втім, якщо вже дотримуватися best practicies, то краще використовувати модуль VPC, також від Anton Babenko):
В тому ж каталозі створюємо файл variables.tf зі змінними, але без значень – тільки декларуємо їх:
variable "vpc_cidr" {
type = string
}
variable "environment" {
type = string
}
Створення Dev/Prod оточень
Переходимо до environments/dev і готуємо файли. Почнемо з параметрів – terraform.tf та provider.tf.
У terraform.tf описуємо потрібні провайдери, версії та бекенд.
У бекенді у key вказуємо шлях до стейт-файлу в директорії dev/ – її буде створено при деплої. А для Prod – вкажемо prod/ (хоча можна взагалі різні корзини):
$ terraform init
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Installing hashicorp/aws v4.67.0...
- Installed hashicorp/aws v4.67.0 (signed by HashiCorp)
Terraform has been successfully initialized!
[/simterm]
І plan:
[simterm]
$ terraform plan
...
Terraform will perform the following actions:
# module.vpc.aws_vpc.env_vpc will be created
+ resource "aws_vpc" "env_vpc" {
+ arn = (known after apply)
+ cidr_block = "10.0.0.0/24"
...
+ tags = {
+ "environment" = "dev"
}
+ tags_all = {
+ "environment" = "dev"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
[/simterm]
Окей – можна створювати ресурси, але як щодо корзини tfvars-envs, яку ми вказали у backend? Якщо ми спробуємо виконати apply зараз, то деплой сфейлиться, бо бакету для бекенду нема.
Тобто – як взагалі підготувати AWS аккаунт до використання Terraform, виконати його bootstraping?
Terraform Backend Bootstrap
Тобто, маємо новий проект, новий аккаунт, і нам десь треба зберігати state-файли. Ми будемо використовувати AWS S3, а потім ще додамо DynamoDB для state-lock, але й корзина, і таблиця в DynamoDB мають бути створені до деплою нового проекту.
Поки бачу три основних варіанти:
“clickops”: все створюємо руками через AWS Console
скриптом або вручну створювати через AWS CLI
рішення по типу Terragrunt або Terraform Cloud, але це поки овер-інжинірінг для такого маленького проекту
мати окремий проект з Terraform, назвемо його bootstrap, в якому створюються ресурси і стейт-файл, який потім імпортуємо в новий бекенд
До речі, якщо маєте GitLab – то в нього є свій бекенд для Terraform-state, див. GitLab-managed Terraform state, і в такому випадку нічого створювати не потрібно (хіба що DynamoDB та AIM, але питання зі стейтами вирішується).
В принципі якщо питання просто створити корзину, то можна й AWS CLI, але як бути, коли планується і S3, і DynamoDB, та ще й окремий IAM юзер для Terraform з власною IAM Policy? Все робити через AWS CLI? І це повторювати для всіх нових проектів вручну? Ну, таке собі.
Перше рішення, яке придумалось – це мати єдиний bootstrap-проект, в якому ми будемо створювати ресурси для всіх іншних проектів, тобто – всі корзини/Dynamo/IAM, просто через різні tfvars – можна було б організувати щось накташлт рішення з Dev/Prod оточеннями, як робили вище. Тобто у репозиторії з bootstrap-проектом мати окремі директорії з власними файлами terraform.tf, provider.tf та terraform.tfvars під кожен новий проект.
В такому випадку можна руками з AWS CLI створити перший бакет для самого проекту bootstrap, і вже в цьому проекті описуємо створення DynamoDB, S3-бакетів, IAM-ресурсів для інших проектів.
Для проекту bootstrap для аутентифікації можна взяти якісь існуючі ACCESS/SECRET ключі, а інші проекти вже зсможуть використовувати IAM юзера або роль, яку ми створимо у бутстрапі.
Виглядає наче робочою ідею, але є ще один варіант – використовувати каталог/репозиторій bootstrap як модуль в кожному проекті, і створювати ресурси перед запуском проекту.
Тобто:
модуль bootsrap – його зберігаємо в репозиторії для доступу з інших проектів
потім при створенні нового проекту – включаємо цей модуль в код, за його допомогою створюємо S3-бакет, AIM та DynamoDB
після створення – імпортуємо state-файл, який отримали після бутстрапу, в нову корзину
і вже тоді починаємо роботу з оточеннями
Спробуємо – як на мене, то цей варіант виглядає непогано.
Видаляємо корзину, яку створили на початку, вона має бути порожня, бо з terraform apply ми нічого не створювали:
Тут блок backend поки закоментований – повернемось до нього, як створимо корзину, поки що state file буде згенеровано локально. У key вказуємо шлях bootstrap/terraform.tfstate – саме туди буде імпортовано наш стейт.
Додаємо файл terraform.tfvars:
tfstates_s3_bucket_name = "tfvars-envs"
region = "eu-central-1"
Тобто, в корні проекту у main.tf ми виконуємо тільки бутстрап для створення корзини, а потім вже з каталогів environments/{dev,prod} створюємо ресурси інфрастуктури.
Створення Bootstrap S3-корзини
У корні виконуємо terraform init:
[simterm]
$ terraform init
Initializing the backend...
Initializing modules...
Downloading git::ssh://[email protected]/setevoy2/terraform-bootsrap.git for bootstrap...
- bootstrap in .terraform/modules/bootstrap
Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 4.6.0"...
- Installing hashicorp/aws v4.67.0...
- Installed hashicorp/aws v4.67.0 (signed by HashiCorp)
...
[/simterm]
Перевіряємо з terraform plan, і як все гаразд – то запускаємо створення корзини:
[simterm]
$ terraform apply
...
# module.bootstrap.aws_s3_bucket.project_tfstates_bucket will be created
+ resource "aws_s3_bucket" "project_tfstates_bucket" {
...
module.bootstrap.aws_s3_bucket_versioning.project_tfstates_bucket_versioning: Creation complete after 2s [id=tfvars-envs]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
[/simterm]
Тепер наступний крок – імпортувати локальний state-файл:
І визиваємо terraform init ще раз – тепер він бачить, що замість local backend має s3 backend, і запропонує мігрувати terraform.tfstate туди – відповідаємо yes:
[simterm]
$ terraform init
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. No existing state was found in the newly
configured "s3" backend. Do you want to copy this state to the new "s3"
backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yes
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v4.67.0
Terraform has been successfully initialized!
[/simterm]
Тепер маємо налаштований бекенд, котрий можемо використовувати для проекту.
Повертаємось до environments/dev/, перевіряємо ще раз з plan, і нарешті створимо наше Dev-оточення:
AWS Cloud Development Kit (AWS CDK) дозволяє описувати інфрастуктуру використовуючи мови програмування TypeScript, JavaScript, Python, Java, C# або Go.
“Під капотом” створює CloudFormation стек, в якому створються ресурси, описані в вашому коді.
Але так як я поки CDK не користувався, то про переваги та недоліки казати нічого не буду.
Єдине, на що відразу можна звернути увагу, це те, ще по-перше – нема state-файлів, як у Terraform, які, звістно, корисні, але додають трохи болю при менеджменті, по-друге – сам CloudFortaion, який має свої недоліки та “це не баг, а фіча”, зато маємо можливість у веб-інтерфейсі AWS Console побачити всі ресурси.
Взагалі, до AWS CDK прийшов тому, що на новому проекті він вже використовується, тож перш ніж тягнути в проект Terraform, треба розібратись з тим, що тут вже є.
UPD: Ну, нє, таки скажу. Виглядає наче й непогано і цікаво, бо “нарешті Python!”, але:
всеж HCL-код виглядає куди більш лаконічно та зрозуміло
прикладів та й банально результатів пошуку у Гуглі по Terraform набагто більше, а це значить і швидкість виконання задачі більша
ну й… дойшов до того, що треба було створити SES домен, і… І нічого. Дуже неочікувано, але в стандартних Construtcs нічого толком не зайшов, а єдиний більш-менш конструкт з Construct Hub мав приклади тільки для TypeScript, навіть у PyDoc. Таке собі задоволення, чесно кажучи
Окрім CDK для самого AWS, є також cdk8s для Kubernetes та CDKTF для Terraform.
Отже, основні поняття, якими будемо оперувати при роботі з AWS CDK:
App: App являє собою такий собі “контейнер”, в якому ми описуємо наш застосунок, і може мати в собі один або більше Stacks (які потім будут сформовані у CloudFormation Stacks). Див. Apps.
Stack: зі Stack буде формуватись CloudFormation Stack або change set. У самому Stack на рівні коду ми описуємо те, які саме ресурси в цьому стеку будуть створені, а ресурси описуємо, використовуючи Constructs. Див. Stacks.
Construct: основні “будівельні блоки” у CDK, в яких описуються компоненти, которі необхідно створити в AWS. Див. Construct.
На Construct зупинимось трохи детальніше, бо вони розподіляють на три основних групи:
AWS CloudFormation-only або L1 (“layer 1”): тут маємо ресурси, які описані і підтримуються самим CloudFormation, і всі такі ресурси мають имена з префіксом Cfn, наприклад, для AWS S3 бакетів це CfnBucket. Всі ці ресурси знаходяться у модулі aws-cdk-lib.
Curated або L2: ці конструкти розроблені командою AWS CDK для спрощення управління інфрастуктурою. Як правило, вони включать в себе ресурси з L1 з деякими дефолтними значеннями та політиками безпеки. Ресурси у aws-cdk-lib готові до використання у production, а якщо ресурс являє собою окремий модуль – то він ще або у стані розробки, або є experimental.
Patterns або L3: Patterns включають в себе декілька ресурсів, які дозволяють побудувати всю архітектуру під конкретний use case. Так само як і з L2 ресурсами, готові до продакшену модулі включені в модуль aws-cdk-lib, а ті, що знаходяться у стані розробки – являть собою окремі модулі.
Для роботи з AWS CDK, навіть якщо ви будете писати на Python, потрібна Node.js, так як всі мови програмування на CDK будуть працювати через бекенд на Node.js.
Для роботи з CDK маємо CLI, через яку можемо створювати нові App, генерувати CloudFormation темплейти, виконувати diff між нашим кодом і існуючими CloudFormation стеками та багато іншого.
Встановлюємо сам CDK – бекенд та CLI:
[simterm]
$ npm install -g aws-cdk
added 1 package, and audited 2 packages in 1s
[/simterm]
Перевіряємо CLI:
[simterm]
$ cdk --help
Usage: cdk -a <cdk-app> COMMAND
Commands:
cdk list [STACKS..] Lists all stacks in the app [aliases: ls]
cdk synthesize [STACKS..] Synthesizes and prints the CloudFormation
template for this stack [aliases: synth]
cdk bootstrap [ENVIRONMENTS..] Deploys the CDK toolkit stack into an AWS
environment
cdk deploy [STACKS..] Deploys the stack(s) named STACKS into your
AWS account
...
[/simterm]
Цікавості заради – куди веде сам файл cdk:
[simterm]
$ which cdk
/home/setevoy/.nvm/versions/node/v16.18.0/bin/cdk
--language: мова програмування, яка буде використовуватись при створенні проекту
--list: отримати список доступних шаблонів
Спробуємо list:
[simterm]
$ cdk init --list
Available templates:
* app: Template for a CDK Application
└─ cdk init app --language=[csharp|fsharp|go|java|javascript|python|typescript]
* lib: Template for a CDK Construct Library
└─ cdk init lib --language=typescript
* sample-app: Example CDK Application with some constructs
└─ cdk init sample-app --language=[csharp|fsharp|go|java|javascript|python|typescript]
[/simterm]
Тут можемо створити шаблон для App, бібліотеки для Construct Library, або створити sample-app, тобто приклад App, в якому вже будуть додані якісь Constructcs.
Створюємо директорію нашого проекту:
[simterm]
$ mkdir cdk-example && cd cdk-example
[/simterm]
Запускаємо init sample-app:
[simterm]
$ cdk init sample-app --language=python
Applying project template sample-app for python
...
Initializing a new git repository...
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: git branch -m <name>
Please run 'python3 -m venv .venv'!
Executing Creating virtualenv...
✅ All done!
source.bat – скрипт для Windows для створення Python virtualenv, який має викликати файл .venv\Scripts\activate.bat:
[simterm]
$ tail -1 source.bat
.venv\Scripts\activate.bat
[/simterm]
Але так як я це роблю на Linux, то каталогу .venv\Scripts намє взагалі, натомість маємо набор скриптів у .venv/bin/ (ну теж якось виглядає… AWS CDK начебто серйозний проект, але таку дрібницю, як скрипти, зроблено через якось… недбало?):
Для Linux використовуємо .venv/bin/activate, який являею собой shell-команди для створення та налаштування змінних оточення:
[simterm]
$ . .venv/bin/activate
(.venv)
[/simterm]
Щоб перевірити, що ми справді у virtualenv можна перевірити значення змінної $VIRTUAL_ENV, яка має шлях до каталогу поточного віртуального оточення, в якому будуть необхідні бібліотеки:
Далі, from cdk_example.cdk_example_stack import CdkExampleStack – імпортуємо клас CdkExampleStack(Stack) з модулю cdk_example_stack.py:
from constructs import Construct
from aws_cdk import (
Duration,
Stack,
aws_iam as iam,
aws_sqs as sqs,
aws_sns as sns,
aws_sns_subscriptions as subs,
)
class CdkExampleStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
queue = sqs.Queue(
self, "CdkExampleQueue",
visibility_timeout=Duration.seconds(300),
)
topic = sns.Topic(
self, "CdkExampleTopic"
)
topic.add_subscription(subs.SqsSubscription(queue))
А в ньому вже бачимо самі ресурси, які будуть створюватись – SQS та SNS з Subscription.
Можна переглянути документацію по ресурсах:
[simterm]
>>> import aws_cdk as cdk
>>> help (cdk.App())
[/simterm]
Де буде описаний клас App:
[simterm]
Help on App in module aws_cdk object:
class App(Stage)
| App(*args: Any, **kwargs) -> Any
|
| A construct which represents an entire CDK app. This construct is normally the root of the construct tree.
|
| You would normally define an ``App`` instance in your program's entrypoint,
| then define constructs where the app is used as the parent scope.
...
[/simterm]
Добре, йдемо далі.
cdk list (або ls) поверне нам список ресурсів в поточному каталозі/проекті:
[simterm]
$ cdk ls
cdk-example
[/simterm]
Далі, можемо спробувати cdk synth, який сгенерує нам шаблон CloudFormation, которий буде використано при деплої ресурсів:
Перед тим, як вже деплоїти проект, нам потрібно налаштувати наш AWS аккаунт (або регіон) для роботи AWS CDK. Для цього використовуємо cdk bootstrap, який створить CloudFormation стек з необхідними для роботи CDK ресурсами – S3-корзиною, ролі та полісі в IAM, ECR репозиторій, та записи у AWS Systems Manager Parameter Store. Див. bootstrapping.
$ aws s3 ls
2023-05-10 13:43:42 cdk-hnb659fds-assets-264***286-eu-central-1
[/simterm]
cdk deploy
І тепер можемо виконати cdk deploy, який створить CloudFormation Stack з нашими SQS/SNS/Subscription:
[simterm]
$ cdk deploy
...
cdk-example: assets built
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:
IAM Statement Changes
┌───┬────────────────────────┬────────┬─────────────────┬───────────────────────────┬────────────────────────────────────────────────────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼────────────────────────┼────────┼─────────────────┼───────────────────────────┼────────────────────────────────────────────────────────┤
│ + │ ${CdkExampleQueue.Arn} │ Allow │ sqs:SendMessage │ Service:sns.amazonaws.com │ "ArnEquals": { │
│ │ │ │ │ │ "aws:SourceArn": "${CdkExampleTopic}" │
│ │ │ │ │ │ } │
└───┴────────────────────────┴────────┴─────────────────┴───────────────────────────┴────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Do you wish to deploy these changes (y/n)? y
...
[/simterm]
Віповідаємо Y:
[simterm]
...
Do you wish to deploy these changes (y/n)? y
cdk-example: deploying... [1/1]
[0%] start: Publishing 20e979ce16c7aba5e874330247d9054b841ea313261b523b47a50fc4cd1d6662:current_account-current_region
[100%] success: Published 20e979ce16c7aba5e874330247d9054b841ea313261b523b47a50fc4cd1d6662:current_account-current_region
cdk-example: creating CloudFormation changeset...
[███████████████████▎······································] (2/6)
1:48:50 PM | CREATE_IN_PROGRESS | AWS::CloudFormation::Stack | cdk-example
1:48:54 PM | CREATE_IN_PROGRESS | AWS::SQS::Queue | CdkExampleQueue
...
[/simterm]
CDK локально сгенерує файл темплейту cdk.out/cdk-example.template.json для CloduForamtion та загрузить його до бакету CDK, який був створений під час виконання cdk bootstrap:
[simterm]
$ aws s3 ls cdk-hnb659fds-assets-264***286-eu-central-1
2023-05-10 13:48:45 5750 20e979ce16c7aba5e874330247d9054b841ea313261b523b47a50fc4cd1d6662.json
Добре – побачили, як воно все працює на всьому готовому, тепер давайте спробуємо створити щось власне, наприклад – S3 корзину.
До імпортів додаємо aws_s3 as s3 та прибираємо sns/sqs, iam та Duration.
Видаляємо ресурси SQS та SNS з класу CdkExampleStack, та описуємо створення корзини – беремо приклад з документації PyPI:
from constructs import Construct
from aws_cdk import (
Stack,
aws_s3 as s3
)
class CdkExampleStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
bucket = s3.Bucket(self, "MyEncryptedBucket",
encryption=s3.BucketEncryption.KMS
)
І поглянемо, що нам поверне cdk diff:
Червоним та - це те, що буде видалятись, зеленим та + – створюватись нове (прям дуже terraform plan нагадало).
Окей – і запускаємо деплой:
[simterm]
$ cdk deploy
✨ Synthesis time: 6.38s
cdk-example: building assets...
...
Do you wish to deploy these changes (y/n)? y
cdk-example: deploying... [1/1]
[0%] start: Publishing 5bb8c7fc8643769d69d5eb9712af36c955f9b509cc05d26740d035e9d7225a16:current_account-current_region
[100%] success: Published 5bb8c7fc8643769d69d5eb9712af36c955f9b509cc05d26740d035e9d7225a16:current_account-current_region
cdk-example: creating CloudFormation changeset...
[████████████▉·············································] (2/9)
11:29:47 AM | UPDATE_IN_PROGRESS | AWS::CloudFormation::Stack | cdk-example
11:31:54 AM | CREATE_IN_PROGRESS | AWS::S3::Bucket | MyEncryptedBucket
...
[/simterm]
Глянемо, як воно виглядає в UI:
Готово.
Ну і приберемо за собою – видалимо стек.
cdk destroy
Перевіримо їм’я стеку з list:
[simterm]
$ cdk ls
cdk-example
[/simterm]
І виконуємо cdk destroy з ім’ям стеку, щоб повністю його видалити:
[simterm]
$ cdk destroy cdk-example
Are you sure you want to delete: cdk-example (y/n)? y
cdk-example: destroying... [1/1]
✅ cdk-example: destroyed
[/simterm]
Глянемо адмінку:
Але бакет та ключ не видалило. А чому?
AWS CDK RemovalPolicy
Тому що у aws_cdk.core є окрема RemovalPolicy, яка по дефолту має значення Retain.
Ця політика контролює дії, які будуть виконані з ресурсами, которі були видалені з-під контролю CloudFormation:
якщо ресурс видалений з шаблону (взагалі я чомусь думав, що CloudFormation видаляє такі ресурси, але то з досвіду, коли шаблони писались делоїлись вручну через AWS CLI, у випадку з CDK, як бачимо, по дефолту вони таки зберігаються)
ресурс потребує заміни шляхом створення нового, тож CloudFormation створює новий, а старий видаляє зі свого контролю, але залишає сам ресурс
CloudFormation стек видалено
Останній пункт у нас і спрацював.
Окей, давайте повторимо експеримент – створимо стек заново, але до корзини додамо параметр removal_policy для її видалення при видаленні стеку, і auto_delete_objects=True, щоб видалити всі об’єкти в ній, бо інакше корзину видалити не можна.
Крім того, ключ для корзини треба створити окремим об’єктом і передати йому власний removal_policy, а потім цей ключ передавати аргументом в параметр encryption_key корзини:
from constructs import Construct
from aws_cdk import (
Stack,
RemovalPolicy,
aws_s3 as s3,
aws_kms as kms
)
class CdkExampleStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
my_key = kms.Key(self, "MyKey",
enable_key_rotation=True,
removal_policy=RemovalPolicy.DESTROY
)
bucket = s3.Bucket(self, "MyEncryptedBucket",
encryption=s3.BucketEncryption.KMS,
encryption_key=my_key,
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True
)
AWS Fargate – ще одне serverless-рішення від Amazon, яке бере на себе управління інфраструктурою, позбавляючи користувача необхідності витрачати час на налаштування ЕС2-інстансів, операційної системи, систем управління контейнерами тощо.
Якщо поглянути на схему з цього відео, то там гарно відображена роль Fargate:
Тобто, AWS бере на себе все, пов’язане з серверами та операційною системою і її компонентами, тоді як нам лишається тільки створити та запустити контейнер.
При цьому, AWS Fargate можна використовувати разом із AWS Elastic Container Service або AWS Elastic Kubernetes Service, і саме його роботу з AWS EKS ми сьогодні й розглянемо.
AWS Fargate vs AWS Lambda
Перше питання, яке з’явилось у мене, коли я почав читати про Fargate – а навіщо, якщо вже є AWS Lambda? В чому різниця? До речі, у відео ще розказується і про AWS App Runner – ще один serverless-сервіс від Амазону, але зараз не про нього (а ще маємо Knative, хоча це вже не про AWS).
Functionality
Отже, концептуально – AWS Fargate являє собою CaaS, тобто Container as a Service, тоді як AWS Lambda – це FaaS, тобто Function as a Service: для роботи з Fargate вам потрібно мати зібраний docker-образ (чи будь-який інший відповідний до Open Container Initiative специфікації), тоді як для роботи з AWS Lambda вам потрібен тільки код – Lambda сама “запакує” його в контейнер, та запустить.
Крім того, для Fargate ви маєте налаштовувати автоскейлінг контейнерів, тоді як у Lambda це відбувається автоматично. Крім того, контейнери у Fargate не будуть скейлитись в нуль, коли немає роботи – для цього ви маєте виключати Fargate tasks самі (або просто скейлити в нуль поди в Kubernetes), а у Lambda фунцкції будуть запинені, як тільки до них перестануть надходити евенти, які тригерять запуск цих функцій.
Вартість та оплата за сервіс
При цьому обидва сервіси мають однакову модель оплати – pay-as-you-go model, тобто ви платите тільки за час, коли ваш контейнер або функція виконуються, хоча й мають відмінності: у Fargate оплата стягується саме за споживані CPU/RAM в секунду, тоді як у Labmda ви платите за кожен виклик функції та за час її виконання. Див. AWS Fargate Pricing.
Use Cases
Fargate добре підходить для роботи довготривалих задач, для яких ви маєте більше можливостей налаштувати робоче оточення, і маєте менше обмежень на CPU/RAM, дискову систему для збереження даних та не маєте таких суттєвих лімітів на розмір даних, які можете відправляти/отримувати.
З іншого боку Lambda дозволяє вам швидше запускати код (бо не маєте потреби збирати образ контейнеру), автоматичний скейлінг “з коробки”, моніторинг, і добре підходить для короткотривалих задач.
AWS Fargate vs AWS EKS EC2 Node Groups
Вже по ходу діла запуску перших подів у EKS з використанням Fargate з’явилося інше питання – а що там з EC2?
В цьому порівняні, у Fargate такі переваги:
більш швидкий скейлінг
може бути більше cost effective рішенням, ніж EC2
не потребує security патчів (хоча Managed Node Groups начебто теж самі встановлюють патчі)
Недоліки Fargate:
менше контролю над інфрастуктурою
іноді рішення з EC2 може бути більш вигідним по вартості
обмеження по CPU/Memory та типам інстансів (наприклад, немає змоги звикористовувати GPU)
У випадку з EC2 ви маєте більший контроль над інфрастуктурою та типами інстансів (GPU, мережа тощо), але це потребує більше роботи інженерів (запуск, обслуговання серверів, моніторинг), до того ж ви платити за сервери незважаючи на те, виконується на них якась робота, чи ні.
Взагалі, ви просто можете в одному EKS-кластері мати і Node Groups, і Fargate-інстанси для різних подів.
Amazon EKS та AWS Fargate
Отже, у EKS наші поди мають чомусь запускатись. Зазвичай для цього використовуються NodeGrops (Managed та Self-managed), які являють собою звичайні AWS EC2 інстанси, але замість віртуальних машин ми можемо використати AWS Fargate, див. Amazon EKS nodes.
Які саме поди будуть запускатись налаштовується у Fargate profiles, які являються частиною вашого EKS-кластеру. Сам EKS інтегруються з Fargate через контролери, які вбудовані в сервіс EKS і працюють на його control plane. Крім того, для запуску подів у Fargate є окремий scheduler – fargate-scheduler (на відміну від default-scheduler, котрий відповідає за запуск подів на ЕС2-інстансах).
ви можете використовувати Amazon EBS CSI controller з Fargate, але не маєте змоги підключати додаткові EBS
при використанні Kubernetes job з Fargate важливо видаляти ці джоби після завершення їхньої роботи навіть якщо Failed, інакше вони продовжуватимуть використовувати Fargate ноди, а ви продовжите платити
Тут все будемо “клікопсити”, якось іншим разом розгорнемо EKS за допомогою Terraform або AWS CDK.
EKS cluster IAM role
Спершу, нам потрібна AIM роль, через яку майбутній кластер буде спілкуватись з сервісами Амазону, див. Amazon EKS cluster IAM role.
Переходимо в АІМ, клікаємо Create role, у Trusted entity type залишаємо AWS service, у Use case зі списку вибираємо EKS – Cluster:
На сторінці Add permissions лишаємо за-замовченням, та натискаємо Next:
Далі, задаємо ім’я ролі, і тиснемо Create Role:
Створення VPC та Subnets
Боже, сто років не створював їх… Тим більш вручну.
Окей, що нам треба:
VPC
її розбити на кілька сабнетів в різних AvailabilityZones
частина – публічні, з Internet Gateway – для всяких AWS LoadBalancer
частина – приватні, тут будуть жити поди Куберу, для них створимо NAT Gateway
і ще там щось було з SecurityGroups
Поїхали.
І – оп… Приїхали) Інтерфейс створення VPC переробили прям круто…
Переходимо до VPC, створюємо нову мережу, вибираємо VPC and more – мені просто цікаво, як воно, і виглядає воно прям реально зручно – все створюємо відразу, а не півгодини клікаємо по VPC dashboard, а потім думаємо де ми накосячили в route tables.
Тож створюємо VPC з сабнетами в кожній Availability Zone, створюємо публічні сабнети з Internet Gateway, та приватні с NAT Gateway у кожній Availability Zone (дороже, але надійніше, якщо таке робити для Продакшену). Заодно додамо VPC S3 endpoints – зараз він не потрібен, але взагалі дуже корисна штука в плані security та cost-effective:
Внизу перевіряємо, що DNS hostnames та DNS resolution включені – це вимога для роботи Fargate (описано у тому ж AWS Fargate considerations, якщо ви його пропустили):
Клікаємо Create.
Чорт – реально круто зробили!
Чекаємо створення ресурсів:
Створення EKS cluster
Переходимо до Elastic Kubernetes Service, клікаємо Add cluster > Create:
Задаємо ім’я кластеру, вибираємо створену роль, та клікаємо Next (Secrets encryption – щось новеньке, треба якось потестити):
Далі, вибираємо нашу VPS – сабнети підтянуться самі, нижче вибирамо SecurityGroup, наразі можна дефолтну з нашой VPC:
Далі, налаштовуємо доступ до нашого кластеру.
AWS Fargate Pod зависає у статусі Pending
Трохи забігаючи наперед – про можливу проблему. Після налаштування начебто всього – тестовий под завис у стаутсі Pedning з такими повідомленнями:
[simterm]
$ kubectl describe pod nginx
Name: nginx
Namespace: default
...
Labels: eks.amazonaws.com/fargate-profile=fargate-test-eu-central-1-profile
...
Status: Pending
...
Warning LoggingDisabled 11m fargate-scheduler Disabled logging because aws-logging configmap was not found. configmap "aws-logging" not found
Warning FailedScheduling 8m48s fargate-scheduler Pod provisioning timed out (will retry) for pod: default/nginx
[/simterm]
Я вже все перебрав і перегуглив – Fargate profiles, SecurityGroup EKS, NAT Gateways у subnets – всюди все вірно. WTF?
Виявилось, що коли в цьому пості трохи нижче написав:
Вазагалі, звісно бажано відключати публічний доступ до АПІ, і ходити через приватний ендпоінт, наприклад через ВПН. Але зараз залимо обидва варвіанти – і публічний, і приватний, тіль задамо ліміт на ІП.
То побіг далі, і не переключив доступ до кластеру з дефолтного значення Public на Public and private, що й призвело до проблеми.
І це сказано у перших строках документації, і я навіть цитував це, коли описував процес:
Without the private endpoint enabled, the CIDR blocks that you specify for public access must include the outbound sources from your VPC.
Але я тупо пропустив переключити опцію, і півгодини намагався зрозуміти в чьому проблема.
Окей, йдемо далі – на цей раз вже правильно.
Отже, взагалі, звісно бажано відключати публічний доступ до Kubernetes API, і ходити через приватний ендпоінт, наприклад через VPN. Але зараз включимо обидва варіанти – і публічний, і приватний, тільки задамо ліміт на мій домашній IP.
Знаходимо його:
[simterm]
$ curl ifconfig.me
217.***.***.253
[/simterm]
Та додаємо його у дозволені:
Далі, налаштовуємо логування. Тут теж бажано включати якщо не всі, то хоча б логи API-серверу, Audit або Authentificator, та Scheduler:
На наступному кроці, вибираємо Addons (теж наче не було такого раніше), тут ще й GuardDuty з’явився:
На наступній сторінці не бачу, що можна було б міняти – лишаємо, клікаємо Next:
І нарешті останнє – ревью, та створюємо кластер:
Чекаємо. Раніше це займало хвилин 15-20, але були новини, що Амазон пришвидшив процесс.
Налаштування kubectl
Для перевірки того, що з кластером все гаразд і для подальшого тестування подів додамо його у наш ~/.kube/config.
Додаємо у конфіг:
[simterm]
$ aws --profile setevoy eks update-kubeconfig --name fargate-test-eu-central-1-cluster --alias setevoy-fargate-test-eu-central-1-cluster
Added new context setevoy-fargate-test-eu-central-1-cluster to /home/setevoy/.kube/config
[/simterm]
Перевіряємо:
[simterm]
$ kk get pod --all-namespaces
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-cbbbbb9cb-2hhx2 0/1 Pending 0 18h
kube-system coredns-cbbbbb9cb-4xf2w 0/1 Pending 0 18h
[/simterm]
Поки що маємо тільки два поди с CoreDNS, зараз у Pending, бо намає ані Woker Nodes, ані Fargate profile.
Переходимо до Fargate.
Підключення AWS Fargate
Тепер, як маємо EKS кластер, настав час підключати Fargate.
If you restrict access to the public endpoint of your cluster using CIDR blocks, we recommend that you also enable private endpoint access. This way, Fargate pods can communicate with the cluster. Without the private endpoint enabled, the CIDR blocks that you specify for public access must include the outbound sources from your VPC.
В нашому випадку ніяких Node Groups нема, тож продовжуємо.
EKS pod execution IAM Role
Спочатку нам треба додати АІМ роль, яка дозволить подам у Fargate комунікувати з Амазоном, див. Amazon EKS pod execution IAM role (обожнюю документацію Амазону).
Переходимо в AIM > Roles > Create role.
На цей раз вибираємо EKS – Fragate pod:
Далі – Next, на сторінці Add permissions лишаємо, як є (можна подивитись самі пермішени), переходимо далі, задаємо ім’я і натискаємо Create role:
Після створення знаходимо роль, переходимо до вкладки Trust relationships, редагуємо роль:
Натискаємо Update policy, та переходимо до створення Fargate profile.
Створення Fargate profile
Fargate профіль описує, які саме поди з Kubernetes-кластру будут запускатись у Fargate, див. AWS Fargate profile.
Для створення, переходимо до EKS, потім до нашого кластеру, на вкладці Compute знизу знаходимо Add Fargate profile:
Задаємо ім’я, АІМ роль підставилась автоматично, як і приватні сабнету нашої VPC:
У Pod selectors описується які поди з яких неймспейсів будуть запускатись з цим Fargate профілем.
Зараз для тесту нехай будуть всі, але в цілому можна створювати різні профайли для різних типів подів з виборкою по неймспейсам та/або лейблам, які додані подам:
Далі, перевіряємо, що все вірно, та створюємо профайл:
Створення зайняло хвилин 5.
Запуск Kubernetes-подів у Fargate
Отже, поки що маємо тільки два поди з CoreDNS, які знаходяться у статусі Pending.
Щоб CoreDNS поди запустились у Fargate – редагуємо їхній Deployment, та прибираємо аннотацію eks.amazonaws.com/compute-type: ec2:
За хвилину-дві перевіряємо:
[simterm]
$ kubectl -n kube-system get pod
NAME READY STATUS RESTARTS AGE
coredns-75694977b5-m7smc 0/1 Pending 0 5m48s
coredns-75694977b5-tgwdl 1/1 Running 0 50s
[/simterm]
Перший пішов.
І після запуску перших подів на вкладці Compute нашого кластеру маємо побачити Fargate-ноди у EKS-кластері:
Поки стартує другий под з CoreDNS – додамо звичайний тестовий под, щоб ще раз побачити як воно працює:
[simterm]
$ kubectl run nginx --image=nginx
pod/nginx created
[/simterm]
І за пару хвилин перевіряємо поди:
[simterm]
$ kubectl get pod --all-namespaces
NAMESPACE NAME READY STATUS RESTARTS AGE
default nginx 1/1 Running 0 2m27s
kube-system coredns-75694977b5-r8d6b 1/1 Running 0 46s
kube-system coredns-75694977b5-tgwdl 1/1 Running 0 5m52s
[/simterm]
Та Fargate-ноди:
Щодо івенту “Disabled logging because aws-logging configmap was not found” – можно окремо налаштувати логгінг, див. Fargate logging, на запуск подів не впливає.
Ще з цікавого, що добре знати, це те, що IAM-роль для наших подів додається до aws-auth ConfigMap кластеру:
[simterm]
$ kk -n kube-system get cm aws-auth -o yaml
apiVersion: v1
data:
mapRoles: |
- groups:
- system:bootstrappers
- system:nodes
- system:node-proxier
rolearn: arn:aws:iam::264***286:role/AmazonEKSFargatePodExecutionRole-fargate-test-eu-central-1
username: system:node:{{SessionName}}
[/simterm]
Так начебто все.
Можна пробувати користуватись замість звичайних EC2.