В попередній частині серії по налаштуванню Okta зробили SSO для Grafana (див. Okta: налаштування Grafana SSO з OIDC та Role mapping) – тепер більш цікава задача: треба налаштувати SSO для AWS, і мати не тільки log in – а і users provisioning.
З приводу Terraform: свідомо роблю без нього, бо зараз ми використовуємо Okta акаунт разом з іншим проектом і потім будемо відокремлюватись і перероблювати сетап. Ну і, крім того – я не займався налаштуваннями Okta з ~2020 року, тому перший час краще “поклікопсити”, аби краще розібратись з тими змінами, які за цей час сталися.
Аналогічно з Terraform для AWS – якщо всякі VPC/EKS у нас вже зроблені з Terraform, то налаштування, які відносяться до account management поки роблю руками, бо 100% ми будемо або переїжджати в новий акаунт, або будемо розділяти поточний, і поки невідомо як це все буде виглядати.
Але коли переїдемо – то 100% будуть пости по Terraform з Okta та AWS.
AWS та сервіси для User Management
Перш ніж почати налаштування Okta – давайте коротко про те, що взагалі в AWS є з сервісів, які мають відношення до управлінню юзерами і доступами:
AWS IAM: базовий сервіс – юзери, групи, ролі, політики
AWS IAM Identity Center (колишній AWS Single Sign-On): те, що ми будемо використовувати для Okta – централізоване управління доступом до різних AWS Accounts, інтеграція з Identity Providers (IdP – Okta, Azure Active Directory, etc)
AWS Organizations: централізоване управління різними AWS Accounts – Service Control Policies (SCP), спільні CloudTrail, Config, GuardDuty, централізований білінг
AWS Control Tower: автоматичне налаштування AWS Organizations, IAM Identity Center, загальний compliance, security
Варіанти AWS SSO та Okta
Є два підходи до інтеграції Okta з AWS:
AWS Account Federation (legacy):
прямий SAML між Okta і кожним AWS акаунтом окремо через IAM Identity Providers – для кожного акаунту треба окремо створювати IAM Roles з Trust Policy на Okta, окремо налаштовувати SAML
при наявності 10 акаунтів – 10 раз повторювати одне і те саме налаштування
SCIM (provisioning) з Okta не підтримується – тобто юзери і групи не синхронізуються автоматично
IAM Identity Center:
централізований підхід – Okta підключається один раз через SAML, юзери і групи синхронізуються автоматично за SCIM протоколом
Permission Sets (aka IAM Policies для юзерів і груп) – права визначаються один раз і призначаються на будь-яку кількість акаунтів
при додаванні нового акаунту в AWS Organization – просто вибираємо існуючі групи та Permission Sets, без додаткового налаштування SAML
Ми будемо робити модно-маладьожно, з IAM Identity Center:
Okta: буде нашим Idetity Provider – юзери створюються там, логін тільки через Okta
IAM Identity Center: буде отримувати аутентифікованих юзерів від Okta та виконувати авторизацію з Permission Sets
AWS Organizations дає нам централізоване управління кількома AWS акаунтами – об’єднує акаунти в ієрархію (Organizational Unit, OU – повіяло ностальгією за OpenLDAP) з єдиним білінгом, є основою для multi-account management і обов’язковою умовою для повноцінного IAM Identity Center з multi-account SSO.
Що дає AWS Organizations
Billing: єдиний consolidated billing на всі акаунти. До того ж всякі Reserved Instances і Savings Plans можна використовувати між всіма акаунтами організації..
Security / Governance
Єдина точка менеджменту різними security services:
SCPs (Service Control Policies): політики обмежень на рівні акаунту або OU, які діють поверх будь-яких IAM прав і які не можна обійти навіть з AdministratorAccess, наприклад – “ніхто не може вимкнути CloudTrail” або “дозволити створення нових ресурсів тільки в заданих AWS Regions“
AWS Config aggregator: збирає дані про конфігурацію ресурсів з усіх акаунтів в одне місце – можна бачити чи всі ресурси відповідають заданим правилам, наприклад – “всі S3 buckets мають бути зашифровані” або “всі EC2 інстанси мають мати певні теги“
CloudTrail organization trail: єдиний CloudTrail для усіх акаунтів, не треба в кожному налаштовувати окремо
GuardDuty, Security Hub, Macie: централізоване управління всіма security services
Networking: RAM (Resource Access Manager): дозволяє використовувати спільні ресурси між акаунтами без необхідності налаштовувати це між кожною парою акаунтів.
Account isolation (головна причина multi-account):
можна (і треба) мати Production акаунт повністю ізольованим від Dev – випадковий terraform destroy в Dev не торкнеться Prod
ще рекомендується мати і окремий акаунт з обмеженим доступом для security services
обмежуємо blast radius одним акаунтом: якщо пушнули ACCESS/SECRET ключі в GitHub – то “під роздачу” попаде тільки один акаунт
хоча краще ключі не використовувати взагалі
Що відбувається при створенні Organizations
Нічого не ламається: всі існуючі IAM Users, IAM Roles, IAM Policies, всі сервіси (EKS, RDS, S3) продовжують працювати. Поточний акаунт стає management account, з’являється root OU.
Єдиний момент, який треба мати на увазі, це сам management account – його потім змінити не можна. Тому перевіряємо, що створюємо Organization з правильного акаунту – там де billing і root доступ.
Створення AWS Organization
Переходимо в AWS Organization, клікаємо Create an organization:
AWS рекомендує створювати Organization з окремого акаунту – але нам, як маленькому стартапу, підійде і поточний, в якому маємо всі наші сервіси:
Після створення Organization, AWS пропонує включити Centralize root access for member accounts – відключити root accounts, і всі адміністративні дії виконувати тільки з management account.
Нам це поки не актуально, бо взагалі маємо тільки один акаунт, але взагалі з точки зору безпеки штука корисна:
Поїхали до самого цікавого.
Створення Okta App – AWS IAM Identity Center
Спершу додамо Okta App – IAM Identity Center, бо в самому AWS IAM Identity Center потрібні будуть параметри SAML від Okta:
Отримуємо лінк на SAML metadata:
У нас в Okta кастомний домен, в браузері свариться на сертифікат, а через HSTS нема можливості цю помилку ігнорувати:
Що нам дасть IAM Identity Center, і що будемо налаштовувати:
AWS Access Portal: буде єдина сторінка входу в усі акаунти організації
Identity Source: налаштуємо source of truth для юзерів, в нашому випадку буде External Ientity Provider – Okta
Account Assignments: прив’язка User Groups в IAM Identity Center, які далі синхронізуємо з Okta – до Permission Set для конкретного AWS акаунту, тобто – “Okta Group з іменем org-DevOps має AdministratorAccess в акаунті <accountName>“
Permission Sets: набір IAM policies, який IAM Identity Center автоматично створює як IAM Role (з іменем, яке починається з AWSReservedSSO_) в цільовому AWS акаунті при підключенні User Group до Permission Sets, і далі, при логіні в акаунт – юзер використовує цю роль
AWS Organizations is recommended, but not required, for use with IAM Identity Center. If you haven’t set up an organization, you do not have to. If you’ve already set up AWS Organizations and are going to add IAM Identity Center to your organization, make sure that all AWS Organizations features are enabled. For more information, see IAM Identity Center and AWS Organizations.
Поїхали – переходимо в IAM Identity Center, клікаємо Enable:
Якщо AWS Organization ще нема – AWS пропонує її створити, якщо не хочемо мати Organization – можна включити IAM Identity Center в режимі account instance:
Переходимо в Settings > Identity Source, в Actions вибираємо Change Identity Source:
Вибираємо External type:
Отримуємо URLs, зберігаємо собі:
IAM Identity Center Assertion Consumer Service (ACS) URL
IAM Identity Center issuer URL
В Identity Provider Metadata завантажуємо файл metadata.xml, який скачали з Okta App:
При зміні IAM Identity Center виводить попередження про зміни для юзерів – але це відноситься тільки для юзерів самого IAM Identity Center, яких в нашому випадку ще нема – логін для звичайних IAM Users буде працювати, як і раніше:
Налаштування SAML в Okta AWS IAM Identity Center App
Пишемо ACCEPT, клікаємо Change – отримуємо налаштування для SAML в Okta App:
Повертаємось до Okta App, переключаємось на Sign On, клікаємо Edit та задаємо адреси:
AWS SSO ACS URL: це IAM Identity Center Assertion Consumer Service (ACS) URL із AWS IAM Identity Center
AWS SSO issuer URL: це IAM Identity Center issuer URL із AWS IAM Identity Center
Власне, на цьому з аутентифікацією все.
Але залогінитись юзери ще не можуть – трохи далі налаштуємо це.
Поки зробимо Users та Groups provisiong – синхронізацію груп та юзерів із Okta до AWS IAM Identity Center.
Налаштування Provisioning з Okta до IAM Identity Center
Повертаємось до IAM Identity Center > Settings, клікаємо Enable в Automatic Provisioning:
Отримуємо URL та Access Token.
Токен відразу зберігаємо – бо більше його не побачимо:
Повертаємось до Okta > Provisioning > Configure API Integration:
Групи із IAM Identity Center в Okta нам не потрібні – ми будемо робити тільки з Okta до IAM Identity Center, тому знімаємо галочку, погоджуємось з попередженням:
Задаємо URL, токен, клікаємо Test API Credentials:
“Єсть контакт!”:
Зберігаємо, клікаємо Edit, включаємо синхронізацію юзерів, їхнії атрибутів та деактивацію юзерів (виключили акаунт в Okta – виключили в AWS):
Тепер у нас під назвою App все зелене – маємо всі інтеграції:
Assigning Okta Users та Okta Groups до Okta IAM Identity Center App
Переходимо в Assign, додаємо цю App до Okta Group:
Залишаємо всі дефолтні атрибути:
І вже маємо юзерів в IAM Identity Center:
Але не групи – тут поки пусто:
Створення Permission Set для IAM Identity Center User Groups
Permission Sets визначає те, які права доступу будуть у юзера чи групи в AWS Account, тобто:
в Okta маємо Okta Group (org-DevOps)
Okta виконує group push в IAM Identity Center (про це далі)
в IAM Identity Center отримуємо нову групу org-DevOps
цю групу додаємо до AWS Account
в AWS Account створиться IAM Role з іменем AWSReservedSSO_<Permission_Set_name>
при логіні в акаунт – юзер виконує Assume Role цієї ролі
Створюємо новий Permission Set:
В Custom Permission Set можна вибрати власні політики, описати inline policy, або використати вже готові набори.
Для девопсів робимо AdministartorAccess:
Session duration можна поставити побільше:
Зберігаємо новий Permission Set, але Provisioned status поки Not provisioned – бо цей Permission Set ще нікому підключений:
Синхронізація Okta Groups з Okta Push Groups
Для синхронізації Okta Groups до AWS IAM Identity Center – переходимо в Push Groups, вибираємо групу – при чому необов’язково, щоб вона була Assigned до цієї App:
Вибираємо Okta Group:
Група готова до push в IAM Identity Center, і маємо дві опції – Create Group, якщо такої групи в AWS ще нема, або Link Group – зв’язати групу в Okta з вже існуючою групою в AWS:
Клікаємо Save, починається процес синхронізації:
Готово:
Перевіряємо групи в IAM Identity Center – є нова група з двома юзерами:
Потім в Okta можна відключити синхронізацію:
Підключення IAM Identity Center User Groups до AWS Accounts
Аби юзери цієї групи могли логінитись в AWS Account – виконуємо Assign вже в самому IAM Identity Center:
Вибираємо групу:
Вибираємо створений раніше Permission Set:
В списку AWS Accounts тепер маємо підключений Permission Set:
І в самому AWS Account в IAM Roles маємо нову роль:
Final: логін з SSO через AWS Access Portal
Знаходимо URL нашого AWS Access Portal – це буде єдина точка входу всіх юзерів:
Або клікаємо на App в Okta.
Попадаємо на сторінку вибору акаунтів, відразу бачимо Permission Set з яким можемо залогінитись:
Логінимось, і маємо доступ до всіх наших сервісів:
Власне – на цьому і все.
SSO та user provisioning налаштований, логін працює.
Для AWS Access Portal можемо налаштувати власний URL – але тільки в зоні awsapps.com – клікаємо Edit:
Задаємо власне ім’я:
І далі ходимо через https://example.awsapps.com/start.
Налаштування AWS CLI з SSO
Всі старі доступи з ACCESS/SECRET ключами ще працюють, але відразу налаштовуємо собі новий логін з SSO.
Виконуємо aws configure sso, з --profile вказуємо для якого саме акаунту буде логін з SSO:
$ aws configure sso --profile work
SSO session name (Recommended): org-sso
SSO start URL [None]: https://example.awsapps.com/start
SSO region [None]: us-east-1
SSO registration scopes [sso:account:access]:
Attempting to automatically open the SSO authorization page in your default browser.
...
Відкриється браузер, дозволяємо доступ:
І терміналі бачимо повідомлення, що SSO для профайлу work налаштований:
...
The only AWS account available to you is: 492***148
Using the account ID 492***148
The only role available to you is: DevOps-AdministratorAccess
Using the role name "DevOps-AdministratorAccess"
Default client Region [us-east-1]:
CLI default output format (json if not specified) [None]:
To use this profile, specify the profile name using --profile, as shown:
aws sts get-caller-identity --profile work
Перевіряємо як ми залогінені – маємо наш власний UserId, який має assumed-role/AWSReservedSSO_DevOps-AdministratorAccess:
На моєму хості з FreeBSD (вже є) запущений мій особистий щоденник, який, як і RTFM, працює на WordPress.
Отже, для нього треба підняти стандартний стек FEMP – FreeBSD + NGINX + PHP-FPM + MariaDB, а заодно налаштувати virtualhosts для сервісів типу Grafana, VictoriMetrics VM UI, Syncthing WebUI, Jellyfin тощо.
Робити будемо базовий сетап, без FreeBSD Jails – бо це чисто домашні внутрішні сервіси, але колись, у 2011-2013 роках, блог RTFM працював саме на такому сетапі, хіба що тоді ще була MySQL, а не MariaDB.
На цьому хості зараз FreeBSD v14.3, але принципової різниці з 15 нема.
Налаштування SSL буде окремим постом, із self-signed sertificate – тут всі віртуалхости на стандартному HTTP і порту 80.
root@setevoy-nas:~ # nginx -t && service nginx reload
І відкриваємо в браузері http://grafana.setevoy:
Створення NGINX virtualhost для VictoriaMetrics та редіректи
На відміну від Grafana, для доступу до VM UI у VictoriaMetrics нам треба URI /vmui/ – тому відразу налаштуємо редірект: якщо на NGINX приходить запит на victoria.setevoy – то відправляємо на victoria.setevoy/vmui/:
root@setevoy-nas:~ # sysrc mysql_enable="YES"
root@setevoy-nas:~ # service mysql-server start
Запускаємо скрипт mariadb-secure-installation для дефолтних налаштувань:
root@setevoy-nas:~ # mariadb-secure-installation
Проходимось по основним параметрам, тут можна всюди відповідати просто “yes” – хіба що задати пароль root:
root@setevoy-nas:~ # mariadb-secure-installation
/usr/local/bin/mysql_secure_installation: Deprecated program name. It will be removed in a future release, use 'mariadb-secure-installation' instead
...
Switch to unix_socket authentication [Y/n]
Enabled successfully!
Reloading privilege tables..
... Success!
...
Change the root password? [Y/n]
New password:
Re-enter new password:
Password updated successfully!
Reloading privilege tables..
... Success!
...
Remove anonymous users? [Y/n]
... Success!
...
Disallow root login remotely? [Y/n]
... Success!
...
Remove test database and access to it? [Y/n]
- Dropping test database...
... Success!
- Removing privileges on test database...
... Success!
...
Reload privilege tables now? [Y/n]
... Success!
Cleaning up...
All done! If you've completed all of the above steps, your MariaDB
installation should now be secure.
Thanks for using MariaDB!
Створення MariaDB database та user
Підключаємось до сервера:
root@setevoy-nas:~ # mysql -u root -p
Enter password:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 13
Server version: 11.4.9-MariaDB FreeBSD Ports
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
root@localhost [(none)]>
Створюємо базу, юзера з паролем, даємо юзеру доступ до цієї бази:
root@localhost [(none)]> CREATE DATABASE blog_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Query OK, 1 row affected (0.003 sec)
root@localhost [(none)]> CREATE USER 'blog-test'@'localhost' IDENTIFIED BY 'localpass';
Query OK, 0 rows affected (0.001 sec)
root@localhost [(none)]> GRANT ALL PRIVILEGES ON blog_test.* TO 'blog-test'@'localhost';
Query OK, 0 rows affected (0.001 sec)
root@localhost [(none)]> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.001 sec)
Виходимо, пробуємо підключитись з цим юзером:
root@setevoy-nas:~ # mysql -u blog-test -p blog_test
Enter password:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 14
Server version: 11.4.9-MariaDB FreeBSD Ports
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
blog-test@localhost [blog_test]>
Установка WordPress
Завантажуємо архів з останнім релізом, розпаковуємо, переносимо файли в каталог /usr/local/www/blog.setevoy/:
Міграція RTFM з DigitalOcean до AWS пройшла без проблем, і потроху “обживаюсь на новому місці”.
Інфраструктура нова, все нове – а тому перший час хочеться уважно постежити за станом серверів та блогу, а тому треба налаштувати базовий моніторинг для WordPress: NGINX, PHP-FPM, базу даних та інфраструктуру, на якій все це крутиться.
Сам стек моніторингу вже розгорнутий на домашньому NAS з FreeBSD – є VictoriaMetrics, VictoriaLogs, Grafana, vmalert та Alertmanager з відправкою алертів в Telegram та ntfy.sh.
По цьому стеку писав в серії постів по FreeBSD та домашньому NAS:
AWS ALB: картина того, що відбувається на Load Balancer
AWS EC2: різні метрики по стану самих інстансів
NGINX: метрики веб-сервера
PHP-FPM: метрики воркерів FPM
Крім того, треба збирати системні логи операційної системи та логи NGINX і PHP.
Логи RDS теж можуть бути корисними – але це вже на випадок реальних проблем, а тоді вже можна просто подивитись в CloudWatch Logs.
Для збору метрик на EC2 використав:
node_exporter: базові метрики EC2 – CPU, RAM, диск, мережа
nginx_exporter: простенький, метрик мало, але нехай буде (окремо зробимо метрики з логів NGINX)
php_fpm_exporter: метрики PHP-FPM – процеси, використання воркерів, slow requests
yace_exporter: збирає з CloudWatch дефолтні метрики по стану ALB та RDS
Для логів поки взяв Fluent Bit, який писатиме до VictoriaLogs. Взагалі, пізніше для збору логів спробую vlagent, зараз робив “швиденько” – тому взяв те, що в мене вже працює на FreeBSD/NAS.
Щоб node_exporter бачив всі мережеві інтерфейси – задаємо network_mode: host, щоб всі PID – задаємо pid: host.
З точки зору security це не ідеально, бо контейнер з network_mode: host дає повний доступ до мережі хоста, а pid: host дає йому видимість всіх процесів. Але для моніторингу особистого блогу – нормально.
Запускаємо:
[root@ip-10-0-3-146 ~]# cd /opt/monitoring && docker compose up -d
Перевіряємо метрики:
[root@ip-10-0-3-146 ~]# curl -s http://localhost:9100/metrics | grep node_exporter_build
# HELP node_exporter_build_info A metric with a constant '1' value labeled by version, revision, branch, goversion from which node_exporter was built, and the goos and goarch for the build.
# TYPE node_exporter_build_info gauge
node_exporter_build_info{branch="HEAD",goarch="amd64",goos="linux",goversion="go1.25.3",revision="654f19dee6a0c41de78a8d6d870e8c742cdb43b9",tags="unknown",version="1.10.2"} 1
Налаштування vmagent на FreeBSD
Додаємо збір метрик до VictoriaMetrics. На FreeBSD для vmagent використовується конфіг /usr/local/etc/prometheus/prometheus.yml – додаємо туди новий таргет.
В мене в job_name: "node_exporter" вже є один таргет – 127.0.0.1:9100 для метрик самої FreeBSD – туди ж вписуємо 10.100.0.20:9100, де 10.100.0.20 – це адреса EC2 в мережі WireGuard (хоча потім створю Static DNS record на MikroTik):
Перезапускаємо vmagent та перевіряємо метрики в VictoriaMetrics:
Моніторинг AWS з YACE Exporter
Для AWS-метрик будемо використовувати yet-another-cloudwatch-exporter (YACE) – він забирає метрики з CloudWatch і віддає їх у форматі Prometheus. Трохи детальніше про нього писав у Prometheus: yet-another-cloudwatch-exporter – сбор метрик AWS CloudWatch, досі використовую на робочих проектах.
До цієї ролі треба додати IAM Policy для YACE, яка надасть доступу до CloudWatch та iam:ListAccountAliases – щоб відображати ім’я акаунта замість числового ID в метриках:
Створюємо конфіг /opt/monitoring/yace-config.yml. В exportedTagsOnMetrics вказуємо, які AWS-теги додавати до метрик – потім в Grafana і алертах можна буде виводити ім’я, а не ARN.
За збір метрик з CloudWatch платимо гроші, тому тут беремо тільки те, що дійсно корисне:
[root@ip-10-0-3-146 ~]# curl -s http://127.0.0.1:5000/metrics | grep aws_
# HELP aws_applicationelb_active_connection_count_sum Help is not implemented yet.
# TYPE aws_applicationelb_active_connection_count_sum gauge
aws_applicationelb_active_connection_count_sum{account_id="264***286",dimension_AvailabilityZone="",dimension_LoadBalancer="app/rtfm-alb/cd76dd0d557838f8",name="arn:aws:elasticloadbalancing:eu-west-1:264***286:loadbalancer/app/rtfm-alb/cd76dd0d557838f8",region="eu-west-1",tag_Name="rtfm-alb-main"} 336
...
Тепер логи. Основні логи – це NGINX та PHP errors. Їх будемо відправляти до VictoriaLogs на FreeBSD хості через http output – див. документацію VictoriaLogs по Fluentbit Setup.
Real IP в NGINX
Трафік до EC2 іде через Cloudflare та ALB, тому якщо нічого не налаштовувати – в логах NGINX замість реального IP клієнта буде адреса ALB. Cloudflare передає реальний IP у заголовку CF-Connecting-IP, а для NGINX є модуль ngx_http_realip_module, якому можна вказати з якого заголовка брати IP клієнта.
Додаємо до nginx.conf (не конфіг віртуалхоста, а конфіг самого NGINX), в секцію http {}:
http {
# trust ALB (all traffic comes from within VPC)
set_real_ip_from 10.0.0.0/16;
# get real client IP from Cloudflare header
real_ip_header CF-Connecting-IP;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
...
Перезавантажуємо NGINX та перевіряємо, що в логах з’явились реальні IP:
Конфігурація Fluent Bit – парсери для NGINX та PHP
Основний конфіг /etc/fluent-bit/fluent-bit.conf в мене виглядає так:
[SERVICE]
Flush 5
Daemon Off
Log_Level info
Parsers_File /etc/fluent-bit/parsers-custom.conf
[INPUT]
Name tail
Path /var/log/nginx/rtfm.co.ua-access.log
Tag nginx.access
DB /var/lib/fluent-bit/nginx-access.db
Parser nginx_access
[INPUT]
Name tail
Path /var/log/nginx/rtfm.co.ua-error.log
Tag nginx.error
DB /var/lib/fluent-bit/nginx-error.db
[INPUT]
Name tail
Path /var/log/php/rtfm.co.ua/rtfm.co.ua-error.log
Tag php.error
DB /var/lib/fluent-bit/php-error.db
[FILTER]
Name record_modifier
Match nginx.access
Record host aws-rtfm-main
Record job nginx
Record log_type access
Record site rtfm.co.ua
[FILTER]
Name record_modifier
Match nginx.error
Record host aws-rtfm-main
Record job nginx
Record log_type error
Record site rtfm.co.ua
[FILTER]
Name record_modifier
Match php.error
Record host aws-rtfm-main
Record job php-fpm
Record log_type error
Record site rtfm.co.ua
[FILTER]
Name lua
Match nginx.access
script /etc/fluent-bit/make_msg.lua
call make_msg
[Output]
Name http
Match *
host nas.setevoy
port 9428
uri /insert/jsonline?_stream_fields=stream,job,host,log_type,site&_msg_field=log&_time_field=date
format json_lines
json_date_format iso8601
compress gzip
Тут:
[SERVICE]: глобальні параметри Fleunt Bit
[INPUT]: читаємо три файли, кожному задаємо власний tag, аби далі мати окремі фільтри
[FILTER]: тут з record_modifier по тегу з [INPUT] фільтруємо який саме лог модифікувати і додаємо нові поля, які потім можна використовувати в VictoriaLogs та алертах; у Fluent Bit на FreeBSD, де є власний NGINX і FPM має такі самі налаштування, тільки, звісно, інші значення полів
останній [FILTER] викликає Lua-скрипт для створення поля logs, див. нижче
В дефолтному конфігу Fluent Bit не було парсера для nginx_access – тому створив власний і підключив в [SERVICE] через файл /etc/fluent-bit/parsers-custom.conf:
[PARSER]
Name nginx_access
Format regex
Regex ^(?<remote_addr>[^ ]*) - (?<remote_user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>[^ ]*) (?<path>[^ ]*) (?<protocol>[^ ]*)" (?<status>[^ ]*) (?<bytes>[^ ]*) "(?<referer>[^"]*)" "(?<agent>[^"]*)"
Time_Key time
Time_Format %d/%b/%Y:%H:%M:%S %z
Але тут випилюється поле _msg, яке VictoriaLogs очікує і без якого не дуже зручно дивитись в VMUI.
Пробував зробити з record_modifier, але врешті-решт просто навайбокодив скрипт на Lua, який створює поле log, яке потім передається до VictoriaLogs в &_msg_field=log:
function make_msg(tag, timestamp, record)
record["log"] = record["remote_addr"] .. ' "' .. record["method"] .. ' ' .. record["path"] .. '" ' .. record["status"] .. ' "' .. (record["agent"] or "-") .. '"'
return 1, timestamp, record
end
Приклад того, що написав собі – спочатку задані recording rules з exclude домашніх/робочих IP та адреси самого EC2, потім самі алерти:
groups:
- name: aws-rtfm-nginx-access-metrics
type: vlogs
interval: 1m
rules:
- record: aws:rtfm:nginx:requests_total:rate
expr: |
{job="nginx", log_type="access"}
| not (remote_addr:~"108.***.***.54|178.***.***.184")
| stats rate() as requests_rate
- record: aws:rtfm:nginx:requests_by_status:count
expr: |
{job="nginx", log_type="access"}
| not (remote_addr:~"108.***.***.54|178.***.***.184")
| stats by (status) count() as requests_count
- record: aws:rtfm:nginx:requests_by_status:rate
expr: |
{job="nginx", log_type="access"}
| not (remote_addr:~"108.***.***.54|178.***.***.184")
| stats by (status) rate() as requests_rate
- name: aws-rtfm-nginx-access-alerts
rules:
- alert: "NGINX: Too Many 5xx"
expr: aws:rtfm:nginx:requests_by_status:count{status=~"5.."} > 1
for: 1m
labels:
severity: warning
annotations:
summary: Server-side errors on rtfm.co.ua, users may be affected
description: |-
Domain: rtfm.co.ua
HTTP status: {{ $labels.status }}
Count: {{ $value }} req/min
Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter
- alert: "NGINX: High Request Rate"
expr: aws:rtfm:nginx:requests_total:rate > 10
for: 2m
labels:
severity: warning
annotations:
summary: Unusual traffic spike on rtfm.co.ua
description: |-
Domain: rtfm.co.ua
Rate: {{ $value }} req/sec
Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter
- name: aws-rtfm-php-error-metrics
type: vlogs
interval: 1m
rules:
- record: aws:rtfm:php:errors_total:count
expr: |
{job="php-fpm", log_type="error"}
| stats count() as errors_count
- name: aws-rtfm-php-error-alerts
rules:
- alert: "PHP-FPM: Too Many Errors"
expr: aws:rtfm:php:errors_total:count > 5
for: 2m
labels:
severity: warning
annotations:
summary: Application errors on rtfm.co.ua
description: |-
Domain: rtfm.co.ua
Count: {{ $value }} errors/min
Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter
- name: aws-rtfm-php-fpm-alerts
rules:
- alert: "PHP-FPM: Slow Requests Detected"
expr: increase(phpfpm_slow_requests{pool="rtfm.co.ua"}[5m]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: PHP-FPM slow requests on rtfm.co.ua
description: |-
PHP-FPM slow requests detected during last {{ $for }}
Domain: rtfm.co.ua
Slow requests (last 5m): {{ $value }}
Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter
- alert: "PHP-FPM: Pool Usage High"
expr: phpfpm_active_processes{pool="rtfm.co.ua"} / phpfpm_total_processes{pool="rtfm.co.ua"} * 100 > 80
for: 5m
labels:
severity: warning
annotations:
summary: FPM Pool usage high on rtfm.co.ua
description: |-
FPM Pool usage over 95% during last {{ $for }}
Domain: rtfm.co.ua
Pool used: {{ printf "%.2f" $value }}%
Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter
Рестаримо vmalert, перевіряємо в UI:
Alertmanager та алерти в Telegram і ntfy.sh
Про те, як створити Telegram-бота і налаштувати групу для алертів писав в пості EcoFlow: моніторинг з Prometheus та Grafana, тому тут опишу тільки конфіг Alertmanager – на FreeBSD це файл /usr/local/etc/alertmanager/alertmanager.yml.
В мене три роути і три ресівери – critical алерти дублюються через ntfy.sh, алерти по самій FreeBSD та NGINX/PHP йдуть в Telegram, плюс окремий Telegram канал для алертів EcoFlow:
global:
resolve_timeout: 5m
route:
receiver: "ntfy"
group_by: ["alertname, status"]
group_wait: 10s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers:
- severity="critical"
receiver: "ntfy"
continue: true
- matchers:
- job="ecoflow_exporter"
receiver: "telegram_ecoflow"
- matchers:
- alertname =~ ".*"
receiver: "telegram_system"
receivers:
- name: "ntfy"
webhook_configs:
- url: "https://ntfy.sh/setevoy-nas-alertmanager-alerts"
http_config:
authorization:
type: Bearer
credentials: "***"
send_resolved: true
- name: telegram_system
telegram_configs:
- bot_token: "***"
chat_id: -100***962
api_url: https://api.telegram.org
parse_mode: HTML
message: |
{{ if eq .Status "firing" }}🔥{{ else }}✅{{ end }} <b>{{ .CommonLabels.alertname }}</b>
{{ range .Alerts }}
<b>Status:</b> {{ .Status | toUpper }}
{{ if .Labels.severity }}<b>Severity:</b> {{ .Labels.severity }}{{ end }}
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary }}{{ end }}
{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description }}{{ end }}
{{ end }}
- name: telegram_ecoflow
telegram_configs:
- bot_token: "***"
chat_id: -100***981
api_url: https://api.telegram.org
parse_mode: HTML
message: |
{{ if eq .Status "firing" }}🔥{{ else }}✅{{ end }} <b>{{ .CommonLabels.alertname }}</b>
{{ range .Alerts }}
<b>Status:</b> {{ .Status | toUpper }}
{{ if .Labels.severity }}<b>Severity:</b> {{ .Labels.severity }}{{ end }}
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary }}{{ end }}
{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description }}{{ end }}
{{ end }}
І тепер маємо алерти в Telegram:
Grafana dashboard
Вже не буду описувати весь процес створення, пізніше викладу дашборду десь в GitHub, але в мене виглядає так:
І додатково є “small version” для відображення на 14-дюймовому екрані ноутбука:
Власне, на цьому і все.
Вийшло класно, корисно, вже відловив кілька проблем і перебанив пачку ботів 🙂
При логіні з Okta в Grafana треба автоматично визначати яку Grafana Role йому видати – звичайного Viewer, або Admin, в залежності від того, яка у юзера група в Okta. Є два варіанти того, як це можна зробити – подивимось на обидва.
В Okta є готова App Grafana Labs – але вона підтримує тільки SAML, а хочеться модно-маладьожно, з OIDC – тому створимо окрему інтеграцію.
Єдина проблема, яка в мене виникла – це мапінг вже існуючих в Grafana юзерів із Google SSO з юзерами із Okta – трохи довелось покопатись.
Налаштування Okta
Що треба буде зробити в Okta – це створити нову App з OIDC, взяти її ключі для налаштувань самої Grafana, а потім налаштувати мапінг Okta Groups в Grafana Roles.
Створення Okta OIDC App for Grafana
Переходимо в Applications, створюємо нову апку, вибираємо метод логіну з OIDC, в Application type вказуємо Web Application:
Далі задаємо grant types:
Authorization Code: Grafana зможе виконувати логін юзера
Refresh Token: Grafana зможе оновлювати токен юзера без необхідності його релогіну
В URLs задаємо ті ендпоінти, що вказані в документації – https://<grafana_url>/login/okta та https://<grafana_url>/logout:
Controlled access можна налаштувати пізніше через Assign – або відразу вказати групи, яким буде підключена ця App:
Зберігаємо, і отримуємо ключі для Grafana:
В документації Grafana сказано, що тут ще мають бути і URLs – але їх нема. Втім, вони дефолтні, тому ОК.
Єдиний момент тут, це якщо використовується Okta Custom Domain – але про це вже поговоримо далі, в налаштуваннях самої Grafana.
Тут закінчили, тепер цікаве – мапінг груп Okta в Grafana Roles.
Configure Okta to Grafana role mapping
Тут є два варіанти: або створити кастомні Attributes для нової App, а потім їх задавати для Okta Groups – або в самій Grafana парсити значення в role_attribute_path.
В першому випадку трохи більше клікопсу, але більше гнучкості – а другий простіше, але можна поламати голову з написанням складних умов.
Потестив з обома варіантами – обидва працюють, почнемо з того, як це в документації Grafana – через кастомні атрибути.
Grafana Role на основі Okta App Profile та Custom Attributes
Ідея полягає в тому, що для Profile створеної App ми додаємо новий Attribute, в Grafana вказуємо поле, яке буде містити інформацію про Grafana Role, потім для Okta User або Okta Group задаємо власне значення цього атрибута – і воно передається до Grafana.
Далі, коли ми зробимо Assign цієї App до юзера – App візьме його дефолтні атрибути з Okta User Profile (firstName, lastName, email, login), додасть до них новий атрибут з Grafana Role – і це все разом передасть в Grafana, а далі вже справа самої Grafana – розпарсити поля і визначити Grafana Role цього юзера.
Переходимо до Directory > Profile Editor, знаходимо профайл для нової App:
Клікаємо Add Attribute:
Задаємо тип string, в Enum задаємо список ролей Grafana, в Attribute type можна вказати Group, аби менеджити ролі на рівні груп юзерів:
Далі в документації Grafana в частині Configure Groups claim говориться, що треба налаштувати передачу груп юзерів – але якщо ми передаємо роль через кастомний атрибут – то Grafana Role буде працювати і без цього.
А от якщо робити по другому варіанту – парсити групу і в залежності від групи видавати роль в Grafana – то це треба буде зробити, бо по дефолту групи юзера не передаються.
Отже, додали новий Attribute – повертаємось до списку Applications:
Виконуємо Refresh:
Переходимо до Okta Group, знаходимо підключену Grafana App:
Клікаємо Edit:
Задаємо значення атрибута Grafana Role:
Трохи забігаючи наперед – ось, що ми потім побачимо при логіні в Grafana logs – груп нема, але атрибут grafana_role="Admin" переданий:
...
logger=oauth.okta t=2026-03-27T13:58:14.843471331Z level=debug msg="Received user info response" raw_json="{\"sub\":\"***\",\"name\":\"Arseny Zinchenko\",\"locale\":\"en_US\",\"email\":\"[email protected]\",\"preferred_username\":\"[email protected]\",\"given_name\":\"Arseny\",\"family_name\":\"Zinchenko\",\"zoneinfo\":\"America/Los_Angeles\",\"updated_at\":1774610676,\"email_verified\":true,\"grafana_role\":\"Admin\"}" data="unsupported value type"
Grafana Role на основі Okta Group Name
Інший варіант – не морочитись з кастомними атрибутами, а передавати групи юзера із Okta до Grafana під час логіну, і потім в самій Grafana по імені групи визначати яку ролі підключити.
Для цього в Token claims треба додати передачу групи, і це, начебто, можна зробити через Add Expression і Okta Expression Language з, наприклад, Groups.startsWith():
Groups.startsWith("OKTA", "org-DevOps", 100)
Де “OKTA” – це source групи, а “org-DevOps” – фільтр, аби передавати групу тільки тоді, коли її ім’я починається з “org-DevOps“:
Але тоді Okta свариться, що “‘groups’ is reserved and cannot be used“:
Не став морочитись, і зробив через “Show legacy configuration”:
Тепер при логіні в Grafana ми отримаємо поле groups з усіма групами, до який належить юзер – це приклад з логу Grafana, де це вже налаштовано:
API URL: https://okta.example.com/oauth2/v1/userinfo
Або використовуємо дефолтні з https://<TENANT_ID>.okta.com:
Тепер момент по ролям: якщо роль передаємо через App Profile та Custom Attribute – то в User mapping задаємо значення поля “Role attibute path” просто як grafana_role:
Тоді Grafana прочитає значення поля і замапить “Admin” від Okta в свою локальну роль “Admin”.
Якщо ж робимо через Okta Group Name, тобто передачу групи і без App Profile та кастомних атрибутів – то в Role attibute path пишемо JMESPath expression, в якому вказуємо:
...
logger=oauth.okta t=2026-03-27T11:36:21.745687386Z level=debug msg="Received user info response" raw_json="{\"sub\":\"***\",\"name\":\"Arseny Zinchenko\",\"locale\":\"en_US\",\"email\":\"[email protected]\",\"preferred_username\":\"[email protected]\",\"given_name\":\"Arseny\",\"family_name\":\"Zinchenko\",\"zoneinfo\":\"America/Los_Angeles\",\"updated_at\":1774610676,\"email_verified\":true,\"groups\":[\"orgName Users [old]\",\"Everyone\",\"orgName-All-Users\",\"orgName-DevOps\",\"orgName-All-RnD\",\"orgName-Okta-Admins\"],\"grafana_role\":\"Admin\"}" data="unsupported value type"
logger=user.sync t=2026-03-27T11:36:21.751172007Z level=error msg="Failed to create user" error="user not found" auth_module=oauth_okta auth_id=***
logger=authn.service t=2026-03-27T11:36:21.751234085Z level=error msg="Failed to run post auth hook" client=auth.client.okta id= error="[user.sync.internal] unable to create user: user not found"
...
Що мене тут напрягло – що Grafana намагається “unable to create user” і каже, що “user not found“.
Проблема в тому, що в нашій Grafana вже налаштований Google SSO, і я з ним колись логінився – а тому в Grafana вже є юзер з email":"[email protected]".
Просто коротка замітка, бо доволі часто треба було щось подібне зробити – і тільки сьогодні дізнався, як це круто робиться з vmalert.
Отже, іноді в алерті хочеться вивести кілька $value, наприклад:
- alert: OpenAI Budget Usage
expr: |
openai_budget_used_usd / openai_budget_total_usd * 100 > 80
...
annotations:
summary: OpenAI Budget Usage
description: |-
OpenAI budget used amount is greater than 80% of the total budget
*Budget used percentage*: < $value from the openai_budget_used_usd >
*Budget used amount*: < $value from the expr: openai_budget_used_usd / openai_budget_total_usd * 100 >
*Budget total amount*: < $value from the openai_budget_total_usd >
До цього єдиний варіант, який мені придумався – це вивести $value не з expr – це використати valueFrom:
- alert: OpenAI Spending Too High Warning
expr: (project_spending_current > project_spending_avg_3d * 1.3) > 3
...
valueFrom:
metric: project_spending_today
annotations:
summary: 'OpenAI Spending Too High'
description: |-
Current OpenAI project spending exceeds the 3-day average by 30%
*Project*: `{{ $labels.project }}`
*Spent today*: `{{ $value }}`
(правда, не знаю як це працювало, бо зараз ніде в документації valueFrom не можу знайти – але колись воно в мене працювало саме так)
В такому варіанті алерта – він спрацює на умову в expr: (project_spending_current > project_spending_avg_3d * 1.3) > 3, але в `*Spent today*: {{ $value }} буде значення метрики з valueFrom – metric: project_spending_today.
Але vmalert має Template functions, де є функція query(), яку можемо викликати прямо з алерту, наприклад:
- alert: OpenAI Budget Usage
expr: |
openai_budget_used_usd / openai_budget_total_usd * 100 > 80
...
annotations:
summary: OpenAI Budget Usage
description: |-
OpenAI budget used amount is greater than 80% of the total budget
*Budget used percentage*: `{{ printf "%.0f" $value }}%`
*Budget used amount*: `{{ printf "%.0f" (query "openai_budget_used_usd" | first | value) }}` USD
*Budget total amount*: `{{ printf "%.0f" (query "openai_budget_total_usd" | first | value) }}` USD
І тут:
*Budget used percentage*: `{{ printf “%.0f” $value }}%`: значення з expr: openai_budget_used_usd / openai_budget_total_usd * 100
*Budget used amount*: `{{ printf “%.0f” (query “openai_budget_used_usd” | first | value) }}` USD: значення з openai_budget_used_usd, де first – “взяти перше (останнє) значення метрики”
*Budget total amount*: `{{ printf “%.0f” (query “openai_budget_total_usd” | first | value) }}` USD: аналогічно, але з метрики openai_budget_total_usd
І в результаті маємо алерт, в якому відразу бачимо всі необхідні дані:
Готово.
“Ви ще не користуєтесь VictoriaMetrics? Тоді ми йдемо до вас!” (с)
Ну і головний висновок: RTFM! Треба частіше читати мануали, тим більш у VictoriaMetrics чудова документація.
Продовження налаштування нового сервера для RTFM. Наступний крок – налаштувати можливість відправки пошти з EC2, бо тут можуть бути і важливі листи юзера root, і сам RTFM відправляє листи.
Думав робити з AWS Simple Email Service – чисто для того, аби згадати як з ним працювати, але – не такий вже він і Simple, бо верифікація домену затягнулась.
Тому забив, і зробив зі старим другом – Postfix, який відправляє пошту через налаштований relay – звичайний ящик в Gmail.
cyrus-sasl-plain вже має бути в системі, але про всяк випадок вказуємо і його, а mailx – зручний MUA (Mail User Agent), для тестів або для використання в скриптах.
...
# Basic system aliases -- these MUST be present.
mailer-daemon: postmaster
postmaster: root
# add mailbox for the root user
root: [email protected]
...
Оновлюємо базу:
[root@ip-10-0-1-79 ~]# newaliases
І перевіряємо відправку – в одній консолі запускаємо journalctl -f -u postfix.service, в іншій з mailx відправляємо листа до root:
Коли вже дописував, то нагуглив документацію AWS Integrating Amazon SES with Postfix – аналогічно до того, що ми робили вище, тільки з використанням SMTP AWS SES.
Кожного разу згадую як це робиться, хоча вже десь писав, але давно: треба руками збільшити розмір диску в AWS EC2.
Звикаєш до Kubernetes, де для цього достатньо просто змінити значення в PersistentVolumeClaim, а коли треба зробити руками – починаєш шукати документацію, тому накидаю таку замітку тут.
Інтересу ради пошукав старі записи, знайшов, як це робилось 10 років тому – пост AWS: увеличение размера диска EBS від 30/04/2015: треба було зупиняти EC2, створювати снапшот диску, потім з цього снапшоту створювати новий EBS з новим розміром, потім цей EBS підключати до EC2, потім запускати EC2… Жесть.
Зараз все набагато простіше і, головне – без необхідності зупиняти інстанс:
збільшуємо розмір диска з AWS CLI та modify-volume або методом clickops прямо в AWS Console
в операційній системі оновлюємо partition table – задаємо новий розмір розділу
збільшуємо файлову систему
…
profit!
AWS: Modify EBS volume
Є EC2 з одним root EBS, який треба збільшити:
Вибираємо Modify volume:
Заодно можна додати IOPS, бо дик активно використовується:
Задаємо новий розмір та Throughput:
AWS нагадує, що далі треба буде вносити зміни в саму файлову систему:
Чекаємо завершення змін:
Коли статус стає optimizing або completed – переходимо до файлової системи.
Linux: розширення partition та файлової системи
Перевіряємо що у нас є зараз:
root@ip-10-0-6-162:~# lsblk /dev/nvme0n1
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
nvme0n1 259:0 0 100G 0 disk
├─nvme0n1p1 259:1 0 49G 0 part /
├─nvme0n1p14 259:2 0 4M 0 part
├─nvme0n1p15 259:3 0 106M 0 part /boot/efi
└─nvme0n1p16 259:4 0 913M 0 part /boot
Диск /dev/nvme0n1 – доступно 100 гігабайт, але розділ nvme0n1p1 має розмір ~50 гігабайт.
Можна подивитись детальніше з опцією --output, до якої передаємо список колонок, які хочемо відобразити:
SIZE: розмір самого диску, block device – nvme0n1, 100 G
FSSIZE: на ньому маємо partition з номером 1 – nvme0n1p1, у якого filesystem size – 50G (бачимо 47.4G, бо ext4 резервує частину блоків під метадані та системний резерв ~5%)
Далі нам треба виконати:
growpart: розширити запис розділу nvme0n1p1 в partition table до кінця диску
resize2fs: розширити саму файлову систему до розміру nvme0n1p1
root@ip-10-0-6-162:~# lsblk -o NAME,SIZE,FSSIZE,FSUSED,FSAVAIL,FSUSE%,MOUNTPOINT /dev/nvme0n1p1
NAME SIZE FSSIZE FSUSED FSAVAIL FSUSE% M
nvme0n1p1 99G 47.4G 13.7G 33.7G 29% /
Збільшуємо саму файлову систему:
root@ip-10-0-6-162:~# resize2fs /dev/nvme0n1p1
resize2fs 1.47.0 (5-Feb-2023)
Filesystem at /dev/nvme0n1p1 is mounted on /; on-line resizing required
old_desc_blocks = 7, new_desc_blocks = 13
The filesystem on /dev/nvme0n1p1 is now 25951995 (4k) blocks long.
Перевіряємо ще раз:
root@ip-10-0-6-162:~# lsblk -o NAME,SIZE,FSSIZE,FSUSED,FSAVAIL,FSUSE%,MOUNTPOINT /dev/nvme0n1p1
NAME SIZE FSSIZE FSUSED FSAVAIL FSUSE% M
nvme0n1p1 99G 95.8G 13.7G 82.1G 14% /
Ідея полягає в тому, щоб дозволити підключення до ALB тільки тим клієнтам, які пройдуть аутентифікацію, і в випадку Cloudflare+AWS ALB – Cloudflare має підписувати всі свої запити з TLS-сертифікатом (див. Cloudflare Authenticated Origin Pulls), а AWS ALB буде їх перевіряти – таким чином доступ до Load Balancer URL буде можливий тільки для Cloudflare.
Правда, окрім mTLS я пізніше все одно вирішив додати ще і обмеження по IP – налаштував доступ до ALB тільки для адрес Cloudflare, які задані в AWS Security Rules, бо mTLS – це всеж про аутентифікацію, а Security Rules – це вже саме захист на рівні мережі.
Хоча спочатку думав робити чисто mTLS – бо і цікаво було подивитись, як це в ALB працює – і лінь було робити автоматизацію для оновлення AWS Security Rules.
Приклад тут робився на тестовому домені – тому заодно додамо його на Cloudflare, але обидва рішення вже працюють і для самого rtfm.co.ua.
Для чого, власне, обмежувати доступ до AWS Application Load Balancer:
на Cloudflare є багато Security rules, які блокують небажаний трафік, якщо ж запити йдуть напряму на ALB – то вони дойдуть до NGINX, і тоді частину правил треба робити і там (в мене так і було)
вартість при DDoS: поки блог жив на сервері в DigitalOcean, де просто був Public IP для дроплету, то запити до цього IP на вартість не впливали (тільки додатковий трафік), але в AWS та ALB кількість підключень впливає на вартість
P.S. І знов наче намагаєшся писати стисло – а вийшло багато тексту 🙁
Вартість AWS ALB та LCU
Вартість AWS Load Balancer включає в себе погодинну оплату за сам інстанс, стандартну плату Data Transfer Charge, за виділені Public IPs, та окремо – Load Balancer Capacity Units (LCU).
Для Network Load Balancer є власний юніт – Network Load Balancer Capacity Unit (NLCU), а для Gateway Load Balancer, відповідно, GLCU.
LCU не дуже очевидний юніт, тому кілька слів про нього.
1 LCU – це:
New connections: нові підключення до ALB – 25 підключень в секунду “з’їдають” 1 LCU
Active connection: кожні 3000 активних підключень (або 1500 при використанні mTLS) на хвилину
Processed bytes: 1 LCU покриває 1 GB трафіку на годину (або 0.4 GB для Lambda targets) – рахується сума ingress + egress
Rule evaluations: перевірка 1.000 правил в секунду – це один LCU
Під Rule evaluations маються на увазі Listener rules:
І якщо маємо багато правил по типу “IF path is /api/* THEN forward to target-group-api” – платимо за кожну перевірку кожного нового HTTP request.
При визначені того, з якого саме параметра вище рахувати LCU, AWS бере той, який перший досяг потрібного значення: тобто, як тільки маємо 26 запитів на секунду, але при цьому Processed bytes був лише 10 мегабайт – то нам порахуються 1 витрачений LCU за рахунок New connections.
Отже, у випадку DDoS (а у RTFM кілька раз траплялись, хоча і не сильні) – ми легко можемо “попасти на гроші”.
Насправді якщо використовувати обмеження доступу до ALB через Security Group зі списком тільки дозволених IP – то mTLS не треба взагалі, бо ми вже “ріжемо” підключення на рівні мережі AWS, ще до того, як запит взагалі дійде до самого Load Balancer.
Що дійсно може бути корисним – це:
аутентифікація між сервісами:
наприклад, у нас є Internal ALB, за яким живуть різні сервіси моніторингу, такі як VictoriaLogs
в логах може бути чутлива інформація типу токенів або навіть паролів, тому до цього ендпоінта треба мати обмежений доступ
Як тільки ми включимо цю опцію, Cloudflre при кожному новому запиті до origin – в нашому випадку AWS ALB – почне додавати свій клієнтський сертифікат.
Тут дуже хотілось трохи детальніше описати за SSL/TLS handshake та ключі-сертифікати – але подумав, що в рамках цього посту це буде зайвим, тим більш, що колись розбирався з деталями в пості What is: SSL/TLS в деталях.
Хоча, може, і зроблю новий пост на цю тему, тим більш зараз вже TLS 1.3, а там описаний актуальний на той момент TLS 1.2.
Отже, Cloudflre тільки передає клієнтський сертифікат, а далі вже справа origin – що з ним робити, і наступним кроком ми як раз і налаштуємо його перевірку на самому ALB.
Поки просто вмикаємо опцію – грошей вона не просить (окрім того, що вартість 1 LCU по Active connections буде 1500 замість 3000), значного навантаження на ALB чи додаткового трафіку не зробить:
Для домену додаємо запис CNAME з value == ALB URL:
Перевіряємо, що зараз все працює – бо хоч Authenticated Origin Pulls на Cloudflare ми включили, але на ALB ще ніяких перевірок не виконується:
І запит на URL самого ALB все ще працює, тільки з помилкою SSL:
Налаштування Application Load Balancer
Тепер нам треба налаштувати перевірку клієнтського сертифіката від Cloudflare на нашому ALB.
Отримання Cloudflare Certificate Authority
ALB для перевірки буде використовувати публічний Certificate Authority сертифікат від Cloudflare, який нам треба додати в Trust Store самого Load Balancer, а потім підключити його до ALB Listener.
Майте на увазі, що використання Trust Store теж платне – “$0.0056 per hour per Trust Store Associated with Application Load Balancer when using Mutual TLS“, тобто ~ 4 USD на місяць.
$ openssl x509 -in cloudflare-origin-pull-ca.pem -text -noout
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 6310029703491235425 (0x5791ba9556c22e61)
Signature Algorithm: sha512WithRSAEncryption
Issuer: C=US, O=CloudFlare, Inc., OU=Origin Pull, L=San Francisco, ST=California, CN=origin-pull.cloudflare.net
Validity
Not Before: Oct 10 18:45:00 2019 GMT
Not After : Nov 1 17:00:00 2029 GMT
Subject: C=US, O=CloudFlare, Inc., OU=Origin Pull, L=San Francisco, ST=California, CN=origin-pull.cloudflare.net
...
З важливого тут – Not After : Nov 1 17:00:00 2029:
можна в налаштуваннях ALB для mTLS включити опцію “Allow expired client certificates”
або просто в кінці 2029 року додати в Trust Store новий сертифікат та видалити старий
Створення Trust Store
Для додавання сертифіката до ALB нам треба буде створити Trust Store, але в Trust Store додати сертифікат можна тільки з AWS S3 – тому спочатку завантажуємо в якусь свою корзину:
Ну і сам rtfm.co.ua вже теж на цьому ALB і має включений mTLS.
Захист з AWS Security Group та Cloudflare IP ranges
Але тільки mTLS для повноцінного захисту ALB все ж недостатньо:
при DDoS ми все-одно будемо витрачати LCU на підключення
запити, які пройдуть не через Cloudflare треба фільтрувати на бекенді – в моєму випадку NGINX
Тому самий надійний спосіб – це обмежити трафік на рівні AWS Security Group, яка підключена до Load Balancer: тоді ніяких LCU не буде витрачатись, бо пакети до ALB не дійдуть взагалі.
Є готові Terraform-модулі, наприклад – cloudflare-security-group, але він працює тільки з одною Security Group, є більш просунутий варіант – cloudflare-sg-updater, який шукає SG по тегам.
Втім, особисто я не дуже люблю AWS Lambda і, принаймні поки що, для RTFM не використовую Terraform – то зробив просто з shell-скриптом, який використовує AWS CLI.
AWS CLI на Amazon Linux вже йде в комплекті, треба тільки або налаштувати AWS Profile – або підключити EC2 Instance Profile з IAM-політикою, яка дасть дозвіл на внесення змін в Security Group.
Єдиний нюанс, який треба мати на увазі, якщо пишете скрипт самі: IP іноді змінюються, хоч і рідко, і їх треба видаляти з Security Group. Але при цьому просто видалити всі старі записи, а потім додати нові – не варіант, бо в цей момент ALB втратить підключення до Cloudflare.
Тому в скрипті виконується порівняння старих і нових адрес, і видаляються тільки ті, яких нема в останньому апдейті від Cloudflare.
Створення IAM Role для EC2 Instance profile
Рано чи пізно все одно треба буде додавати IAM Role для інстансу, тому замість використання AWS CLI профайл з ключами краще відразу робити доступ до редагування Security Group через EC2 Instance profile.
Для ролі потрібно дати права на виконання ec2:DescribeSecurityGroups, ec2:AuthorizeSecurityGroupIngress та ec2:RevokeSecurityGroupIngress – створимо окрему IAM Policy.
Створюємо нову Security Group для ALB – правила Inbound залишаємо пустими, в Outbound – залишаємо дефолтний “All to All”:
Створюємо нову IAM Policy, обмежуємо доступ тільки цією Security Group.
Для ec2:DescribeSecurityGroups задати обмеження не можна, бо правило глобальне для всіх SG, тому описуємо двома окремими Statement – один з Resource": "*", другий вже з конкретною SG.
Робимо з обмеженням по Resource: вказуємо обмеження доступу конкретною групою, бо скрипт буде виконувати destructive дії – видалення правил, і для таких дій на випадок “щось пішло не так” завжди краще обмежувати blust radius:
отримувати список актуальних Cloudflare IP CIDR (і IPv4, і IPv6)
з AWS CLI отримувати список поточних правил в Security Group
порівнювати нові адреси і значення з Security Group і додавати тільки ті адреси, яких зараз нема
і аналогічна перевірка для видалення адрес з Security Group – якщо IP нема в актуальному списку від Cloudflare, то видаляємо
Security Group одна, тому її просто задаємо на початку в змінну $SG_ID:
#!/bin/bash
# Update ALB security group with current Cloudflare IP ranges
SG_ID="sg-029ffc5f56be700ea"
PORT=443
# Fetch current Cloudflare IP ranges
CF_IPS=$(curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6)
# Get current IPs from security group
CURRENT_IPS=$(aws ec2 describe-security-groups --group-ids $SG_ID --query "SecurityGroups[0].IpPermissions[?FromPort==\`$PORT\`].[IpRanges[].CidrIp, Ipv6Ranges[].CidrIpv6]" --output text)
# Add missing IPs
for IP in $CF_IPS; do
if ! echo "$CURRENT_IPS" | grep -q "$IP"; then
echo "Adding $IP"
if echo "$IP" | grep -q ":"; then
aws ec2 authorize-security-group-ingress --group-id $SG_ID --ip-permissions "[{\"IpProtocol\":\"tcp\",\"FromPort\":$PORT,\"ToPort\":$PORT,\"Ipv6Ranges\":[{\"CidrIpv6\":\"$IP\"}]}]"
else
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port $PORT --cidr $IP
fi
fi
done
# Remove IPs no longer in Cloudflare list
for IP in $CURRENT_IPS; do
if ! echo "$CF_IPS" | grep -q "$IP"; then
echo "Removing $IP"
if echo "$IP" | grep -q ":"; then
aws ec2 revoke-security-group-ingress --group-id $SG_ID --ip-permissions "[{\"IpProtocol\":\"tcp\",\"FromPort\":$PORT,\"ToPort\":$PORT,\"Ipv6Ranges\":[{\"CidrIpv6\":\"$IP\"}]}]"
else
aws ec2 revoke-security-group-ingress --group-id $SG_ID --protocol tcp --port $PORT --cidr $IP
fi
fi
done
echo "Done"
Можна ще підписатись на Cloudflare changelog/RSS, аби отримувати нотифікації коли щось міняється в IP діапазонах.
Але зміна IP ranges прям дуже рідко буває – останній раз у 2023 році, див IP Ranges:
Власне, на цьому все.
Тепер можна спокійно спати: “frontend” у нас Cloudflare, де є Security rules, WAF і захист від DoS/DDoS, а на “бекенді” ми захищені на рівні мережі, L3/L4 – і Load Balancer та EC2 живуть спокійно.
P.S. Документація AWS – одна з тих речей, за що його дійсно люблю. Колись довелось багато працювати з Azure – і там це дійсно велика проблема. До речі, див. Azure: почему никогда.
Один NAT Gateway – це чверть витрат на AWS, і навіть маючи AWS Credits мене трошки душить жаба.
Є, звісно, варіант прибрати NAT Gateway зі схеми взагалі:
можна просто перевести EC2 в Public Subnet: насправді, описаний сетап з використанням AWS Load Balancer дійсно трохи overkill для проекту типу якогось невеликого блогу – але мені все ж хочеться мати “красиву” інфрастуктуру, побудовану “по канонам” та більш наближену до AWS Well-Architected Framework (свідомо без Reliability – тільки одна Availability Zone, та без infrastructure as code – див. AWS Well-Architected Framework Design Principles)
можна прибрати NAT Gateway взагалі – але залишити EC2 в Private Subnet:
по факту, в описаному сетапі NAT GW використовується тільки на самому початку, коли встановлюються пакети nginx, php та скачується архів з WordPress
але потім, по-перше – треба встановлювати апгрейди із зовнішніх репозиторіїв, по-друге – сам WordPress може ходити в інтернет при запуску своїх cron jobs або робити виклики з плагінів (цікавий приклад є в пості TCP/IP: SYN flood атака на сервер RTFM, та “Hacker News hug of death”, де плагін Page View Count постійно робив запити з серверу RTFM до Cloudflare)
Але є і третій варіант – це власний підняти “poor man’s NAT gateway“: просто запустити окремий мінімальний t3.nano EC2, на ньому мати Linux чи FreeBSD, а там налаштувати звичайний NAT.
Бо по суті AWS VPC можна розглядати по аналогії з домашньою мережею, де в ролі NAT Gateway виступає роутер – TP-Link, MikroTik тощо, і цей роутер можна замінити на будь-який ноутбук або ПК з Linux/FreeBSD та налаштувати там NAT з блекджеком і моніторингом.
В коментах до цього поста підсказали про проект fck-nat – вже готовий до використання AWS AMI, до того ж для fck-nat є Terraform модуль terraform-aws-fck-nat.
UPD: Картина в Costs Explorer через кілька днів:
AWS Managed NAT Gateway прибрано взагалі
додано t2.nano в ролі self-managed EC2 NAT Gateway
EC2 блога замінено з t3.medium на t3.small
Результат: 8-9 березня загальна вартість була рівно $5.00, 13-го стало $3.26.
Плюси-мінуси
Звісно, для якогось production сетапу якогось реального проекту простіше і – головне – надійніше мати стандартний AWS Managed NAT Gateway: zero геморою з апгрейдами, zero проблем з availability – за все відповідає AWS.
Якщо ж мати власний NAT Gateway – то це додатковий час на встановлення апгрейдів, моніторинг, ну і якщо такий інстанс впаде – то інстанси в приватних сабнетах залишаться без доступу в Internet.
Ну і якщо робити автоматизацію – то описати з Terraform один чи кілька NAT Gateways набагато простіше, ніж описувати налаштування окремого EC2 – фактично, достатньо просто вказати один параметр у VPC module resource.
Але якщо подивитись на вартість…
А шо по грошам?
Давайте порівняємо вартість AWS Managed NAT Gateway та власного NAT на t3.nano.
Порівнювати будемо і вартість самого інстансу, і трафіку, бо все рахується окремо.
NAT Gateway vs t3.nano: per-hour pricing
Перше, і саме відчутне – це погодинна оплата:
AWS Managed NAT Gateway: $0.048/година – це ~$32 в місяць
AWS EC2 t3.nano: $0.0059/год – це ~$4.3 на місяць
t3.nano в ролі NAT Gateway для якогось невеликого проекту – з головою: всі операції виконуються в ядрі системи, навантаження на CPU/RAM мінімальне. Єдине. на що варто звертати увагу, це network bandwidth – але для t3.nano маємо до 5 Gbps burst – з запасом, див. Amazon EC2 instance network bandwidth.
NAT Gateway vs t3.nano: per-gigabyte pricing
Тут є нюанс з тим, за що саме ми платимо при використанні AWS Managed NAT Gateway, див. Amazon VPC pricing:
NAT Gateway Data Processing Charge: 1 GB data went through the NAT gateway. The Data Processing charge will result in a charge of $0.045.
тобто кожен переданий гігабайт, незалежно від напрямку, ми платимо $0.045
Data Transfer Charge: This is the standard EC2 Data Transfer charge for internet traffic […] With the Data Transfer Out to Internet rate set at $0.09 per GB […]
і на додачу до NAT Gateway Data Processing – ми ще сплачуємо $0.09 за кожен гігабайт, яки віддали в інтернет
Отже, кожен переданий з VPC в інтернет через AWS Managed NAT Gateway гігабайт нам коштує $0.135.
Тоді як при використанні AWS EC2 instance в ролі NAT Gateway ми сплачуємо тільки Data Transfer Charge $0.09 per GB.
План дій
Отже, що будемо робити:
запустимо EC2 в публічній мережі
Security Group:
дозволяємо тільки out трафік – на вхід з інтернету нічого не має приходити
дозволяємо весь ingress з Private Subnets – наш EC2 буде передавати дані назовні
дозволяємо SSH з VPC або користуємось EC2 Instance Connect
на EC2 маємо якийсь Linux, на якому з iptables налаштовуємо NAT
в Route Tables для VPC і Private Subnets налаштуємо default route – 0.0.0.0/0 через Private IP цього EC2
Вибір операційної системи – в принципі, будь-який Linux, тут будемо робити з Amazon Linux, бо дійсно менше геморою ніж з Debian.
Хоча спочатку взагалі думав про FreeBSD – бо FreeBSD традиційно сильна саме в networking, але як вже основний сервер для самого блогу на Amazon Linux, то не буду розводити зоопарк.
На Amazon Linux 2023 по-дефолту встановлений nftables – але iptables теж є як wrapper, встановлюється з репозиторію окремим пакетом.
Створення NAT Gateway Security Group
Переходимо в VPC, додаємо нову групу:
Створення EC2
Задаємо ім’я, вибираємо операційну систему, тут буде Amazon Linux:
Вибираємо тип інстансу, вибираємо або створюємо ключ для SSH:
В Network settings вибираємо VPC, в Subnet вказуємо той, в якому “main EC2” з блогом – аби уникнути cross Availability Zone traffic (див. Overview of Data Transfer Costs for Common Architectures), та включаємо Auto-assign public IP – бо NAT Gateway повинен мати Public IP, на який буде отримувати пакети від клієнтів в інтернеті:
Тут все – запускаємо створення інстансу.
Після старту робимо важливу зміну в його налаштуваннях – треба виключити перевірку Source/Destination check: вона вказує EC2 приймати і відправляти тільки той трафік, де source або destination співпадає з його власним IP.
Для NAT це треба вимкнути, бо NAT instance по визначенню пересилає чужий трафік, де ні source, ні destination не є його власним IP:
Перевіряємо параметр net.ipv4.ip_forward, який задає ядру системи дозвіл на пересилання пакетів між мережевими інтерфейсами – саме це і є основою будь-якого NAT і роутингу:
[ec2-user@ip-10-0-1-79 ~]$ ip a s
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
...
2: enX0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc fq_codel state UP group default qlen 1000
...
-A POSTROUTING: додаємо правило в POSTROUTING chain – тобто правило спрацьовує після того, як ядро вже вирішило куди відправити пакет – але ще не відправило отримувачу
-o enX0: правило тільки для пакетів що виходять через інтерфейс enX0 (наш публічний інтерфейс)
-j MASQUERADE: підміняємо source IP пакету (Private IP інстансу EC2, на якому буде блог) на IP інтерфейсу enX0 – публічний IP нашого інстансу EC2 з NAT Gateway
В upgrade_type можна задати security, а не встановлювати всі – бо це все ж gateway:
...
# default = all available upgrades
# security = only the security upgrades
upgrade_type = security
...
apply_updates = yes
...
[email]
# TODO in the following rtfm.co.ua posts about setting up AWS
Про відправку email з AWS EC2 інстансів може буду писати окремо в частині по налаштуванню AWS Simple Email Service.
Прийшов час для мажорного апгрейду серверу RTFM, який зазвичай роблю переїздом на новий сервер, бо заодно роблю різні інші апгрейди, як-от версію PHP або навіть міграцію в інший клауд.
Цього разу планую переїжджати з DigitalOcean, де RTFM хоститься з 2020 року. До самого DigitalOcean претензій нуль – всі ці роки системи працювали бєз єдіного разриву (с) Антон Уральский, але, раптом, з’ясував, що в мене на AWS накопичилась купа AWS Credits, які мені як AWS Hero видають кожного року. При цьому за хостинг і бекапи в DigitalOcean я плачу живими гривнями і, в принципі, немало – близько 40 баксів в місяць.
Ну і RTFM в AWS вже колись хостився – з 2015, здається, до 2020 року.
Отже, що треба захостити – це невеликий блог WordPress, тому сетап буде без fault tolerance і high availability.
Робити будемо методом “clickops” – без Terraform, просто руками.
Чому без Terraform – бо, по-перше, мені цікаво глянути, що ж нового з’явилось в AWS Console, бо насправді не так часто туди заглядаю і тим більш щось роблю руками. По-друге – просто нема сенсу робити якусь автоматизацію, бо скоріш за все щось буду перероблювати-міняти і потім більше часу витрачу на зміни в коді, ніж на сам сетап. Ну і сама інфраструктура відносно маленька.
А оскільки це чисто особистий проект для хостінгу одного сайту і без всяких Dev/Staging/Prod оточень – то і сенсу тягнути сюди Terraform мало.
Та і насправді коли робив все описане нижче – ловив дуже приємні флешбеки в роки 2015-2016, коли тільки ще знайомився з AWS і мало користувався Terraform.
І навіть є якийсь особливий вайб в тому, щоб самому все створити ручками, а не просто описати в коді Terraforrm resources.
Втім, новий пост напишу, і буде він більшедля тих, хто тільки знайомиться з AWS і хоче побачити як можна побудувати базову інфрастуктуру для хостінг веб-сайта – або для тих, хто хочеться трохи ностальгії за тими часами, коли ми не все крутили в Кубернітісах 🙂
Намагався описати максимально стисло – але вийшло багато матеріалу.
Планування архітектури
Чисто базовий етап для майже будь-якого веб-сервісу – базова мережа, один EC2, один RDS, все в одній Availabilty Zone.
На EC2 буде Amazon Linux з NGINX та PHP-FPM, база даних блогу – AWS RDS MariaDB.
Спочатку планував Debian, бо система “поставив і забув”, але її використання в AWS потребує трохи геморою – а Amazon Linux працює просто з коробки.
EC2 буде в приватній мережі, без прямого доступу з інтернету, і спочатку для доступу до інстансу думав використати AWS SSM, який насправді ніколи толком не юзав, бо по роботі все в єтіх ваших Кубернетісах, але він прям overkill, потребує достатньо багато додаткових налаштувань – IAM Role та VPC Endpoints, що коштує додаткових грошей, тому все ж вирішив робити через AWS EC2 Instance Connect.
Для доступу до WordPress на EC2 в систему додамо AWS Load Balancer, до якого потім ще можна буде підключити AWS WAF.
І не буде робитись EC2 AutoScaling – бо це теж трошки overkill для маленького блогу. Правда, RDS, у якого мінімум 20 гіг диск при базі RTFM в 1.2 гіга теж таке собі, але нехай буде – подивимось на “традиційний” сетап подібної інфраструктури.
Отже, план такий
AWS Availability Zones:
всі ресурси (EC2 та RDS) будуть в одній AZ, але мережа має бути мінімум в двох
Network – VPC та Subnets:
створимо одну AWS VPC з чотирма Subnets у двох Availability Zones:
Public Subnets: тут будуть жити сервіси, яким треба мати Public IP – Load Balancer, NAT Gateway
Private Subnets: тут буде жити EC2 з WordPress та RDS з MariaDB
налаштуємо AWS Load Balancer для доступу до WordPress
EC2:
один сервер з Amazon Linux та NGINX та PHP-FPM
RDS:
мінімальний інстанс RDS з MariaDB – буде жити в приватному сабнеті в власною Secuity Group та автоматичними бекапами
Route 53:
для доступу до бази даних створимо окрему локальну DNS zone, яка буде доступна тільки в межах VPC
Security:
перший рівень захисту – це мережа: всі робочі ресурси будуть в приватних сабнетах
до них додамо Security Groups – для самого EC2, для AWS RDS і для Load Balancer,
пізніше можна буде глянути на AWS VPC NACL і потрогати AWS WAF – дуже давно з ним не працював
SSH та VPN і доступ до EC2:
EC2 Instance Connect для SSH
пізніше встановлю WireGuard і підключу до мого домашнього MikroTik та зможу підключатись по SSH вже напряму
І кілька слів по самому AWS.
Перше – вибір регіону: тут в першу чергу звертаємо увагу на локацію і клієнтів – якщо у нас основні клієнти в USA, то, логічно, вибираємо регіони там.
Другий момент, на який звертаємо увагу – ціна, бо в кожному регіоні ціни трохи відрізняються, хоч і не в рази.
Тому для RTFM візьму Ірландію (eu-west-1) – там і тихо (не літають шахеди, як в ОАЕ у 2026 році), і з європейських AWS Regions вона сама дешева.
Поїхали.
AWS Costs
Питання костів, коли працюєш з AWS, актуальне завжди.
Описаний нижче сетап вийшов в 5 USD/day, тобто 150 баксів на місяць – і це ще без урахування трафіку і додаткових сервісів типу бекапів, SSM та WAF.
В кінці буде детальний розбір по костам.
Ну і хоча пост називається “сетап базової інфраструктури” – але за великим рахунком для якогось персонального блогу він дуже overengineered: спокійно можна обійтися без приватних сабнетів, без AWS Apliaction Load Balancer, і навіть без AWS RDS. І якби я робив для RTFM і не мав вільних кредитів – то робив би набагато простіше.
Втім, якщо будувати щось більш “production grade”, то описана нижче інфраструктура як раз і є базовою, або, радше, “традиційною” – з розділенням мережевого доступу, з винесенням баз даних на окремий інстанс, з Load Balancer.
Створення VPC
Починаємо з основи всього – VPC.
VPC дасть нам ізоляцію, дасть можливість отримати доступ до ресурсів в приватних сабнетах, дасть можливість зекономити на трафіку – бо зможемо ходити до ресурсів AWS S3 через внутрішні ендпоінти, а не через інтернет.
Нам треба зробити:
Private Subnets: для EC2 та RDS
ще можна і бази даних винести в окремі subnets – але це вже точно поза “сетап базової інфраструктури для WordPress”, тому не робимо
Public Subnets: для Load Balancer та NAT Gateway
AWS ALB потребує мінімум 2 subnets, тому робити будемо у двох Availability Zones, хоча всі ресурси будуть жити тільки в одній.
Основні налаштування
В панелі створення VPC багато чого змінилось з того часу, як я тут щось робив руками – додалась можливість через “VPC and more” створити відразу все – спробуємо, як це працює.
Єдиний, як на мене, недолік в цій можливості “все і одразу” – не так добре розумієш що і для чого створюється, а створення якихось ресурсів взагалі проходить повз уваги: я, наприклад, тільки через декілька днів згадав, що в AWS VPC для Public Subnets створюється ще і Internet gateway.
Тому якщо вперше знайомишся з AWS і VPC, то в старому підході “робити все руками” все ж є сенс.
Якщо хочеться зробити “по-старому” – то описував цей процес ще у 2016 році, якихось кардинальних змін в побудові нетворку не було:
Автогенерація імен ресурсів – теж прикольна штука, і генерує достатньо адекватні імена як раз в тому стилі, як я це завжди робив – з іменем subnet type та Availability Zone:
Вибір CIDR важливий, особливо, якщо планується мати кілька VPC і між ними будувати “мости” у вигляді VPC Peering – треба розрахувати так, щоб адреси не перетинались.
Крім того, конкретно в моєму кейсі, треба враховувати майбутній VPN, у якого власна мережа для клієнтів – 10.100.0.0/24.
AWS по дефолту пропонує 10.0.0.0/16 – можна так і залишити, хоча, звісно, для такого проекту адрес буде забагато.
Але, головне, щоб ця мережа не перетинається з 10.100.0.0/24, бо в 10.0.0.0/16 входять адреси від 10.0.0.0 до 10.0.255.255.
тут я вже почав писати про розрахунок адрес, але вийшло таке полотенце тексту, що вирішив його винести окремим матеріалом
Отже, залишаємо дефолтний блок 10.0.0.0/16:
IPv6 нам не треба, пропускаємо.
Tenancy – щось на дуже дорогому: можливість запускати всі свої EC2 на виділеному для AWS Account hardware серверах, зараз це точно не треба, див. Amazon EC2 Dedicated Instances.
VPC encryption control – щось нове, дозволяє включити контроль використання plaintext трафіку в мережі, нам не треба, пропускаємо.
Number of Availability Zones задаємо у 2, це мінімум для ALB:
Створення VPC Subnets
Далі треба налаштувати сабнети двох типів і створити по одному сабнету кожного типу в кожній Availability Zone.
Перший блок /24 я залишаю “резервним”, да і виглядає так красивіше:
Regional NAT Gateways – нова плюшка від AWS, не так давно з’явилась – дозволяє повністю автоматизувати створення NAT Gateways в нових Availablity Zones, не потребує Public Subnets, автоматично апдейтить Route Tables.
Але коштує, звісно, дорожче, та і взагалі в цьому сетапі не потрібна.
Створюємо класичний Zonal NAT Gateway і тільки в одній Availability Zone:
Завершення налаштувань VPC
VPC Endponts – залишаємо дефолтний S3, бо в мене, наприклад, до S3 пишуться бекапи блогу. Пізніше ще додамо новий, для EC2 Instance Connect.
DNS Options залишаємо включеними – штука корисна і грошей не просить:
DNS hostnames: чи створювати “локальні” імена, наприклад – ip-10-0-3-226.eu-west-1.compute.internal – потрібно, аби коректно працювали RDS, EFS та інші мережеві ресурси
І в результаті маємо таку картину (раніше теж не було, дуже зручно, і, здається, навіть route tables і маршрути створювались руками):
Все – поїхали створювати, займе трохи часу – міжна поки зробити чай.
За кілька хвилин – все готово:
Створення Security Groups
Зробимо три окремі Security Groups – для EC2, для RDS, та для Load Balancer:
В Security Group для EC2 дозволяємо SSH в межах VPC, дозволяємо HTTP від Public Subnets – там будуть інстанси Load Balancer (які, по суті, під капотом являють собою звичайні AWS EC2 – як і для AWS RDS):
Для SSH можна зробити більш суворі правила – дозволити тільки з VPN CIDR та VPC Private Subnet в eu-west-1a, де пізніше буде створюватись EC2 Instnace Connect Ednpoint – але це вже можна буде підтюнити пізніше, коли все буде працювати.
Аналогічно створюємо Security Group для Load Balancer – тут дозволяємо весь In на порти 80 та 443:
І для RDS – відкриваємо порт 3306 з Private Subnets, бо окрім EC2 сюди ніхто ходити не має:
Створення EC2 Instance Connect Endpoint
Коли планував робити з сервер на Debian, то думав доступ робити через AWS SSM – але для SSM потребує аж трьох VPC Endpoints, і за кожен треба платити гроші.
Вибираємо створену вище VPC. Опція “Preserve Client IP” – штука прикольна, передає клієнтський IP замість адреси самого Endpoint – можна буде спробувати пізніше, поки залишаємо в дефолтному “off”:
Створюється довго, хвилин 5 – як раз встигнемо зробити ще чаю та запустити EC2.
Створення EC2
Вибираємо Amazon Linux, задаємо ім’я інстансу:
Вибір типу інстансу та підрахунок потрібної пам’яті
Дуже коротко про типи, бо матеріал і так виходить великий, детальніше див. Amazon EC2 instance types.
Всі AWS EC2 діляться на кілька основних типів інстансів:
general purpose: збалансовані по CPU/RAM та вартості типи
сюди входять і burstable type, такі як t3/t4 – доступ до CPU більш обмежений, але на короткий час може, власне, burst – видаватись 100% процесорного часу, див. CPU Credsts, див. Key concepts for burstable performance instances
приклад general purpose:
t3.medium – 2 vCPU, 4 GiB RAM, ~ 30 USD/month
t3.large: 2 vCPU, 8 GiB RAM, ~ 60 USD/month
m5.large: 2 vCPU, 8 GiB RAM, ~ 69 USD/month
compute optimized: “заточені” під CPU – більше CPU, менше RAM:
Цифри 3/4/5/6 etc – покоління інстансів, чи вище цифра – тим новіше залізо “під капотом”, плюс можливості самого AWS (наприклад, в старих t2 нема підтримки підключення з serial console).
Плюс кожен тип має “підтипи”:
g: процесори Graviton – процесори від самого AWS на архітектурі – можуть бути не з усім сумісні, але використання ~20-30% дешевше, ніж у звичайних типів інстансів ARM при вищій швидкості виконання задач
i: процесори Intel – Intel Xeon, Intel Ice Lake
a: процесори AMD – AMD EPYC
n: окремий модифікатор, “network” – вищий network bandwidth, наприклад, інстанси R6in – Intel Network
Аби прикинути споживання пам’яті кожним можемо глянути в RSS (Resident Set Size), реальна фізична пам’ять процесу – але сюди включається пам’ять на shared бібліотеки, тобто якщо кілька PHP-FPM workers використовують одну і ту ж libc – RSS кожного включає її повністю і сумарний RSS буде завищений.
Втім – нехай буде завищена, бо ми прикидуємо “найгірший” варіант.
Видаляємо перший сертифікат, повторюємо процес створення та валідації – і тепер все готово:
Створення AWS Load Balancer
Використання Load Balancer дасть змогу, власне, балансувати навантаження – якщо планується мати кілька інстансів, то зручно мати один і той статичний URL, який можна використати як CNAME для домену, буде можливість додавати або замінювати інстанси без необхідності внесення змін в DNS домену, і дасть можливість використання AWS Web Application Firewall.
Плюс, ALB спрощує менеджмент SSL/TLS – один раз створюємо сертифікат, підключаємо його до ALB, і SSL termination буде на Load Balancer: клієнти до Load Balancer ходять з HTTPS, а Load Balancer до EC2 по HTTP – простіший конфіг NGINX, нема потреби в налаштуванні Let’s Encrypt.
Хоча за великим рахунком, якщо планується дійсно невеликий персональний сайт – то ALB теж overkill. Втім, якщо є зайві кредити, то його використання дійсно спрощує життя.
Створення Target Group
Load Balancer працює через Target Groups (TG), де кожна TG включає в себе один або декілька EC2 на які ALB буде слати трафік.
Створюємо нову Target Group:
В Type вказуємо Instance, задаємо ім’я групи та протокол, за яким буде відбуватись комунікація ALB з сервісами на EC2 в цій групі.
На EC2 у нас NGINX який приймає підключення на порт 80 – тому в Protocol та Port залишаємо дефолтні параметри:
Якщо на ALB плануємо використовувати і HTTP і HTTPS – то вказуємо HTTP/1, якщо тільки HTTPS – то можна HTTP/2.
Хоча зазвичай для HTTP просто налаштовується redirect на HTTPS і можна було б тут відразу вказати HTTP2 – але деякі клієнти все ще можуть використовувати HTTP v1 – тому залишимо їм право вибору і залишаємо дефолтну опцію HTTP1:
В Health checks можна залишити все як є – Health check path на NGINX буде “/”, Traffic port буде 80:
Вибираємо інстанс(и) для цієї Target Group:
Підтверджуємо створення:
Типи Load Balancers: ALB vs NLB vs GLB vs CLB
Amazon дозволяє створити кілька типів Load Balancer:
Application Load Balancer:
модно-молодьожно
працює на L7 (HTTP/HTTPS), може читати зміст HTTP-запиту і мати окремі налаштування по, наприклад, URI (/api/ – слати на одну Target Group, /users/ – слати на Auth0 тощо)
підтримує роботу з WebSocket, gRPC
Network Load Balancer:
працює на L4 (TCP/UDP) – дуже швидкий, чудовий вибір для high load applications, має можливість використання Static IP
Gateway Load Balancer:
доволі специфічна штука, для роутингу трафіку через сторонні network appliances (firewall, IDS/IPS), я ніколи не користувався
Окремо загадаємо про Classic Load Balancer – легасі, deprecated. Підтримує і L4 і L7 але гірше ніж ALB/NLB окремо.
Задаємо ім’я, тип Internet-facing (тип Internal – корисна опція, коли треба мати ALB, який доступний тільки всередині VPC):
Вибираємо VPC, Subnets та Secuiruty Group, яку робили на початку:
Для HTTP Listener налаштовуємо редірект на HTTPS:
А в HTTPS Listener підключаємо створену вище Target Group:
Підключаємо SSL сертифікат із ACM:
Перевіряємо, що все ОК і створюємо:
Створення займе кілька хвилин – робимо ще один чай.
Налаштування DNS для ALB
Поки створюється ALB – додамо новий запис в Route 53, який буде прив’язаний до створеного Load Balancer.
Тут приклад на іншому домені, але він в AWS ACM був доданий, тому буде працювати без помилок TLS.
Створюємо новий DNS Record, вибираємо тип Alias, знаходимо наш ALB, Routing policy залишаємо Simple (див. Choosing a routing policy):
Перевіряємо, чи все працює (може зайняти 5-10 хвилин на апдейт DNS):
Створення AWS Relational Database Service
Останній крок перед запуском WordPress – створити сервер даних.
З AWS RDS працюю дуже давно, сервіс класний, хоча, звісно, не безкоштовний. Але “перекласти відповідальність” за стабільність і бекапи на плечі AWS – чудове рішення для якогось production.
Плюс інтеграція з AWS IAM, CloudWatch Logs та Metrics, автоматичні бекапи, автоскейлінг – можливостей багато.
Створюємо новий сервер (хоча меню називається “Create database” – але створюється саме окремий інстанс):
В Credentails management можна залишити дефолтний AWS Secrets Manager – він вміє автоматично ротейтити пароль root для сервера, див. Set up automatic rotation for Amazon RDS:
Залишаємо опцію Password and IAM database authentication – хоча інтеграція IAM обмежує доступ тільки до самого серверу, а не баз даних, і все одно треба буде створювати юзера з власними правами доступу і паролем, див. AWS: RDS з IAM database authentication, EKS Pod Identities та Terraform.
Тип інстансу вибираємо мінімально доступний, db.t3.micro – хоча для RTFM і цього вистачить з великим запасом:
Розмір диску – мінімум 20 гігабайт, що при розмірі БД у RTFM в 1.2 гігабайти теж з головою.
Корисна штука для production – storage autoscaling: працює повністю непомітно для сервера і клієнтів:
В Connectivity можна автоматично налаштувати підключення до EC2 – створить всі необхідні параметри в VPC та Subnets, але давайте хоч тут зробимо вручну.
DB Subnet Group – створюємо нову, RDS сам вибере потрібні private subnets, бо далі, в Public access, ми задаємо “No” – сервер баз даних має жити тільки в приватних мережах, без доступу у світ.
У VPC Security Group вибираємо групу, яку створювали на початку:
В Additional monitoring settigns – інтересу раді можна включити Enhanced monitoring, це коштує додаткових грошей – але в production може дуже знадобитись, бо додає метрики по роботі операційної системи (CPU per process, RAM, disk I/O, network, file system), див. довгий пост по PostgreSQL: AWS RDS Performance and monitoring – був цікавий випадок, коли Enhanced monitoring знадобився:
Additional configuration – тут відразу можемо створити базу даних і налаштувати автоматичні бекапи.
Автоматичні бекапи (Periodic snapshots) – дуже рекомендована штука, рятувала не один раз: створює повний snapshot інстансу, і потім з цього снапшоту можна в будь-який момент створити новий інстанс з усіма даними.
До того для RDS є можливість налаштувати Continuous backups – для відновлення стану баз(и) на якийсь конкретний момент часу, див. Amazon Relational Database Service backups.
Базу створимо пізніше вручну, залишаємо бекапи:
І в кінці відразу бачимо приблизну вартість – раніше не було цього, зручно зробили:
DNS та Private hosted zone
Корисна з точки зору безпеки штука – приватні доменні зони, які доступні тільки всередині VPC, див. Working with private hosted zones.
Тому створимо окрему зону з DNS Records, які потрібні тільки в VPC, в нашому випадку – Database URL як раз чудовий приклад:
Знаходимо URL в самому RDS:
Додаємо його як value в CNAME нового запису:
Підключення до RDS
Підключаємось по SSH до EC2, шукаємо пакет mariadb:
[ec2-user@ip-10-0-3-146 ~]$ dnf search mariadb
...
mariadb114.x86_64 : A very fast and robust SQL database server
...
Аби встановити тільки клієнт – вибираємо mariadb без -server:
І підключаємось, використовуючи local DNS record, який створили вище:
[ec2-user@ip-10-0-3-146 ~]$ mysql -h db.rtfm.local -P 3306 -u rtfm_root -p
Enter password:
...
MariaDB [(none)]>
Або можна це трохи автоматизувати з AWS CLI – в RDS є приклад команди:
Запуск WordPress
Ну і нарешті – у нас все готово для запуску WordPress.
Що нам залишилось – це створити базу даних, юзера, і на EC2 встановити PHP.
Створення бази даних в RDS
Створюємо базу даних і юзера – для WordPress рекомендовано utf8mb4_unicode_ci (підтримка всяких емодзі):
MariaDB [(none)]> CREATE DATABASE test_wp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Query OK, 1 row affected (0.064 sec)
MariaDB [(none)]> CREATE USER 'test_wp_user'@'%' IDENTIFIED BY 'test_wp_pass';
Query OK, 0 rows affected (0.058 sec)
MariaDB [(none)]> GRANT ALL PRIVILEGES ON test_wp_db.* TO 'test_wp_user'@'%';
Query OK, 0 rows affected (0.034 sec)
MariaDB [(none)]> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.027 sec)
Установка PHP та модулів
Встановлюємо PHP і модулі – хоча тут не всі модулі, не пам’ятаю, що треба ще для роботи RTFM, але це базовий набір для WordPress та, в принципі, будь-якого вебсайту:
Для перевірки PHP створюємо тестовий файл /var/www/html/index.php:
<?php phpinfo(); ?>
Виконуємо nginx check config && reload:
[root@ip-10-0-3-146 ~]# nginx -t && systemctl reload nginx
nginx: [warn] conflicting server name "_" on 0.0.0.0:80, ignored
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
І перевіряємо файл index.php в браузері:
Установка WordPress
Завантажуємо архів, розпаковуємо, міняємо власника та групу на nginx:nginx:
Відкриваємо в браузері – не завантажуються CSS та картинки.
Але це ОК, далі поправимо, не критично.
Критично буде далі з RDS – тому спочатку дочитайте цю частину:
Клікаємо Let’s go, задаємо параметри підключення до RDS:
І ловимо помилку “Error establishing a database connection“:
Ну… 🙂
WordPress users know that feeling 🙂
AWS RDS та WordPress “Error establishing a database connection”
Перше, що можна спробувати – це створити файл wp-config.php вручну і задати параметри явно:
...
// ** Database settings - You can get this info from your web host ** //
/** The name of the database for WordPress */
define( 'DB_NAME', 'test_wp_db' );
/** Database username */
define( 'DB_USER', 'test_wp_user' );
/** Database password */
define( 'DB_PASSWORD', 'test_wp_pass' );
/** Database hostname */
define( 'DB_HOST', 'db.rtfm.local' );
/** Database charset to use in creating database tables. */
define( 'DB_CHARSET', 'utf8mb4' );
...
Але в цьому конкретному випадку це не допоможе.
Тому встановлюємо php-cli:
[root@ip-10-0-3-146 html]# dnf install -y php-cli
І спершу перевіряємо, чи працює DNS Resolver на нашу приватну DNS zone:
Тепер пробуємо MySQL connect – і от тут вже ловимо саму помилку – “Connections using insecure transport are prohibited“:
[root@ip-10-0-3-146 html]# php -r "mysqli_connect('db.rtfm.local', 'test_wp_user', 'test_wp_pass', 'test_wp_db') or die(mysqli_connect_error());"
PHP Fatal error: Uncaught mysqli_sql_exception: Connections using insecure transport are prohibited while --require_secure_transport=ON. in Command line code:1
Stack trace:
#0 Command line code(1): mysqli_connect()
#1 {main}
thrown in Command line code on line 1
Тут два варіанти вирішення – або в wp-config.php примусово включити SSL для підключення (рекомендується):
Болюча тема для будь-якого IaaS/PaaS провайдеру – будь-то Google Cloud Engine, Microslop Microsoft Azure чи AWS.
Коротко пройдемось по описаному вище сетапу – що і скільки в результаті коштує по грошам.
В Cost Explorer бачимо таку картину:
5 доларів на день, за 30 днів буде 150 доларів.
Ну – дуже не слабо, як для приватного блогу. Але і описана вище інфраструктура трохи завелика для такого проекту.
Подивимось що саме нам так дорого обходиться.
EC2-Other costs
Часте питання – “що за EC2 Other в Cost Explorer“, бо не дуже зрозуміла назва.
Фактично, сюди входять Public IP адреси, трафік, EBS volumes.
Подивитись що конкретно нам обходиться в $1.29 можна в тому ж Cost Explorer – у Filters > Service вибираємо EC2 Other, а в Group by > Dimension вибираємо Usage type або API Operation:
Власне, бачимо вартість NAT Gateway.
Деталі заходимо в документації Amazon VPC pricing, і рахуємо: $0.045 на годину, множимо на 24 і маємо 1.080 долари на добу, або 30+ доларів на місяць.
І це ще без трафіку через NAT Gateway, який рахується окремо – $0.048 за кожен гігабайт в cross-region або cross AvailabilityZone трафіку.
Власне, саме з цієї причини варто мати VPC Endpoint для S3: тип gateway безкоштовний, зато трафік буде йти не через NAT Gateway – а всередині VPC.
EBS Volumes costs
На скріншоті вище бачимо API Operation CreateVolume-Gp3 – це вартість EBS, який підключений до EC2, див. Amazon EBS pricing: 50 гігабайт диск дає нам $4.4 на місяць, або $0.147/день.
Диск для RDS рахується окремо:
EC2-Instances costs
Тут все просто – маємо один t3.medium, який коштує $0.0416 – маємо $0.99 в день.
Але до того ж і тут рахується трафік – він $0.04 до $0.09 за гігабайт outgoing в залежності від об’єму.
Вхідний трафік не оплачується.
Втім, є свої нюанси з трафіком:
ALB: трафік, який віддаємо клієнтам через Load Balancer – оплачується
NAT Gateway: тут взагалі платимо двічі:
NAT GW processing fee: це з costs самого NAT Gateway, аза кожен переданий через нього назовні гігабайт
EC2 Data Transfer Out: і додатково платимо за кожен гігабайт “у світ” з самого EC2
RDS: дані в межах одної Availability Zone не оплачуються, але якщо є cross-AZ або cross-region сетап – то платимо $0.01/GB за вхідний і вихідний трафік
платимо за LCU (Load Balancer Capacity Units) – навантаження на ALB, загальна вартість буде залежати від того, скільки ALB опрацював запитів від клієнтів (або під час DDoS :trollface: )
платимо за outgoing трафік – але тільки за трафік з ALB, бо трафік між EC2 та ALB в межах одної Availability Zone безкоштовний