Создание базового образа AMI при помощью Packer, настройка его окружения через Ansible и безболезненная передача параметра ami_id в Terraform для деплоя инстанса
Дано: три классических окружения dev, stg и prd.
Воркфлоу: Terraform для деплоя инстансов, сетей и прочих лоад балансеров на AWS. Затем плейбуками Ansible разворачивается обвязка внутри инстансов для обеспечения работы и мониторинга приложения. И, наконец, деплой самого приложения тем же анзиблом.
Задача: создать AMI, чтобы деплоить уже готовый образ и не конфигурировать само приложение после создания инстанса, исключив второй и третий шаг из текущего воркфлоу.
Трудность: существует два вида Ansible тасков. Те, которые зависят от имени/IP адреса/etc текущего хоста при применении плейбука и те, которые не зависят. Назову первые ИмяЗависимыми. Идея в том, что надо найти способ применять эти таски не меняя логику работы плейбуков.
Новый воркфлоу: Packer создает AMI, Ansible конфигурирует окружение внутри образа, Terraform разворачивает инстансы используя новый образ и далее функцией cloud-init вызывает ansible-playbook с применением только для ИмяЗависимых тасков, которые определяются при помощи тега.
Ansible
Структура каталогов Ansible/Packer репы:
. ├── ansible │ ├── group_vars │ │ ├── all │ │ ├── dev │ │ ├── nonprd │ │ ├── prd │ │ └── stg │ ├── inventory │ │ ├── dev │ │ ├── prd │ │ └── stg │ ├── roles │ │ ├── role_1 │ │ │ ├── tasks │ │ │ │ └── main.yml │ │ │ └── templates │ │ │ ├── name_dependent_template.j2 │ │ │ └── name_not_dependent_template.j2 │ │ ├── role_2 │ │ │ └── ... │ │ └── role_n │ │ └── ... │ ├── playbook_1.yml │ ├── playbook_2.yml │ └── playbook_n.yml └── packer └── ami.json
Плейбук выглядит обычно, за исключением переменной {{ target }}, которая создана для возможности определения хоста/группы при помощи аргумента --extra-vars, файл ansible/playbook_1.yml:
---
- name: Run my playbook
  hosts: "{{ target }}"
  gather_facts: True
  user: "ubuntu"
  become: true
  roles:
    - role: role_1
    - role: role_2
ubuntu - дефолтный существующий пользователь для базовых AMI с Ubuntu.
Роль тоже вполне обычная, но только для ИмяЗависимых тасков определен тег name_dependent_config, по которому будет запускаться анзибл на шаге деплоя инстанса из кастомного AMI, файл ansible/roles/role_1/tasks/main.yml:
---
- name: name dependent config
  template: src=name_dependent_template.j2 dest=/etc/app/name_dependent_template.conf
  tags:
    - name_dependent_config
- name: name NOT dependent config
  template: src=name_not_dependent_template.j2 dest=/etc/app/name_not_dependent_template.conf
Внутри темплейта могут быть любые ИмяЗависящие переменные, такие как {{ ansible_default_ipv4.address }}, {{ inventory_hostname }} и любые другие из фактов.
Packer
Конфиг для создания образа, файл ami.json:
{
  "variables": {
    "user": "ubuntu"
  },
  "builders": [{
    "type": "amazon-ebs",
    "access_key": "{{ user `aws_access_key` }}",
    "secret_key": "{{ user `aws_secret_key` }}",
    "region": "us-east-1",
    "source_ami": "ami-a4dc46db",
    "instance_type": "t2.micro",
    "ssh_username": "{{ user `user` }}",
    "ami_name": "{{ user `env` }}-ami",
    "force_deregister": true,
    "force_delete_snapshot": true,
    "tags": {
      "Name": "{{ user `env` }}"
    }
  }],
  "provisioners": [{
      "type": "shell",
      "inline": [
        "sudo apt-get update",
        "sudo apt-get install -y software-properties-common",
        "sudo apt-add-repository ppa:ansible/ansible",
        "sudo apt-get update",
        "sudo apt-get install -y ansible"
    },
    {
      "type": "ansible-local",
      "staging_directory": "/home/{{ user `user` }}/ansible",
      "playbook_dir": "../ansible",
      "group_vars": "../ansible/group_vars",
      "playbook_files": [
        "../ansible/playbook_1.yml",
        "../ansible/playbook_2.yml"
      ],
      "extra_arguments": [
        "--extra-vars 'target=127.0.0.1'"
      ],
      "inventory_groups": "{{ user `env` }},{{ user `ansible_groups` }}"
    }
  ]
}
Опишу только важные для задачи параметры, остальные легко гуглятся в рунете.
force_deregister и force_delete_snapshot - удалять старые AMI и снапшоты после очередного запуска packer build, действуй на свое усмотрение. В любом случае, в терраформе есть параметр most_recent, который берет последний из созданных AMI, если фильтру соответствуют несколько.
"Name": "{{ user `env` }}" - присваиваемые теги вновь созданному AMI для идентификации его в Terraform'е
Provisioners: shell - запускает произвольные команды на хосте. В данном случае устанавливается ansible
staging_directory - путь, куда при сборке образа копируется весь каталог ансибла и там же остается при поднятии инстанса. Необходим, чтобы после создания инстанса терраформом, запустить cloud-init скрипт для ИмяЗависимых тасков. Если не указать, то по дефолту будет /tmp каталог, который, как известно, очищается при перезагрузке ОС.
playbook_dir - путь до каталога с плейбуками ansible, который копируется в staging_directory
playbook_files - путь до плейбуков, который Packer передаст для выполнения в Ansible
extra_arguments - параметры для Ansible, которые Packer передаст при запуске каждого плейбука
inventory_groups - указать группы, если в Ansible существуют group_vars зависимые переменные. Packer внесет хост в эти группы для корректного определения group_vars.
Создать образ можно командой, выполняется для каждого окружения:
packer build -var 'env=dev' -var 'ansible_groups=nonprd' -var 'aws_access_key=dev_aws_access_key' -var 'aws_secret_key=dev_aws_secret_key' ami.json packer build -var 'env=stg' -var 'ansible_groups=nonprd' -var 'aws_access_key=stg_aws_access_key' -var 'aws_secret_key=stg_aws_secret_key' ami.json packer build -var 'env=prd' -var 'ansible_groups=prd' -var 'aws_access_key=prd_aws_access_key' -var 'aws_secret_key=prd_aws_secret_key' ami.json
После выполнения создадутся новые AMI с именами dev-ami, stg-ami, prd-ami и тегами dev, stg, prd, соответственно.
На данном этапе, при создании образа, Ansible запускает оба вида тасков.
Terraform
Определение AMI ID
Самый простой способ узнать ID нового образа для регистрации в качестве переменной Terraform'а - создать data ресурс с фильтрацией по заранее созданным тегам в Packer'е:
variable "env" {
  default = "dev"
}
data "aws_ami" "custom_ami" {
  filter {
    name   = "state"
    values = ["available"]
  }
  filter {
    name   = "tag:Name"
    values = ["${var.env}"]
  }
  most_recent = true
}
И указать переменную custom_ami при создании инстанса:
resource "aws_instance" "my_instance" {
  ami = "${data.aws_ami.custom_ami.id}"
  ...
}
Запуск ИмяЗависимых тасков
Для автоматического запуска анзибля будет использоваться инструмент cloud-init. Для создания и передачи скрипта, выполняющегося после поднятия инстанса, используется провайдер data.template_file и указывается при создании инстанса как user_data:
variable "count" {
  default = "4"
}
data "template_file" "my_instance" {
  template = "${file("init.tpl")}"
  count = "${var.count}"
  vars {
    hostname = "${format("${var.env}-%02d", count.index)}"
    env = "${var.env}"
  }
}
resource "aws_instance" "my_instance" {
  ami = "${data.aws_ami.custom_ami.id}"
  count = "${var.count}"
  ...
  user_data = "${element(data.template_file.my_instance.*.rendered, count.index)}"
}
Переменные hostname и env необходимо указывать именно так, для корректной интерполяции в значения из темплейта.
Установка хостнейма и запуск плейбуков только с определенными тегами (таски без этого тега выполняться не будут) описаны в файле init.tpl:
#!/bin/bash
echo ${hostname} > /etc/hostname
echo '127.0.1.1 ${hostname}' >> /etc/hosts
hostname ${hostname}
sudo su ubuntu
ansible-playbook /home/ubuntu/ansible/playbook_1.yml --extra-vars 'target=${hostname}' -c local -i /home/ubuntu/ansible/inventory/${env} --tags "name_dependent_config"
ansible-playbook /home/ubuntu/ansible/playbook_2.yml --extra-vars 'target=${hostname}' -c local -i /home/ubuntu/ansible/inventory/${env} --tags "name_dependent_config"
sudo apt purge ansible
sudo rm -R /etc/ansible/
rm -R /home/ubuntu/ansible/
sudo reboot
Опционально, создание пользователя с необходимыми правами для сборки образа.
# Create User packer
resource "aws_iam_user" "packer" {
  name = "packer"
  path = "/system/"
}
resource "aws_iam_access_key" "packer" {
  user = "${aws_iam_user.packer.name}"
}
# Packer user access for creave AMI
resource "aws_iam_user_policy" "packer" {
  name = "Packer_access_for_creave_AMI"
  user = "${aws_iam_user.packer.name}"
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
      "Effect": "Allow",
      "Action" : [
        "ec2:AttachVolume",
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:CopyImage",
        "ec2:CreateImage",
        "ec2:CreateKeypair",
        "ec2:CreateSecurityGroup",
        "ec2:CreateSnapshot",
        "ec2:CreateTags",
        "ec2:CreateVolume",
        "ec2:DeleteKeypair",
        "ec2:DeleteSecurityGroup",
        "ec2:DeleteSnapshot",
        "ec2:DeleteVolume",
        "ec2:DeregisterImage",
        "ec2:DescribeImageAttribute",
        "ec2:DescribeImages",
        "ec2:DescribeInstances",
        "ec2:DescribeRegions",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeSnapshots",
        "ec2:DescribeSubnets",
        "ec2:DescribeTags",
        "ec2:DescribeVolumes",
        "ec2:DetachVolume",
        "ec2:GetPasswordData",
        "ec2:ModifyImageAttribute",
        "ec2:ModifyInstanceAttribute",
        "ec2:ModifySnapshotAttribute",
        "ec2:RegisterImage",
        "ec2:RunInstances",
        "ec2:StopInstances",
        "ec2:TerminateInstances"
      ],
      "Resource" : "*"
  }]
}
EOF
}
											