AWS: миграция RTFM 2.3 – инфраструктура для RTFM и создание CloudFormation шаблона – VPC, subnets, EC2

By | 10/07/2017
 

В общем-то это продолжение моей навязчивой идеи создать нормальный стек для RTFM.

Первые попытки я начал ещё год тому, когда разбирался с CloudFormation вообще (раз, два, три).

В прошлую субботу – немного повспоминал CloudFormation на примере создания шаблона для стека Jenkins, который и будет заниматься провиженом всего окружения (ибо по работе всё это время приходилось иметь дело в основном с Azure Resource Manager).

Jenkins будет иметь две основные задачи – развёртывание AWS CloudFormation стека с инфрастуктурой и применение Ansible плейбука для сетапа и настройки сервисов на серверах.

Описание инфрастуктуры и стека

В целом – инфрастуктура получается следующая:

(рисовалось в https://cloudcraft.co)

По ходу реализации – может поменяется, но на данный момент предполагаю такую схему.

В стеке будут 3 EC2 интанса:

  1. nginx: Bastion хост с NGINX для проксирования и редиректа трафика.
    На нём же – PSAD для определения сканирования портов и Fail2ban – для бана попыток брутфорса SSH и прочих вредных действий.
    В публичной сети, с выделенным AWS Elastic IP.
  2. php-fpm: основной хост для сервисов. Т.к. RTFM работает на WordPress – то PHP, PHP-FPM. Тут же, наверно, будет крутится Prometheus, веб-интерфейсы типа Postfixadmin и что-там-у-меня-ещё-есть (часть сервисов типа почты вообще до сих пор, ещё года с 2012-го, работают в Дата-Центре “Воля”, когда-то надеюсь мигрировать и их в AWS).
    В приватной сети, доступ через Bastion хост.
    Назовём его Services.
  3. 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 е нужен, а выйдет немного дешевле):

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}]'

Диск для хоста Services (php-fpm), в которо будут хранится файлы WordPress и других сайтов, тип gp2:

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}]'

И для DB – тоже gp2:

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}]'

Стоимость EBS разделов – тут>>>, описание – тут>>>.

Проверяем:

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

CloudFormaion

VPC

Можно взять готовый шаблон из набора AWS отсюда>>>, например – A single Amazon EC2 in an Amazon VPC.

Но т.к. там много лишнего и большую часть всё равно надо будет удалить – я напишу свой (велосипед).

Получившийся шаблон можно посмотреть тут>>>.

Для начала потребуются такие ресурсы:

  1. VPC
  2. Subnets
  3. 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:

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"
}

Запускаем создание стека create-stack с опцией --disable-rollback, что бы иметь возможность почитать ошибки:

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"
}

Проверяем:

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 >.<

ОК – удаляем его:

aws cloudformation delete-stack --stack-name rfm-dev

Создаём с нормальным именем:

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"
}

Продолжаем.

Сейчас есть ресурсы 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:

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'
[
"UPDATE_COMPLETE"
]
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:

aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json

Всё создалось, хорошо, продолжаем.

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:

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

Повторяем для приватной подсети, с той разницей, что в маршруте (ресурс 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"
        }
      }
    }
...

Обновляем стек:

aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json

Проверяем:

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

Вроде ОК всё.

Что бы быстро проверить сеть, преждем чем приступать к добавлению EC2 – можно запустить два интанса вручную, из портала AWS.

Запускаем, проверяем.

Интанс в публичной сети:

ssh -i ~/.ssh/rtfm_jenkins.pem admin@34.252.81.173
...
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

ОК.

Теперь – надо с него подключиться к интансу в приватной сети.

Копируем ключ, и подключаемся по приватному IP, пробуем пинг:

admin@ip-10-0-1-216:~$ ssh -i rtfm_jenkins.pem admin@10.0.129.125
...
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

Что-то не так…

Ах, ну да:

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"}, обновляем стек:

aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json

Ждём, пока пересоздастся NAT GW:

Но – нет:

Хорошо – удаляем старый NAT GW вручную, запускаем апдейт ещё раз:

 

И пробуем ещё раз:

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

Ура.

Убиваем созданные вручную интансы, и переходим к добавлению их в шаблон.

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:

aws ec2 create-key-pair --key-name rtfm-dev --query 'KeyMaterial' --output text > ~/.ssh/rtfm-dev.pem

Меняем права доступа:

chmod 400 ~/.ssh/rtfm-dev.pem
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:

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*"

Параметры с указанием типа используемого интанса – лучше сразу определить три разных, для каждого интанса:

...
    "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):

aws cloudformation update-stack --stack-name rtfm-dev --template-body file://rtfm_stack_2017.json --parameters ParameterKey=HomeAllowLocation,ParameterValue=188.***.***.114/32

Ждём окончания создания ресурсов:

aws cloudformation describe-stacks --stack-name rtfm-dev --query 'Stacks[*].StackStatus' --output text
UPDATE_COMPLETE

Проверяем.

Логинимся на Bastion:

ssh -i ~/.ssh/rtfm-dev.pem admin@54.229.46.48
...
admin@ip-10-0-1-70:~$

Копируем на него ключ, пробуем подключиться на DB:

admin@ip-10-0-1-70:~$ ssh -i .ssh/rtfm-dev.pem admin@10.0.129.230
...
admin@ip-10-0-129-230:~$

ОК, выходим – и подключаемся на Services:

admin@ip-10-0-1-70:~$ ssh -i .ssh/rtfm-dev.pem admin@10.0.129.152
...
admin@ip-10-0-129-152:~$

Вроде всё работает.

Для проверки – удаляем стек:

aws cloudformation delete-stack --stack-name rtfm-dev

Удаляется довольно долго – пока добавим подключение разделов.

Подключение 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"
          }
        ]
      }
    },
...

Проверяем, создаём стек:

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

Подключаемся на Bastion – проверяем разделы:

ssh -i ~/.ssh/rtfm-dev.pem admin@52.210.5.121
...
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

xvdb – наш EBS.

На всякий случай – DB хост:

admin@ip-10-0-1-57:~$ ssh -i rtfm-dev.pem admin@10.0.129.188
...
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

В общем – готово:

Resource Group:

Ожидал проблем при пересоздании из-за того, что не добавлял явного указания атрибута DependsOn – но вроде всё ОК.

Ещё раз ссылка на сам шаблон.