AWS Elastic Kubernetes Service: автоматизация создания кластера, часть 1 — CloudFormation

Автор: | 03/31/2020
 

Задача: продумать автоматизацию развёртывания AWS Elastic Kubernetes Service кластера.

Используем:

  • Ansible: для автоматизации создания CloudFormation стеков и запуска eksctl с нужными параметрами
  • CloudFormation с NestedStacks: для создания инфрастуктуры — VPC, подсетей, SecurityGroups, IAM-роли, etc
  • eksctl: для создания самого кластера, используя ресурсы, созданные CloudFormation

Идея заключается в следующем:

  1. Ansible использует модуль cloudformation , создаёт инфрастуктуру
  2. используя Outputs созданного стека CloudFormation — Ansible из шаблона генерирует файл настроек для eksctl
  3. Ansible вызывает eksctl, передавая ему конфиг кластера, и создаёт или обновляет кластер

Выбор eksctl обуславливался во-первых крайне сжатыми сроками, во-вторых — под капотом он использует CloudFormation, который используется в проекте давно, так что наши стеки будут более-менее однородными.

Запускаться всё будет из Jenkins-джоб, используя Docker-образ с AWS CLI, Ansible и eksctl.

Вообще, не стоит данный пример рассматривать именно как «Best Practice» для автоматизации развёртывания кластеров Elastic Kubernetes Service — скорее, это такой себе Proof of Concept, и больше пример того, как шаг за шагом смутная идея в голове превращается в реальный код и работающие сервисы. А как и какие инструменты использовать — Terraform или CloudFormation, или kops или eksctl — вопрос вторичный.

Вообще, для Ansible имеется два модуля, которые могут облегчить работу с Kubernetes — k8s и kubectl, но их статусы preview и community не обрадовали — поэтому пока обойдёмся без них.

Пост получился очень большим, потому разбит на две части:

  • в первой, этой — займёмся написанием шаблона CloudFormation
  • во втором — напишем Ansible плейбук и роли, которые будут запускать CloudFormation и eksctl

Надеюсь, что в процессе написания особых неточностей не допустил, тем не менее — могут быть, т.к. писалось не один день с неоднократными исправлениями-переделываниями. Но всё описано пошагово, так что общую идею ухватить можно.

Все получившиеся в результате написания файлы доступны в репозитории eksctl-cf-ansible — тут ссылка на бранч с результатами, получившимися именно в конце написания этого и следующего постов. Возможно потом будут ещё изменения.

Вторая часть — AWS: Elastic Kubernetes Service — автоматизация создания кластера, часть 2 — Ansible, eksctl.

CloudFormation stacks

Начнём с CloudFormation стека.

Нам потребуются ресурсы:

  • 1 VPC
  • две публичные подсети под Application Load Balancers, Bastion-хосты, Internet Gateways
  • две приватные подсети под Kubernetes Worker Nodes EC2, NAT Gateways

EKS AMI для Worker Nodes eksctl найдёт сам, но список есть тут>>>.

Используем CloudFormation Netsted Stacks (см. AWS: CloudFormation — вложенные стеки и Import/Export параметров):

  1. корневой стек, eks-root.json — описывает ресурсы, определяет шаблоны
    1. шаблон eks-region-networking.json:
      1. одна VPC
      2. Internet Gateway
        1. Internet Gateway Association
    2. шаблон eks-azs-networking.json — все ресурсы дублируются в двух разных AvailabilityZones:
      1. по одной публичной подсети
      2. по одной приватной подсети
      3. RouteTable для публичных подсетей
        1. к ней Route в сеть 0.0.0.0/0 через Internet Gateway
        2. и SubnetRouteTableAssociation для подключения RouteTable к публичной подсети в этой AvailabilityZone
      4. приватная RouteTable
        1. к ней Route в сеть 0.0.0.0/0 через NAT Gateway
        2. SubnetRouteTableAssociation для подключения RouteTable к приватной подсети в этой AvailabilityZone
      5. NAT Gateway
        1. Elastic IP для NAT Gateway

Начинаем с рутового шаблона.

Root stack

Создадим корневой шаблон, который будет вызывать все вложенные.

В корне репозитория создаём каталоги:

mkdir -p roles/cloudformation/{tasks,files,templates}

В каталоге roles/cloudformation/files/ создаём файл eks-root.json — это будет шаблон нашего корневого стека:

cd roles/cloudformation/files/
touch eks-root.json
Параметры

Тут сразу стоит подумать о том — как будут использоваться блоки адресов в проекте. Например — стоит избегать использования сетей, которые могут пересекаться, во избежание будущих проблем с VPC Peering.

Второй нюанс, о котором стоит подумать сразу — это модель сети в будущем кластере вообще, и используемый плагин.

Elastic Kubernetes Service по-умолчанию предлагает плагин CNI (Container Network Interface), который позволяет использование сетевого интерфейса Worker Node ЕС2 (ENI — Elastic Network Interface). Используя этот CNI — Kubernetes будет выделять подам IP из пула сети VPC, см. amazon-vpc-cni-k8s и Pod Networking (CNI).

Такое решение имеет плюсы и недостатки, см. отличный обзор от Weave Net — AWS and Kubernetes Networking Options and Trade-offs, f про другие плагины в документации самого Kubernetes — Cluster Networking.

См. также VPC and Subnet Sizing.

Пока добавляем сеть 10.0.0.0/16 для VPC — потом её «нарежем» на 4 подсети:

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "AWS CloudFormation stack for Kubernetes cluster",
  
  "Parameters": {
    
    "VPCCIDRBlock": {
      "Description": "VPC CidrBlock",
      "Type": "String",
      "Default": "10.0.0.0/16"
    }
    
  },

Подсети поделим так:

  • одна публичная в AvailabilityZone 1-А, /20, 4094 адресов
  • одна приватная в AvailabilityZone 1-А, /20, 4094 адресов
  • одна публичная в AvailabilityZone 1-В, /20, 4094 адресов
  • одна приватная в AvailabilityZone 1-В, /20, 4094 адресов

Можно использовать ipcalc:

ipcalc 10.0.0.0/16 --s 4094 4094 4094 4094 | grep Network | cut -d" " -f 1,4 | tail -4
Network: 10.0.0.0/20
Network: 10.0.16.0/20
Network: 10.0.32.0/20
Network: 10.0.48.0/20

4094 адреса должно хватить на все EС2 и поды в каждой подсети.

По ходу дела нашёл очень классный калькулятор подсетей — http://www.subnetmask.info.

И добавляем параметр EKSClusterName — сюда из Ansible будем передавать имя создаваемого кластера для создания тегов:

...
    "EKSClusterName": {
      "Description": "EKS cluster name",
      "Type": "String"
    } 
...

Network Region stack

Создаём второй шаблон, назовём его eks-region-networking.json.

VPC

В нём будем создавать VPC, и из рутового шаблона в сетевой передадим параметр VPC сети и имя кластера, а обратно — через Outputs получим ID созданной VPC:

{
  "AWSTemplateFormatVersion" : "2010-09-09",
  "Description" : "AWS CloudFormation Region Networking stack for Kubernetes cluster",
  
  "Parameters" : {
    
    "VPCCIDRBlock": {
      "Description": "VPC CidrBlock",
      "Type": "String"
    },
    "EKSClusterName": {
      "Description": "EKS cluster name",
      "Type": "String"
   }
    
  },
  
  "Resources" : {
    
    "VPC" : {
      "Type" : "AWS::EC2::VPC",
      "Properties" : {
        "CidrBlock" : { "Ref": "VPCCIDRBlock" },
        "EnableDnsHostnames": true,
        "EnableDnsSupport": true,
        "Tags" : [
          {
            "Key" : "Name",
            "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "vpc"] ] } },
          {
            "Key" : { "Fn::Join" : [ "", [ "kubernetes.io/cluster/", {"Ref" : "EKSClusterName"}] ] },
            "Value" : "owned"
          }
        ]
      }
    }
    
  },
  
  "Outputs" : {
    
    "VPCID" : {
      "Description" : "EKS VPC ID",
      "Value" : { "Ref" : "VPC" }
    }
    
  }
}

Возвращаемся к рутовому шаблону — добавляем создание первого вложенного стека.

VPC ID получаем из Outputs network region стека, и выводим в Outputs корневого стека, что бы потом в Ansible использовать его для значения переменной, которая потом будет использоваться в шаблоне файла настроек для eksctl.

Теперь полность root шаблон выглядит так:

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "AWS CloudFormation stack for Kubernetes cluster",

  "Parameters": {

    "VPCCIDRBlock": {
      "Description": "VPC CidrBlock",
      "Type": "String",
      "Default": "10.0.0.0/16"
    }

  },

  "Resources": {

    "RegionNetworkStack": {
      "Type": "AWS::CloudFormation::Stack",
      "Properties": {
        "TemplateURL": "eks-region-networking.json",
        "Parameters": {
          "VPCCIDRBlock": { "Ref": "VPCCIDRBlock" },
          "EKSClusterName": { "Ref": "EKSClusterName"}
        }
      }
    }

  },

  "Outputs": {

    "VPCID" : {
      "Description" : "EKS VPC ID",
      "Value" : { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] }
    }

  }
}

Создаём корзину в этом регионе:

aws --profile arseniy --region eu-west-2 s3api create-bucket --bucket eks-cloudformation-eu-west-2 --region eu-west-2 --create-bucket-configuration LocationConstraint=eu-west-2

В Production сетапе для такой корзины будет крайне полезно включить S3 Versioning, что бы хранить копии предыдущих шаблонов на случай проблем (хотя всё-равно все исходные шаблоны хранятся в Github-репозиториях проекта).

Включаем:

aws --region eu-west-2 --profile arseniy s3api put-bucket-versioning --bucket eks-cloudformation-eu-west-2 --versioning-configuration Status=Enabled

Упаковываем eks-root.json и eks-region-networking.json в AWS S3, результат сохраняем в /tmp в файле packed-eks-stacks.json:

cd roles/cloudformation/files/
aws --profile arseniy --region eu-west-2 cloudformation package --template-file eks-root.json --output-template /tmp/packed-eks-stacks.json --s3-bucket eks-cloudformation-eu-west-2 --use-json

Деплоим:

aws --profile arseniy --region eu-west-2 cloudformation deploy --template-file /tmp/packed-eks-stacks.json --stack-name eks-dev
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - eks-dev

Проверяем:

Дочерний стек создан, VPC в нём тоже.

Internet Gateway

Добавляем Internet Gateway и VPCGatewayAttachment, приводим Resources регион-стека к виду:

...
  "Resources" : {

    "VPC" : {
      "Type" : "AWS::EC2::VPC",
      "Properties" : {
        "CidrBlock" : { "Ref": "VPCCIDRBlock" },
        "EnableDnsHostnames": true,
        "EnableDnsSupport": true,
        "Tags" : [
          {
            "Key" : "Name",
            "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "vpc"] ] } },
          {
            "Key" : { "Fn::Join" : [ "", [ "kubernetes.io/cluster/", {"Ref" : "EKSClusterName"}] ] },
            "Value" : "owned"
          }
        ]
      }
    },

    "InternetGateway" : {
      "Type" : "AWS::EC2::InternetGateway",
      "Properties" : {
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "igw"] ] } }
        ]
      }
    },
    
    "AttachGateway" : {
       "Type" : "AWS::EC2::VPCGatewayAttachment",
       "Properties" : {
         "VpcId" : { "Ref" : "VPC" },
         "InternetGatewayId" : { "Ref" : "InternetGateway" }
       }
    }

  },
...

В Outputs добавляем передачу InternetGateway ID в рутовый стек, откуда он передаст его в Network AvailabilityZones stack для создания RouteTables для публичных подсетей:

...
  "Outputs" : {

    "VPCID" : {
      "Description" : "EKS VPC ID",
      "Value" : { "Ref" : "VPC" }
    },
    "IGWID" : {
      "Description" : "InternetGateway ID",
      "Value" : { "Ref" : "InternetGateway" }
    }

  }
}

Переходим к Network AvailabilityZones стеку.

Network AvailabilityZones stack

Далее начнём создавать ресурсы, которые будут дублироваться в двух AvailabilityZone.

К ним относятся:

  1. по одной публичной подсети
  2. по одной приватной подсети
  3. RouteTable для публичных подсетей
    1. к ней Route в сеть 0.0.0.0/0 через Internet Gateway
    2. и SubnetRouteTableAssociation для подключения RouteTable к публичной подсети в этой AvailabilityZone
  4. приватная RouteTable
    1. к ней Route в сеть 0.0.0.0/0 через NAT Gateway
    2. SubnetRouteTableAssociation для подключения RouteTable к приватной подсети в этой AvailabilityZone
  5. NAT Gateway
    1. Elastic IP для NAT Gateway

Основной вопрос — как выбрать AvailabilityZone для этих дочерних стеков, ведь при создании ресурсов в них, например AWS::EC2::Subnet, нам надо указать AvailabilityZone?

Решение — используем Fn::GetAZs, которую будем вызывать из рутового стека — и получим все AvailabilityZone региона, в котором он создан, а затем, используя их (1a1b1c) — будем передавать в наши NetworkAvailabilityZones-стеки.

В большинстве регионов есть три AvailabilityZone — будем использовать первые две.

Начнём с создания подсетей — по одной публичной и приватной, в двух AvailabilityZone региона.

В этот стек нам надо будет передавать несколько новых параметров:

  • VPC ID из «регионального» сетевого стека
  • CIDR публичной подсети
  • CIDR приватной подсети
  • AvailabilityZone, в которой будут создаваться ресурсы
  • Internet Gateway ID из «регионального» сетевого стека, потребуется позже для RouteTables

Создаём файл шаблона, назовём его eks-azs-networking.json.

Параметры

Добавляем в него параметры:

{
  "AWSTemplateFormatVersion" : "2010-09-09",
  "Description" : "AWS CloudFormation AvailabilityZones Networking stack for Kubernetes cluster",

  "Parameters" : {
    "VPCID": {
      "Description": "VPC for resources",
      "Type": "String"
    },
    "EKSClusterName": {
      "Description": "EKS cluster name",
      "Type": "String"
    },
    "PublicSubnetCIDR": {
      "Description": "PublicSubnetCIDR",
      "Type": "String"
    },
    "PrivateSubnetCIDR": {
      "Description": "PrivateSubnetCIDR",
      "Type": "String"
    },
    "AZ": {
      "Description": "AvailabilityZone for resources",
      "Type": "String"
    },
    "IGWID": {
      "Description": "InternetGateway for PublicRoutes",
      "Type": "String"
    }
  },
Подсети

Добавляем блок Resources и создание двух ресурсов — публичная и приватная подсети:

...
"Resources" : {
  
  "PublicSubnet" : {
    "Type" : "AWS::EC2::Subnet",
    "Properties" : {
      "VpcId" : { "Ref" : "VPCID" },
      "CidrBlock" : {"Ref" : "PublicSubnetCIDR"},
      "AvailabilityZone" : { "Ref": "AZ" },
      "Tags" : [
        {
          "Key" : "Name",
          "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-net", { "Ref": "AZ" } ] ] }
        },
        {
          "Key" : { "Fn::Join" : [ "", [ "kubernetes.io/cluster/", {"Ref" : "EKSClusterName"}] ] },
          "Value" : "shared"
        },
        {
          "Key" : "kubernetes.io/role/elb",
          "Value" : "1"
        }
      ]
    }
  },
  
  "PrivateSubnet" : {
    "Type" : "AWS::EC2::Subnet",
    "Properties" : {
      "VpcId" : { "Ref" : "VPCID" },
      "CidrBlock" : {"Ref" : "PrivateSubnetCIDR"},
      "AvailabilityZone" : { "Ref": "AZ" },
      "Tags" : [
        {
          "Key" : "Name",
          "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "private-net", { "Ref": "AZ" } ] ] }
        },
        {
          "Key" : { "Fn::Join" : [ "", [ "kubernetes.io/cluster/", {"Ref" : "EKSClusterName"}] ] },
          "Value" : "shared"
        },
        {
          "Key" : "kubernetes.io/role/internal-elb",
          "Value" : "1"
        }
      ]
    }
  }
  
},

Обратите внимание на теги "kubernetes.io/role/elb" у публичной и "kubernetes.io/role/internal-elb" у приватных подсетей — они потребуются позже для запуска Kubernetes AWS ALB Ingress controller.

В Outputs добавляем передачу созданных subnets-ID обратно в рутовый стек, что бы Ansible потом добавил их в конфиг для eksctl, заодно выводим AvailabilityZone, в которой создан стек:

...
  "Outputs" : {
    
    "StackAZ" : {
      "Description" : "Stack location",
      "Value" : { "Ref" : "AZ" }
    },
    "PublicSubnetID" : {
      "Description" : "PublicSubnet ID",
      "Value" : { "Ref" : "PublicSubnet" }
    },
    "PrivateSubnetID" : {
      "Description" : "PrivateSubnet ID",
      "Value" : { "Ref" : "PrivateSubnet" }
    }
    
  }
}

Возвращаемся к рутовому шаблону — добавляем создание ещё двух стеков, по одному на каждую AvailabilityZone — приводим его блок Resources к виду:

...
  "Resources": {

    "RegionNetworkStack": {
      "Type": "AWS::CloudFormation::Stack",
      "Properties": {
        "TemplateURL": "eks-region-networking.json",
        "Parameters": {
          "VPCCIDRBlock": { "Ref": "VPCCIDRBlock" }
        }
      }
    },

    "AZNetworkStackA": {
      "Type": "AWS::CloudFormation::Stack",
      "Properties": {
        "TemplateURL": "eks-azs-networking.json",
        "Parameters": {
          "VPCID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] },
          "AZ": { "Fn::Select": [ "0", { "Fn::GetAZs": "" } ] },
          "IGWID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.IGWID"] },
          "EKSClusterName": { "Ref": "EKSClusterName"},
          "PublicSubnetCIDR": "10.0.0.0/20",
          "PrivateSubnetCIDR": "10.0.32.0/20"
        }
      }
    },
    "AZNetworkStackB": {
      "Type": "AWS::CloudFormation::Stack",
      "Properties": {
        "TemplateURL": "eks-azs-networking.json",
        "Parameters": {
          "VPCID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] },
          "AZ": { "Fn::Select": [ "1", { "Fn::GetAZs": "" } ] },
          "IGWID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.IGWID"] },
          "EKSClusterName": { "Ref": "EKSClusterName"},
          "PublicSubnetCIDR": "10.0.16.0/20",
          "PrivateSubnetCIDR": "10.0.48.0/20"
        }
      }
    }

  },
...

Internet Gateway ID получаем из Outputs «регионального» стека, и передаём через Parameters ресурсов AZNetworkStackА и AZNetworkStackB.

Блоки адресов для подсетей пока хардкодим — позже используем Mappings.

Тут:

  • Fn::GetAZs"VPCID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] } — в создаваемые стеки передаём VPC ID созданной из region networking стека
  • "AZ": { "Fn::Select": [ "0", { "Fn::GetAZs": "" } ] } — выбираем первый элемент («0«) из списка AvailabilityZones региона, во втором стеке — второй элемент («1«)
  • PublicSubnetCIDR и PrivateSubnetCIDR пока хардкодим

Кроме того, в Outputs рутового стека добавляем subnets-ID из наших AvailabilityZones-стеков, что бы потом использовать их в Ansible для файла параметров eksctl:

...
  "Outputs": {

    "VPCID" : {
      "Description" : "EKS VPC ID",
      "Value" : { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] }
    },
    "AStackAZ" : {
      "Description" : "Stack location",
      "Value" : { "Fn::GetAtt": ["AZNetworkStackA", "Outputs.StackAZ"] }
    },
    "APublicSubnetID" : {
      "Description" : "PublicSubnet ID",
      "Value" : { "Fn::GetAtt": ["AZNetworkStackA", "Outputs.PublicSubnetID"] }
    },
    "APrivateSubnetID" : {
      "Description" : "PrivateSubnet ID",
      "Value" : { "Fn::GetAtt": ["AZNetworkStackA", "Outputs.PrivateSubnetID"] }
    },
    "BStackAZ" : {
      "Description" : "Stack location",
      "Value" : { "Fn::GetAtt": ["AZNetworkStackB", "Outputs.StackAZ"] }
    },
    "BPublicSubnetID" : {
      "Description" : "PublicSubnet ID",
      "Value" : { "Fn::GetAtt": ["AZNetworkStackB", "Outputs.PublicSubnetID"] }
    },
    "BPrivateSubnetID" : {
      "Description" : "PrivateSubnet ID",
      "Value" : { "Fn::GetAtt": ["AZNetworkStackB", "Outputs.PrivateSubnetID"] }
    }

  }
}

Упаковываем, генерируем новый шаблон:

!610
aws --profile arseniy --region eu-west-2 cloudformation package --template-file eks-root.json --output-template /tmp/packed-eks-stacks.json --s3-bucket eks-cloudformation-eu-west-2 --use-json

Деплоим:

aws --profile arseniy --region eu-west-2 cloudformation deploy --template-file /tmp/packed-eks-stacks.json --stack-name eks-dev

Проверяем:

Заканчиваем — добавляем оставшиеся ресурсы.

Нам осталось добавить:

  1. RouteTable для публичной подсети
    • к ней Route в сеть 0.0.0.0/0 через Internet Gateway
    • и SubnetRouteTableAssociation для подключения RouteTable к публичной подсети в этой AvailabilityZone
  2. RouteTable для приватной посети
    • к ней Route в сеть 0.0.0.0/0 через NAT Gateway
    • SubnetRouteTableAssociation для подключения RouteTable к приватной подсети в этой AvailabilityZone
  3. NAT Gateway
    • Elastic IP для NAT Gateway
NAT Gateway

В Resources добавляем создание NAT Gateway и Elastic IP для него:

...
    "NatGwIPAddress" : {
      "Type" : "AWS::EC2::EIP",
      "Properties" : {
        "Domain" : "vpc"
      }
    },

    "NATGW" : {
      "DependsOn" : "NatGwIPAddress",
      "Type" : "AWS::EC2::NatGateway",
      "Properties" : {
        "AllocationId" : { "Fn::GetAtt" : ["NatGwIPAddress", "AllocationId"]},
        "SubnetId" : { "Ref" : "PublicSubnet"},
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "nat-gw", { "Ref": "AZ" } ] ] } }
        ]
      }
    }
...
Public RouteTable

Добавляем в каждой AvailabilityZone по RouteTable для публичных подсетей.

Для паблик роута нужен Internet Gateway ID, который передаём из Регионального стека в рутовый, а потом в AvailabilityZones-стек.

Добавляем RouteTable, один Route в 0.0.0.0/0 через Internet Gateway, и создаём SubnetRouteTableAssociation:

...
    "PublicRouteTable": {
      "Type": "AWS::EC2::RouteTable",
      "Properties": {
        "VpcId": { "Ref": "VPCID" },
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-rtb"] ] } }
        ]
      }
    },
    
    "PublicRoute": {
      "Type": "AWS::EC2::Route",
      "Properties": {
        "RouteTableId": {
          "Ref": "PublicRouteTable"
        },
        "DestinationCidrBlock": "0.0.0.0/0",
        "GatewayId": {
          "Ref": "IGWID"
        }
      }
    },
    
    "PublicSubnetRouteTableAssociation": {
      "Type": "AWS::EC2::SubnetRouteTableAssociation",
      "DependsOn": "PublicRouteTable",
      "Properties": {
        "SubnetId": {
          "Ref": "PublicSubnet"
        },
        "RouteTableId": {
          "Ref": "PublicRouteTable"
        }
      }
    }
...
Private RouteTable

Аналогично — в AvailabilityZones стек добавляем RouteTable и связанные с ней ресурсы, только Route задаём уже не через Internet Gateway — а через NAT Gateway:

...
    "PrivateRouteTable": {
      "Type": "AWS::EC2::RouteTable",
      "Properties": {
        "VpcId": { "Ref": "VPCID" },
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "priv-route", { "Ref": "AZ" } ] ] } }
        ]
      }
    },
    
    "PrivateRoute": {
      "Type": "AWS::EC2::Route",
      "Properties": {
        "RouteTableId": {
          "Ref": "PrivateRouteTable"
        },
        "DestinationCidrBlock": "0.0.0.0/0",
        "NatGatewayId": {
          "Ref": "NATGW"
        }
      }
    },
    
    "PrivateSubnetRouteTableAssociation": {
      "Type": "AWS::EC2::SubnetRouteTableAssociation",
      "Properties": {
        "SubnetId": {
          "Ref": "PrivateSubnet"
        },
        "RouteTableId": {
          "Ref": "PrivateRouteTable"
        }
      }
    }
...

Упаковываем, деплоим, проверяем:

Все сети и маршруты поднялись — уже всё должно работать.

Теперь можно вручную запустить ЕС2 инстансы в публичной и приватной подсетях, и проверить:

  1. SSH к ЕС2 в публичной подсети, что бы проверить работу публичной подсети
  2. SSH с ЕС2 в публичной к инстансу в приватной, что бы проверить роутинг приватной подсети в целом
  3. ping из приватной в мир, что бы проверить работу NAT

Mappings, адреса для подсетей

Последнее, что хотелось бы изменить в AvailabilityZones стеке — это реализовать нормальную передачу блоков для подсетей.

В параметр VPCCIDRBlock мы передаём полную сеть, типа 10.0.0.0/16:

...
    "VPCCIDRBlock": {
      "Description": "VPC CidrBlock",
      "Type": "String",
      "Default": "10.0.0.0/16"
    }
...

Из которой нам надо нарезать 4 посети по /20 — для двух публичных, и двух приватных подсетей.

Сейчас блоки адресов для них мы передаём явно при создании самих ресурсов подсетей:

...
        "Parameters": {
          "VPCID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] },
          "AZ": { "Fn::Select": [ "0", { "Fn::GetAZs": "" } ] },
          "IGWID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.IGWID"] },
          "EKSClusterName": { "Ref": "EKSClusterName"},
          "PublicSubnetCIDR": "10.0.0.0/20",
          "PrivateSubnetCIDR": "10.0.32.0/20"
        }
...

Что, разумеется, нехорошо, ибо лишает гибкости — хочется иметь возможность передавать из Jenkins-параметров желаемый блок адресов для VPC, что бы потом не возникало проблем при создании VPC peerings.

Посмотрим, что у нас используется при построении 4-х блоков адресов /20 для подсети в VPC с блоком 10.0.0.0/16:

  • 10.0 — начало сети
  • блок третьего октета — 0163248
  • маска подсети — /20

При этом сети VPC будут в виде 10.0.0.0/16, 10.1.0.0/16, 10.2.0.0/16 и т.д. — под каждое рабочее окружение своя сеть.

Как бы их скомбинировать?

Можно использовать Fn::Split — по точке вырезать первые два октета из сети VPC — получим 10.0. или 10.1 и т.д.

А если VPC передадут в виде 192.168.0.0/16?… Значит — первые два октета будем получать отдельными выборками.

А для последних двух октетов и маски подсети — можно добавить CloudFormation Mappings, и потом объединить всё через Fn::Join.

Попробуем так — добавляем маппинг в рутовый стек:

...
  "Mappings": {

    "AZSubNets": {
      "public": {
        "zoneA": "0.0/20",
        "zoneB": "16.0/20"
      },
      "private": {
        "zoneA": "32.0/20",
        "zoneB": "48.0/20"
      }
    }
  },
...

Теперь самое интересное: в ресурсах AZNetworkStackА и AZNetworkStackB рутового стека в их Parameters стека вместо:

...
"PublicSubnetCIDR": "10.0.0.0/20",
...

Нам надо создать что-то вроде:

<VPC-CIDR-ПЕРВЫЙ-ОКТЕТ>  + <VPC-CIDR-ВТОРОЙ-ОКТЕТ> + <ЗОНА-ИЗ-МАППИНГА>

Т.е. что-то вроде такого:

{ «Fn::Join» : [ «.», [ { «VPC-CIDR-ПЕРВЫЕ-ДВА-ОКТЕТА» ] }, «ЗОНА-ИЗ-МАППИНГА»] ] } }

Что бы получить VPC-CIDR-ПЕРВЫЙ-ОКТЕТ — используем Fn::Select и Fn::Split:

{ «Fn::Select» : [ «0», { «Fn::Split»: [«.», { «Ref»: «VPCCIDRBlock»}]}] }

Аналогично — для второго, но в Fn::Select используем индекс 1:

{ «Fn::Select» : [ «1», { «Fn::Split»: [«.», { «Ref»: «VPCCIDRBlock»}]}] }

А для выбора из маппинга используем Fn::FindInMap, в которой используем тип подсети public или private, и делаем выборку по AvailabilityZone:

{ «Fn::FindInMap» : [ «AZSubNets», «public», «zone-a»» ] }

Собираем всё вместе, для AZNetworkStackА получается следующий блок:

...
          "PublicSubnetCIDR": {
            "Fn::Join" : [".", [
              { "Fn::Select": [ "0", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::Select": [ "1", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::FindInMap" : [ "AZSubNets", "public", "zoneA" ] } 
            ]]
          },                  
          "PrivateSubnetCIDR": { 
            "Fn::Join" : [".", [
              { "Fn::Select": [ "0", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::Select": [ "1", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::FindInMap" : [ "AZSubNets", "private", "zoneA" ] } 
            ]]
          }
..

В { "Fn::FindInMap" : [ "AZSubNets", "private", "zoneA" ] } для AZNetworkStackB соответсвенно используем zoneB.

Собираем всё вместе — теперь ресурсы вложенных стеков должны выглядеть так:

...
    "AZNetworkStackA": {
      "Type": "AWS::CloudFormation::Stack",
      "Properties": {
        "TemplateURL": "eks-azs-networking.json",
        "Parameters": {
          "VPCID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] },
          "AZ": { "Fn::Select": [ "0", { "Fn::GetAZs": "" } ] },
          "IGWID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.IGWID"] },
          "EKSClusterName": { "Ref": "EKSClusterName"},
          "PublicSubnetCIDR": {
            "Fn::Join" : [".", [
              { "Fn::Select": [ "0", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::Select": [ "1", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::FindInMap" : [ "AZSubNets", "public", "zoneA" ] }
            ]]
          },
          "PrivateSubnetCIDR": {
            "Fn::Join" : [".", [
              { "Fn::Select": [ "0", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::Select": [ "1", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::FindInMap" : [ "AZSubNets", "private", "zoneA" ] }
            ]]
          }
        }
      }
    },
    "AZNetworkStackB": {
      "Type": "AWS::CloudFormation::Stack",
      "Properties": {
        "TemplateURL": "eks-azs-networking.json",
        "Parameters": {
          "VPCID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.VPCID"] },
          "AZ": { "Fn::Select": [ "1", { "Fn::GetAZs": "" } ] },
          "IGWID": { "Fn::GetAtt": ["RegionNetworkStack", "Outputs.IGWID"] },
          "EKSClusterName": { "Ref": "EKSClusterName"},
          "PublicSubnetCIDR": {
            "Fn::Join" : [".", [
              { "Fn::Select": [ "0", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::Select": [ "1", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::FindInMap" : [ "AZSubNets", "public", "zoneB" ] }
            ]]
          },
          "PrivateSubnetCIDR": {
            "Fn::Join" : [".", [
              { "Fn::Select": [ "0", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::Select": [ "1", { "Fn::Split": [".",  { "Ref": "VPCCIDRBlock"} ] } ] },
              { "Fn::FindInMap" : [ "AZSubNets", "private", "zoneB" ] }
            ]]
          }
        }
      }
    }
...

Деплоим, проверяем:

Собственно — ничего и не изменилось, т.к. блоки подсетей у нас остались прежними.

eksctl — создание стека

Запустим тестовый кластер, что бы проверить, что всё работает — а потом займёмся Ansible и остальной автоматизацией.

Параметры берём из Outputs корневого стека:

Можно уже сразу создать каталоги для будущей роли eksctl, аналогично тому, как мы создавали их для CloudFormation в самом начале этого поста:

cd ../../../
mkdir -p roles/eksctl/{templates,tasks}

Пробуем — создаём делаем конфиг eks-cluster-config.yml:

touch roles/eksctl/templates/eks-cluster-config.yml
cd roles/eksctl/templates/

В файл вносим основные параметры для создания кластера:

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: eks-dev
  region: eu-west-2
  version: "1.15"
nodeGroups:
  - name: worker-nodes
    instanceType: t3.medium
    desiredCapacity: 2
    privateNetworking: true
vpc:
  id: "vpc-00f7f307d5c7ae70d"
  subnets:
    public:
      eu-west-2a:
        id: "subnet-06e8424b48709425a"
      eu-west-2b:
        id: "subnet-07a23a9e23cbb382a"
    private:
      eu-west-2a:
        id: "subnet-0c8a44bdc9aa6726f"
      eu-west-2b:
        id: "subnet-026c14589f4a41900"
  nat:
    gateway: Disable
cloudWatch:
  clusterLogging:
    enableTypes: ["*"]

Создаём кластер:

eksctl --profile arseniy create cluster -f eks-cluster-config.yml

Обратите внимание, что eksctl при создании стека для кластера добавляет к его имени eksctl + <НАШЕ-ИМЯ-КЛАСТЕРА> + cluster — будем учитывать в следующей задаче, когда начнём писать роли для Ansible.

Создание кластера занимает около 15 минут, плюс после него CloudFormation создаст второй стек, с Worker Nodes  — так что пока можно попить чай.

Ждём, пока запустятся Worker Nodes:

...
[ℹ]  nodegroup "worker-nodes" has 2 node(s)
[ℹ]  node "ip-10-0-40-30.eu-west-2.compute.internal" is ready
[ℹ]  node "ip-10-0-63-187.eu-west-2.compute.internal" is ready
[ℹ]  kubectl command should work with "/home/setevoy/.kube/config", try 'kubectl get nodes'
[✔]  EKS cluster "eks-dev" in "eu-west-2" region is ready

Проверяем:

Стек готов.

Наш локальный kubectl уже должен быть настроен на новый кластер самим eksctl — проверяем текущий конекст:

kubectl config current-context
arseniy@eks-dev.eu-west-2.eksctl.io

Проверяем доступ к кластеру и его ноды:

kubectl get nodes
NAME                                        STATUS   ROLES    AGE   VERSION
ip-10-0-40-30.eu-west-2.compute.internal    Ready    <none>   84s   v1.15.10-eks-bac369
ip-10-0-63-187.eu-west-2.compute.internal   Ready    <none>   81s   v1.15.10-eks-bac369

Собственно — с CloudFormation мы закончили.

Вторая часть — AWS: Elastic Kubernetes Service — автоматизация создания кластера, часть 2 — Ansible, eksctl.

Ссылки по теме

Kubernetes

Ansible

AWS

EKS
CloudFormation