Рассказываем про наш опыт импорта и адаптации конфигураций инфраструктуры, ранее развернутой вручную в AWS, в формат Terraform. Зачем? Причин может быть много: и отказоустойчивость, и упрощение горизонтального и вертикального масштабирования, и многие другие. С них и начнем эту статью.

Проблематика

Без каких-либо процессов автоматизации управления инфраструктурой вы неизбежно столкнетесь с известными ограничениями:

  • невозможно быстро пересоздать инфраструктуру;

  • нет истории изменений, произведенных в инфраструктуре;

  • нет контроля используемых версий ПО;

И это не просто вопрос удобства. Подобные ограничения влияют на критичные для бизнеса показатели: на скорость устранения возникающих проблем в инфраструктуре, на отказоустойчивость… в конечном счете — на uptime. Это понимание привело к расцвету подхода IaC.

На уровне программных служб вопрос решается системами управления конфигураций (Ansible, Chef и т.п.), однако они не покрывают более низкий — аппаратный — слой, а ведь он тоже требует внимания.

Многие из вас уже знакомы на практике с решениями и для этого уровня, такими как Terraform. Оно выглядит уместным, когда мы создаем инфраструктуру с нуля. Но будет ли оно таким же, если перед нами production, состоящий из десятков (сотен, …) компонентов? Ведь потребуются колоссальные трудозатраты на написание сценариев развертывания для каждого элемента.

В этой статье мы подойдем к решению вопроса с другой стороны и попробуем импортировать уже существующую production-инфраструктуру, а затем — адаптировать полученные конфигурации для возможности бесшовного перехода на автоматизированный способ управления.

Планирование и подготовка

Для реализации поставленной задачи — импорта конфигураций — были рассмотрены различные инструменты (в частности, сам Terraform, CLI-утилиту AWS, Terraforming), но мы остановились на утилите Terraformer. Основные причины — полнота и корректность выходных данных, а также удобство использования. Разработчик данного решения при выполнении аналогичной задачи столкнулся с ограничениями, упомянутыми во введении. В результате и появился этот проект, реализующий функции, которых нам так не хватало.

Стоит отметить, что процедура импортирования и адаптации планировалась сразу в нескольких регионах, не связанных друг с другом. Работы при этом необходимо было провести итеративно, чтобы в один момент времени затрагивать только один регион. Решение — реализовать полностью готовое окружение, предварительно проверив его работу в тестовой среде. Поэтому будем использовать Docker-контейнер как удобный способ для быстрого разворачивания окружения с привязкой к конкретным версиям ПО.

Собирать и запускать Docker-образа будем посредством утилиты werf, воспользовавшись её синтаксисом (stapel) вместо Dockerfile. В этом нет какого-либо архитектурного ограничения (обычный docker тоже подойдет) — все дело в более удобном/привычном подходе.

Возьмем базовый образ на основе Ubuntu и выполним в него установку Terraform и плагина Terraformer. Также потребуется добавить конфигурацию и файл с учетными данными доступа в AWS. Вот что получается в случае stapel-манифеста для werf (несложно переписать и на Dockerfile при такой необходимости):

configVersion: 1
project: terraform
---
 
{{- $params := dict -}}
{{- $_ := set $params "UbuntuVersion" "18.04" -}}
{{- $_ := set $params "UbuntuCodename" "bionic" -}}
{{- $_ := set $params "TerraformVersion" "0.13.6" -}}
{{- $_ := set $params "TerraformerVersion" "0.8.10" -}}
{{- $_ := set $params "WorkDir" "/opt/terraform" -}}
{{- $_ := set $params "AWSDefaultOutput" "json" -}}
 
{{- $_ := env "AWS_SECRET_ACCESS_KEY" | set $ "AWSSecretAccessKey" -}}
{{- $_ := env "AWS_ACCESS_KEY_ID" | set $ "AWSAccessKeyId" }}
{{- $_ := env "AWS_REGION" | set $ "AWSRegion" }}
{{- $_ := env "CI_ENVIRONMENT_SLUG" | set $ "Environment" }}
 
---
image: terraform
from: ubuntu:{{ $params.UbuntuVersion }}
git:
- add: /
 to: "{{ $params.WorkDir }}"
 owner: terraform
 group: terraform
 excludePaths:
 - "*.tfstate"
 - "*.tfstate.backup"
 - "*.bak"
 - ".gitlab-ci.yml"
 stageDependencies:
   setup:
   - "regions/*"
   - "states/*"
ansible:
 beforeInstall:
 - name: "Install essential utils"
   apt:
     name:
     - tzdata
     - apt-transport-https
     - curl
     - locales
     - locales-all
     - unzip
     - python
     - groff
     - vim
     update_cache: yes
 - name: "Remove old timezone symlink"
   file:
     state: absent
     path: "/etc/localtime"
 - name: "Set timezone"
   file:
     src: /usr/share/zoneinfo/Europe/Moscow
     dest: /etc/localtime
     owner: root
     group: root
     state: link
 - name: "Create non-root main application user"
   user:
     name: terraform
     comment: "Non-root main application user"
     uid: 7000
     shell: /bin/bash
     home: {{ $params.WorkDir }}
 - name: "Remove excess docs and man files"
   file:
     path: "{{`{{ item }}`}}"
     state: absent
   with_items:
   - /usr/share/doc/
   - /usr/share/man/
 - name: "Disable docs and man files installation in dpkg"
   copy:
     content: |
       path-exclude="/usr/share/doc/*"
     dest: "/etc/dpkg/dpkg.cfg.d/01_nodoc"
 - name: "Generate ru_RU.UTF-8 default locale"
   locale_gen:
     name: ru_RU.UTF-8
     state: present 
 install:
 - name: "Download terraform"
   get_url:
     url: https://releases.hashicorp.com/terraform/{{ $params.TerraformVersion }}/terraform_{{ $params.TerraformVersion }}_linux_amd64.zip
     dest: /usr/src/terraform_{{ $params.TerraformVersion }}_linux_amd64.zip
     mode: 0644
 - name: "Install terraform"
   shell: |
     unzip /usr/src/terraform_{{ $params.TerraformVersion }}_linux_amd64.zip -d /usr/local/bin
     terraform -install-autocomplete
 - name: "Install terraformer provider"
   get_url:
     url: https://github.com/GoogleCloudPlatform/terraformer/releases/download/{{ $params.TerraformerVersion }}/terraformer-aws-linux-amd64
     dest: /usr/local/bin/terraformer
     mode: 0755
 - name: "Make AWS config files"
   shell: |
     set -e
     mkdir "{{ $params.WorkDir }}/.aws"
     touch "{{ $params.WorkDir }}/.aws/credentials"
     touch "{{ $params.WorkDir }}/.aws/config"
     chown -R 7000:7000 "{{ $params.WorkDir }}/.aws"
     chmod 0700 "{{ $params.WorkDir }}/.aws"
     chmod 0600 "{{ $params.WorkDir }}/.aws/credentials"
 beforeSetup:
 - name: "Write AWS credentials"
   shell: |
     set -e
     printf "[default]\noutput = %s\nregion = %s\n" "{{ $params.AWSDefaultOutput }}" "{{ $.AWSRegion }}" >> "{{ $params.WorkDir }}/.aws/config"
     printf "[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n" "{{ $.AWSAccessKeyId }}" "{{ $.AWSSecretAccessKey }}" >> "{{ $params.WorkDir }}/.aws/credentials"
 - name: "Create region directories"
   file:
     path: "{{`{{ item }}`}}"
     state: directory
     owner: 7000
     group: 7000
     mode: 0775
   with_items:
     - "{{ $params.WorkDir }}/regions/{{ $.Environment }}"
     - "{{ $params.WorkDir }}/states/{{ $.Environment }}"
 setup:
 - name: "Init"
   shell: |
     terraform init
   args:
     chdir: "{{ $params.WorkDir }}"
   become: true
   become_user: terraform
 beforeSetupCacheVersion: "{{ $.AWSRegion }}-{{ $.AWSAccessKeyId }}"
 setupCacheVersion: "{{ $.Environment }}-{{ $.AWSRegion }}-{{ $.AWSAccessKeyId }}"

Итак, собираем образ, указав переменные с учетными данными пользователя AWS IAM. Значения можно явно указывать каждый раз при запуске команды вручную или же определить в CI-среде:

# werf build
CI_ENVIRONMENT_SLUG="eu" AWS_SECRET_ACCESS_KEY="XXX" AWS_ACCESS_KEY_ID="YYY" AWS_REGION="eu-central-1" werf build --stages-storage :local

Теперь запускаем контейнер, используя собранный образ и те же самые значения переменных. Если указать иные значения, то будет ошибка несоответствия конфигураций. Поскольку мы взаимодействуем с действующей инфраструктурой через слои абстракции, необходимо запускать исключительно то, что было собрано:

# werf run terraform
CI_ENVIRONMENT_SLUG="eu" AWS_SECRET_ACCESS_KEY="XXX" AWS_ACCESS_KEY_ID="YYY" AWS_REGION="eu-central-1" werf run --stages-storage :local --docker-options="--rm -ti -w /opt/terraform/ -u terraform" terraform -- /bin/bash

Реализация

Инициализация

Этап инициализации стартовой конфигурации Terraform для установки плагина провайдера можно включить в сам образ. Конечно, это увеличит его объем, но и предоставит полностью рабочее решение в рамках конкретного образа, используемого для проведения работ, — при любой итерации запуска (что важно в рамках задачи).

Для предустановки плагина провайдера создадим файл (providers.tf) и поместим его в рабочую директорию в образе:

terraform {
 required_providers {
   aws = {
     source  = "hashicorp/aws"
     version = "~> 3.25"
   }
 }
}
 
provider "aws" {
 profile = "default"
}

Инициализируем конфигурацию Terraform. Если вы устанавливаете плагин провайдера на этапе сборки, то команду инициализации требуется включить последним шагом, при этом запуск вручную уже не потребуется.

~$ terraform init
Initializing the backend...
~~~
Terraform has been successfully initialized!

Импорт

Выполняем импорт ресурсов. В данном примере используем следующие ключи:

aws

название провайдера;

--path-pattern="{output}/"

путь к файлам конфигураций, сгенерированным утилитой;

--compact=true

запись конфигураций всех типов ресурсов в единый файл. Файлы с описанием ресурсов, переменных, состояний будут записаны в разные файлы;

--regions=eu-central-1

регион провайдера;

--resources=elasticache,rds

типы импортируемых ресурсов.

~$ terraformer import aws --path-pattern="{output}/" --compact=true --regions=eu-central-1 --resources=elasticache,rds
aws importing region eu-central-1
aws importing... elasticache
                ~~~
aws importing... rds
                ~~~
aws Connecting.... 
aws save 
aws save tfstate

Меняем рабочую директорию на каталог, созданный утилитой импорта, и запускаем импорт в данной директории (для инициализации импортированной конфигурации ресурсов):

~$ cd generated/
~/generated$ terraform init

Initializing the backend...
                ~~~
Terraform has been successfully initialized!

Выполняем предварительное планирование применения импортированной конфигурации:

~/generated$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

data.terraform_remote_state.local: Refreshing state...
                ~~~

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_db_instance.tfer--db1 will be updated in-place
  ~ resource "aws_db_instance" "tfer--db1" {
                ~~~
      + delete_automated_backups              = true
                ~~~
      + skip_final_snapshot                   = false
                ~~~
    }

  # aws_db_instance.tfer--db2 will be updated in-place
  ~ resource "aws_db_instance" "tfer--db2" {
                ~~~
      + delete_automated_backups              = true
                ~~~
      + skip_final_snapshot                   = false
                ~~~
    }

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

При планировании возникли несоответствия между файлом состояния, полученным при импорте, и действующей конфигурацией ресурсов. Поэтому Terraform сообщает, что собирается модифицировать два ресурса aws_db_instance. Это может происходить, например, из-за разницы в доступном наборе инструкций утилиты импорта и Terraform'а. Это вполне штатная ситуация, т.к. используемые утилиты не принадлежат одному вендору, и одна является абстракцией поверх другой. Кроме того, возможно и некоторое отставание по наличию доступного функционала.

В данном случае в импортированной конфигурации ресурсов и файле состояния tfstate отсутствуют по два параметра в двух ресурсах aws_db_instance:

  • delete_automated_backups

  • skip_final_snapshot

Для устранения конфликтов выясним актуальные значения указанных параметров через штатные интерфейсы AWS: web-gui или aws-cli. По результатам этого анализа дополним файл конфигурации ресурсов:

~/generated$ vim resources.tf

  delete_automated_backups = "false"
            ~~~
  skip_final_snapshot = "false"

~/generated$ vim terraform.tfstate

  "delete_automated_backups": "false",
            ~~~
  "skip_final_snapshot": "false",

… и выполним повторное планирование применения импортированной конфигурации:

~/generated$ terraform plan
Refreshing Terraform state in-memory prior to plan...

После внесенных изменений планирование выдает желаемый результат:

0 to add, 0 to change, 0 to destroy

На этом предварительная подготовка к адаптации ресурсов завершена и можно применять конфиг.

Дополнение и слияние

Однако могут возникать ситуации, когда требуется дополнить конфиг ресурсов.

Вернемся в исходную директорию и выполним импорт конфигурации для другого типа ресурсов, которые тоже требуется добавить в планируемый конфиг. Важно, что директория path-pattern должна отличаться от предыдущей, т.к. Terraformer не изменяет наименования файлов, в которые записывает результаты импорта, а значит при использовании той же директории перезапишет уже существующие файлы.

В данном примере добавим к уже импортированной конфигурации ресурсы EC2, которые имеют общий тег NodeRole=node, указав это в ключе --filter:

~/generated$ cd ../
~$ terraformer import aws --path-pattern="./ec2/" --compact=true --regions=eu-central-1 --resources=ec2_instance --filter="Name=tags.NodeRole;Value=node"
aws importing region eu-central-1
aws importing... ec2_instance
Refreshing state... aws_instance.tfer--i-002D-03f57062-node-1
Refreshing state... aws_instance.tfer--i-002D-009fc9e6-node-2
Refreshing state... aws_instance.tfer--i-002D-0b4b4c7c-node-3
aws Connecting.... 
aws save 
aws save tfstate

Переходим в директорию с импортированным конфигом и выполняем инициализацию:

~$ cd ec2/
~/ec2$ terraform init

Initializing the backend...

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding hashicorp/aws versions matching "~> 3.29.0"...
- Finding latest version of -/aws...
- Installing -/aws v3.29.0...
- Installed -/aws v3.29.0 (signed by HashiCorp)
- Installing hashicorp/aws v3.29.0...
- Installed hashicorp/aws v3.29.0 (signed by HashiCorp)

The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, we recommend adding version constraints in a required_providers block
in your configuration, with the constraint strings suggested below.

* -/aws: version = "~> 3.29.0"

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Выполняем планирование:

~/ec2$ terraform plan

По аналогии с предыдущим планированием приводим конфиг к нужному состоянию:

0 to add, 0 to change, 0 to destroy

… и делаем слияние файлов конфигов resources.tf простым переносом блоков конфигураций.

Слияние файлов состояний tfstate делается через встроенную функцию Terraform — state mv. Важно, что добавление состояния в общий файл производится только для одного ресурса за раз. Поэтому при наличии нескольких таких ресурсов необходимо повторить операцию для каждого из них.

~/ec2$ cd ../generated/
~/generated$ terraform state mv -state=../ec2/terraform.tfstate -state-out=terraform.tfstate 'tfer--i-002D-03f57062-node-1' 'tfer--i-002D-03f57062-node-1'

Применение конфигурации

Пришло время применить конфигурацию. Обязательно следует обратить внимание на то, что Terraform не планирует ничего изменять или удалять. Ведь даже самые, казалось бы, безобидные изменения могут вызвать перезапуск инстанса. В некоторых ситуациях возможно и полное пересоздание ресурса. Поэтому идеальным состоянием перед применением конфигурации является именно Plan: 0 to add, 0 to change, 0 to destroy.

~/regions/eu$ terraform apply
                ~~~
Terraform will perform the following actions:

Plan: 0 to add, 0 to change, 0 to destroy.
                ~~~
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Миграция состояния в S3

Для удобства можно загрузить файл tfstate в хранилище S3. Дальнейшее взаимодействие с этим файлом состояния будет производиться непосредственно механизмами Terraform. Преимущество такого варианта хранения — функция блокировки файла во время использования. Благодаря этому становится возможной одновременная работа команды инженеров над объемной инфраструктурой без риска конфликтов при запуске критичных операций. И, конечно же, размещение состояния в S3 предполагает надежность хранения и версионирование файла.

Миграция файла состояния tfstate начинается с создания отдельного каталога, в котором будут описаны файлы конфигурации, предназначенные только для создания ресурсов, обеспечивающих функции хранения состояния.

Изначально создадим конфиг провайдера:

provider "aws" {
  region = "eu-central-1"
}

terraform {
  required_providers {
    aws = {
      version = "~> 3.28.0"
    }
  }
}

Затем — конфиг ресурса aws_s3_bucket. Данный ресурс создает выделенное хранилище в S3:

resource "aws_s3_bucket" "terraform_state" {
  bucket = "eu-terraform-state"
  lifecycle {
    prevent_destroy = true
  }
  versioning {
    enabled = true
  }
  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }
}

Особенностями представленной конфигурации являются следующие параметры:

bucket

название каталога для хранения;

lifecycle { prevent_destroy = true }

запрет на удаление ресурса;

versioning { enabled = true }

включение версионирования файла terraform.tfstate на стороне S3-хранилища;

server_side_encryption_configuration

настройки шифрования.

Также добавим в конфиг ресурс aws_dynamodb_table. Это таблица AWS DynamoDB, в которой будет храниться информация о блокировках файла terraform.tfstate:

resource "aws_dynamodb_table" "terraform_locks" {
  name = "eu-terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key = "LockID"
  attribute {
    name = "LockID"
    type = "S"
  }
}

name

название таблицы для записи информации о блокировках;

billing_mode = "PAY_PER_REQUEST"

тип оплаты. Значение PAY_PER_REQUEST позволяет оплачивать по количеству обращений к таблице;

hash_key

attribute {
name = "LockID"
type = "S"
}

указание первичного ключа таблицы.

Инициализируем конфигурацию, после чего выполняем планирование и применение:

~$ terraform init
Initializing the backend...
                ~~~
Terraform has been successfully initialized!

~/states/eu$ terraform plan
Terraform will perform the following actions:

  # aws_dynamodb_table.terraform_locks will be created

  # aws_s3_bucket.terraform_state will be created

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

~/states/eu$ terraform apply
Terraform will perform the following actions:

  # aws_dynamodb_table.terraform_locks will be created

  # aws_s3_bucket.terraform_state will be created

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

aws_dynamodb_table.terraform_locks: Creating...
aws_s3_bucket.terraform_state: Creating…

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

Переходим в директорию с конфигурацией ресурсов AWS и создаем в ней файл backend.tf следующего содержания:

~$ cd ../../regions/eu
~$ vim backend.tf
terraform {
 backend "s3" {
   bucket = "eu-terraform-state"
   key = "terraform.tfstate"
   region = "eu-central-1"
   dynamodb_table = "eu-terraform-locks"
   encrypt = true
 }
}

Если попытаться выполнить какие-либо действия с текущей конфигурацией ресурсов, то возникнет ошибка:

Backend reinitialization required. Please run "terraform init".
Reason: Initial configuration of the requested backend "s3"

Для ее устранения требуется повторно запустить инициализацию. При этом Terraform обратится в созданное ранее хранилище S3 и запросит файл terraform.tfstate. Поскольку хранилище еще пустое, он предложит нам перенести локальный state-файл в хранилище S3:

~/regions/eu$ terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes

Releasing state lock. This may take a few moments...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Terraform has been successfully initialized!

И, наконец, планируем окончательную версию конфигурации:

~/regions/eu$ terraform plan
Refreshing Terraform state in-memory prior to plan...
                ~~~
No changes. Infrastructure is up-to-date.

Итоги

Теперь конфигурация обозначенных нами ресурсов успешно адаптирована. Можно сохранить ее в Git-репозитории, тем самым обеспечив версионирование и (в некотором виде) децентрализацию хранения. Файл состояния также хранится в хранилище S3 с версионированием и включением блокировки на время сессии.

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

Конечно же, это всего лишь часть мер, которые следует принять для обеспечения отказоустойчивости проекта. В частности, не стоит забывать про динамические данные, их восстановление, а также возможность миграции между различными провайдерами.

Заключение

Существует достаточно много способов выполнить импорт конфигурации ресурсов того или иного облачного провайдера. Как показала практика, без кастомизации всё равно не обходится, и способ решения приходится подгонять под конкретные инструменты. Тем не менее, утилита Terraformer помогла нам выполнить поставленную задачу. Конечно, понадобилось время на исследования, реализацию и отладку, но, когда процесс был отработан, мы импортировали конфигурации ресурсов нескольких кластеров в установленные сроки и без каких-либо проблем со стороны инфраструктуры.

При планировании подобных работ необходимо изначально проанализировать (а может быть, даже проверить пару примеров на практике) целесообразность подобного импорта с точки зрения трудоемкости. Вероятно, для небольшого количества ресурсов будет проще и быстрее написать конфиги Terraform с нуля, либо импортировать по одному штатными средствами, а после адаптировать их. Но когда предстоит работа с большим количеством однородных ресурсов, автоматизация помогает реализовать, казалось бы, невыполнимую задачу.

P.S.

Читайте также в нашем блоге:

Комментарии (2)