2018-06-12

О Terraform

Я освоил Terraform. Это ещё один замечательный инструмент от HashiCorp. Это те ребята, которые за immutable infrastructure. Которые сделали Vagrant, Consul, Packer и кучку других инструментов, популярных в альтернативной-Docker вселенной.
Terraform делает только одну задачу. И делает её хорошо. Задача эта: создать, собрать и настроить и ввергнуть во тьму ресурсы. Любые ресурсы, которые можно описать в виде набора свойств, понятных провайдеру этих самых ресурсов. В первую очередь речь идёт о ресурсах наших любимых вычислительных облаков. AWS и Azure. И множества других.
Terraform — это не есть единое API для всех облаков. За единым API попробуйте сходить к Kubernetes. Terraform — это единый способ описания ресурсов. Возможно, сразу в нескольких облаках. Но вот сами типы ресурсов, их имена и атрибуты, будут в каждом облаке свои. И в них придётся разобраться.
Зато описания ресурсов будут в текстовом виде. В файлах формата HCL (это нечто среднее между JSON и YAML). В вашей системе контроля версий. Это и называется Infrastructure as Code.
Terraform logo
Terraform написан на Go. Так что вся установка сводится к распаковке единственного бинарного файла из архива, прописыванию его в PATH и настройке автодополнения в вашей shell.
Поехали по терминологии.
Конфигурация. Configuration. Это набор *.tf файлов в текущем каталоге, то есть где вы запустили terraform. В этих файлах вы описываете то, что вам нужно создать и поддерживать. Не важно, как вы эти файлы назовёте. Не важно, в каком порядке в них всё опишете. Есть только небольшое соглашение, что должны быть файлы: main.tf — типа заглавные определения, variables.tf — все входные переменные, outputs.tf — все выходные переменные данной конфигурации.
Провайдеры. Providers. Плагины, отдельные бинарники, которые определяют весь остальной набор того, что можно задать в конфигурации. Они скачиваются при инициализации конфигурации. Инициализация делается командой terraform init.
Вот как может выглядеть описание провайдера для амазонового облака (AWS):
provider "aws" {
    version = "~> 1.20"
    region = "${var.aws_region}"
    profile = "${var.aws_profile}"
}
Конструкции с фигурными скобками — это interpolations. В данном случае берутся значения двух входных переменных.
Переменные. Variables. Входные значения для вашей конфигурации. Их надо явно описать:
variable "aws_profile" {}
variable "aws_region" {}
Переменные бывают трёх типов: строка, список, map. В данном случае тип не указан, и подразумевается строка. С поддержкой вложенных типов, типа списка mapов, всё настолько неопределённо, что лучше считать, что вложенных типов нет.
Значения для переменных, как и положено, можно передавать кучей разных способов. Через переменные окружения типа TF_VAR_aws_profile. Через параметры командной строки типа -var 'aws_profile="terraform"'. Через файлы *.tfvars, которые нужно явно передавать в командной строке: -var-file ../test.tfvars. Через файлы *.auto.tfvars в текущем каталоге, которые подгружаются автоматически. Понятное дело, есть правила переопределения переменных, а mapы даже мержатся.
Есть ещё выходные переменные. Это некоторые значения, которые являются выходом вашей конфигурации. Эти значения потом можно легко получить командой terraform output.
Ресурсы. Resources. То, ради чего всё это и затевается. Ресурсы, поддерживаемые провайдером, которые нужно создать, или модифицировать, или удалить.
У каждого ресурса есть тип, зависящий от провайдера, имя, в контексте данной конфигурации, и набор аргументов, которые нужно передать ресурсу. Также ресурс имеет некоторые атрибуты, это какие-то, возможно, вычисляемые значения, которые становятся известны, когда ресурс создан.
Вот как может выглядеть, например, создание S3 бакета:
resource "aws_s3_bucket" "frontend_bucket" {
    bucket = "${var.environment}-frontend"
    acl = "public-read"
    website {
        index_document = "index.html"
        error_document = "index.html"
    }
    tags {
        Name = "${var.deployment} Frontend"
        Environment = "${var.environment}"
        Deployment = "${var.deployment}"
    }
}
Атрибуты одних ресурсов могут быть указаны как interpolations при создании других ресурсов. Тогда Terraform понимает, что ресурсы имеют зависимость, и создаёт их в правильном порядке.
resource "aws_cloudfront_distribution" "frontend_s3_distribution" {
    origin {
        // вот ссылка на S3 bucket
        domain_name = "${aws_s3_bucket.frontend_bucket.bucket_domain_name}"
        origin_id = "S3-${var.environment}-frontend"
    }

    //... у CloudFront Distribution ооочень много аргументов
}
Чаще всего ресурсы один в один соответствуют тем ресурсам, что можно создать через API или в консоли управления облаком. Но иногда встречаются и «псевдоресурсы», которые представляют собой команды модификации «настоящих» ресурсов.
Например, Security Groups в AWS. Часто нужно, чтобы одна Security Group ссылалась на другую, а та ссылалась на эту. В консоли это проблем не вызывает, создаём обе, а потом модифицируем права доступа, чтобы они ссылались друг на друга.
Terraform так не умеет. Он считает, что все ресурсы должны быть созданы за один присест, без многошаговых модификаций, в порядке, согласно их зависимостям (а если нет зависимостей, то даже параллельно). А здесь получается циклическая зависимость, которая Terraform не устраивает.
Для решения проблемы придумали «псевдоресурс» aws_security_group_rule, который фактически является шагом модификации «настоящего» ресурса aws_security_group. Циклическая зависимость разрывается. Настоящие ресурсы создаются в несколько шагов.
// первая security group
resource "aws_security_group" "alb_security_group" {
    name        = "${var.environment}-alb-security-group"
    description = "Allows HTTPS from Anywhere into ALB"
    vpc_id      = "${var.vpc_id}"

    // здесь правила доступа прописаны внутри ресурса
    ingress {
        from_port   = 443
        to_port     = 443
        protocol    = "tcp"
        cidr_blocks = [ "0.0.0.0/0" ]
        ipv6_cidr_blocks = [ "::/0" ]
    }

    egress {
        from_port   = 0
        to_port     = 0
        protocol    = "-1"
        // тут ссылаемся на вторую security group
        security_groups = [ "${aws_security_group.ecs_cluster_security_group.id}" ]
    }
}

// вторая security group
resource "aws_security_group" "ecs_cluster_security_group" {
    // тут нет ссылок на первую security group
    name        = "${var.environment}-ecs-cluster-security-group"
    description = "Allows traffic from ALB"
    vpc_id      = "${var.vpc_id}"
}

// правило доступа для второй security group
resource "aws_security_group_rule" "allow_alb_for_ecs" {
    security_group_id = "${aws_security_group.ecs_cluster_security_group.id}"

    type = "ingress"
    from_port = 0
    to_port = 0
    protocol = "-1"
    // вот тут ссылка на первую security group
    source_security_group_id = "${aws_security_group.alb_security_group.id}"
}

// еще одно правило доступа для второй security group
resource "aws_security_group_rule" "allow_egress_for_ecs" {
    security_group_id = "${aws_security_group.ecs_cluster_security_group.id}"

    type = "egress"
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = [ "0.0.0.0/0" ]
    ipv6_cidr_blocks = [ "::/0" ]
}
Обратите внимание на передачу списков. Почему такие вольности, и почему кавычки обязательны, я не знаю.
    // одиночное значение можно обернуть в список
    list = [ "${var.single}" ]
    // список можно передать как список
    list = "${var.list}"
    // а можно обернуть в скобки
    list = [ "${var.list}" ]
    // а можно даже к списку добавить ещё значение
    list = [ "${var.list}", "${var.single}" ]
    // или даже объединить два списка
    list = [ "${var.list1}", "${var.list2}" ]
Источники данных. Data Sources. Провайдеры умеют не только создавать ресурсы, но и запрашивать атрибуты уже имеющихся ресурсов. Например, чтобы вставить какие-нибудь свойства в ваши ресурсы. Для этого и нужны источники данных.
Но есть и более интересные применения. Например, провайдер template позволяет читать локальные файлы-шаблоны, подставляя туда переменные. Синтаксис шаблонов полностью повторяет синтаксис interpolations. Что довольно убого. Циклов, например, туда не завезли.
Но есть очень интересный способ «добавить» циклы в шаблоны. Дело в том, что и у ресурсов, и у источников данных есть аргумент count. По умолчанию он равен одному, и создаётся ровно один ресурс или источник данных. (Если поставить нуль, то ничего создано не будет). А если поставить больше единицы, то будет создано несколько ресурсов или источников данных. И все атрибуты этих ресурсов или источников данных можно получить в виде списка. Это общая практика в Terraform: по спискам аргументов формировать списки ресурсов. А уж превратить список значений в тот же массив JSON — дело техники.
// определение провайдера
provider "template" {
    version = "~> 1.0"
}

data "template_file" "container_definition" {
    // читаем этот файл
    template = "${file("${path.module}/container-definition.json")}"
    // столько раз, сколько у нас задано контейнеров
    count = "${length(var.containers)}"

    vars {
        // тут доступ к элементу списка
        name = "${element(var.containers, count.index)}"
        // тут много списков...
        image = "${element(aws_ecr_repository.repository.*.repository_url, count.index)}"
        cpu = "${element(var.container_cpu_limits, count.index)}"
        memory = "${element(var.container_memory_limits, count.index)}"
        port = "${element(var.container_ports, count.index)}"
        region = "${var.region}"
        log_group = "${aws_cloudwatch_log_group.log_group.name}"
        log_stream_prefix = "${element(var.containers, count.index)}"
        environment = "${element(var.container_environments, count.index)}"
    }
}

resource "aws_ecs_task_definition" "task_definition" {
    family                   = "${var.environment}-${var.service}"
    // тут формируем json array из списка отрендеренных шаблонов
    container_definitions    = "[ ${join(",", data.template_file.container_definition.*.rendered)} ]"
    requires_compatibilities = ["FARGATE"]
    network_mode             = "awsvpc"
    cpu                      = "${var.cpu_limit}"
    memory                   = "${var.memory_limit}"
    execution_role_arn       = "${var.ecs_execution_role_arn}"
    task_role_arn            = "${var.ecs_execution_role_arn}"
}
Обратите внимание, для доступа ко всем переменным и атрибутам используется единый синтаксис с префиксами.
  • var.var_name — значение переменной
  • resource_type.resource_name.attribute_name — значение атрибута ресурса, определённого в данной конфигурации
  • resource_type.resource_name.*.attribute_name — список значений атрибутов ресурса, если count был больше единицы
  • data.data_source_type.data_source_name.attribute_name — значение атрибута источника данных
  • data.data_source_type.data_source_name.*.attribute_name — список значений атрибутов источника данных, если count был больше единицы
  • count.index — индекс текущей «итерации» «цикла», если count больше единицы
  • path.module — путь (в файловой системе) текущего модуля
  • module.module_name.module_output — значение выходной переменной модуля
Почти со всеми этими выражениями можно интерактивно поиграть, если выполнить команду terraform console.
Модули. Modules. Любая конфигурация в Terraform может рассматриваться как модуль. Текущая конфигурация, в текущем каталоге, называется root module. Любые другие конфигурации, в других каталогах файловой системы, из реестра модулей от HashiCorp, из репозитория на GitHub или Bitbucket, просто доступные по HTTP, можно подключить к текущей конфигурации и использовать.
При подключении модулю даётся имя. Под этим именем он будет здесь доступен. Нужно указать местоположение модуля. Одно и то же местоположение модуля можно подключать несколько раз под разными именами. Так что думайте об опубликованных конфигурациях как о классах, которые можно переиспользовать. А конкретное подключение здесь можно считать экземпляром класса. Наследования только нет :) Зато делегирование — пожалуйста. Модули могут использовать другие модули.
Кроме имени, модулю при подключении нужно задать аргументы. Это те самые входные переменные. Внутри модуля они будут использоваться как ${var.var_name}. А результат работы модуль предоставляет в виде выходных переменных. На них в данной конфигурации можно ссылаться как ${module.module_name.var_name}. В общем-то, почти аналогично использованию ресурсов.
// подключаем модуль и даём ему имя "vpc"
module "vpc" {
    // определение модуля берём из локальной ФС двумя уровнями выше
    source = "../../modules/vpc"

    // передаём аргументы
    environment = "${var.environment}"
    deployment = "${var.deployment}"
    region = "${var.aws_region}"
    availability_zones = "${var.aws_availability_zones}"

    // хардкодим аргументы
    vpc_cidr = "172.31.0.0/16"
    public_subnets_cidr = [ "172.31.64.0/24", "172.31.68.0/24", "172.31.69.0/24" ]
    public_subnets_ipv6_cidr = [ "2600:1f14:ee6:6300::/64", "2600:1f14:ee6:6301::/64", "2600:1f14:ee6:6302::/64" ]
    private_subnets_cidr = [ "172.31.65.0/24", "172.31.66.0/24", "172.31.67.0/24" ]
}

// вывод модуля транслируем в вывод данной конфигурации
output "public_subnet_ids" {
    // на вывод модуля ссылаемся так
    value = [ "${module.vpc.public_subnet_ids}" ]
}

output "private_subnet_ids" {
    value = [ "${module.vpc.private_subnet_ids}" ]
}
Резюмируем. Имеем набор *.tf файлов в текущем каталоге. Это — конфигурация. Или root module. Который может подключать другие модули из разных мест. Другие модули — это такие же *.tf файлы. Они принимают переменные и возвращают переменные. По ходу дела описывают ресурсы и используют источники данных.
Имеем полное декларативное описание того, что мы хотим получить. Как это работает?
Состояние. State. Второе, после конфигурации, ключевое понятие Terraform. Состояние хранит состояние ресурсов. Как оно обстоит на самом деле в этом облаке. По умолчанию состояние — это один большой (не сильно большой) JSON файл terraform.tfstate, который создаётся в текущем каталоге.
Этот файл состояния можно хранить в системе контроля версий. Но, если сильно много разработчиков будут править конфигурацию Terraform, состояние тоже будет часто меняться. И придётся постоянно править конфликты, не говоря уже о том, что не стоит забывать делать pull.
Поэтому лучше использовать remote state. Состояние можно хранить в Consul. А, в случае AWS, в S3. Тоже будет один файлик, но в облаке, и с версионированием. И будет всем доступен, и постоянно будет из облака дёргаться. Норм.
Поехали.
Сначала нашу конфигурацию нужно проинициализировать. Команда terraform init выкачивает плугины (провайдеры, бэкенды для remote state), модули (с GitHub, например), и сваливает это в скрытый подкаталог .terraform. Этот подкаталог не нужно хранить в системе контроля версий. Но он нужен для нормальной работы Terraform. Поэтому terraform init -input=false — обязательный шаг, чтобы запускать Terraform из CI.
Вторая команда для использования Terraform в обычной жизни: terraform apply. На самом деле она выполняет несколько шагов.
Шаг первый. Refresh. Terraform сравнивает текущее известное состояние с реальным состоянием ресурсов в облаке. Провайдер производит кучу запросов на чтение через облачное API. И состояние обновляется. При первом запуске состояние является пустым. Значит, обновлять нечего, и Terraform считает, что ни одного ресурса не существует.
Шаг второй. Plan. Terraform сравнивает текущее известное (и только что обновлённое) состояние с конфигурацией. Если в состоянии ресурса нет, а в конфигурации он есть, ресурс будет создан. Если в состоянии ресурс есть, а в конфигурации его нет, ресурс будет удалён. Если изменились аргументы ресурса, то, если это возможно, ресурс будет обновлён на месте. Или же ресурс будет удалён, а на его месте будет создан новый ресурс того же типа, но с новыми аргументами.
План будет представлен пользователю. С полным указанием того, что будет создано, удалено, или изменено. В той мере, насколько это известно до начала настоящего выполнения. (Идентификаторы ресурсов, например, как правило назначаются случайно, и становятся известны лишь после настоящего создания ресурса.) Надо ответить «yes», чтобы перейти к следующему шагу.
Шаг третий. Apply. Собственно, применение плана, созданного на предыдущем шаге. Согласно зависимостям. При этом снова изменяется состояние, туда записываются все настоящие свойства созданных ресурсов.
Ну вот и всё. Всё просто. Сверяемся, сравниваем, высчитываем разницу, накатываем изменения. В отличие от Ansible, сверка состояния делается один раз перед построением плана и для всей конфигурации сразу.
А теперь — нюансы.
Terraform — прост и упрям. И он не делает rollback.
К сожалению, в большинстве случаев создание даже одного ресурса — не атомарно. Даже создание S3 bucket — это с десяток разных вызовов, в основном, чтобы отдельно выяснить разные свойства этого бакета (get запросы). Если какой-то запрос не выполнился (а в моём случае наиболее частой причиной ошибок был недостаток прав на отдельные операции), Terraform считает, что создание ресурса провалилось.
Но в реальности ресурс таки мог создаться. Но это может не найти отражения в состоянии Terraform. И при повторном запуске может случиться повторная попытка создания ресурса, которая может сломаться теперь уже из-за конфликта имён. Или в облаке может оказаться более одного желаемого ресурса.
Кроме того, Terraform совсем не в курсе ресурсов, созданных автоматически при вызове API облака (пример: Elastic Network Interface в AWS создаются неявно). И не в курсе ресурсов, не описанных в конфигурации, но от которых могут зависеть его ресурсы (пример: Security Group в AWS не получится удалить, пока хоть кто-то её использует, но вот кто этот кто, Terraform знать не всегда может).
Но Terraform упрям. Так что после правки прав доступа, правки ошибок в конфигурации, и нескольких запусков terraform apply, облако таки неумолимо перейдёт в желаемое состояние. Плюс может остаться немного мусора — ненужных ресурсов, которые были созданы, но не были удалены. Мусор придётся подчистить ручками.
Потом, полагаю, придёт культура всё делать через Terraform, и сразу угадывать нужные права. И мусора будет меньше.
Можно ли перенести инфраструктуру, набитую ручками в консоли, в Terraform? Можно.
Пишете конфигурацию. Лучше добавлять один ресурс за одним. Делаете terraform import. По сути, вы сопоставляете имена ресурсов в конфигурации (resource_type.resource_name) с реальными идентификаторами существующих ресурсов (они разные для разных типов ресурсов). Terraform пытается прочитать атрибуты ресурсов и записать их в состояние. Делаете terraform plan и смотрите, не пытается ли Terraform что-то поправить. Если пытается, правите конфигурацию, и снова смотрите на план. В идеале Terraform скажет, что всё ок, и ничего править не будет. В случае похуже он всё-таки что-нибудь пересоздаст.
Аналогичным образом, только используя terraform refresh, можно привести конфигурацию в соответствие с теми изменениями, которые кто-то сделал своими шаловливыми ручками. (И ручки потом оторвать).
Импорт не всегда работает идеально. Совсем не работает для «псевдоресурсов», о которых я говорил ранее. Сложности возникают со сложными ресурсами, с множеством вложенных сущностей, вроде тех же Security Group или Route Table в AWS. Но после terraform apply и перетряхивания внутренностей ресурсов в угоду Terraform, всё устаканивается.
Рефакторинг. Рефакторинг конфигурации Terraform. Он возможен. Нужно только действовать аккуратно.
Переименование ресурса. Скорее всего Terraform предложит удалить старый ресурс, и создать новый, с новым именем. Если это неприемлемо, можно попробовать удалить ресурс из состояния командой terraform state rm, а потом сделать terraform import. Есть ещё специальная команда terraform state mv, которая вроде как специально предназначена для этого рефакторинга. Но я с помощью state mv как-то добился стабильного крэша Terraform. С тех пор остерегаюсь.
Разбиение одной большой конфигурации на несколько маленьких. На это есть несколько причин.
Причина первая. terraform apply выполняется не сильно быстро. И чем больше ресурсов имеется в конфигурации, тем медленнее. Ему же надо проверить состояние каждого ресурса, даже если в конфигурации этот ресурс и не менялся. Имеет смысл выделить части конфигурации исходя из частоты изменений и «охвата территории». Скажем, VPC вам придётся настраивать лишь один раз, и потом почти никогда не менять. ECS кластер вы будете создавать каждый раз, когда будет появляться новое окружение, и потом опять без изменений. А сервисы нужно подновлять каждый индивидуально почти при каждом деплое.
Причина вторая. Terraform пока плохо работает с версионируемыми ресурсами. Типичный пример: ECS Task Definition. Этот ресурс нельзя удалить, только пометить как неактивный. Этот ресурс нельзя изменить, только создать новую ревизию. Поэтому на каждый terraform apply будет «удалён» старый Task Definition, и создан новый Task Definition, даже если в конфигурации ничего не менялось. И обновлён ECS Service, который этот Task Definition использует.
Это приведёт к тому, что этот Service обновит и перезапустит свои таски. То есть произойдёт настоящий редеплой. Это хорошо, потому что можно делать редеплой ECS сервисов с помощью Terraform. Это плохо, потому что я не хочу редеплоить все два десятка моих сервисов одновременно по terraform apply одной большой конфигурации. Сервисы нужно выносить в отдельные конфигурации Terraform.
Хорошо. Создать ещё одну папочку с *.tf файлами рядом — не проблема. Перенести туда управление ресурсами с помощью terraform import тоже можно. Но ведь наши сервисы должны кое-что знать о кластере: имя кластера, те же Security Groups, ещё параметров по мелочи.
Конфигурации зависят от других конфигураций. Из более общих конфигураций нужно передать параметры более конкретным конфигурациям. Кластер должен знать о VPC, сервисы должны знать о кластере.
Можно передать всё, что нужно, горстку нужных идентификаторов, ручками. Как входные переменные нашей конфигурации. Будет работать. Если вы правильно скопируете эти идентификаторы.
Но есть более спортивный способ. Если у нас есть remote state, его можно подключить как источник данных, и прочитать выходные переменные совершенно другой конфигурации, чьё состояние мы подключили.
// наше состояние
terraform {
    backend "s3" {
        bucket = "my-lovely-terraform"
        key    = "environment/service/terraform.tfstate"
        region = "eu-west-1"
    }
}

// состояние региона
data "terraform_remote_state" "region" {
    backend = "s3"
    config {
        bucket = "my-lovely-terraform"
        // в другом файлике в S3
        key    = "environment/region/terraform.tfstate"
        region = "eu-west-1"
    }
}

// состояние кластера
data "terraform_remote_state" "cluster" {
    backend = "s3"
    config {
        bucket = "my-lovely-terraform"
        // в другом файлике в S3
        key    = "environment/cluster/terraform.tfstate"
        region = "eu-west-1"
    }
}

module "service" {
    source = "../../modules/ecs-service"

    // ссылаемся на выходные переменные других конфигураций
    cluster = "${data.terraform_remote_state.cluster.cluster_name}"
    ecs_execution_role_arn = "${data.terraform_remote_state.cluster.ecs_execution_role_arn}"
    vpc_id = "${data.terraform_remote_state.region.vpc_id}"
    subnet_ids = "${data.terraform_remote_state.region.private_subnet_ids}"
    security_group_ids = [ "${data.terraform_remote_state.cluster.cluster_security_group_id}", "${data.terraform_remote_state.region.security_group_ids}" ]

    //... в этом модуле у меня ещё много аргументов
}
Кроме модулей, у нас появляется возможность держать конфигурацию более-менее независимых кусков ресурсов отдельно. Это может быть удобно.
Terraform vs
Кажется, Terraform приживётся. Кажется, можно выкинуть Ansible, и оставить для управления контейнерами в AWS лишь terraform, docker и aws. Собственно, почему я пошёл в Terraform? Потому что Ansible, как оказалось, не умеет Fargate.
Terraform не заменяет Ansible, а дополняет его. Terraform создаёт ресурсы (виртуальные машины, где угодно), а Ansible настраивает их (через ssh). Другое дело, что если ресурсы — Docker контейнеры, то их и настраивать (изнутри) не нужно.

2018-06-11

Об AWS

Переход на Terraform заставил меня узнать об амазоновом облаке такие вещи, которые лучше бы я и не знал. О Terraform — в следующий раз. А сейчас — снова об AWS.
AWS Regions
Регионы. Regions. Датацентры Амазона разбросаны по всему миру. Ну кроме России. У регионов очень милые имена. В Ирландии — eu-west-1. Во Франкфурте — eu-central-1. В Орегоне (штат США такой, на западном побережье) — us-west-2. В Северной Вирджинии (это уже восток США) — us-east-1. N. Virginia — это «домашний» регион AWS, здесь раньше всего появляются новые плюшки.
Как правило, все ресурсы Амазона привязаны к конкретному региону, и живут именно в нём. Даже там, где существуют глобальные пространства имён, вроде бакетов S3, всё равно данные хранятся в конкретном регионе.
Зоны доступности. Availability Zones. Их несколько (как правило три) в каждом регионе. Амазон вроде как обещает, что никогда не будет отключать регион весь сразу, а только отдельные зоны доступности. Поэтому настоятельно рекомендуется размещать отказоустойчивые кластеры одновременно в нескольких зонах. Имя зоны доступности получается из имени региона добавлением буковки. В Орегоне, например, есть три зоны: us-west-2a, us-west-2b, us-west-2c.
Самый главный (и исторически первый) ресурс в амазоновом облаке — это виртуальные машины EC2. Elastic Compute Cloud. Вот эти вот зоны доступности и многое остальное, о чём я расскажу позднее, оно идёт от EC2. И даже если вы не работаете с EC2 напрямую, наверняка почти все сервисы Амазона работают поверх EC2. И многие соображения и ограничения из EC2 распространяются и на эти сервисы.
Идентификаторы ресурсов. О-о-о... Тут полнейший разнобой. Чаще всего встречается буквенно-цифровое обозначение, где по первым буквам можно догадаться, что это за ресурс. i-0de8aad2fb20a47ce — это идентификатор инстанса EC2. subnet-3447c77f, vpc-5e757727 — это подсеть и VPC. А вот такое: E1VO622G1KKGV1 — это CloudFront Distribution. Но похоже, что рано или поздно всё перейдёт на ARN (Amazon Resource Name) — идентификаторы, технически очень похожие на URN. Вот тот же самый CloudFront Distribution, но в виде ARN: arn:aws:cloudfront::999999999999:distribution/E1VO622G1KKGV1. А вот ARN Application Load Balancerа: arn:aws:elasticloadbalancing:us-west-2:999999999999:loadbalancer/app/sitec-test-alb/fb97bc09692c72ea.
VPC, subnets
Ничто не мешает заводить EC2 инстансы (и даже Fargate контейнеры), и раздавать им сразу публичные IP адреса (пока они есть, и пока они не заблокированы). Но когда у вас куча микросервисов, вовсе не обязательно выставлять каждый из них голой попой в Интернеты (и платить за публичный IP для каждого). То же касается и всяких баз данных и кэшей (которые managed сервисы в Амазоне). Их все можно элегантно объединить в VPC, Virtual Private Cloud.
Можно подумать, что VPC — это просто серая сеть, которая объединяет созданные в ней ресурсы. Так оно и есть. Но несколько сложнее.
VPC создаётся на весь регион. Но внутри должна содержать подсети, subnets. А каждая подсеть уже создаётся для конкретной availability zone. У подсети определён CIDR блок адресов. Либо серые IPv4, какие выберете. Либо настоящие IPv6, какие разрешит Амазон. Ресурсы в VPC всегда создаются в определённой подсети, и получают IP адрес из её блока.
Точнее не сами ресурсы, а их сетевые интерфейсы, Elastic Network Interfaces. Такие интерфейсы в VPC получают не только EC2 инстансы, но и RDS инстансы, и ElastiCache инстансы, и задачи Fargate, и даже NAT Gateway и Elastic Load Balancer. И ко всем ним применимы правила, о которых далее.
Из VPC нужно ходить в интернеты. Чтобы пакеты какие-нибудь скачать. Чтобы к DynamoDB или ECS подрубиться. Хоть это и амазоновые сервисы, но с точки зрения «из VPC» они находятся «там».
Internet Gateway
Способ выйти в интернеты номер раз. Нужен Internet Gateway. Он один на весь VPC. Ну просто ресурс такой, у него даже настроек нет. Нужно, чтобы у EC2 инстанса или ELB в подсети был настоящий IP адрес. Нужно прописать маршрут в таблице маршрутизации (Route Table), что, мол, в интернеты ходить через этот Gateway. Ну и связать данную подсеть с этой таблицей маршрутизации.
На каждую avaiability zone нам нужна своя подсеть. Назовём их публичными. Одна таблица маршрутизации, тоже публичная. Где будет сказано, что в 0.0.0.0/0 и ::/0 (не забываем про IPv6) нужно ходить через Internet Gateway. И Route Table Accosiations, чтобы сказать, что каждая из наших публичных подсетей работает с данной таблицей маршрутизации. Но это пока ещё не всё.
NAT Gateway
Способ выйти в интернеты номер два. Здесь не нужны публичные IP на каждом интерфейсе. Потому что будет NAT. Нужен NAT Gateway. Этой штуке нужно выдать публичный Elastic IP и разместить её Elastic Network Interface в одной из публичных подсетей, что мы создали ранее. Достаточно будет одного NAT Gateway на VPC.
А теперь мы можем создать ещё подсети. Снова в каждой availability zone. И ещё одну таблицу маршрутизации. Только в маршрутах теперь указываем, что в интернеты ходить надо через NAT Gateway. Связываем новые подсети с этой новой таблицей маршрутизации. Теперь интерфейсам в этих подсетях не нужно иметь публичный адрес. Их трафик будет натиться на NAT Gateway. Так как прямые подключения из интернетов в эти подсети будут невозможны, назовём их приватными. Как и в случае любого NAT, мы сэкономили на публичных адресах.
В случае IPv6 экономить на адресах нужды нет. Но ограничить входящие подключения тоже хочется. От греха подальше. Для этого нужен Egress-Only Internet Gateway. Он работает так же как обычный Internet Gateway, но пропускает только исходящие (egress) соединения.
VPC network
Аналогично работает Elastic Load Balancer (ELB). Его интерфейсы, где он слушает входящий трафик, должны быть расположены в публичной подсети, и иметь реальный IP. Ему главное эти подсети указать, а остальное он сам сделает. А дальше он будет прокидывать запросы на ваши бэкенды уже в приватных подсетях. Только ему нужно, чтобы его интерфейс и интерфейс бэкенда были в одной avaiability zone. Так что одним адресом на всю VPC не отделаетесь. Нужно по адресу и интерфейсу на каждую зону доступности.
Ну а теперь последний, но очень толстый нюанс. Security Groups.
Все эти наши Elastic Network Interface, что EС2 инстансов, что Fargate taskов, что load balancerов, что RDS или ElastiCache, обязательно привязаны к некоей Security Group. А часто даже к нескольким Security Group. Будем говорить, что интерфейс (или сервис) находится в Security Group.
А сами эти Security Group — это такой firewall. Точнее то, что в классических файерволах называют «zone». Некая зона, или группа ресурсов, для которой заданы правила фильтрации входящего и исходящего трафика, а также правила проброса трафика между зонами.
Вот и для Security Group можно задать правила для входящего трафика. С каких IP адресов, на какие порты можно. Из какой другой Security Group можно. Можно задать правила для исходящего трафика. На какие IP адреса, на какие порты можно. На какие другие Security Group можно. Отдельно задаётся, можно ли интерфейсам из одной Security Group ходить к другим интерфейсам в этой же группе, то есть разрешён ли self трафик.
Security Groups
Например, возьмём load balancer. Пусть он живёт в своей Security Group, а балансируемые им сервисы — в другой Security Group.
Load balancerу нужно разрешить входящий трафик отовсюду (из сетей 0.0.0.0/0 и ::/0) на те порты, что он слушает (443 в случае HTTPS). И разрешить исходящий трафик в Security Group балансируемых сервисов, явно указав эту самую их группу. Причём разрешить нужно как порты, куда будет идти балансируемый трафик, так и порты, куда будет делаться health check. В частном случае можно все порты на выход открыть.
А в Security Group, где расположены ваши сервисы, нужно, наоборот, разрешить входящий трафик из Security Group load balancerа, на основные порты, и порты health check. А если этим сервисам нужен доступ к какому-нибудь DynamoDB, или это контейнеры ECS, то им ещё понадобится разрешить исходящий трафик куда угодно (опять 0.0.0.0/0 и ::/0).
Конечно, проще поместить весь ваш маленький кластер, вместе с load balancers и всякими ElastiCache, в одну единственную Security Group. Разрешить в ней self, входящий трафик на пару публичных портов балансера, и исходящий трафик. И всё. Но это тоже нужно не забыть сделать.
Вот такой он сложный, этот VPC. А если учесть, что при старте Fargate контейнеров или Lambda, которым нужен доступ к VPC, выделение Elastic Network Interface занимает где-то минуту, становится ещё и грустно. Но, се ля ви.
ECS on EC2
Кстати, о ECS и Fargate.
ECS. Elastic Container Service. Штука для запуска Докеров в Амазоне. Запущенный там сервис можно связать с load balancer. Лучше с Application Load Balancer, который понимает HTTP(S) и может разруливать трафик, исходя из пути запроса. Ну и терминацией TLS занимается. И вот с момента, когда наш сервис связался с балансером, ELB начитает играть существенную роль в работе ECS.
Во-первых, у нас таки будет нормальная балансировка трафика. Можно запустить несколько экземпляров сервиса (в ECS это называется task), балансер будет обо всех них в курсе, и будет делать честный round-robin запросов на них. Можно даже включить какой-то stickyness.
Во-вторых, балансер будет проверять доступность каждого экземпляра сервиса. Application Load Balancer умеет делать это одним единственным способом. Он посылает HTTP запрос на указанный порт и указанный путь, и ожидает ответ «200 OK» (или любой другой, какой настроите). Если какой-то task не ответил, балансер рапортует об этом ECS, а ECS прибивает задачу (и запускает новую).
Проблемой тут может быть время холодного старта. Те же Spring Boot приложения начинают слушать порт и отвечать на запросы через минуту-другую (зависит от доступного CPU) после запуска. Поэтому в настройках ECS сервиса нужно тщательно указывать параметр «health check grace period». Мол, ненене, на мнение load balancer о здоровье задачи мы начинаем обращать внимание только спустя вот столько секунд.
В-третьих, балансер следит за временем обработки запроса. Если ваш сервис не ответил в течение 60 секунд, пользователь, конечно, получит свои «504 Gateway Timeout». Но балансер снова нажалуется ECS, а тот снова прибьёт провинившуюся задачу.
А если эта задача — единственный экземпляр этого сервиса? Правильно, пользователь будет видеть «503 Service Unavailable» ещё пару минут, пока новая задача не прочухается и не станет видна здоровой балансеру.
Что делать? Можно увеличить этот шестидесятисекундный таймаут по умолчанию. Но зачем? Если бэкенд не отвечает минуту, значит, это какой-то неправильный бэкенд. Надо с этим что-то делать. Подымать несколько задач на сервис, чтобы и нагрузка балансировалась, и умирали не все сразу. Искать узкие места. Думать, как делать тяжёлые операции асинхронными...
Fargate
Fargate. Это новый способ запуска контейнеров в ECS. Совсем недавно был доступен только в Северной Вирджинии, теперь появился в Ирландии и Орегоне точно.
Раньше для запуска контейнеров вы заводили виртуальные машинки EC2. Запускали их из специального образа, где уже есть Docker и ECS агент. Скажем, пару машинок t2.medium, с 2 vCPU и 4 гигами памяти на борту. Запускаете их в разных availability zone, конечно же. (vCPU — это условный попугайский CPU, которым меряется производительность в Амазоне). И вот на этих машинках вы запускаете столько контейнеров, сколько влезет.
В случае Fargate вы запускаете ваши контейнеры незнамо где. Вы просто задаёте для таска ECS требования по памяти и CPU (обязательно оба). И Fargate запустит таску на «железе», соответствующему этим требованиям. Выбор доступного «железа» — не сильно большой. Минимум — 0.25 vCPU и 512 мегабайт памяти.
0.25 vCPU — это сильно мало. Тот же контейнер, когда вы запускали его на EC2, если вы его не ограничивали по CPU, мог воспользоваться всеми 2 vCPU, доступными на этой виртуалке. А с Fargate — только 0.25. На времени запуска Spring Boot это сказывается ооочень драматично. (Ирония ещё в том, что на t2 инстансах EC2 эти 2 vCPU достаются очень дёшево, там на полную катушку можно их юзать лишь где-то 20% времени, что более чем достаточно, чтобы делать быстрые холодные старты, а потом не делать ничего, нагружающего CPU)
512 мегабайт ОЗУ — это сильно много. А «много» — это в облаках значит, что ещё и дорого. Вы что, серьёзно считаете, что каждому экземпляру вашего микросервиса нужно 512 мегабайт памяти? Я, может быть, хочу 1 vCPU и 256 памяти. Но такого нет.
Можно воткнуть в одну задачу/сервис несколько контейнеров. В конце-концов, задачи в ECS — это такие podы Kubernetes. Тогда получится как-то более разумно обойтись с памятью. Но на один сервис в ECS можно натравить только один load balancer. В результате, в одну задачу/сервис вы можете воткнуть лишь один «публичный» контейнер, плюс несколько «приватных» контейнеров. «Приватные» смогут общаться только друг с другом, но не смогут принимать запросы из внешнего мира.
Docker hosts
Далее. В Fargate почему-то сломали то, что всегда работало в Docker. Файл /etc/hosts.
В Docker у каждого контейнера появляется уникальное имя. И это имя является и доменным именем этого контейнера. И это имя прописывается в /etc/hosts и указывает, да хоть бы даже на 127.0.0.1. Это — нормально.
В Fargate нужной записи в /etc/hosts почему-то не появляется. В результате, как минимум в Java, попытки получить адрес локального интерфейса завершаются UnknownHostException. Лечится грязно, но лечится:
CMD echo "127.0.0.1 $HOSTNAME" >> /etc/hosts && exec java ...
Далее. Как контейнеру узнать IP адрес, на котором он запущен? При запуске в EC2 можно было спросить http://169.254.169.254/latest/meta-data/local-ipv4 и получить ответ. В Fargate этот фокус не работает.
Зато работает ECS Task Metadata. Запросив http://169.254.170.2/v2/metadata можно узнать почти всё об этой задаче. Включая набор контейнеров, и IP адрес, на котором они все запущены.
    private fun queryMetadata(): String {
        val query = URL("http://169.254.170.2/v2/metadata")
        log.debug("Querying $query")
        val metadata = query.openConnection().apply { connectTimeout = metadataConnectTimeout }
            .getInputStream().reader()
        val host = parseMetadata(metadata)
        return host
    }

    private fun parseMetadata(input: Reader): String {
        val json: JsonNode? = ObjectMapper().readTree(input)
        return json?.get("Containers")?.get(0)?.get("Networks")?.get(0)?.get("IPv4Addresses")?.get(0)?.asText()
            ?: "unknown"
    }
Fargate
Fargate не выглядит серебряной пулей. Если у вас много мелких контейнеров, да ещё и каждый обслуживает внешние запросы, то кластер на EC2 окажется существенно дешевле. А развернуть несколько EC2 инстансов с Terraform проблемы не составляет. Или вообще на Lambda/Serverless перейти?..

2018-05-20

О DynamoDB

А продолжим о DynamoDB.
Краткое содержание предыдущей серии. DynamoDB — одна из старейших облачных NoSQL БД. Живёт в облаке Амазона (aka AWS).
Модель данных у DynamoDB очень напоминает таковую у кассандрового CQL. Есть таблицы. В таблицах хранятся itemы. В таблице определён первичный ключ, по которому ищутся itemы. Первичный ключ состоит из обязательного partition key (он же hash key) и необязательного sort key (он же range key).
Partition key определяет партицию, куда будут помещены данные. Поиск возможен только по полному совпадению значения этого ключа. Получается, что любая операция в БД работает только с единственной партицией.
Sort key отсортирован. Возможен поиск по диапазону значений этого ключа.
Кроме ключей в itemах можно хранить атрибуты. Каждый атрибут имеет имя и значение. Значения бывают разных типов: строки, числа, множества (set), списки (list) и карты (map). Элементами множеств, списков и карт могут быть другие типы. Таким образом можно хранить любые структурированные данные, а ля JSON.
DynamoDB Schema
Посмотрим подробнее, как можно строить запросы. В Spring, как всегда, есть свои обёртки над всякоразными API разных БД. А в стандартном амазоновом SDK вся пляска идёт вокруг объекта Table (если мы не используем мапинг в Java объекты). Для запросов этот объект реализует интерфейс QueryApi. Самый мощный его метод выглядит так:
ItemCollection<QueryOutcome> query(QuerySpec spec)
Соответственно, всё, что может QuerySpec, может и DynamoDB. А ItemCollection результата можно просто засунуть в for-each цикл и вытащить все itemы.
В каждом запросе должны или могут присутствовать:
  • Точное значение partition key.
  • Условие выборки по sort key. Больше, меньше, между, начинается с подстроки.
  • Направление сортировки по sort key. Можно в обе стороны.
  • Список атрибутов, которые нужно извлечь. Проекция.
  • Фильтры для дальнейшего уточнения выборки. По любым атрибутам можно проверить кучу условий. Больше, меньше, между, существует ли, содержит ли подстроку. Важно, что лишь первичный ключ ограничивает набор просматриваемых itemов в хранилище. Фильтры лишь отсеивают itemы, которые нужно вернуть. Поэтому нужно хорошо думать о первичных ключах, чтобы они ограничивали выборку, а фильтры использовать лишь как вспомогательный инструмент.
  • Условие объединения фильтров, если их больше одного. «И» или «ИЛИ».
  • Флаг строгой целостности. Да, DynamoDB пытается предоставить какие-то гарантии, чтобы чтение после записи могло прочитать только что записанные данные.
  • Ограничения на количество просматриваемых записей, размер и количество страниц. Под капотом DynamoDB работает через HTTP, и размер страницы ограничивает размер одного HTTP ответа. Каждая страница возвращается отдельным HTTP ответом. Все эти тонкости хорошо запрятаны в SDK и на уровне итерации по результатам запроса почти незаметны.
DynamoDB Query
У нас есть два взаимно исключающих способа работы с DynamoDB. Это касается условия выборки по ключу, проекций и фильтров.
Первый способ — более старый. Раньше появился в API. Здесь вы указываете точные значения ключей, имена атрибутов, конкретные операции и значения для сравнения.
Такой запрос на Kotlin выглядит примерно так:
val filters = sensors.map { QueryFilter(it).exists() }

val querySpec = QuerySpec()
    .withHashKey("u", location)
    .withRangeKeyCondition(RangeKeyCondition("t").between(
        Instant.parse("2018-04-20T00:00:00Z").epochSecond,
        Instant.parse("2018-04-21T00:00:00Z").epochSecond))
    .withScanIndexForward(false)
    .withAttributesToGet(*sensors.toTypedArray())
    .withConditionalOperator(ConditionalOperator.OR)
    .withQueryFilters(*filters.toTypedArray())
    .withMaxResultSize(1000)

val items = table.query(querySpec)
for (item in items) {
    // ...
}
Второй способ — более новый. Здесь появляется понятие выражений, expressions. Для ключей, проекций и фильтров. Текстовые читабельные выражения. Примерно такие же, что стоят после WHERE в SQL. И в эти выражения можно подставлять параметры.
Почему-то выделяют два вида параметров. Параметры для имён используются для подстановки имён атрибутов, и выглядят они в выражениях как «#name». Параметры для значений используются для подстановки значений, с которыми будут сравниваться ключи или атрибуты, и выглядят они как «:value». Задаются имена и значения обычными мапами.
Запрос с выражениями на Kotlin выглядит примерно так:
val querySpec = QuerySpec()
    .withKeyConditionExpression(
        "u = :location AND t BETWEEN :start AND :end")
    .withScanIndexForward(false)
    .withProjectionExpression(sensors.mapIndexed { index, _ -> "#attr$index" }
        .joinToString(", "))
    .withFilterExpression(sensors.mapIndexed { index, _ -> "attribute_exists(#attr$index)" }
        .joinToString(" OR "))
    .withMaxResultSize(1000)
    .withNameMap(sensors.mapIndexed { index, name -> "#attr$index" to name }.toMap())
    .withValueMap(mapOf(
        ":location" to location,
        ":start" to Instant.parse("2018-04-20T00:00:00Z").epochSecond,
        ":end" to Instant.parse("2018-04-21T00:00:00Z").epochSecond
    ))

val items = table.query(querySpec)
for (item in items) {
    // ...
}
Я тут замутил, запрашиваю произвольный набор атрибутов и выискиваю itemы, где эти атрибуты присутствуют.
Казалось бы, зачем эта морока с составлением строковых выражений (где ещё и некоторые слова зарезервированы). Объектами ведь проще. Для запросов оно, наверное, и так. Но для апдейтов выражения дают некоторые уникальные возможности.
Conditional Update
Апдейты делаются через интерфейс UpdateItemApi, который, конечно же, реализуется нашим Table.
Дело в том, что я считаю метрики. И интенсивно использую возможность атомарного инкремента числовых значений. Ну и атомарного добавления элементов во множества тоже.
Оно работает так. Я говорю, что хочу добавить в item с данным первичным ключом, к атрибуту с конкретным именем, такое-то число. Если атрибута или даже всего itemа не существует, он создаётся автоматически. Начальным значением считается нуль, и он атомарно инкрементируется на указанное число. Если атрибут уже существует (и содержит число), его значение просто инкрементируется. Можно даже одним запросом обновить так несколько атрибутов одного itemа. Очень удобно.
Выглядит это примерно так:
val key = PrimaryKey("u_period", hashKey, "t", rangeKey)

val updateOps = listOf(
    AttributeUpdate("${sensor}_count").addNumeric(aggregate.count),
    AttributeUpdate("${sensor}_sum").addNumeric(aggregate.sum)
)

table.updateItem(key, *updateOps.toTypedArray())
AttributeUpdate задаёт операции над конкретным атрибутом. А затем список операций выполняется над конкретным itemом.
И теперь добавляем две магии. Их присутствие сильно подняло крутизну DynamoDB в моих глазах.
Магия первая. Перед апдейтом можно проверить выполнение некоторого условия на любых атрибутах itemа. Если условие выполняется, апдейт происходит. Если условие не выполняется, но наш код получит ConditionalCheckFailedException и может что-то с этим сделать. Условие можно добавить, передав в updateItem помимо AttributeUpdate ещё и коллекцию объектов Expected.
Магия вторая. Оказывается, выражения позволяют обращаться к вложенным атрибутам с указанием пути (path). Если у вас есть атрибут с именем «map», содержащий мапу, а в этой мапе есть поле с именем «field», то можно использовать путь «map.field», чтобы обратиться с значению в мапе. Если где-то там есть список, то можно использовать числовой индекс элемента в списке в квадратных скобках: «list[0]». Как в JSONPath. Появление специальных символов типа точки вносит некоторую неоднозначность, поэтому настоятельно рекомендуется такие символы не использовать в именах атрибутов. Эти вложенные пути работают только в выражениях, и это хороший повод перейти на выражения.
На самом деле, любой апдейт должен или может содержать:
  • Точное значение partition key.
  • Точное значение sort key, если он есть. Как видите, нельзя проапдейтить несколько itemов одной операцией.
  • Набор операций обновления данного itemа или update expression.
  • Набор условий для проверки возможности обновления данного itemа или condition expression.
Итак. А что, если у нас много счётчиков. И они образуют развесистую иерархию. Например, мы хотим подсчитать, сколько раз какой-то сенсор принимал определённые значения. При этом мы знаем название сенсора, и это может быть именем атрибута. Но мы не хотим вдаваться в детали, какие именно значения принимал каждый сенсор. Мы просто засовываем в атрибут мапу, где имена полей будут значениями, а хранить мы будем счётчики, сколько раз это значение встречалось.
И теперь мы хотим атомарно инкрементировать числа, вложенные в мапы. Как мы делали это с числами, которые непосредственно хранились в атрибутах. С вложенными путями в выражениях это возможно. Нужно задать update expression вида «SET map.nested = map.nested + 1». Это будет работать.
Но беда в другом. Сама мапа, если её не существует, нет такого атрибута, не будет создана автоматически. И то же касается всех вложенных полей мапы. Нельзя прибавить число к тому, чего нет.
Проблему можно решить таким алгоритмом. Добавляем condition expression, который проверяет наличие вложенного поля для инкремента. Если условие не срабатывает, ловим исключение и делаем уже другой апдейт: создаём нужное поле мапы, сразу с начальным значением. И ставим здесь другое условие: на существование самой мапы. Если условие не срабатывает, ловим исключение и уже создаём мапу.
Как-то так:
try {
    table.updateItem(key,
        "SET #sensor.#state.#name = #sensor.#state.#name + :inc",
        "attribute_exists(#sensor.#state.#name)",
        mapOf("#sensor" to sensor, "#state" to state, "#name" to name),
        mapOf(":inc" to 1))
} catch (e: ConditionalCheckFailedException) {
    try {
        table.updateItem(key,
            "SET #sensor.#state.#name = :newName",
            "attribute_exists(#sensor.#state)",
            mapOf("#sensor" to sensor, "#state" to state, "#name" to name),
            mapOf(":newName" to 1))
    } catch (e: ConditionalCheckFailedException) {
        // ...
    }
}
Тут получается рекурсивный отлов исключений, в зависимости от глубины вложенности нашей мапы. В худшем случае, когда это совершенно свежий item, придётся сделать соответствующее число неудачных попыток, прежде чем создать сразу вложенную мапу нужной глубины. Зато последующий инкремент того же поля сразу сделает нужный атомарный апдейт.
Не вполне ясно, как тут будет с гонками, сериализуются ли апдейты в одной партиции. Пока вроде работает, но одиночные ошибки, когда разные клиенты будут одновременно пытаться создать мапу, поди ещё отлови.
Обратите внимание, что вложенные пути, которые с точками, должны присутствовать именно в выражении. Если передать строку с точками в качестве параметра, это не будет работать как вложенный путь.
fun UpdateItemApi.tryToIncrement(key: PrimaryKey, path: List<String>, increment: Int) {
    val pathExpression = path.toPathExpression()
    val attributesMap = path.toNameMap()

    try {
        updateItem(
            key,
            "SET $pathExpression = $pathExpression + :inc",
            "attribute_exists($pathExpression)",
            attributesMap,
            mapOf(":inc" to increment)
        )
    } catch (e: ConditionalCheckFailedException) {
        tryToCreateMap(key, path, increment)
    }
}

private fun UpdateItemApi.tryToCreateMap(key: PrimaryKey, path: List<String>, value: Any) {
    val pathExpression = path.toPathExpression()
    val attributesMap = path.toNameMap()

    val upperPath = path.dropLast(1)
    val upperPathExpression = upperPath.toPathExpression()

    if (upperPath.isNotEmpty()) {
        try {
            updateItem(
                key,
                "SET $pathExpression = :new",
                "attribute_exists($upperPathExpression)",
                attributesMap,
                mapOf(":new" to value)
            )
        } catch (e: ConditionalCheckFailedException) {
            val lastName = path.last()
            tryToCreateMap(key, upperPath, mapOf(lastName to value))
        }
    } else {
        updateItem(
            key,
            "SET $pathExpression = :new",
            attributesMap,
            mapOf(":new" to value)
        )
    }
}
С такими мощными условными атомарными обновлениями вложенных полей DynamoDB изрядно приближается по удобству к той же MongoDB. При этом все соглашения по поводу стоимости и производительности остаются в силе. DynamoDB вполне можно использовать для счётчиков realtime подсчёта агрегатов (да, я сам не понял, что сказал :).

2018-05-02

О tinc

Непонятно почему, заинтересовался я VPNами. Оказывается, VPNом называют всё что ни попадя. Совершенно разные технологии, созданные для разных целей.
Путаницы вносит ещё наверное то, что в Android VPNом называют довесок к сетевым настройкам. Приложение, которое может гонять через себя трафик любого другого приложения. Как оно гоняет трафик, через настоящий VPN, через прокси, через /dev/astral — не важно. В Android всё это будет называться VPN.
Typical VPN Vision
Начнём с простого. HTTP proxy. Старый добрый Squid. Или даже маленький его карманный «аналог» Polipo. Как правило это кэширующие прокси. Это значит, что если много пользователей будут запрашивать одну и ту же страницу, эта страница может быть сохранена в кэше и выдана из кэша. Можно сэкономить на трафике. Существенно, кстати. Поэтому раньше провайдеры частенько принудительно заруливали клиентов на прокси. Это называлось transparent proxy.
Но с HTTPS так не выйдет. Потому что тут есть сертификаты, и клиент проверяет идентичность сервера. Поэтому HTTPS трафик пропускается через прокси с помощью метода CONNECT. Получается, что браузер разговаривает с прокси по протоколу HTTP (или HTTPS), и говорит ему: «А соедини-ка меня с тем вот сервером на таком-то порту». И дальше шлёт через прокси произвольный трафик на этот порт. Как правило, прокси сконфигурированы пускать клиентов только на 443 порт.
Ребята пошли дальше и подумали, что вовсе не нужен HTTP, чтобы общаться с прокси. И придумали специальный протокол под названием SOCKS. Это именно протокол общения с прокси. И в текущей его версии под номером пять можно даже попросить прокси перенаправлять UDP датаграммы на нужный сервер.
SOCKS изначально придумывался как способ договориться с межсетевым экраном. Мол, пусти меня туда, потому что. Можно рассматривать это как некий аналог UPnP, только не ограниченный локальной сетью.
SOCKS оказался достаточно удобным, чтобы направить трафик конкретного приложения (которое умеет быть SOCKS клиентом) через некоторое другое приложение-прокси, чтобы это прокси сделало с этим трафиком что-нибудь хитрое. Именно таким образом организуется вход в туннель Shadowsocks или Тёмный Интернет Tor. Обратите внимание, что соответствующие прокси вы запускаете локально, максимум, в той же локальной сети. А вот вход в них, перенаправление, например, трафика браузера, делается через SOCKS.
Можете побаловаться вот такой командой, если у вас есть ssh доступ к какому-нибудь серверу:
$ ssh -D 1080 -f -C -q -N [email protected]
У вас получится маленький прокси «для бедных» на localhost:1080, который будет гонять трафик по ssh через сервер, к которому вы подключились. Можете направить на него трафик того же браузера и посмотреть, будет ли тормозить. В принципе, с помощью программок, называемых общим словом proxifier, можно заслать в SOCKS прокси трафик почти любого приложения, даже которое не знает, что такое SOCKS.
SOCKS прокси — это лишь точка входа в какой-то туннель. И запускать прокси где-то там — не очень хорошая идея. Сам по себе SOCKS не занимается шифрованием данных. И даже пароли аутентификации в SOCKS5 идут открытым текстом.
SOCKS Proxy Usage
И вот тут наконец-то появляется слово VPN. Virtual Private Network. Задача состояла в том, чтобы обеспечить подключение удалённых сотрудников к локальной (частной) сети компании. Или связь нескольких удалённых филиалов. Это — бизнес. Серьёзные ребята вообще прокладывают свой личный кабель между офисами. А те, кто не может, вынуждены пользоваться публичным Интернетом. А чтобы нехорошие конкуренты не подслушали, нужно шифрование.
VPNов такого рода весьма много. Большинство — проприетарные. vpnc, OpenConnect — придумали Cisco, и подключаться там нужно к их железкам. pptp придумали Microsoft, и он лучше всего поддерживался в Windows. Единственный свободный — OpenVPN.
Все эти VPNы работают схожим образом. По принципу старых добрых аналоговых модемов. Клиент явно подключается к серверу, создаётся соединение point-to-point. А дальше весь (или не весь, как настроен клиент) трафик идёт через это соединение. Соединение разорвалось? Надо переподключаться. И всё такое.
А как же связь двух филиалов? Нет, не так. Как же связь двух датацентров? Есть у нас такая задача на одном проекте. Есть несколько географически распределённых датацентров. В каждом сидит несколько серверов. И серверам в разных датацентрах нужно интенсивно общаться. БД там реплицировать. За эталонными данными, чтобы тут закэшировать, сходить. Нужно.
Обычно для такого рода вещей используют туннели. Давным давно я использовал GRE. Просто на машрутизаторе появляется ещё один интерфейс. И нужные пакеты мы пропихиваем через него. И они магическим образом вылезают через подобный интерфейс на другом маршрутизаторе. Хоть пройдя полинтернета между ними. Туннель. И никакого специального соединения не требуется, ибо это всего лишь ещё одна обёртка пакетов перед отправкой.
На самом деле можно даже без специальных протоколов. IP можно заворачивать в IP. IPv4 в IPv6 и наоборот. Это то, что называется ipip, ipipv6, ipip6 и тому подобное. Все эти прелести давно поддерживаются ядром Linux.
Но эти туннели — без шифрования. Просто обёртка пакетов. Можно добавить шифрование IPsec. Если разберётесь, как настроить. И почему-то с этого момента эти самые туннели снова начинают называть VPN.
Но самая крутая штука из этой серии шифрованных туннелей — WireGuard. Написано с нуля. Модные шифры. Работает на уровне модулей ядра, и использует шифрование ядра. Вроде как поддерживается в Android и OpenWrt. Отлично, но дополнительные модули ядра не загрузишь в контейнер OpenVZ. И все эти туннели, как правило, требуют, чтобы оба конца туннеля имели статический адрес, что не всегда возможно.
В общем, встречайте, Tinc. Чудесная штука, ведущая родословную аж с 1998 года. (OpenVPN — с 2001). Шифрованный (aka VPN) многоконечный туннель (aka mesh сеть).
Network
Допустим, у нас есть два датацентра. В одном имеется сеть 10.10.1.0/24 и некий шлюз с публичным интернет адресом 1.2.3.4. В другом имеется сеть 10.10.2.0/24 и некий шлюз с публичным интернет адресом 3.4.5.6. Мы хотим, чтобы сервера в одной сети успешно могли подключаться к серверам в другой сети, и наоборот. Как всегда в таких случаях, сети в двух датацентрах не должны пересекаться.
Демон, который делает tinc, конечно же называется tincd. И у него очень интересная конфигурация. Один демон может обслуживать несколько различных mesh сетей. А для каждой сети нужна отдельная папочка конфигурации. Вот и создадим /etc/tinc/vpntun/hosts. «vpntun» — это название нашей сети. Теоретически, оно может быть любым, но по умолчанию tincd создаст tun интерфейс с этим именем, и лучше, чтобы это было одно слово, без дефисов и подчёркиваний.
В подкаталоге «hosts» нужно сложить файлы хостов. Каждый демон tincd должен знать всех других демонов tincd в той же mesh сети «в лицо». Иначе он откажется иметь с ними дело. Поэтому на наших обоих гейтвеях должно лежать по два файла. Это составляет некоторую сложность в конфигурации tinc. Файлы хостов нужно распространить по всем хостам.
Пусть один файл называется netA:
Address = 1.2.3.4

Subnet = 10.10.1.0/24

-----BEGIN RSA PUBLIC KEY-----
...
-----END RSA PUBLIC KEY-----
А другой называется netB:
Address = 3.4.5.6

Subnet = 10.10.2.0/24

ConnectTo = netA

-----BEGIN RSA PUBLIC KEY-----
...
-----END RSA PUBLIC KEY-----
В этих файлах описана вся конфигурация нашей сети.
Address — это публичный статический адрес, по которому можно подключиться к данному tincd. Если, конечно, он есть. Если нет, ну что ж, значит, этот tincd будет работать в «клиентском» режиме, сам принимать подключения не будет, но может подключаться в другим серверам. Для подключения нужно открыть порт 655 (по умолчанию), и tcp, и udp.
Subnet — сети (можно указать несколько), обслуживаемые данным tincd. Это очень важный параметр. Дело в том, что после того, как пакет будет направлен ОС в tun интерфейс, tincd должен решить, на какой узел mesh сети он должен его направить. Для этого и используются эти сведения. Маршрутизация. Если вы хотите «выйти» из mesh сети в интернеты, вам нужно объявить, что один из узлов «обслуживает» сеть 0.0.0.0/0.
ConnectTo — к какому узлу подключаться. Mesh сеть надо с чего-то начать. И в данном случае сеть B подключается к сети A. Если сетей больше двух, вероятно, имеет смысл настроить так, чтобы все сети подключались к некоторому «центральному» узлу. Дальнейший трафик, согласно идеологии tinc, может идти и напрямую между ближайшими tincd демонами. Но для начала они должны как-то все узнать друг о друге.
А дальше идёт публичный ключ. Тут как в ssh, аутентификация узлов происходит по RSA ключу. Публичные известны всем. А приватные каждый узел хранит у себя. Сгенерить ключи просто. Команда tincd -n vpntun -K4096 создаст файлы /etc/tinc/vpntun/rsa_key.priv и /etc/tinc/vpntun/rsa_key.pub. А ещё допишет публичный ключ в «свой» файл в каталоге hosts.
Какой хост «свой» задаётся в следующем файле конфигурации /etc/tinc/vpntun/tinc.conf. Тут достаточно написать кто мы есть и какую версию IP будем использовать.
Name = netA
AddressFamily = ipv4
Соответственно, на другом хосте вместо «netA» должно быть «netB».
И этого ещё недостаточно. Нужно настроить tun интерфейс, и запульнуть в него нужные маршруты. Делается это двумя скриптами, которые tincd запускает, когда создаёт или гасит интерфейс. Эти файлы должны быть исполняемыми (chmod +x tinc-*).
Файл /etc/tinc/vpntun/tinc-up:
#!/bin/sh

ip link set $INTERFACE up
ip addr add 10.10.1.1/16 dev $INTERFACE

ip route add 10.10.0.0/16 dev $INTERFACE
Что мы тут делаем? Мы подымаем интерфейс. Это понятно. И назначаем этому интерфейсу такой же адрес, что на уже существующем eth1. Это возможно, пока маски на eth1 и vpntun различаются, а они различаются. На самом деле без разницы, какой IP вы навесите на интерфейс туннеля. И лучше навесить адрес из локальной сети, чтобы не запутаться. Однако иметь на интерфейсе туннеля некий уникальный адрес имеет смысл, когда вы подключаете к VPN один единственный хост. Тогда tinc нет нужды знать, в какой локальной сети этот хост сейчас находится, он просто будет слать пакеты на этот единственный уникальный туннельный адрес.
И добавляем маршрут. Если мы знаем, что все наши последующие датацентры будут в сетях 10.10.2.0/24, 10.10.3.0/24 и так далее, мы можем просто зарулить всю сеть 10.10.0.0/16 в tinc. Тогда локалка будет локально. А остальные датацентры будут где-то там, за tinc. Очень удобно.
На другом tincd tinc-up будет таким же, только изменится адрес интерфейса. Он будет 10.10.2.1/16.
Файл /etc/tinc/vpntun/tinc-down:
#!/bin/sh

ip addr del 10.10.1.1/16 dev $INTERFACE
ip link set $INTERFACE down

ip route del 10.10.0.0/16
Если tinс погашен, все следы нужно замести. Хотя не обязательно.
Ну вроде и всё. Много файлов? Ну зато они все на месте и выполняют свою функцию. Как сисадмин говорю, что конфигурация tinc очень удобна. Единственная сложность: синхронизировать содержимое hosts на все узлы. Вероятно, имеет смысл эти файлы вообще держать где-то в системе контроля версий, и рассылать через Ansible. Таки там зашита топология сети.
После создания всех файлов можно запустить tincd в дебаге:
$ sudo tincd -n vpntun -D -d3
И посмотеть, что выйдет. Завершить демона можно по Ctrl + \.
Если всё хорошо, то заставляем эту нашу сеть vpntun подыматься самостоятельно при старте. В файл /etc/tinc/nets.boot нужно добавить строчку vpntun. Ну и убедиться, что сервис tinc стартует при запуске системы.
Tinc Logo
Итак, у нас есть разные задачи. Направить трафик одного приложения (или даже отдельных запросов) куда-нибудь туда, чтобы что-нибудь обойти. Подключить один хост к закрытой сети где-то там. Соединить несколько сетей безопасным образом. И для этого у нас есть прокси, VPN, туннели. А ещё есть tinc, который не просто зашифрованный туннель из одной точки в другую, а волшебный телепорт для передачи пакетов в несколько связанных точек.