Terraform: создание проекта с EC2, VPC и AWS cross-region VPC peering

Автор: | 28/06/2018

Одной из неприятных неожиданностей в использовании CloudFormation оказался факт того, что он не умеет создавать VPC peering между VPC в разных регионах.

Т.е., сама возможность создания cross-region peering была анонсирована>>> AWS в ноябре 2017, но по состоянию на июнь 2018 – AWS CloudFormation просто не имеет опции region.

В результате он пытается выполнить пиринг между сетями в одном регионе, и, конечно же, фейлится.

На форумах AWS есть соответствующая тема>>>, но от разработчиков пока комментариев нет.

Что бы решить эту проблему – было решено стек для Jenkins создавать с помощью Terraform (он у нас единственный размещается в Европе, поближе к нам, остальные стеки в США).

Ниже – пример создания EC2, VPC и VPC peering между сетями в двух регионах.

В стеке (регион eu-west-1) будет работать Jenkins, который будет подключен к Prometheus мониторингу в регионе us-east-2 через VPC peering.

Структура проекта

Структура файлов и каталогов получается следующая:

[simterm]

$ tree terraform/
terraform/
├── ec2
│   ├── ec2_icmp_and_default_sg.tf
│   ├── ec2.tf
│   ├── jenkins_security_group.tf
│   └── variables.tf
├── main.tf
├── terraform_exec.sh
├── variables.tf
└── vpc
    ├── variables.tf
    └── vpc.tf

[/simterm]

Тут каталоги ec2 и vpc – модули, а скрипт terraform_exec.sh используется для запуска Terraform plan/apply/destroy.

Скрипт

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

В проекте используется AWS S3 в качестве бекенда, инициализируется в функции terraform_config().

Затем при создании стека выполняется terraform_plan(), запрашивается подтверждение, и затем – terraform_apply(). Аналогично – при выполнении destroy.

Сам скрипт:

#/usr/bin/env bash

HELP="\n\t-a: apply
\n\t-D: delete
\n\t-e: environement to be used, default to \"dev\"\n\t"

# set default action to apply
apply=1
destroy=

while getopts "aDe:h" opt; do
    case $opt in
        a)
            apply=1
            ;;
        D)
            apply=
            destroy=1
            ;;
        e)
            ENV=$OPTARG
            ;;
        h)
            echo -e $HELP
            exit 0
            ;;
        ?)
            echo -e $HELP && exit 1
            ;;
 esac
done

# global vars
[[ -z $ENV ]] && ENV="dev"
AWS_PROFILE="jenkins-ci-provisioning"
AWS_REGION="eu-west-1"
CLUSTER_NAME="jenkins-ci-$ENV"

# monitoring peering data
MON_PROD_VPC_ID="vpc-51e8b639"
MON_PROD_REGION="us-east-2"
MON_PROD_VPC_CIDR="10.0.1.0/24"

# terraform backend vars
TF_BE_S3_BUCKET="terraform-$CLUSTER_NAME"
TF_BE_S3_STATE_KEY="$TF_BE_S3_BUCKET.tfstate"

echo -e "\nENV=$ENV
AWS CLI profile: $AWS_PROFILE
AWS region: $AWS_REGION
Application cluster name: $CLUSTER_NAME
Terraform backend S3 bucket name: $TF_BE_S3_BUCKET
Terraform backend key filename: $TF_BE_S3_STATE_KEY
"

read -p "Are you sure to proceed? [y/n] " -r
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
fi

# load modules
terraform get

# setup backend
terraform_config () {

    terraform init \
        -backend-config="bucket=$TF_BE_S3_BUCKET" \
        -backend-config="key=$TF_BE_S3_STATE_KEY" \
        -backend-config="region=$AWS_REGION" \
        -backend-config="profile=$AWS_PROFILE"
}


terraform_plan () {

    terraform plan \
        -var env=$ENV \
        -var aws-region=$AWS_REGION \
        -var aws-profile=$AWS_PROFILE \
        -var cluster-name=$CLUSTER_NAME \
        -var monitoring-prod-vpc-id=$MON_PROD_VPC_ID \
        -var monitoring-prod-region=$MON_PROD_REGION \
        -var monitoring-prod-vpc-cidr=$MON_PROD_VPC_CIDR
}

terraform_apply () {

    terraform apply \
        -var env=$ENV \
        -var aws-region=$AWS_REGION \
        -var aws-profile=$AWS_PROFILE \
        -var cluster-name=$CLUSTER_NAME \
        -var monitoring-prod-vpc-id=$MON_PROD_VPC_ID \
        -var monitoring-prod-region=$MON_PROD_REGION \
        -var monitoring-prod-vpc-cidr=$MON_PROD_VPC_CIDR

}

terraform_destroy () {

    terraform plan -destroy \
        -var env=$ENV \
        -var aws-region=$AWS_REGION \
        -var aws-profile=$AWS_PROFILE \
        -var cluster-name=$CLUSTER_NAME \
        -var monitoring-prod-vpc-id=$MON_PROD_VPC_ID \
        -var monitoring-prod-region=$MON_PROD_REGION \
        -var monitoring-prod-vpc-cidr=$MON_PROD_VPC_CIDR

    echo
    read -p "Plan complete. Are you sure to proceed? [y/n] " -r
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        exit 1
    fi

    terraform destroy \
        -var env=$ENV \
        -var aws-region=$AWS_REGION \
        -var aws-profile=$AWS_PROFILE \
        -var cluster-name=$CLUSTER_NAME \
        -var monitoring-prod-vpc-id=$MON_PROD_VPC_ID \
        -var monitoring-prod-region=$MON_PROD_REGION \
        -var monitoring-prod-vpc-cidr=$MON_PROD_VPC_CIDR
}

apply () {

    terraform_config || exit 1
    terraform_plan || exit 1

    read -p "Plan complete. Are you sure to proceed? [y/n] " -r
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        exit 1
    fi

    echo
    terraform_apply || exit 1
}

if [[ $apply == 1 ]]; then
    echo -e "\nRunning Apply action..."
    apply
elif [[ $destroy == 1 ]]; then
    echo -e "\nRunning Destroy action..."
    terraform_destroy
else
    echo -e "\nERROR: action does not set, exitting."
    exit 1
fi

Terraform

main.tf

В main.tf настраивается бекенд, создаётся провайдер, получается список availability зон.

Затем вызываются два модуля – ec2 и vpc, которым передаются необходимые переменные, часть которых передаётся из глобальных переменных скрипта, часть формируется в самом main.tf (например, eu-west-1a получается из data.aws_availability_zones.available.names[0]).

Файл выглядит так:

terraform {
  backend "s3" {
  }
}

provider "aws" {
    region = "${var.aws-region}"
    profile = "${var.aws-profile}"
}

data "aws_availability_zones" "available" {
    state = "available"
}

module "vpc" {
    source = "vpc"
    env = "${var.env}"
    aws-availability-zone = "${data.aws_availability_zones.available.names[0]}"
    monitoring-prod-vpc-id = "${var.monitoring-prod-vpc-id}"
    monitoring-prod-region = "${var.monitoring-prod-region}"
    monitoring-prod-vpc-cidr = "${var.monitoring-prod-vpc-cidr}"
    aws-profile = "${var.aws-profile}"
}

module "ec2" {
    source = "ec2"
    env = "${var.env}"
    aws-availability-zone = "${data.aws_availability_zones.available.names[0]}"
    jenkins-public-subnet-id = "${module.vpc.jenkins-public-subnet-id}"
    jenkins-vpc-id = "${module.vpc.jenkins-vpc-id}"
}

Модуль ec2

Тут выполняется создание EC2 инстанса, к нему подключается существующий EBS с данными Jenkins и Elastic IP.

Содержимое каталога модуля:

[simterm]

$ ls -l ec2/
total 16
-rw-r--r-- 1 setevoy setevoy  440 Jun 27 16:22 ec2_icmp_and_default_sg.tf
-rw-r--r-- 1 setevoy setevoy 1176 Jun 27 15:20 ec2.tf
-rw-r--r-- 1 setevoy setevoy 1032 Jun 28 10:25 jenkins_security_group.tf
-rw-r--r-- 1 setevoy setevoy 1611 Jun 27 14:27 variables.tf

[/simterm]

Основной файл модуля – ec2.tf:

resource "aws_volume_attachment" "jenkins-data-ebs-attach" {
    device_name = "/dev/xvdb"
    volume_id = "${lookup(var.ec2-data-ebs-id, var.env)}"
    instance_id = "${aws_instance.jenkins-ec2.id}"
}

resource "aws_instance" "jenkins-ec2" {
    ami = "${var.aws-ec2-ami-id}"
    instance_type = "${lookup(var.aws-ec2-type, var.env)}"
    key_name = "${lookup(var.aws-key-name, var.env)}"
    associate_public_ip_address = "true"
    availability_zone = "${var.aws-availability-zone}"
    vpc_security_group_ids = ["${aws_security_group.jenkins-web-ssh-sg.id}", "${aws_security_group.jenkins-default-sg.id}"]
    subnet_id = "${var.jenkins-public-subnet-id}"

    tags {
        "Name" = "jenkins-ec2-${var.env}"
    }
}

resource "aws_eip" "jenkins-eip" {
    instance = "${aws_instance.jenkins-ec2.id}"
    vpc = true

    tags {
        "Name" = "jenkins-ec2-${var.env}-eip"
    }
}

VPC subnet получается из outputs модуля vpc.

Dev/Production в Terraform

Для того, что переопределить различные параметры для Dev и Production окружений в Terrafrom можно применить три подхода.

variables mapping

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

Например, параметр instance_type для модуля ec2 и его ресурса aws_instance создаётся следующим образом:

...
instance_type = "${lookup(var.aws-ec2-type, var.env)}"
...

Далее, в файле variables.tf, создаётся сам mapping, в котором задаются два типа инстансов:

...
variable "aws-ec2-type" {
    description = "EC2 instance type for Dev and prod"
    type = "map"
    default = {
        "dev"           = "t2.nano"
        "production"    = "t2.medium"
    }
}
...

В зависимости от значения переменной env, которая задаётся в скрипте – выбирается одно из значений – t2.nano для Dev, или t2.medium для Production.

End/Prod каталоги

Другой вариант – более гибкий и позволяющий правильнее использовать саму концепцию модулей в Terraform – это создать каталоги, например develop и production, каждый со своим файлом main.tf и variables.tf.

Далее из main.tf каждого каталога вызывался бы модуль ec2, а из variables.tf каждого каталога – задавались бы значения переменных.

Вариант хороший, но в данном случае стек достаточно простой, с одним EC2, потому использовался mapping.

Terraform workspaces

И третий вариант – workspaces в Terraform. Но всё-таки его предназначение другое (тестирование проекта без внесения изменений в state-данные текущей инфрастуктуры).

Тем не менее – использовать тоже можно, например из документации:

...
resource "aws_instance" "example" {
  count = "${terraform.workspace == "default" ? 5 : 1}"

  # ... other arguments
}
...

Модуль vpc

И последним – пример модуля vpc:

[simterm]

$ ls -l vpc/
total 8
-rw-r--r-- 1 setevoy setevoy 1073 Jun 28 10:37 variables.tf
-rw-r--r-- 1 setevoy setevoy 2382 Jun 28 10:39 vpc.tf

[/simterm]

Основной файл – vpc.tf:

provider "aws" {
    alias = "peer"
    region = "${var.monitoring-prod-region}"
    profile = "${var.aws-profile}"
}

resource "aws_vpc" "jenkins-vpc" {    
    cidr_block                        = "${lookup(var.jenkins-vpc-cidr, var.env)}"
    assign_generated_ipv6_cidr_block  = true
    enable_dns_hostnames              = true
    
    tags {
        "Name" = "jenkins-${var.env}-vpc"
    }
}

resource "aws_subnet" "jenkins-public-subnet" {
    vpc_id            = "${aws_vpc.jenkins-vpc.id}"
    cidr_block        = "${lookup(var.jenkins-pub-subnet-cidr, var.env)}"
    availability_zone = "${var.aws-availability-zone}"
    
    tags {
        "Name" = "jenkins-${var.env}-pub-net"
    }
}

resource "aws_internet_gateway" "jenkins-igw" {
    vpc_id = "${aws_vpc.jenkins-vpc.id}"
}

resource "aws_route_table" "jenkins-route-tbl" {
    vpc_id = "${aws_vpc.jenkins-vpc.id}"

    route {
        cidr_block = "0.0.0.0/0"
        gateway_id = "${aws_internet_gateway.jenkins-igw.id}"
    }

    route {
        cidr_block = "${var.monitoring-prod-vpc-cidr}"
        gateway_id = "${aws_vpc_peering_connection.monitoring-prod-vpc-peer.id}"
    }

    tags {
        Name = "jenkins-${var.env}-route-table"
    }
}

resource "aws_route_table_association" "public-assoc" {
    subnet_id = "${aws_subnet.jenkins-public-subnet.id}"
    route_table_id = "${aws_route_table.jenkins-route-tbl.id}"
}

resource "aws_vpc_peering_connection" "monitoring-prod-vpc-peer" {
    peer_vpc_id = "${var.monitoring-prod-vpc-id}"
    vpc_id = "${aws_vpc.jenkins-vpc.id}"
    peer_region ="${var.monitoring-prod-region}"
  
    tags {
        Name = "VPC Peering Jenkins and Monitoring Prod"
    }
}

resource "aws_vpc_peering_connection_accepter" "monitoring-peer-accepter" {
    provider                  = "aws.peer"
    vpc_peering_connection_id = "${aws_vpc_peering_connection.monitoring-prod-vpc-peer.id}"
    auto_accept               = true
}

output "jenkins-public-subnet-id" {
  value = "${aws_subnet.jenkins-public-subnet.id}"
}

output "jenkins-vpc-id" {
  value = "${aws_vpc.jenkins-vpc.id}"
}
cross-region VPC peering

VPC peering создаётся с помощью ресурса aws_vpc_peering_connection:

...
resource "aws_vpc_peering_connection" "monitoring-prod-vpc-peer" {
    peer_vpc_id = "${var.monitoring-prod-vpc-id}"
    vpc_id = "${aws_vpc.jenkins-vpc.id}"
    peer_region ="${var.monitoring-prod-region}"
  
    tags {
        Name = "VPC Peering Jenkins and Monitoring Prod"
    }
}
...

Которому в параметре peer_region передаётся регион стека мониторинга (us-east-2), который определяется в переменной $MON_PROD_REGION скрипта terraform_exec.sh.

Для активации пиринга – используется ресурс aws_vpc_peering_connection_accepter, который использует дополнительный aws-провайдер, у которого задаётся регион и alias:

...
provider "aws" {
    alias = "peer"
    region = "${var.monitoring-prod-region}"
    profile = "${var.aws-profile}"
}
...

Переменная monitoring-prod-region так же задаётся в скрипте terraform_exec.sh в переменной $MON_PROD_REGION.

Как и в модуле ec2 – тут используется mapping для разделения dev/prod параметров, например VPC CIDR-ы:

...
variable "jenkins-vpc-cidr" {
    type = "map"
    default = {
        "dev" = "10.0.4.0/24"
        "production" = "10.0.5.0/24"
    }
}
...

Создание стека

“А теперь со всем этим говном на борту мы попробуем взлететь” (с)

Создание стека (привычка от CloudFormation называть стеком):

[simterm]

$ ./terraform_exec.sh -a                                                                                                          

ENV=dev                                                                                                                                                                                                                                       
AWS CLI profile: jenkins-ci-provisioning                                                                                                                                                                                                      
AWS region: eu-west-1                                                                                                                                                                                                                         
Application cluster name: jenkins-ci-dev                                                                                                                                                                                                      
Terraform backend S3 bucket name: terraform-jenkins-ci-dev                                                                                                                                                                                    
Terraform backend key filename: terraform-jenkins-ci-dev.tfstate                                                                                                                                                                              

Are you sure to proceed? [y/n] y                                                                                                                                                                                                              
- module.vpc                                                                                                                                                                                                                                  
- module.ec2                                                                                                                                                                                                                                  

Running Apply action...                                                                                                                                                                                                                       
Initializing modules...
- module.vpc
- module.ec2

Initializing the backend...

Initializing provider plugins...

...

Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + module.ec2.aws_eip.jenkins-eip
      id:                                          <computed>
      allocation_id:                               <computed>
      association_id:                              <computed>
      domain:                                      <computed>
      instance:                                    "${aws_instance.jenkins-ec2.id}"
      network_interface:                           <computed>
      private_ip:                                  <computed>
      public_ip:                                   <computed>
      tags.%:                                      "1"
      tags.Name:                                   "jenkins-ec2-dev-eip"
      vpc:                                         "true"

  + module.ec2.aws_instance.jenkins-ec2
      id:                                          <computed>
      ami:                                         "ami-34414d4d"
      associate_public_ip_address:                 "true"
      availability_zone:                           "eu-west-1a"

...

Plan: 12 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

Plan complete. Are you sure to proceed? [y/n] y

data.aws_availability_zones.available: Refreshing state...

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + module.ec2.aws_eip.jenkins-eip
      id:                                          <computed>

...

module.ec2.aws_eip.jenkins-eip: Creation complete after 2s (ID: eipalloc-ec98d0d1)
module.ec2.aws_volume_attachment.jenkins-data-ebs-attach: Still creating... (10s elapsed)
module.ec2.aws_volume_attachment.jenkins-data-ebs-attach: Still creating... (20s elapsed)
module.ec2.aws_volume_attachment.jenkins-data-ebs-attach: Creation complete after 23s (ID: vai-1099139600)

Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

[/simterm]

Пиринг:

Проверяем пинг с хоста Jenkins к хосту с мониторингом:

[simterm]

admin@ip-10-0-4-10:~$ ping 10.0.1.6 -c 1
PING 10.0.1.6 (10.0.1.6) 56(84) bytes of data.
64 bytes from 10.0.1.6: icmp_seq=1 ttl=64 time=85.2 ms

--- 10.0.1.6 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 85.214/85.214/85.214/0.000 ms

[/simterm]

И обратно:

[simterm]

admin@monitonrig-production:~$ ping 10.0.4.10 -c 1
PING 10.0.4.10 (10.0.4.10) 56(84) bytes of data.
64 bytes from 10.0.4.10: icmp_seq=1 ttl=64 time=85.4 ms

--- 10.0.4.10 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 85.440/85.440/85.440/0.000 ms

[/simterm]

Готово.