[Terraform + SaltStack] Готовим PrestoDB кластер в скороварке (Часть #1)

Что здесь интересного?
image
Рецепт приготовления вкусного и полезного PrestoDB кластера используя скороварку на базе Terraform и SaltStack в публичном облаке AWS. Рассмотрим подробно нюансы подготовки к работе самой скороварки, необходимые шаги для правильного приготовления самого блюда и, естественно, немножко расскажем о потреблении готового блюда. Эту часть можно использовать как учебный материал по Terraform.
итак, приступим:
Ингредиенты для рецепта

  • Terraform — 1 шт.
  • SaltStack — 1 мастер и 1+ миньен
  • PrestoDB — 1 координатор и 1+ воркер
  • AWS аккаунт — 1 шт.
  • Смекалка и напильник — по вкусу

Рассмотрим ингредиенты подробнее: (без правил их приготовления)
1. Terraform — Замечательный инструмент от парней из Hashicorp (они же сделали такие, весьма полезные, штуки как Vagrant, Consul, Packer, Vault и др.) используемый для создания и модификации инфраструктур в различных облачных и не только окружениях.
2. SaltStack — Инструмент для автоматизированного конфигурирования и настройки серверов. Ваш покорный слуга уже писал об этом здесь и тут.
3. PrestoDB — Надстройка над Big Data провайдерами для возможности делать запросы к ним на родном и понятном SQL. Разработана ребятами из Facebook, которые перевели ее в статус OSS за что им огромное спасибо.
4. AWS (или любое другое публичное/приватное облако, к примеру: GCE или OpenStack) из списка поддерживаемых Terraform в котором будет в последствии работать наш PrestoDB кластер. Мы будем использовать AWS т.к. он наиболее распространен (среди public cloud платформ) и понятен многим без массы дополнительных пояснений.
5. В статье будут описаны лишь базовые принципы работы связки указанных продуктов, и некоторые хитрости для облегчения процесса, но я не буду подробно останавливаться на нюансах работы того или иного компонента — по каждому из них в принципе можно написать книгу. Потому адаптировать указанные приемы используя голову очень приветствуется. И еще — не пишите в комментариях, что что-то настроено не оптимально (в частности PrestoDB) — это не та цель которую я преследую.

Готовим скороварку!
В любом кулинарном рецепте есть умолчание о том, что сковородки и кастрюли уже готовы к приготовлению, но в нашем случае правильная подготовка скороварки (Terraform+SaltStack) является чуть-ли не на 80% залогом успешного приготовления блюда.
Итак — начнем с Terraform. Ну есть же CloudFormation для AWS или SaltCloud от создателей SaltStack так почему же выбран именно Terraform? Основная фишка Terraform в его простоте и понятном DSL — для создания инстанса (или 10ти) необходимо и достаточно такого описания (подразумеваем Terraform скачан и находится в пределах $PATH):

provider "aws" {
        access_key = "XXXXXXXXXXXXXXXXXXXXX" # AWS IAM key 
        secret_key = "******************************************" # AWS IAM secret
        region = "us-east-1" # region used to create resources
}

resource "aws_instance" "example_inst" {
        ami = "ami-6d1c2007" # CentOS 7 AMI located in US-East-1
        instance_type = "t2.medium" 
        count = "1" # or "10" can be used for parallel creation
        vpc_security_group_ids = [ "default-sg" ] # some security group with at least 22 port opened 
        key_name = "secure_key" # pre created AWS E2 key pair
        subnet_id = "sub-abcdef123" # AWS VPC subnet 
}


и простой последовательности команд:

terraform plan
terraform apply

описательная часть вполне понятна и, мне кажется, не требует пояснений для тех, кто знаком с AWS. Подробнее про доступные AWS ресурсы тут. Конечно же подразумеваем, что AWS аккаунт, ключи которого указаны в Terraform конфигурации, имеет привилегии на создание необходимых ресурсов.
Собственно самое интересное кроется в вызовах самого Terraform — terraform plan — делает «прикидку» того, что необходимо сделать с последнего состояния (в нашем примере — надо создать новый инстанс) и показывает какие ресурсы будут созданы, удалены или модифицированы, apply — собственно запустит процесс создания запланированных ресурсов. В случае если Terraform уже запускался и Вы изменили конфигурацию (скажем, добавили инстансов) на этапе планирования будет показано какие недостающие ресурсы будут созданы и apply может создать недостающие.

terraform destroy

поможет полностью убрать все ресурсы созданные при помощи Terraform (учитываются находящиеся в текущем каталоге .tfstate файлы, хранящие описание состояние созданной инфраструктуры).
Важный момент, о котором не стоит забывать — terraform в большинстве случаев не будет модифицировать уже имеющиеся ресурсы — он просто удалит старые и пересоздаст заново. Это значит, к примеру, что если вы создали инстанс типа t2.medium и потом поменяли конфигурацию указав новый тип для инстанса, скажем m4.xlarge, то при запуске apply Terraform сначала уничтожит ранее созданный, а потом создаст новый. Это может показаться странным для пользователей AWS (можно было остановить инстанс, поменять его тип и запустить заново не потеряв при этом данных на диске), но это сделано для предоставления одинаково прогнозируемого поведения на всех платформах. И еще одно: Terraform не умеет (да и не должен уметь по своей природе) контролировать ресурсы во время их жизненного цикла — это значит, что Terraform не предоставляет команды типа stop или reboot для созданных с его помощью инстансов — Вы должны использовать другие средства для управления созданной инфраструктурой.
Terraform предоставляет прекрасный набор функционала доступный в своем DSL — это переменные (https://www.terraform.io/docs/configuration/variables.html), интерполяторы (необходимы для итерирования, модификации переменных), модули и т.д. Вот один из примеров использования всего этого:

# Cluster shortname
variable cluster_name { default = "example-presto" }

# Count of nodes in cluster
variable cluster_size { default = 3 }

# Default owner for all nodes
variable cluster_owner { default = "user@example.com" }

# Default AWS AMI to use for cluster provisioning
variable cluster_node_ami { default = "ami-6d1c2007" }

# Default AWS type to use for cluster provisioning
variable cluster_node_type { default = "t2.large" }

# Defualt VPC subnet
variable cluster_vpc_subnet { default = "subnet-da628fad" }

# Default Security group to apply to instances
variable cluster_sg { default = "sg-xxxxxxx" }

# Default KeyPair to use for provisioning
variable cluster_keyname { default = "secure_key" }

# Clurter worker nodes
resource "aws_instance" "worker_nodes" {
        
        ami = "${var.cluster_node_ami}"
        instance_type = "${var.cluster_node_type}"
        count = "${var.cluster_size - 1}" # one node will be used for coordinator
        vpc_security_group_ids = [ "${var.cluster_sg}" ]
        key_name = "${var.cluster_keyname}"
        subnet_id = "${var.cluster_vpc_subnet}"

        disable_api_termination = true

        tags {
                Name = "${var.cluster_name}-cluster-worker-${format("%02d", count.index+1)}"
                Owner = "${var.cluster_owner}"
                Purpose = "PrestoDB cluster '${var.cluster_name}' node ${format("%02d", count.index+1)}"
        }
}

тут пример использования переменных, арифметических операций над ними, интерполяция с помощью format, использование индекса текущего элемента (если создается несколько однотипных инстансов), а также тегирование ресурсов.
Но только лишь создания/уничтожения инстансов не достаточно — необходимо их еще как-то инициализировать (скопировать файлы, установить и настроить специфичный софт, обновить систему, провести конфигурацию кластера и т.д.) для этого Terraform вводит понятие Provisioners. К основным из них относятся file, remote-exec, chef и null-resource. Типичными операциями являются копирование файлов и запуск скриптов на удаленном инстансе.
Вот предыдущий пример с включенными операциями провиженинга:

# Localy stored SSH private key filename
variable cluster_keyfile { default = "~/.ssh/secure_key.pem" }

# Clurter worker nodes
resource "aws_instance" "worker_nodes" {
        
        ami = "${var.cluster_node_ami}"
        instance_type = "${var.cluster_node_type}"
        count = "${var.cluster_size - 1}" # one node will be used for coordinator
        vpc_security_group_ids = [ "${var.cluster_sg}" ]
        key_name = "${var.cluster_keyname}"
        subnet_id = "${var.cluster_vpc_subnet}"

        disable_api_termination = true

        tags {
                Name = "${var.cluster_name}-cluster-worker-${format("%02d", count.index+1)}"
                Owner = "${var.cluster_owner}"
                Purpose = "PrestoDB cluster '${var.cluster_name}' node ${format("%02d", count.index+1)}"
        }

        # Copy bootstrap script
        provisioner "file" {
                source = "bootstrap-script.sh"
                destination = "/tmp/bootstrap-script.sh"
                connection {
                        type = "ssh"
                        user = "centos"
                        private_key = "${file("${var.cluster_keyfile}")}"
                }
        }

        # Running provisioning commands
        provisioner "remote-exec" {
                inline = [
                        "yum -y update",
                        "sudo sh /tmp/bootstrap-script.sh"
                ]
                connection {
                        type = "ssh"
                        user = "centos"
                        private_key = "${file("${var.cluster_keyfile}")}"
                }
        }
}

Основное замечание — указание информации о соединении к удаленному хосту — для AWS это чаще всего доступ по ключу — потому Вы должны указать где именно этот ключ лежит (для удобства была введена переменная). Обратите внимание, что атрибут private_key в секции connection не может принимать путь к файлу (только ключ текстом) — вместо этого используется интерполятор $file{} который открывает файл на диске и возвращает его содержимое.
Мы добрались до создания простого кластера состоящего из нескольких инстансов (не будем вдаваться в подробности содержимого файла bootstrap-script.sh — положим, что там прописана установка необходимого софта). Давайте рассмотрим, как в нашей скороварке делать кластера с выделенным мастером. В общем будем полагать, что worker ноды кластера должны знать где находится master нода для того, чтобы в ней зарегистрироваться и в дальнейшем получать задачи (давайте оставим всякие вкусности типа Raft и Gossip протоколы для установления мастера и распространения информации в кластере для других статей) — для простоты — положим worker должен знать ip адрес master-а. Как это реализовать в Terraform? Для начала надо создать отдельный инстанс для master-а:

resource "aws_instance" "master_node" {
        ami = "${var.cluster_node_ami}"
        instance_type = "${var.cluster_node_type}"
        count = "1"

        <...skipped...>

        provisioners {
        <...skipped...>
        }
}

затем, добавим зависимость в worker ноды:

# Clurter worker nodes
resource "aws_instance" "worker_nodes" {
        depends_on = ["aws_instance.master_node"] # dependency from master node introduced
        ami = "${var.cluster_node_ami}"
        instance_type = "${var.cluster_node_type}"
        count = "${var.cluster_size - 1}" # one node will be used for coordinator
        
        <...skipped...>
}


модификатор ресурса depends_on можно использовать для задания порядка выполнения задач по созданию инфрастуктур — Terraform не будет создавать worker ноды до тех пор пока не будет полностью создана master нода. Как видно из примера в качестве зависимости (тей) можно указывать список конструируемый из типа ресурса с указанием через точку его имени. В AWS Вы можете создавать не только инстансы, но и VPC, сети и т.д. — их нужно будет указывать как зависимости для использующих VPC ресурсов — это будет гарантировать правильный порядок создания.
Но, продолжим с передачей адреса master ноды всем worker нодам. Для этого Terraform предоставляет механизм ссылок на раннее созданные ресурсы — т.е. вы можете просто извлечь информацию о ip адресе master ноды в описании worker-а:

# Clurter worker nodes
resource "aws_instance" "worker_nodes" {
        depends_on = ["aws_instance.master_node"] # dependency from master node introduced
        ami = "${var.cluster_node_ami}"
        instance_type = "${var.cluster_node_type}"
        count = "${var.cluster_size - 1}" # one node will be used for coordinator
        
        <...skipped...>

       # Running provisioning commands
        provisioner "remote-exec" {
                inline = [
                        "yum -y update",
                        "sudo sh /tmp/bootstrap-script.sh ${aws_instance.master_node.private_ip}" # master-ip passed to script
                ]
                connection {
                        type = "ssh"
                        user = "centos"
                        private_key = "${file("${var.cluster_keyfile}")}"
                }
        }
}

т.е. при помощи переменных вида ${aws_instance.master_node.private_ip} можно получить доступ к почти любой информации о ресурсе. В данном примере подразумеваем, что bootstrap-script.sh может принимать в качестве параметра адрес master ноды и использовать его в последствии для внутреннего конфигурирования.
Иногда не достаточно и таких связей, — к примеру, необходимо вызвать какие-то скрипты на стороне master ноды после подключения worker нод (принять ключи, запустить init задачи на worker нодах и т.п.) для этого есть механизм который в Terraform называется null-resource — это fake ресурс который с помощью механизма зависимостей (см. выше) может быть создан после того, как будут созданы все master и worker ноды. Вот пример такого ресурса:

resource "null_resource" "cluster_provision" {
        depends_on = [
                "aws_instance.master_node",
                "aws_instance.worker_nodes"
        ]

        # Changes to any instance of the workers cluster nodes or master node requires re-provisioning
        triggers {
                cluster_instance_ids = "${aws_instance.master_node.id},${join(",", aws_instance.worker_nodes.*.id)}"
        }

        # Bootstrap script can run only on master node
        connection {
                host = "${aws_instance.coordinator_node.private_ip}"
                type = "ssh"
                user = "centos"
                private_key = "${file("${var.cluster_keyfile}")}"
        }

        provisioner "remote-exec" { 
                inline = [
                        <... some after-provision scripts calls on master node...>
                ]
        }
}

небольшое пояснение:
1. depends_on — мы указываем список тех ресурсов, которые должны быть готовы заранее.
2. triggers — формируем стоку (id всех инстансов через запятую, в нашем случае) изменение которой вызовет выполнение всех указанных в этом ресурсе провизионеров.
3. указываем на каком инстансе нужно выполнить скрипты провизионинга указанные в этом ресурсе в секции connection.

Если Вам нужно совершить несколько шагов на разных серверах — создавайте несколько null-resource с указанием необходимых зависимостей.

В целом, описанного будет достаточно для создания достаточно сложных инфрастурктур с помощью Terraform.
Вот еще несколько важных советов для тех, кто любит учиться на чужих ошибках:
1. Не забывайте бережно хранить .tfstate файлы в которых Terraform хранит последнее состояние созданной инфрастуркутуры (ко всему — это json файл, который можно использовать как исчерпывающий источник информации о созданных ресурсах)
2. Не меняйте созданные при помощи Terraform ресурсы вручную (используя консоли управления самим сервисами и другие внешние фреймворки) — при следующем запуске plan & apply вы получите пересоздание не соответствующего текущему описанию ресурса, что будет весьма неожиданно и часто плачевно.
3. Старайтесь сначала оттестировать свои конфигурации на небольших по размеру инстансах / небольшом их количестве, — много ошибок очень тяжело отловить в ходе создания конфигураций, а встроенный в Terraform валидатор покажет только синтаксические ошибки (и то не все).

Во второй части рассмотрим продолжение приготовления к работе скороварки — опишем как положить на верх созданной инфраструктуры SaltStack master + minions чтобы поставить PrestoDB.

© Habrahabr.ru