Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Рассказываем про наш опыт импорта и адаптации конфигураций инфраструктуры, ранее развернутой вручную в 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!
Импорт
Выполняем импорт ресурсов. В данном примере используем следующие ключи:
| название провайдера; |
| путь к файлам конфигураций, сгенерированным утилитой; |
| запись конфигураций всех типов ресурсов в единый файл. Файлы с описанием ресурсов, переменных, состояний будут записаны в разные файлы; |
| регион провайдера; |
| типы импортируемых ресурсов. |
~$ 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"
}
}
}
}
Особенностями представленной конфигурации являются следующие параметры:
| название каталога для хранения; |
| запрет на удаление ресурса; |
| включение версионирования файла |
| настройки шифрования. |
Также добавим в конфиг ресурс 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"
}
}
| название таблицы для записи информации о блокировках; |
| тип оплаты. Значение |
| указание первичного ключа таблицы. |
Инициализируем конфигурацию, после чего выполняем планирование и применение:
~$ 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.
Читайте также в нашем блоге:
«Мониторим основные сервисы в AWS с Prometheus и exporter’ами для CloudWatch»;
«Как мы сэкономили 2000 USD на трафике из Amazon S3 с помощью nginx-кэша».