В общем-то это продолжение моей навязчивой идеи создать нормальный стек для RTFM.
Первые попытки я начал ещё год тому, когда разбирался с CloudFormation вообще (раз, два, три).
В прошлую субботу – немного повспоминал CloudFormation на примере создания шаблона для стека Jenkins, который и будет заниматься провиженом всего окружения (ибо по работе всё это время приходилось иметь дело в основном с Azure Resource Manager).
Jenkins будет иметь две основные задачи – развёртывание AWS CloudFormation стека с инфрастуктурой и применение Ansible плейбука для сетапа и настройки сервисов на серверах.
Содержание
Описание инфрастуктуры и стека
В целом – инфрастуктура получается следующая:
(рисовалось в https://cloudcraft.co)
По ходу реализации – может поменяется, но на данный момент предполагаю такую схему.
В стеке будут 3 EC2 интанса:
- nginx: Bastion хост с NGINX для проксирования и редиректа трафика.
На нём же – PSAD для определения сканирования портов и Fail2ban – для бана попыток брутфорса SSH и прочих вредных действий.
В публичной сети, с выделенным AWS Elastic IP. - php-fpm: основной хост для сервисов. Т.к. RTFM работает на WordPress – то PHP, PHP-FPM. Тут же, наверно, будет крутится Prometheus, веб-интерфейсы типа Postfixadmin и что-там-у-меня-ещё-есть (часть сервисов типа почты вообще до сих пор, ещё года с 2012-го, работают в Дата-Центре “Воля”, когда-то надеюсь мигрировать и их в AWS).
В приватной сети, доступ через Bastion хост.
Назовём его Services. - mariadb – собственно, хост сервера баз данных MariaDB (почему не AWS RDS? Ибо деньги, см тут>>>).
В приватной сети, доступ через Bastion хост.
Назовём его просто DB.
Долго думал – использовать ли Docker Compose/Swarm или вообще AWS ECS – но решил не городить лишнего. Простой блог на WordPress, достаточно обычных EC2 с сервисами.
Все EC2 интансы будут иметь дополнительный data-диск с данными – Elastic Block Store. Бекапиться данные будут через EBS-snapshot-ы. Базы данных – наверно просто скриптом с mysqldump
в корзину AWS S3.
Конфиги NGINX (Ansible шаблоны) – в репозитории, апдейты через Jenkins и Ansible.
“Замочки” на схеме – Security Groups и, может быть, ACL для VPC.
Справа – список того, что хотелось бы добавить после того, как сделаю (ну – я ж таки надеюсь) основное:
- CloudFront – для изображений, ибо в блоге их достаточно много (плагинов для WordPress и CloudFront – море)
- CloudWatch – собирать логи с EC2-инстансов (см. тут>>>)
- CloudWatch – для метрик с EC2, которые потом будут експортится в Prometheus (
cloudwatch_exporter
) - Ещё один EC2 для Prometheus (или запускать его на хосте с php-fpm, с ещё одним EBS разделом для данных самого Prometheus)
- Route 53 – мигрировать управление доменами и управлять ими через приватный репозиторий в Bitbucket (или всё-таки купить Github аккаунт полноценный?), Jenkins и CloudFormation шаблоны
- EC2 Systems Manager – для управления и апдейтов серверов и сервисов
Jenkins EC2 – в отдельном стеке.
В общем – планов море, осталось найти время и настроение для реализации 🙂
Сегодня – начну писать шаблон для основного стека.
EBS разделы для интансов – создадим руками, т.к. предполагается что при развёртывании стека CloudFormation будет подключать уже имеющиеся разделы с данными к создаваемым EC2 интансам.
Подготовка
Создание EBS
Диск для Bastion (nginx) – тип Magnetic (хотя он уже старый, но NGINX большой I/O е нужен, а выйдет немного дешевле):
[simterm]
$ aws ec2 create-volume --availability-zone eu-west-1a --size 8 --volume-type standard --tag-specifications 'ResourceType=volume,Tags=[{Key=Name,Value=rtfm-dev-bastion-data},{Key=Env,Value=rtfm-dev}]'
[/simterm]
Диск для хоста Services (php-fpm), в которо будут хранится файлы WordPress и других сайтов, тип gp2
:
[simterm]
$ aws ec2 create-volume --availability-zone eu-west-1a --size 8 --volume-type gp2 --tag-specifications 'ResourceType=volume,Tags=[{Key=Name,Value=rtfm-dev-services-data},{Key=Env,Value=rtfm-dev}]'
[/simterm]
И для DB – тоже gp2
:
[simterm]
$ aws ec2 create-volume --availability-zone eu-west-1a --size 8 --volume-type gp2 --tag-specifications 'ResourceType=volume,Tags=[{Key=Name,Value=rtfm-dev-db-data},{Key=Env,Value=rtfm-dev}]'
[/simterm]
Стоимость EBS разделов – тут>>>, описание – тут>>>.
Проверяем:
[simterm]
$ aws ec2 describe-volumes --filters Name=tag-value,Values=rtfm-dev --query 'Volumes[*].{ID:VolumeId,Tag:Tags}' --output text vol-0fc9af657fd7c35c9 TAG Name rtfm-dev-bastion-data TAG Env rtfm-dev vol-03e393869e1b03cb7 TAG Env rtfm-dev TAG Name rtfm-dev-services-data vol-0301aba1a28e0c3b5 TAG Env rtfm-dev TAG Name rtfm-dev-db-data
[/simterm]
CloudFormaion
VPC
Можно взять готовый шаблон из набора AWS отсюда>>>, например – A single Amazon EC2 in an Amazon VPC.
Но т.к. там много лишнего и большую часть всё равно надо будет удалить – я напишу свой (велосипед).
Получившийся шаблон можно посмотреть тут>>>.
Для начала потребуются такие ресурсы:
- VPC
- Subnets
- Internet Gateway
Начинаем с параметров:
{ "AWSTemplateFormatVersion" : "2010-09-09", "Description" : "AWS CloudFormation Template EC2", "Parameters" : { "VPCCIDRBlock": { "Description": "VPC CidrBlock", "Type": "String", "Default": "10.0.0.0/16" }, "PublicSubnetCIDR": { "Description": "Public Subnet CIDR", "Type": "String", "Default": "10.0.1.0/24" }, "PrivateSubnetCIDR": { "Description": "Private Subnet CIDR", "Type": "String", "Default": "10.0.129.0/24" }, "ENV": { "Description": "Environment type. rtfm-dev or rtfm-production.", "Type": "String", "Default": "rtfm-dev" } }, ...
Единственный тут интересный параметр – это ENV, с помощью которого потом можно будет сформировать AWS Resource Group.
Далее – добавляем ресурсы.
Для начала добавим VPC, две подсети и Internet Gateway.
AWS::EC2::VPC
... "Resources" : { "VPC" : { "Type" : "AWS::EC2::VPC", "Properties" : { "CidrBlock" : {"Ref" : "VPCCIDRBlock"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "vpc"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } },
Тут из интересного – тег {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "vpc"] ] } }
.
Для формирования тега Name – используем функцию Fn::Join
, которая использует разделитель “-” и два значения – {“Ref” : “AWS::StackName”} и “vpc” (или “public-net” и т.д.).
AWS::StackName
– является псевдо-параметром, который вернёт имя стека. Т.е. – тег полностью будет выглядеть как “rtfm-dev-vpc” (если стек называется rtfm-dev).
AWS::EC2::Subnet
Теперь – две подсети:
... "PublicSubnet" : { "Type" : "AWS::EC2::Subnet", "Properties" : { "VpcId" : { "Ref" : "VPC" }, "CidrBlock" : {"Ref" : "PublicSubnetCIDR"}, "AvailabilityZone" : {"Ref" : "AvalabilityZone"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-net"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, "PrivateSubnet" : { "Type" : "AWS::EC2::Subnet", "Properties" : { "VpcId" : { "Ref" : "VPC" }, "CidrBlock" : {"Ref" : "PrivateSubnetCIDR"}, "AvailabilityZone" : {"Ref" : "AvalabilityZone"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "private-net"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, ...
AWS::EC2::InternetGateway
И Internet Gateway, для доступа в Интернет из VPC:
... "InternetGateway" : { "Type" : "AWS::EC2::InternetGateway", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "igw"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } } ...
Полностью шаблон сейчас выглядит так:
{ "AWSTemplateFormatVersion" : "2010-09-09", "Description" : "AWS CloudFormation Template EC2", "Parameters" : { "VPCCIDRBlock": { "Description": "VPC CidrBlock", "Type": "String", "Default": "10.0.0.0/16" }, "PublicSubnetCIDR": { "Description": "Public Subnet CIDR", "Type": "String", "Default": "10.0.1.0/24" }, "PrivateSubnetCIDR": { "Description": "Private Subnet CIDR", "Type": "String", "Default": "10.0.129.0/24" }, "ENV": { "Description": "Environment type. rtfm-dev or rtfm-production.", "Type": "String", "Default": "rtfm-dev" } }, "Resources" : { "VPC" : { "Type" : "AWS::EC2::VPC", "Properties" : { "CidrBlock" : {"Ref" : "VPCCIDRBlock"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "vpc"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, "PublicSubnet" : { "Type" : "AWS::EC2::Subnet", "Properties" : { "VpcId" : { "Ref" : "VPC" }, "CidrBlock" : {"Ref" : "PublicSubnetCIDR"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-net"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, "PrivateSubnet" : { "Type" : "AWS::EC2::Subnet", "Properties" : { "VpcId" : { "Ref" : "VPC" }, "CidrBlock" : {"Ref" : "PrivateSubnetCIDR"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "private-net"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, "InternetGateway" : { "Type" : "AWS::EC2::InternetGateway", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "igw"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } } } }
Выполняем validate-template
:
[simterm]
$ aws cloudformation validate-template --template-body file://rtfm_stack_2017.json { "Parameters": [ { "ParameterKey": "PublicSubnetCIDR", "DefaultValue": "10.0.1.0/24", "NoEcho": false, "Description": "Public Subnet CIDR" }, { "ParameterKey": "PrivateSubnetCIDR", "DefaultValue": "10.0.129.0/24", "NoEcho": false, "Description": "Private Subnet CIDR" }, { "ParameterKey": "VPCCIDRBlock", "DefaultValue": "10.0.0.0/16", "NoEcho": false, "Description": "VPC CidrBlock" }, { "ParameterKey": "ENV", "DefaultValue": "rtfm-dev", "NoEcho": false, "Description": "Environment type. rtfm-dev or rtfm-production." } ], "Description": "AWS CloudFormation Template EC2" }
[/simterm]
Запускаем создание стека create-stack
с опцией --disable-rollback
, что бы иметь возможность почитать ошибки:
[simterm]
$ aws cloudformation create-stack --disable-rollback --stack-name rfm-dev --template-body file://rtfm_stack_2017.json { "StackId": "arn:aws:cloudformation:eu-west-1:264418146286:stack/rfm-dev/d9da51b0-ab4b-11e7-8947-50a686333cfd" }
[/simterm]
Проверяем:
VPC:
Подсети:
Обратите внимание на Volumes – там три раздела с тегом rtfm-dev, созданные в начале.
Собственно, в этом и суть групп ресурсов – просто удобное отображание ресурсов, связанных чем-то общим, в данном случае – группировка по тегу.
Продолжаем с VPC.
Во-первых – надо добавить параметр Availability zone, что бы все ресурсы создавались в одной зоне (в особенности – EC2, к которым потом надо будет подключать EBS разделы):
... "AvalabilityZone": { "Description": "Avalability Zone for all resources", "Type": "String", "Default": "eu-west-1a" } ...
В ресурсы Prublic и Private подсетей добавляем property AvailabilityZone
:
... "PublicSubnet" : { "Type" : "AWS::EC2::Subnet", "Properties" : { "VpcId" : { "Ref" : "VPC" }, "CidrBlock" : {"Ref" : "PublicSubnetCIDR"}, "AvailabilityZone" : {"Ref" : "AvalabilityZone"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-net"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, ...
Ещё и только сейчас заметил, что опечатался в имени стека – rfm-dev >.<
ОК – удаляем его:
[simterm]
$ aws cloudformation delete-stack --stack-name rfm-dev
[/simterm]
Создаём с нормальным именем:
[simterm]
$ aws cloudformation create-stack --disable-rollback --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json { "StackId": "arn:aws:cloudformation:eu-west-1:264418146286:stack/rtfm-dev/d232ebf0-ab52-11e7-ad6c-500c3d7e5a8d" }
[/simterm]
Продолжаем.
Сейчас есть ресурсы VPC, две посети и IGW.
Дальше потребуются AWS::EC2::VPCGatewayAttachment
, AWS::EC2::NatGateway
для доступа из приватной сети, AWS::EC2::EIP
для NAT Gateway, AWS::EC2::RouteTable
ресурсы AWS::EC2::Route
(описание маршрутов) и AWS::EC2::SubnetRouteTableAssociation
.
Вроде всё?
Начнём добавлять по одному.
AWS::EC2::VPCGatewayAttachment
В Ресурсы добавляем подключение IGW к VPC:
... "AttachGateway" : { "Type" : "AWS::EC2::VPCGatewayAttachment", "Properties" : { "VpcId" : { "Ref" : "VPC" }, "InternetGatewayId" : { "Ref" : "InternetGateway" } } }, ...
Validate:
[simterm]
$ aws cloudformation validate-template --template-body file://rtfm_stack_2017.json
[/simterm]
И выполняем апдейт:
[simterm]
$ aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json
[/simterm]
Проверяем статус:
[simterm]
$ aws cloudformation describe-stacks --stack-name rtfm-dev --query 'Stacks[*].StackStatus' [ "UPDATE_COMPLETE" ]
[/simterm]
AWS::EC2::EIP
Получаем Elastic IP, который будет использоваться NAT Gateway:
... "IPAddress" : { "Type" : "AWS::EC2::EIP", "Properties" : { "Domain" : "vpc" } } ...
AWS::EC2::NatGateway
(примечание: тут я промахнулся с подсетью – NAT GW должен быть в публичной сети, а я скопипастил имя PrivateSubnet – "SubnetId" : { "Ref" : "PrivateSubnet"}
– ниже показано как исправлял)
Добавляем AWS::EC2::NatGateway
:
... "NAT" : { "DependsOn" : "IPAddress", "Type" : "AWS::EC2::NatGateway", "Properties" : { "AllocationId" : { "Fn::GetAtt" : ["IPAddress", "AllocationId"]}, "SubnetId" : { "Ref" : "PrivateSubnet"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "natgw"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } } ...
Можно сделать ещё раз stack update:
[simterm]
$ aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json
[/simterm]
Всё создалось, хорошо, продолжаем.
AWS::EC2::RouteTable
Далее требуется две Route Table – для публичной и приватной подсетей.
Начнём с таблицы для публичной сети: нам потребуется добавить саму таблицу, маршрут для подсети PublicSubnet в 0.0.0.0 через созданный InternetGateway, и подключить таблицу маршрутизации к подсети.
Таблица:
... "PublicRouteTable": { "Type": "AWS::EC2::RouteTable", "Properties": { "VpcId": { "Ref": "VPC" }, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-route"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } } ...
AWS::EC2::Route
Теперь – маршрут:
... "PublicRoute": { "Type": "AWS::EC2::Route", "Properties": { "RouteTableId": { "Ref": "PublicRouteTable" }, "DestinationCidrBlock": "0.0.0.0/0", "GatewayId": { "Ref": "InternetGateway" } } }, ...
AWS::EC2::SubnetRouteTableAssociation
Теперь – подключение таблицы маршрутизации к подсети:
... "PublicSubnetRouteTableAssociation": { "Type": "AWS::EC2::SubnetRouteTableAssociation", "Properties": { "SubnetId": { "Ref": "PublicSubnet" }, "RouteTableId": { "Ref": "PublicRouteTable" } } } ...
Можно сделать validate и update:
[simterm]
$ aws cloudformation validate-template --template-body file://rtfm_stack_2017.json $ aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json $ aws cloudformation describe-stacks --stack-name rtfm-dev --query 'Stacks[*].StackStatus' --output text UPDATE_COMPLETE
[/simterm]
Повторяем для приватной подсети, с той разницей, что в маршруте (ресурс AWS::EC2::Route
) в Properties указываем NAT Gateway:
... "NatGatewayId": { "Ref": "NAT" } ...
Добавляем три ресурса:
... "PrivateRouteTable": { "Type": "AWS::EC2::RouteTable", "Properties": { "VpcId": { "Ref": "VPC" }, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "private-route"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } }, "PrivateRoute": { "Type": "AWS::EC2::Route", "Properties": { "RouteTableId": { "Ref": "PrivateRouteTable" }, "DestinationCidrBlock": "0.0.0.0/0", "NatGatewayId": { "Ref": "NAT" } } }, "PrivateSubnetRouteTableAssociation": { "Type": "AWS::EC2::SubnetRouteTableAssociation", "Properties": { "SubnetId": { "Ref": "PrivateSubnet" }, "RouteTableId": { "Ref": "PrivateRouteTable" } } } ...
Обновляем стек:
[simterm]
$ aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json
[/simterm]
Проверяем:
[simterm]
$ aws ec2 describe-route-tables --route-table-ids rtb-a804b5ce rtb-bc0bbada --output text ROUTETABLES rtb-bc0bbada vpc-3c92d15b ASSOCIATIONS False rtbassoc-eb0e8292 rtb-bc0bbada subnet-fc2d6cb5 ROUTES 10.0.0.0/16 local CreateRouteTable active ROUTES 0.0.0.0/0 nat-0d1c39594f55cd309 CreateRoute active TAGS aws:cloudformation:logical-id PrivateRouteTable TAGS aws:cloudformation:stack-name rtfm-dev TAGS aws:cloudformation:stack-id arn:aws:cloudformation:eu-west-1:264418146286:stack/rtfm-dev/d232ebf0-ab52-11e7-ad6c-500c3d7e5a8d TAGS Env rtfm-dev TAGS Name rtfm-dev-private-route ROUTETABLES rtb-a804b5ce vpc-3c92d15b ASSOCIATIONS False rtbassoc-b90d81c0 rtb-a804b5ce subnet-d432739d ROUTES 10.0.0.0/16 local CreateRouteTable active ROUTES 0.0.0.0/0 igw-dcacb9b8 CreateRoute active TAGS Name rtfm-dev-public-route TAGS aws:cloudformation:logical-id PublicRouteTable TAGS aws:cloudformation:stack-id arn:aws:cloudformation:eu-west-1:264418146286:stack/rtfm-dev/d232ebf0-ab52-11e7-ad6c-500c3d7e5a8d TAGS aws:cloudformation:stack-name rtfm-dev TAGS Env rtfm-dev
[/simterm]
Вроде ОК всё.
Что бы быстро проверить сеть, преждем чем приступать к добавлению EC2 – можно запустить два интанса вручную, из портала AWS.
Запускаем, проверяем.
Интанс в публичной сети:
[simterm]
$ ssh -i ~/.ssh/rtfm_jenkins.pem [email protected] ... admin@ip-10-0-1-216:~$ ping ya.ru -c 1 PING ya.ru (87.250.250.242) 56(84) bytes of data. 64 bytes from ya.ru (87.250.250.242): icmp_seq=1 ttl=42 time=43.5 ms --- ya.ru ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 43.508/43.508/43.508/0.000 ms
[/simterm]
ОК.
Теперь – надо с него подключиться к интансу в приватной сети.
Копируем ключ, и подключаемся по приватному IP, пробуем пинг:
[simterm]
admin@ip-10-0-1-216:~$ ssh -i rtfm_jenkins.pem [email protected] ... admin@ip-10-0-129-125:~$ ping ya.ru -c 1 PING ya.ru (87.250.250.242) 56(84) bytes of data. --- ya.ru ping statistics --- 1 packets transmitted, 0 received, 100% packet loss, time 0ms
[/simterm]
Что-то не так…
Ах, ну да:
NAT Gateway должен быть в публичной сети:
"NAT" : { "DependsOn" : "IPAddress", "Type" : "AWS::EC2::NatGateway", "Properties" : { "AllocationId" : { "Fn::GetAtt" : ["IPAddress", "AllocationId"]}, "SubnetId" : { "Ref" : "PrivateSubnet"}, "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "natgw"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ] } },
Меняем "SubnetId" : { "Ref" : "PrivateSubnet"}
, на "SubnetId" : { "Ref" : "PublicSubnet"}
, обновляем стек:
[simterm]
$ aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json
[/simterm]
Ждём, пока пересоздастся NAT GW:
Но – нет:
Хорошо – удаляем старый NAT GW вручную, запускаем апдейт ещё раз:
И пробуем ещё раз:
[simterm]
admin@ip-10-0-129-125:~$ ping ya.ru -c 1 PING ya.ru (87.250.250.242) 56(84) bytes of data. 64 bytes from ya.ru (87.250.250.242): icmp_seq=1 ttl=41 time=44.5 ms --- ya.ru ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 44.592/44.592/44.592/0.000 ms
[/simterm]
Ура.
Убиваем созданные вручную интансы, и переходим к добавлению их в шаблон.
EC2
Собственно, осталось самое простое – добавить создание трёх EC2 (один, Bastion – в публичной сети, и два – Services и DB – в приватной), и добавить к ним Security Group-ы.
AWS::EC2::KeyPair
Добавляем параметр для указания ключа для авторизации:
... "KeyName": { "Description": "Name of an existing EC2 KeyPair to enable SSH access to the instance", "Type": "AWS::EC2::KeyPair::KeyName", "ConstraintDescription": "must be the name of an existing EC2 KeyPair.", "Default": "rtfm-dev" } ...
Создаём ключ для доступа с помощью create-key-pair
, сохраняем в файл ~/.ssh/rtfm-dev.pem
:
[simterm]
$ aws ec2 create-key-pair --key-name rtfm-dev --query 'KeyMaterial' --output text > ~/.ssh/rtfm-dev.pem
[/simterm]
Меняем права доступа:
[simterm]
$ chmod 400 ~/.ssh/rtfm-dev.pem
[/simterm]
AWS::EC2::EIP
Добавляем ещё один ресурс Elastic IP, для Bastion/NGINX:
... "BastionIPAddress" : { "Type" : "AWS::EC2::EIP", "Properties" : { "Domain" : "vpc" } }, ...
AWS::EC2::SecurityGroup
Теперь – группы безопасности, т.к. они будут подключаться потом к EC2.
Группы будет три – одна для Bastion и с правилами доступа к NGINX, одна – для сервера Services, с правилами доступа от NGINX к PHP-FPM, и третья – для хоста DB, с правилами доступа SSH с Bastion и 3306 – с хоста Services.
Позже – надо будет их обновить, что бы дать доступ Jenkins и Ansible.
Для доступа по SSH – добавим параметр HomeAllowLocation
, в котором будет передавать мой домашний IP для доступа по SSH:
... "HomeAllowLocation": { "Description": "The IP address range that can be used to SSH to the EC2 instances", "Type": "String", "MinLength": "9", "MaxLength": "18", "AllowedPattern": "(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2})", "ConstraintDescription": "must be a valid IP CIDR range of the form x.x.x.x/x." } ...
Создаём группу для Bastion:
... "BastionSecurityGroup" : { "Type" : "AWS::EC2::SecurityGroup", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "bastion-sg"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "VpcId" : {"Ref" : "VPC"}, "GroupDescription" : "Enable SSH, ICMP access from home, HTTP/S from anywhere", "SecurityGroupIngress" : [ { "IpProtocol" : "tcp", "FromPort" : "22", "ToPort" : "22", "CidrIp" : { "Ref" : "HomeAllowLocation"} }, { "IpProtocol" : "tcp", "FromPort" : "80", "ToPort" : "80", "CidrIp" : "0.0.0.0/0" }, { "IpProtocol" : "tcp", "FromPort" : "443", "ToPort" : "443", "CidrIp" : "0.0.0.0/0" }, { "IpProtocol" : "icmp", "FromPort" : "8", "ToPort" : "-1", "CidrIp" : { "Ref" : "HomeAllowLocation"} } ] } }, ...
Ограничение доступа на Dev – потом через allow/deny в конфигах NGINX.
Группа для Services – разрешаем пока только SSH с Bastion хоста (через указание имени его группы безопасности):
... "ServicesSecurityGroup" : { "Type" : "AWS::EC2::SecurityGroup", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "services-sg"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "VpcId" : {"Ref" : "VPC"}, "GroupDescription" : "Enable SSH access from Bastion", "SecurityGroupIngress" : [ { "IpProtocol" : "tcp", "FromPort" : "22", "ToPort" : "22", "SourceSecurityGroupId" : { "Ref" : "BastionSecurityGroup" } } ] } }, ...
И группа для DB – разрешаем SSH с Bastion SG и Services SG:
... "DBSecurityGroup" : { "Type" : "AWS::EC2::SecurityGroup", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "db-sg"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "VpcId" : {"Ref" : "VPC"}, "GroupDescription" : "Enable SSH access from Bastion", "SecurityGroupIngress" : [ { "IpProtocol" : "tcp", "FromPort" : "22", "ToPort" : "22", "SourceSecurityGroupId" : { "Ref" : "BastionSecurityGroup" } }, { "IpProtocol" : "tcp", "FromPort" : "22", "ToPort" : "22", "SourceSecurityGroupId" : { "Ref" : "ServicesSecurityGroup" } } ] } } ...
AWS::EC2::Instance
Далее – добавляем создание трёх инстансов.
Добавляем параметр AMIID
, с указанием AMI, из которого будут подниматься машины:
... "AMIID": { "Description": "Debian AMI ID", "Type": "String", "Default": "ami-cc5aa0b5" }, ...
Найти Debian AMI ID можно с помощью describe-images
:
[simterm]
$ aws ec2 describe-images --filters "Name=name,Values=debian-stretch*" "Name=root-device-type,Values=ebs" "Name=image-type,Values=machine" "Name=manifest-location,Values=aws-marketplace*"
[/simterm]
Параметры с указанием типа используемого интанса – лучше сразу определить три разных, для каждого интанса:
... "BastionInstanceType": { "Description": "Bastion EC2 instance type", "Type": "String", "Default": "t2.nano", "AllowedValues": [ "t2.nano", "t2.micro", "t2.small" ], "ConstraintDescription": "Must be a valid EC2 instance type." }, "ServicesInstanceType": { "Description": "Services EC2 instance type", "Type": "String", "Default": "t2.micro", "AllowedValues": [ "t2.nano", "t2.micro", "t2.small" ], "ConstraintDescription": "Must be a valid EC2 instance type." }, "DBInstanceType": { "Description": "DB EC2 instance type", "Type": "String", "Default": "t2.nano", "AllowedValues": [ "t2.nano", "t2.micro", "t2.small" ], "ConstraintDescription": "Must be a valid EC2 instance type." } ...
И сами инстансы.
Bastion:
... "BastionEC2Instance" : { "Type" : "AWS::EC2::Instance", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "bastion"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "InstanceType" : { "Ref" : "BastionInstanceType" }, "SecurityGroupIds" : [ { "Ref": "BastionSecurityGroup" } ], "KeyName" : { "Ref" : "KeyName" }, "ImageId" : { "Ref" : "AMIID" }, "AvailabilityZone": {"Ref" : "AvalabilityZone"}, "SubnetId": { "Ref" : "PublicSubnet" } } }, ...
Services – подсеть Private:
... "ServicesEC2Instance" : { "Type" : "AWS::EC2::Instance", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "services"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "InstanceType" : { "Ref" : "ServicesInstanceType" }, "SecurityGroupIds" : [ { "Ref": "ServicesSecurityGroup" } ], "KeyName" : { "Ref" : "KeyName" }, "ImageId" : { "Ref" : "AMIID" }, "AvailabilityZone": {"Ref" : "AvalabilityZone"}, "SubnetId": { "Ref" : "PrivateSubnet" } } }, ...
И аналогичный – DB:
... "DBEC2Instance" : { "Type" : "AWS::EC2::Instance", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "db"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "InstanceType" : { "Ref" : "DBInstanceType" }, "SecurityGroupIds" : [ { "Ref": "DBSecurityGroup" } ], "KeyName" : { "Ref" : "KeyName" }, "ImageId" : { "Ref" : "AMIID" }, "AvailabilityZone": {"Ref" : "AvalabilityZone"}, "SubnetId": { "Ref" : "PrivateSubnet" } } }, ...
AWS::EC2::EIPAssociation
Последним – добавляем подключение EIP к Bastion:
... "BastionEIPAssociation" : { "Type": "AWS::EC2::EIPAssociation", "Properties": { "AllocationId": { "Fn::GetAtt": [ "BastionIPAddress", "AllocationId" ] }, "InstanceId": {"Ref" : "BastionEC2Instance"} } } ...
Выполняем validate, и запускаем update, передав параметр HomeAlloLocation
(дальше он будет передаваться из переменных Jenkins):
[simterm]
$ aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json --parameters ParameterKey=HomeAllowLocation,ParameterValue=188.***.***.114/32
[/simterm]
Ждём окончания создания ресурсов:
[simterm]
$ aws cloudformation describe-stacks --stack-name rtfm-dev --query 'Stacks[*].StackStatus' --output text UPDATE_COMPLETE
[/simterm]
Проверяем.
Логинимся на Bastion:
[simterm]
$ ssh -i ~/.ssh/rtfm-dev.pem [email protected] ... admin@ip-10-0-1-70:~$
[/simterm]
Копируем на него ключ, пробуем подключиться на DB:
[simterm]
admin@ip-10-0-1-70:~$ ssh -i .ssh/rtfm-dev.pem [email protected] ... admin@ip-10-0-129-230:~$
[/simterm]
ОК, выходим – и подключаемся на Services:
[simterm]
admin@ip-10-0-1-70:~$ ssh -i .ssh/rtfm-dev.pem [email protected] ... admin@ip-10-0-129-152:~$
[/simterm]
Вроде всё работает.
Для проверки – удаляем стек:
[simterm]
$ aws cloudformation delete-stack --stack-name rtfm-dev
[/simterm]
Удаляется довольно долго – пока добавим подключение разделов.
Подключение EBS
Последним шагом – надо подключить к каждому интансу созданные в самом начале EBS разделы.
Добавляем три параметра:
... "BastionEBSID": { "Description": "Existing EBS volume with Bastion/NGINX data", "Type": "String", "Default": "vol-0fc9af657fd7c35c9" }, "ServicesEBSID": { "Description": "Existing EBS volume with Services data", "Type": "String", "Default": "vol-03e393869e1b03cb7" }, "DBEBSID": { "Description": "Existing EBS volume with DB data", "Type": "String", "Default": "vol-0301aba1a28e0c3b5" } ...
И обновляем интансы – в Properties добавляем Volume
:
... "BastionEC2Instance" : { "Type" : "AWS::EC2::Instance", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "bastion"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "InstanceType" : { "Ref" : "BastionInstanceType" }, "SecurityGroupIds" : [ { "Ref": "BastionSecurityGroup" } ], "KeyName" : { "Ref" : "KeyName" }, "ImageId" : { "Ref" : "AMIID" }, "AvailabilityZone": {"Ref" : "AvalabilityZone"}, "SubnetId": { "Ref" : "PublicSubnet" }, "Volumes" : [ { "VolumeId" : { "Ref" : "BastionEBSID" }, "Device" : "/dev/xvdb" } ] } }, "ServicesEC2Instance" : { "Type" : "AWS::EC2::Instance", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "services"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "InstanceType" : { "Ref" : "ServicesInstanceType" }, "SecurityGroupIds" : [ { "Ref": "ServicesSecurityGroup" } ], "KeyName" : { "Ref" : "KeyName" }, "ImageId" : { "Ref" : "AMIID" }, "AvailabilityZone": {"Ref" : "AvalabilityZone"}, "SubnetId": { "Ref" : "PrivateSubnet" }, "Volumes" : [ { "VolumeId" : { "Ref" : "ServicesEBSID" }, "Device" : "/dev/xvdb" } ] } }, "DBEC2Instance" : { "Type" : "AWS::EC2::Instance", "Properties" : { "Tags" : [ {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "db"] ] } }, {"Key" : "Env", "Value" : {"Ref" : "ENV"} } ], "InstanceType" : { "Ref" : "DBInstanceType" }, "SecurityGroupIds" : [ { "Ref": "DBSecurityGroup" } ], "KeyName" : { "Ref" : "KeyName" }, "ImageId" : { "Ref" : "AMIID" }, "AvailabilityZone": {"Ref" : "AvalabilityZone"}, "SubnetId": { "Ref" : "PrivateSubnet" }, "Volumes" : [ { "VolumeId" : { "Ref" : "DBEBSID" }, "Device" : "/dev/xvdb" } ] } }, ...
Проверяем, создаём стек:
[simterm]
$ aws cloudformation validate-template --template-body file://rtfm_stack_2017.json $ aws cloudformation create-stack --disable-rollback --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json --parameters ParameterKey=HomeAllowLocation,ParameterValue=188.***.***.114/32
[/simterm]
Подключаемся на Bastion – проверяем разделы:
[simterm]
$ ssh -i ~/.ssh/rtfm-dev.pem [email protected] ... admin@ip-10-0-1-57:~$ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT xvda 202:0 0 8G 0 disk └─xvda1 202:1 0 8G 0 part / xvdb 202:16 0 8G 0 disk
[/simterm]
xvdb
– наш EBS.
На всякий случай – DB хост:
[simterm]
admin@ip-10-0-1-57:~$ ssh -i rtfm-dev.pem [email protected] ... admin@ip-10-0-129-188:~$ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT xvda 202:0 0 8G 0 disk └─xvda1 202:1 0 8G 0 part / xvdb 202:16 0 8G 0 disk
[/simterm]
В общем – готово:
Ожидал проблем при пересоздании из-за того, что не добавлял явного указания атрибута DependsOn
– но вроде всё ОК.
Ещё раз ссылка на сам шаблон.