Цього разу піде мова про Atlantis, програму що автоматизує роботу із Terraform та в деяких випадках може значно спростити взаємодію із ним.
Atlantis - це самодостатній додаток на Golang, що слідкує за відкритими Pull Requests системи контролю версій за допомогою event-ів, що власне остання і надсилає. Під системою контролю версій мається на увазі Github, GitLab, Bitbucket чи Azure DevOps. Тобто це дещо складніше ніж просто Git, адже у неї має бути свій API, web-панель для рецензування та перегляду коду і т.п.
Із Atlantis зі змінами Terraform коду можна взаємодіяти напряму із web-панелі системи контролю версій, слідкувати за останніми змінами, перевіряти чи ці зміни були справді застосовані перед прийняттям коду в main вітку та застосовувати їх. Це справді може бути корисним та зручним, все залежить від процесів всередині команди та наскільки подібна логіка прийнятна.
У статті я розгляну 2 випадки установки та використання Atlantis. Перший, простіший, із застосуванням коду напряму через Terraform, а наступний - через, мабуть, найвідоміший wrapper Terragrunt. У якості системи контролю версій я буду використовувати Github, a сам Atlantis запустимо в Докері.
Створимо тестовий репозиторій в GitHub для розміщення тестового Terraform коду. Не буду довго на цьому зупинятись, адже в даному процесі немає нічого особливого.
Опишемо створення звичайного EC2 інстансу за допомогою мови HCL останньої версії Terraform. На момент написання статті остання версія - це v1.0.1
Створимо окрему директорію для коду:
$ cd simple-web
Опишемо провайдера, s3-бакет для стейтів та dynamo-db таблицю для локів. Це все важливо як для звичайного використання Тераформу, так і для використання його в тандемі із Atlantis.
provider "aws" {
region = var.region
allowed_account_ids = [var.account_id]
profile = var.profile
}
# no interpolation here
terraform {
backend "s3" {
bucket = "mine-tf-states"
encrypt = true
dynamodb_table = "mine-tf-locks"
key = "mine-inf.tfstate"
region = "us-east-1"
profile = "my-aws-account"
}
}
Security-групи для майбутнього EC2 інстансу. Лишимо відкритими лише ssh та http порти.
resource "aws_security_group" "web_sg" {
name = "web-sg"
lifecycle {
create_before_destroy = true
}
tags = {
Name = "web-sg"
}
}
resource "aws_security_group_rule" "allow_http_inbound" {
type = "ingress"
security_group_id = aws_security_group.web_sg.id
from_port = var.http_port
to_port = var.http_port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group_rule" "allow_ssh_inbound" {
type = "ingress"
security_group_id = aws_security_group.web_sg.id
from_port = var.ssh_port
to_port = var.ssh_port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group_rule" "allow_all_outbound" {
type = "egress"
security_group_id = aws_security_group.web_sg.id
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
Власне створення самого EC2-інстансу та ssh-ключа для доступу до нього:
resource "aws_key_pair" "my_key" {
key_name = "my-key"
public_key = var.public_key
}
resource "aws_instance" "web" {
# Ubuntu Server 20.04 LTS (HVM), SSD Volume Type in us-east-1
ami = "ami-0dd76f917833aac4b"
instance_type = "t3a.nano"
key_name = aws_key_pair.my_key.key_name
vpc_security_group_ids = ["${aws_security_group.web_sg.id}"]
}
user_data = <<-EOF
#!/bin/bash
echo "<h1>Say hello to Atlantis!</h1>" > index.html
nohup busybox httpd -f -p 8080 &
EOF
}
Виведемо адресу майбутньої новоствореної віртуальної машини в bash-сесію:
$ cat 99-outputs.tf
output "web_public_ip" {
value = aws_instance.web.public_ip
}
Ну і всі необхідні змінні:
$ cat variables.tf
variable "region" {
description = "The AWS region to create resources in"
type = string
default = "us-east-1"
}
variable "profile" {
description = "profile to use aws creds"
type = string
default = "my-aws-account"
}
variable "account_id" {
description = "AWS account ID"
type = string
default = "889248482628"
}
variable "http_port" {
description = "for HTTP requests"
type = string
default = 8080
}
variable "ssh_port" {
description = "for SSH requests"
type = string
default = 22
}
# replace ssh key on your own
variable "public_key" {
description = "ssh key for EC2 machines"
type = string
default = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDB6n9K78fB+4HzwqgwLQkd94bawaMdxF6LHqm4eeBWcvVKdE4EJwTGNA6QgfklJevZZFx/BU7xKn0I1iU86Z8Jwmn0dSaohFpcT+ckDoFfugbkQ28nO80rhOZK02m3U/HN7DjJePgLHkpMt6B527DFKrfNvFPZ9h/0EofH/4V2Xj6YFeaomxXtg31hZ7qcKSrI54YmtzFhUau0H6ndr45jVgzTqxYiIk/ioARt/hsmAzglym0/OE2qsGgpf+0gXZo7sLjvuaMhLuM7RqryOqEddqLeZnksJ6hippNUoC9esgJSBWrUSCTRenjurPDtqK2ZN7BS55Z3LG0eOsonJqBB my-email@example.com"
}
Gitignore варто створити вже за власним смаком:
.terraform
.terraform.lock.hcl
Не варто сильно орієнтуватись на якість цього коду, він приведений лиш для прикладу.
Установимо awscli, додамо AWS-профіль із необхідними доступами та назвемо його my-aws-account. Створимо s3-бакет для стейту та увімкнемо його версіонування:
--bucket mine-tf-states \
--region us-east-1
$ aws s3api --profile my-aws-account put-bucket-versioning \
--bucket mine-tf-states \
--versioning-configuration Status=Enabled
$ aws dynamodb --profile my-aws-account create-table \
--region us-east-1 \
--table-name mine-tf-locks\
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
Перед комітом коду, його варто перевірити локально. Це правило стосується не лише даного конкретного випадку, це хороше правило для всього життя:
Plan: 6 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ web_public_ip = (known after apply)
...
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
web_public_ip = "54.242.154.152"
Комітимо код у виществорений репозиторій. Має щось вийти на зразок цього:
1.2. BASIC ATLANTIS PREPARATION
Перед стартом Atlantis створимо нового користувача в GitHub. Це дуже бажано зробити, хоча і не обов'язково, адже таким чином людям, що працюють разом із вами, буде простіше зрозуміти, хто публікує коментарі до пулл ріквестів:
Створимо токен для вищезгаданого користувача. Для цього потрібно перейти по пунктам Settings -> Developer settings -> Generate new token, обрати scope repo та поки занотувати кудись токен:
Є також і інші варіанти надання доступу користувачу .
Atlantis-у також необхідний спеціальний секрет для вебхуків, для підвищення рівня безпеки. Згенерувати його просто і можна це зробити будь яким зручним для вас способом, наприклад за посиланням.
Додамо його до конфігурації репозиторію дещо пізніше, а наразі сфокусуємось на запуску Atlantis. Для його роботи потрібно надати всі необхідні доступи та ключі, тому створимо директорії, котрі надалі змонтуємо в контейнер:
$ cp ~/.ssh/yourkey ~/atlantis/.ssh/
$ cp ~/.aws/credentials ~/atlantis/.aws/
$ chmod 600 ~/atlantis/.ssh/yourkey
$ chown 100.1000 -R ~/atlantis/
Публічну частину ключа необхідно додати до налаштувань користувача GitHub:
Підготувавши всі необхідні доступи, запустимо контейнер:
-e GIT_SSH_COMMAND="ssh -i /home/atlantis/.ssh/yourkey -o 'StrictHostKeyChecking no'" \
-v /home/ipeacocks/atlantis:/home/atlantis \
ghcr.io/runatlantis/atlantis server \
--atlantis-url="http://my.public.node.address:4141" \
--gh-user=alantis-tf-bot \
--gh-token=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 \
--repo-allowlist=github.com/ipeacocks/atlantis-terraform-1 \
--gh-webhook-secret=rojkznpcrddoilcvoxmxmcskvgbgkoflvafyaipqgjrchtuagugqzqbyquydapeutxyagqo
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c239b75f51e0 runatlantis/atlantis "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:4141->4141/tcp, :::4141->4141/tcp amazing_noether
my.public.node.address:4141 - публічна адреса вузла, адже GitHub має відправляти вебхуки на нього. Ми скористались офіційним latest образом, проте загалом користуватись цим тегом - погана практика.
Додаємо вебхук до репозиторію GitHub. Для цього потрібно виконати наступне:
- Натиснути Webhooks в налаштуваннях GitHub-репозиторію
- Натиснути Add webhook
- В поле Payload URL написати http://my.public.node.address:4141/events (чи https:// у випадку активного SSL), тобто публічна адреса, на якій працює Atlantis
- У випадаючому списку Content type обрати application/json
- Вписати попередньо згенерований секрет до поля Webhook Secret, що ми згенерували раніше (він може бути лише один)
- Обрати Let me select individual events та встановити прапорці навпроти наступних полів: Pull request reviews, Pushes, Issue comments, Pull requests
- Лишити активованим перемикач Active
- Натиснути Add webhook
Все готово, перевіряємо роботу Atlantis. Для цього створимо пулл ріквест, в котрому змінили лише тип EC2-інстансу.
Одразу після створення пулл ріквесту, Atlantis запускає terraform plan:
Із Atlantis також можна взаємодіяти напряму в тілі пулл ріквесту і, наприклад, попросити застосувати код:
До закриття пулл ріквесту Atlantis продовжує зберігати план:
Описана вище логіка є стандартною, що активована по-замовчуванню. Звісно її можна змінити і про це ми поговоримо далі, якщо ви ще не стомились і тема залишилась цікавою.
2. ATLANTIS USAGE WITH TERRAGRUNT AND FEW TERRAFORM VERSIONS
Часто Terraform не використовують напряму, а використовують для цього врапери на зразок Terragrunt чи Terraspace. Перший допомагає ліпше організувати код та позбавитись повторень змінних. Також він вміє описувати залежності при запуску коду із декількох директорій та навчився управляти локами та писати стейти в s3 раніше за сам Terraform.
Ця стаття зовсім не про подібні врапери, але для того щоб продемонструвати складнішу структуру, більш схожу на справжній проект, необхідно буде також торкнутись і роботи Terragrunt. Виходячи з тих же обставин, зробимо припущення, що у нас також лишився код на Terrafrom 0.11 версії, останньої перед міграції на HCL2.
Отже встановимо Terraform v0.11.15 (для legacy коду) та v1.0.1. Також завантажимо останній Terragrunt v0.31.0.
$ curl -fsSL https://github.com/gruntwork-io/terragrunt/releases/download/v0.31.0/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt
$ chmod +x /usr/local/bin/terragrunt
$ curl -fsSL https://releases.hashicorp.com/terraform/0.11.15/terraform_0.11.15_linux_amd64.zip -o /tmp/terraform_0.11.15.zip
$ unzip /tmp/terraform_0.11.15.zip -d /usr/local/bin/
$ mv /usr/local/bin/terraform /usr/local/bin/terraform-011
$ chmod +x /usr/local/bin/terraform-011
$ rm -rf /tmp/terraform_011.zip
$ curl -fsSL https://releases.hashicorp.com/terraform/1.0.1/terraform_1.0.1_linux_amd64.zip -o /tmp/terraform_1.0.1.zip
$ unzip -o /tmp/terraform_1.0.1.zip -d /usr/local/bin
$ chmod +x /usr/local/bin/terraform
$ rm -rf /tmp/terraform_1.0.1.zip
У якості прототипу візьмемо код із моєї попередньої статті та дещо змінимо його. Спочатку створимо директорії:
Отже global я зазвичай лишаю для речей, які не мають регіону, наприклад IAM-ресурси чи глобальний бакет для секретів. А в us-east-1 буде розміщений код специфічних для регіону ресурсів. Опишемо загальний конфігураційний файл Террагранту для всього регіону us-east-1:
remote_state {
backend = "s3"
config = {
region = "us-east-1"
profile = "env-dev"
bucket = "env-dev-tf-states-us-east-1"
key = "us-east-1/${path_relative_to_include()}/terraform.tfstate"
encrypt = true
dynamodb_table = "env-dev-tf-locks-us-east-1"
}
}
terraform {
extra_arguments "common_var" {
commands = [
"apply",
"plan",
"import",
"push",
"destroy",
"refresh",
"apply-all",
"destroy-all"
]
}
}
inputs = {
region = "us-east-1"
account_id = "889248482628"
environment_name = "env-dev"
remote_states_bucket = "env-dev-tf-states-us-east-1"
global_remote_states_bucket = "env-dev-tf-states-us-east-1"
}
Тут ми описали загальні змінні (секція inputs), таблицю DynamoDB для локів та s3 бакет для стейтів Тераформу (секція remote_state). Надалі локи/стейти описувати не потрібно.
Створимо щойно описані ресурси:
--bucket env-dev-tf-states-us-east-1 \
--region us-east-1
$ aws s3api --profile env-dev put-bucket-versioning \
--bucket env-dev-tf-states-us-east-1 \
--versioning-configuration Status=Enabled
$ aws dynamodb --profile env-dev create-table \
--region us-east-1 \
--table-name env-dev-tf-locks-us-east-1 \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
env-dev - це профіль із доступом до аккаунту AWS, тобто ~/.aws/credentials має бути відповідно заповнений.
Створимо директорію для коду мережі та опишемо її створення:
provider "aws" {
region = "${var.region}"
allowed_account_ids = ["${var.account_id}"]
profile = "${var.profile}"
}
terraform {
backend "s3" {}
}
$ cat 01-network.tf
# Declare the data source
# All available zones (AZ) list
data "aws_availability_zones" "available" {}
resource "aws_vpc" "this" {
cidr_block = "${var.vpc_cidr}"
enable_dns_support = "${var.enable_dns_support}"
enable_dns_hostnames = "${var.enable_dns_hostnames}"
Name = "${var.environment_name}"
}
}
resource "aws_subnet" "public" {
vpc_id = "${aws_vpc.this.id}"
cidr_block = "${var.subnet_public_cidr}"
Name = "${var.environment_name}-pub"
}
}
resource "aws_subnet" "private" {
vpc_id = "${aws_vpc.this.id}"
cidr_block = "${var.subnet_private_cidr}"
Name = "${var.environment_name}-priv"
}
}
resource "aws_internet_gateway" "this" {
vpc_id = "${aws_vpc.this.id}"
Name = "${var.environment_name}"
}
}
resource "aws_eip" "private_nat" {
vpc = true
tags {
Name = "${var.environment_name}-priv"
}
}
# EIP of NAT Gateway needs to be in public network
resource "aws_nat_gateway" "private" {
allocation_id = "${aws_eip.private_nat.id}"
subnet_id = "${aws_subnet.public.id}"
depends_on = ["aws_internet_gateway.this"]
Name = "${var.environment_name}-priv"
}
}
$ cat 02-routing.tf
resource "aws_route_table" "public" {
vpc_id = "${aws_vpc.this.id}"
tags {
Name = "${var.environment_name}-pub"
}
}
resource "aws_route" "to_internet_gateway" {
route_table_id = "${aws_route_table.public.id}"
destination_cidr_block = "0.0.0.0/0"
gateway_id = "${aws_internet_gateway.this.id}"
}
resource "aws_route_table_association" "public" {
subnet_id = "${aws_subnet.public.id}"
route_table_id = "${aws_route_table.public.id}"
}
resource "aws_route_table" "private" {
vpc_id = "${aws_vpc.this.id}"
tags {
Name = "${var.environment_name}-priv"
}
}
resource "aws_route" "to_nat_gateway" {
route_table_id = "${aws_route_table.private.id}"
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = "${aws_nat_gateway.private.id}"
}
resource "aws_route_table_association" "private" {
subnet_id = "${aws_subnet.private.id}"
route_table_id = "${aws_route_table.private.id}"
}
$ cat 99-output.tf
output "vpc_id" {
value = "${aws_vpc.this.id}"
}
output "public_subnet_id" {
value = "${aws_subnet.public.id}"
}
output "private_subnet_id" {
value = "${aws_subnet.private.id}"
}
Це вже власне локальний файл terragrunt.hcl, котрий вказує на використання головного terragrunt.hcl із глобальними змінними та параметрами. Також тут вказано, що потрібно використовувати legacy terraform v0.11:
# Terragrunt stuff
include {
path = "${find_in_parent_folders()}"
}
dependencies {
paths = []
}
terraform_binary = "terraform-011"
terraform_version_constraint = ">= 0.11"
variable "region" {
description = "The region of AWS"
}
variable "environment_name" {
description = "Environment name to tag EC2 resources with (tag=environment)"
}
variable "profile" {
description = "profile to use aws creds"
}
variable "account_id" {
description = "profile to use aws creds"
}
variable "remote_states_bucket" {
description = "Bucket name to store remote states and import/export outputs"
}
# Local vars
variable "vpc_cidr" {
default = "172.28.0.0/16"
description = "vpc cidr"
}
variable "subnet_public_cidr" {
default = "172.28.0.0/24"
description = "cidr of the public subnet"
}
variable "subnet_private_cidr" {
default = "172.28.3.0/24"
description = "cidr of the private subnet"
}
variable "enable_dns_support" {
description = "Enable DNS support in VPC"
default = true
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in VPC"
default = true
}
Пояснення до описаного коду можна знайти у статті, на яку я вже посилався. Тут загалом VPC, публічна та приватна мережі та все, що необхідно для їх роботи. Код навмисно написаний на 0.11 терраформі для надання інфраструктури вигляду реального, часом legacy, вигляду. Опис мережі - це мабуть найкласичніший варіант.
Тепер опишемо створення DNS-зони та опцій DHCP для виществореного VPC, проте вже на 1.0.1 терраформі:
$ cat 00-init.tf
provider "aws" {
region = var.region
allowed_account_ids = [var.account_id]
profile = var.profile
}
terraform {
backend "s3" {}
}
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.remote_states_bucket
key = format("%s/10-network/terraform.tfstate", var.region)
region = var.region
profile = var.profile
}
}
$ cat 01-main.tf
resource "aws_vpc_dhcp_options" "this" {
domain_name = var.dns_zone_name
domain_name_servers = ["AmazonProvidedDNS"]
tags = {
Name = var.environment_name
}
}
resource "aws_vpc_dhcp_options_association" "this" {
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
dhcp_options_id = aws_vpc_dhcp_options.this.id
}
resource "aws_route53_zone" "internal" {
name = var.dns_zone_name
vpc {
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
}
}
$ cat 99-output.tf
output "domain_internal_zone_name" {
description = "Internal domain name"
value = aws_route53_zone.internal.name
}
output "domain_internal_zone_id" {
description = "Internal domain zone id"
value = aws_route53_zone.internal.id
}
$ cat terragrunt.hcl
include {
path = "${find_in_parent_folders()}"
}
dependencies {
paths = []
}
$ cat variables.tf
variable "region" {
description = "The region of AWS"
}
variable "environment_name" {
description = "Environment name to tag EC2 resources with (tag=environment)"
}
variable "profile" {
description = "profile to use aws creds"
}
variable "account_id" {
description = "profile to use aws creds"
}
variable "remote_states_bucket" {
description = "Bucket name to store remote states and import/export outputs"
}
variable "dns_zone_name" {
description = "internal dns name"
default = "env-dev.int"
}
І нарешті перейдемо до створення кількох віртуальних машин за балансувальником, де і запустимо наш тривіальний сервіс. Спочатку опишемо створення ключа:
$ cd atlantis-terraform-2/env-dev/30-ec2/10-keypair
provider "aws" {
region = var.region
allowed_account_ids = [var.account_id]
profile = var.profile
}
terraform {
backend "s3" {}
}
$ cat 01-main.tf
resource "aws_key_pair" "this" {
key_name = format("%s-main", var.environment_name)
public_key = var.public_key
}
$ cat 99-output.tf
output "key_name" {
value = "${aws_key_pair.this.key_name}"
}
$ cat terragrunt.hcl
include {
path = "${find_in_parent_folders()}"
}
dependencies {
paths = []
}
$ cat variables.tf
variable "region" {
description = "The region of AWS"
}
variable "environment_name" {
description = "Environment name to tag EC2 resources with (tag=environment)"
}
variable "profile" {
description = "profile to use aws creds"
}
variable "account_id" {
description = "profile to use aws creds"
}
variable "remote_states_bucket" {
description = "Bucket name to store remote states and import/export outputs"
}
# replace it on your own
variable "public_key" {
default = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDB6n9K78fB+4HzwqgwLQkd94bawaMdxF6LHqm4eeBWcvVKdE4EJwTGNA6QgfklJevZZFx/BU7xKn0I1iU86Z8Jwmn0dSaohFpcT+ckDoFfugbkQ28nO80rhOZK02m3U/HN7DjJePgLHkpMt6B527DFKrfNvFPZ9h/0EofH/4V2Xj6YFeaomxXtg31hZ7qcKSrI54YmtzFhUau0H6ndr45jVgzTqxYiIk/ioARt/hsmAzglym0/OE2qsGgpf+0gXZo7sLjvuaMhLuM7RqryOqEddqLeZnksJ6hippNUoC9esgJSBWrUSCTRenjurPDtqK2ZN7BS55Z3LG0eOsonJqBB my-email@example.com"
description = "ssh key to use for EC2 machines"
}
Звісно для логічності існування цієї інфраструктури також потрібен бастіон хост в публічній мережі, для можливості аутентифікації на віртуальних машинах в приватній підмережі. Але я пропущу його створення, щоб ще більше не роздувати цю, і без того довжелезну, статтю.
$ cd atlantis-terraform-2/env-dev/30-ec2/30-web
$ cat 00-init.tf
provider "aws" {
region = var.region
allowed_account_ids = [var.account_id]
profile = var.profile
}
terraform {
backend "s3" {}
}
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.remote_states_bucket
key = format("%s/10-network/terraform.tfstate", var.region)
region = var.region
profile = var.profile
}
}
data "terraform_remote_state" "route53" {
backend = "s3"
config = {
bucket = var.remote_states_bucket
key = format("%s/20-route53/terraform.tfstate", var.region)
region = var.region
profile = var.profile
}
}
data "terraform_remote_state" "key" {
backend = "s3"
config = {
bucket = var.remote_states_bucket
key = format("%s/30-ec2/10-keypair/terraform.tfstate", var.region)
region = var.region
profile = var.profile
}
}
$ cat 01-main.tf
data "aws_ami" "ubuntu_latest" {
most_recent = true
filter {
name = "name"
values = [format("ubuntu/images/hvm-ssd/ubuntu-%s-amd64-server-*", var.ubuntu_version)]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical
}
resource "aws_instance" "this" {
count = var.instances_count
ami = data.aws_ami.ubuntu_latest.id
instance_type = var.instance_type
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = data.terraform_remote_state.key.outputs.key_name
tags = {
Name = format("%s-%s-%s", var.environment_name, var.service_name, count.index)
}
user_data = <<-EOF
#!/bin/bash
echo "<h1>Say hello to Atlantis from web-${count.index}</h1>" > index.html
nohup busybox httpd -f -p "${var.web_port}" &
EOF
root_block_device {
volume_type = "gp2"
volume_size = var.block_volume_size
}
}
resource "aws_elb" "classic" {
name = format("%s-%s-classic", var.environment_name, var.service_name)
subnets = [data.terraform_remote_state.network.outputs.public_subnet_id]
security_groups = [aws_security_group.web_clb.id]
instance_port = var.web_port
instance_protocol = var.protocol
lb_port = var.web_ext_port
lb_protocol = var.protocol
}
health_check {
healthy_threshold = var.hc_healthy_threshold
unhealthy_threshold = var.hc_unhealthy_threshold
timeout = var.hc_timeout
target = format("%s:%s", var.protocol, var.web_port)
interval = var.hc_interval
}
instances = aws_instance.this[*].id
idle_timeout = var.idle_timeout
connection_draining = var.connection_draining
connection_draining_timeout = var.connection_draining_timeout
Name = format("%s-%s-classic", var.environment_name, var.service_name)
}
}
resource "aws_route53_record" "this" {
count = length(aws_instance.this)
zone_id = data.terraform_remote_state.route53.outputs.domain_internal_zone_id
name = format("%s-%s-%s", var.environment_name, var.service_name, count.index)
type = "A"
ttl = 60
records = [element(aws_instance.this.*.private_ip, count.index)]
}
$ cat 02-sgs.tf
resource "aws_security_group" "web" {
name = format("%s-%s", var.environment_name, var.service_name)
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
lifecycle {
create_before_destroy = true
}
tags = {
Name = format("%s-%s", var.environment_name, var.service_name)
}
}
resource "aws_security_group_rule" "allow_web_ssh_inbound" {
type = "ingress"
security_group_id = aws_security_group.web.id
from_port = var.ssh_port
to_port = var.ssh_port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group_rule" "allow_web_http_inbound" {
type = "ingress"
security_group_id = aws_security_group.web.id
from_port = var.web_port
to_port = var.web_port
protocol = "tcp"
source_security_group_id = aws_security_group.web_clb.id
}
resource "aws_security_group_rule" "allow_web_icmp_inbound" {
type = "ingress"
security_group_id = aws_security_group.web.id
from_port = -1
to_port = -1
protocol = "icmp"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group_rule" "allow_web_all_outbound" {
type = "egress"
security_group_id = aws_security_group.web.id
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group" "web_clb" {
name = format("%s-%s-clb", var.environment_name, var.service_name)
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
create_before_destroy = true
}
tags = {
Name = format("%s-web-clb", var.environment_name)
}
}
resource "aws_security_group_rule" "allow_web_clb_http_inbound" {
type = "ingress"
security_group_id = aws_security_group.web_clb.id
from_port = var.web_ext_port
to_port = var.web_ext_port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group_rule" "allow_web_clb_icmp_inbound" {
type = "ingress"
security_group_id = aws_security_group.web_clb.id
from_port = -1
to_port = -1
protocol = "icmp"
cidr_blocks = ["0.0.0.0/0"]
}
resource "aws_security_group_rule" "allow_web_clb_all_outbound" {
type = "egress"
security_group_id = aws_security_group.web_clb.id
from_port = 0
to_port = 0
protocol = -1
cidr_blocks = ["0.0.0.0/0"]
}
$ cat 99-output.tf
output "web-clb" {
value = aws_elb.classic.dns_name
}
$ cat terragrunt.hcl
include {
path = "${find_in_parent_folders()}"
}
dependencies {
paths = ["../10-keypair", "../20-route53", "../10-network"]
}
Тут ми вже виставили залежності між ресурсами, що були створені раніше. Тобто, якщо запустити код в директорії як find ./ -type d -name ".terraform" | xargs rm -rf && terragrunt apply-all -auto-approve, то Terragrunt буде проходити по залежностям і застосовувати кожну із директорій.
variable "region" {
description = "The region of AWS"
}
variable "environment_name" {
description = "Environment name to tag EC2 resources with (tag=environment)"
}
variable "profile" {
description = "profile to use aws creds"
}
variable "account_id" {
description = "profile to use aws creds"
}
variable "remote_states_bucket" {
description = "Bucket name to store remote states and import/export outputs"
}
variable "service_name" {
description = "Service name"
default = "web"
}
variable "ubuntu_version" {
description = "Ubuntu distro version"
default = "focal-20.04"
}
variable "ssh_port" {
description = "SSH port number"
default = 22
}
variable "web_port" {
description = "Web port number"
default = 8080
}
variable "web_ext_port" {
description = "Web external port number"
default = 80
}
variable "block_volume_size" {
description = "Block volume size"
default = 10
}
variable "instances_count" {
description = "Amount of EC2 instances"
default = 2
}
variable "instance_type" {
description = "Instance type"
default = "t3a.nano"
}
variable "protocol" {
description = "Protocol name"
default = "TCP"
}
variable "hc_healthy_threshold" {
description = "LB health check healthy threshold"
default = 3
}
variable "hc_unhealthy_threshold" {
description = "LB health check unhealthy threshold"
default = 3
}
variable "hc_timeout" {
description = "LB health check timeout"
default = 4
}
variable "hc_interval" {
description = "LB health check interval"
default = 30
}
variable "idle_timeout" {
description = "Idle timeout"
default = 90
}
variable "connection_draining" {
description = "Do you wish to enable connection draining ?"
default = true
}
variable "connection_draining_timeout" {
description = "Connection draining timeout"
default = 90
}
Врешті-решт структура директорій матиме вигляд:
atlantis-terraform-2/env-dev
├── global
│ └── .placeholder
└── us-east-1
├── 10-network
│ ├── 00-init.tf
│ ├── 01-network.tf
│ ├── 02-routing.tf
│ ├── 99-output.tf
│ ├── terragrunt.hcl
│ └── variables.tf
├── 20-route53
│ ├── 00-init.tf
│ ├── 01-main.tf
│ ├── 99-output.tf
│ ├── terragrunt.hcl
│ └── variables.tf
├── 30-ec2
│ ├── 10-keypair
│ │ ├── 00-init.tf
│ │ ├── 01-main.tf
│ │ ├── 99-output.tf
│ │ ├── terragrunt.hcl
│ │ └── variables.tf
│ ├── 20-bastion
│ │ └── .placeholder
│ └── 30-web
│ ├── 00-init.tf
│ ├── 01-main.tf
│ ├── 02-sgs.tf
│ ├── 99-output.tf
│ ├── terragrunt.hcl
│ └── variables.tf
└── terragrunt.hcl
Нарешті! Тепер тестуємо код локально, для цього потрібно запустити terragrunt apply в кожній директорії із кодом, розміщуємо його в GitHub-репозиторії та переходимо до наступного пункту.
2.2. ATLANTIS DOCKER IMAGE BUILD AND CONFIGURATION
Офіційний docker імедж, котрий було використано в першій частині статті, наразі нам не підходить, адже ми маємо запускати код через Terragrunt та до того ж всього у нас тепер дві версії Терраформу. Проте ми можемо взяти офіційний імедж за основу:
FROM ghcr.io/runatlantis/atlantis:v0.17.2
ARG TG_VERSION="v0.31.0"
ARG TG_REPO=https://github.com/gruntwork-io/terragrunt/releases/download
ARG TF_REPO=https://releases.hashicorp.com/terraform
RUN apk add --update \
unzip \
&& rm -rf /var/cache/apk/*
# remove default terraform
RUN rm -f /usr/local/bin/terraform
RUN curl -fsSL "${TG_REPO}"/"${TG_VERSION}"/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt && \
chmod +x /usr/local/bin/terragrunt
RUN curl -fsSL "${TF_REPO}"/"${TF_OLD_VERSION}"/terraform_"${TF_OLD_VERSION}"_linux_amd64.zip -o /tmp/terraform_"${TF_OLD_VERSION}".zip && \
unzip /tmp/terraform_"${TF_OLD_VERSION}".zip -d /usr/local/bin/ && \
mv /usr/local/bin/terraform /usr/local/bin/terraform-11 && \
chmod +x /usr/local/bin/terraform-11 && \
rm -rf /tmp/terraform_"${TF_OLD_VERSION}".zip
RUN curl -fsSL "${TF_REPO}"/"${TF_VERSION}"/terraform_"${TF_VERSION}"_linux_amd64.zip -o /tmp/terraform_"${TF_VERSION}".zip && \
unzip -o /tmp/terraform_"${TF_VERSION}".zip -d /usr/local/bin && \
chmod +x /usr/local/bin/terraform && \
rm -rf /tmp/terraform_"${TF_VERSION}".zip
Зберемо новий докер імедж:
$ docker build -t atlantis-custom .
Як і раніше копіюємо всі необхідні дані, що необхідні для роботи Atlantis (якщо цього ще не робили раніше):
$ cp ~/.ssh/yourkey ~/atlantis/.ssh/
$ cp ~/.aws/credentials ~/atlantis/.aws/
$ chmod 600 ~/atlantis/.ssh/yourkey
$ chown 100.1000 -R ~/atlantis/
100.1000 - це uid.gid користувача atlantis всередині майбутнього контейнера. Створюємо конфігураційний файл, де вкажемо деталі як необхідно запускати Terraform код:
repos:
- id: "/.*/"
workflow: terragrunt
# apply_requirements: [approved, mergeable]
apply_requirements: [mergeable]
allowed_overrides: [workflow]
allow_custom_workflows: true
workflows:
terragrunt:
plan:
steps:
# simple hack for avoiding wrong colour interpretation during auto init by 'terragrunt plan'
- run: terragrunt init -no-color && terragrunt plan -no-color -out=${PLANFILE}
apply:
steps:
- run: terragrunt apply -no-color ${PLANFILE}
Тож ми вказали яких директорій все стосується, при яких умовах можна виконувати застосування коду ([mergeable], тобто якщо немає конфліктів. Проте у випадку роботи в команді ліпше обрати [approved, mergeable], тобто щоб ще хтось виставив approve вашим змінам). В розділі workflows вказуємо які повні команди стоять за plan та apply.
Як і в п.1.2 треба створити всі необхідні токени на стороні GitHub та вебхук до нового репозиторію github.com/ipeacocks/atlantis-terraform-2 в GitHub.
Запускаємо Atlantis:
-p 4141:4141 \
-e GIT_SSH_COMMAND="ssh -i /home/atlantis/.ssh/yourkey -o 'StrictHostKeyChecking no'" \
-v ~/atlantis:/home/atlantis \
atlantis-custom server \
--atlantis-url="http://my.public.node.address:4141" \
--gh-user=alantis-tf-bot \
--gh-token=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 \
--repo-allowlist=github.com/ipeacocks/atlantis-terraform-2 \
--gh-webhook-secret=rojkznpcrddoilcvoxmxmcskvgbgkoflvafyaipqgjrchtuagugqzqbyquydapeutxyagqo \
--repo-config=/home/atlantis/config/repos.yaml
І тепер пересвідчимось що все дійсно працює. Для цього створимо нову бранчу, дещо змінимо та зробимо PR.
Після створення пулл ріквесту вебхук буде одразу відправлено на адресу Atlantis:
Проте таке стандартне поводження можна за необхідності змінити. Завдяки Террагранту, код в першій директорії буде застосований 0.11.15 Терраформом, а в другій - останньою версією 1.0.1. Прямо в тілі пулл ріквесту можна і застосувати код:
Після мерджу коду в main-вітку Atlantis видалить всі попередньо сформовані плани.
3. BOTTOM LINE
Надіюсь я вас не дуже стомив. На мою думку, необхідність в подібному сервісі для будь-яких команд не завжди є. Схоже Atlantis гарно підійде для команд, котрі володіють не надто великою та рідко змінюваною інфраструктурою. Складно уявити яку справжню користь принесе Atlantis у випадку гіганського пулл ріквесту із купою нових сервісів чи змін. Звісно такі пулл ріквести буде простіше переглядати, адже буде гарно відображено в яких директоріях зміни і чи застосований код у кожній із директорій. Але чи безпечно буде застосовувати так багато змін? Знову ж, чи мають зміни в пулл ріквесті бути застосовані перед його створенням? Якщо ні, то де вони попередньо мають бути протестовані? На окремому AWS аккаунті, котрий має бути піднятим лише для подібних сценаріїв? Якось забагато питань, що вже натякають на неуніверсальність інструменту.
Atlantis також може бути корисним у разі надання прав створювати ресурси окремим людям, в котрих немає доступ до адміністративної консолі AWS. Наприклад, якщо необхідно створити нового користувача чи інший простий ресурс без значних залежностей. Тобто пулл ріквест таким чином має бути переглянутий і у разі успіху може бути застосований Атлантісом і змерджений в основну вітку репозиторія.
У Atlantis є певні проблеми якщо необхідно працювати із ресурсами для яких необхідно знаходитись в одній підмережі при умові, що кодова база лежить в єдиному репозиторії, тобто коли dev/rc/prod описані в одному місці. Скажімо ви встановили RDS/mysql базу даних в dev середовищі (окрема мережа) і хочете додати користувача до неї. Atlantis знаходиться в prod-середовищі, і dev мережа банально недоступна. На думку одразу спадає підняти по Atlantis-серверу в кожній із мереж, а потім відправляти вебхуки із GitHub одночасно всім серверам...це можливо, але Atlantis не зрозуміє таких жестів, адже він просто запуститься двічі, бо не може розрізняти dev/rc/prod директорії і вибірково реагувати на зміни.
Немає коментарів:
Дописати коментар