Зачем cloudinit
EC2-инстанс при первом старте запускает cloud-init, который читает
user_data (любой текст до 16 KiB). Поддерживаются форматы:
- cloud-config YAML, декларативные команды (создать пользователя, написать файл, поставить пакет).
- shell script,
#!/bin/bashи поехали. - boothook, выполнить до cloud-init main run.
- mime-multipart, несколько форматов в одном.
Когда конфигурация сложная (надо и YAML, и скрипт): пишут multi-part
MIME руками: boundaries, headers, base64-encode. Это утомительно.
Provider cloudinit собирает MIME за тебя.
Установка
terraform { required_providers { cloudinit = {source = "hashicorp/cloudinit"
version = "~> 2.3"
}
}
}
Без конфигурации, всё локально.
cloudinit_config, простой кейс
data "cloudinit_config" "bootstrap" {gzip = false
base64_encode = true
part {content_type = "text/cloud-config"
content = yamlencode({package_update = true
package_upgrade = false
packages = [
"nginx",
"jq",
]
write_files = [
{path = "/etc/nginx/conf.d/app.conf"
content = file("${path.module}/templates/nginx.conf")owner = "root:root"
permissions = "0644"
},
]
runcmd = [
"systemctl enable nginx",
"systemctl start nginx",
]
})
}
}
resource "aws_instance" "web" {ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
user_data = data.cloudinit_config.bootstrap.rendered
}
Что произошло:
cloudinit_configсобрал MIME с одной частью (YAML).base64_encode = true, итог в base64 (EC2 user_data ожидает base64).data.cloudinit_config.bootstrap.rendered, готовый blob.
Без provider'а пришлось бы вручную писать MIME-boundaries и base64-encode.
Multi-part: YAML + bash
Иногда нужно и cloud-config (декларатив), и shell script (для того, чего в cloud-config нет).
data "cloudinit_config" "app" {gzip = true
base64_encode = true
part {filename = "init.cfg"
content_type = "text/cloud-config"
content = yamlencode({users = [
{name = "appuser"
sudo = "ALL=(ALL) NOPASSWD:ALL"
shell = "/bin/bash"
ssh_authorized_keys = [tls_private_key.deploy.public_key_openssh]
},
]
package_update = true
packages = ["docker.io", "awscli"]
})
}
part {filename = "register.sh"
content_type = "text/x-shellscript"
content = templatefile("${path.module}/scripts/register.sh.tpl", {env = var.env
cluster = aws_ecs_cluster.app.name
})
}
}
Два part: cloud-config + shell. Cloud-init выполнит сначала YAML (создать пользователя, поставить пакеты), потом shell (зарегистрировать в ECS cluster через AWS CLI).
gzip = true
user_data на EC2, 16 KiB лимит. Gzip эффективен, конфиг 30KB ужимается до 5-7KB. Cloud-init сам распаковывает.
templatefile для динамики
cloudinit и templatefile, идеальная пара. Шаблон файла с переменными:
templates/register.sh.tpl:
#!/usr/bin/env bash
set -euo pipefail
ENV="${env}"CLUSTER="${cluster}"echo "Registering in $CLUSTER ($ENV)" >> /var/log/register.log
aws ecs register-container-instance \
--cluster "$CLUSTER" \
--instance-identity-document "$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document)"
В HCL:
part {content_type = "text/x-shellscript"
content = templatefile("${path.module}/templates/register.sh.tpl", {env = var.env
cluster = aws_ecs_cluster.app.name
})
}
templatefile подставляет ${env} и ${cluster} на этапе plan.
Результат, готовый к выполнению bash.
Полный пример: EC2 со Stack
resource "tls_private_key" "deploy" {algorithm = "ED25519"
}
resource "aws_key_pair" "deploy" {key_name = "deploy"
public_key = tls_private_key.deploy.public_key_openssh
}
data "cloudinit_config" "web" {gzip = true
base64_encode = true
part {content_type = "text/cloud-config"
content = yamlencode({ users = [{name = "deploy"
sudo = "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys = [tls_private_key.deploy.public_key_openssh]
}]
package_update = true
packages = ["nginx"]
})
}
part {content_type = "text/x-shellscript"
content = templatefile("${path.module}/templates/configure.sh.tpl", {site_name = var.site_name
})
}
}
resource "aws_instance" "web" {ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
key_name = aws_key_pair.deploy.key_name
vpc_security_group_ids = [aws_security_group.web.id]
user_data = data.cloudinit_config.web.rendered
tags = { Name = "${var.site_name}-web"}
lifecycle {ignore_changes = [user_data] # см. Подводный камень
}
}
Подводные камни
-
user_data выполняется ОДИН РАЗ. Только при первой загрузке. Меняешь user_data → Terraform хочет пересоздать инстанс. Если этого не нужно (просто новый конфиг для будущих инстансов).
lifecycle.ignore_changes = [user_data]. Иначе любая правка скриптов = плановое уничтожение парка. -
Лимит 16 KiB. До base64-encoding. С
gzip = trueвлезает гораздо больше, но всё равно: не файлохранилище. Большие artifact'ы тяни через S3 в самом user_data. -
Ошибки в cloud-config не видны Terraform'у. Apply прошёл, инстанс стартовал, user_data «выполнился». Что внутри,
tail -f /var/log/cloud-init.logна самом инстансе. Терраформ не знает что у тебя пакет не поставился. -
YAML отступы.
yamlencode()решает большую часть, но если делаешь inline YAML, отступы критичны. Cloud-init YAML строгий. -
runcmdзапускается ПОСЛЕ packages. Если runcmd зависит от установленного пакета, норм. Если до, фейл.bootcmdзапускается раньше. -
gzip не для всех. Старый cloud-init (CentOS 6, Amazon Linux 1), может не распаковать. Современный (Ubuntu 20+, AL2/AL2023). OK.
-
Sensitive в user_data. Если в части есть пароль/токен, пометь output sensitive, защити state. Сама cloudinit_config не пометит за тебя.
-
Тестировать локально через
cloud-init schema --config-file .... Утилита cloud-init умеет валидировать YAML до отправки на инстанс. Полезно в pre-commit.