Если вы серьёзно используете AWS (Amazon Web Services), то наверняка знаете про возможность описать инфраструктуру с помощью JSON шаблонов. В AWS этот сервис называется CloudFormation. По сути это решение позволяет вам описать желаемое состояние любых ресурсов, доступных в AWS (инстансы, слои opsworks, ELB, security groups и т.д.). Набор ресурсов называется стеком. После загрузки CloudFormation шаблона система сама либо создаст необходимые ресурсы в стеке, если их ещё нет, либо попытается обновить существующие до желаемого состояния.
Это хорошо работает если у вас есть небольшое количество ресурсов, но как только инфраструктура разрастается появляются проблемы:
- В JSON нет возможности использовать циклы и для похожих ресурсов приходится повторять одни и те же параметры и в случае изменения тоже (не DRY)
- Для записи конфигурации для cloud-init нужен двойной escaping
- В JSON нет комментариев и он имеет плохую человеко-читаеммость
Для того чтобы избежать подобных проблем инженеры из Heavy Water написали на ruby DSL и CLI для генерации и работы с этими шаблонами под названием SparkleFormation (github).
DRY
Когда я пришёл на свой текущий проект у нас был CloudFormation шаблон, содержащий около 1500 строк описания ресурсов и около 0 строк комментариев. После использования SparkleFormation шаблон стал занимать 300 строк, многие из которых комментарии. Как мы это добились? Для начала посмотрим как работает CloudFormation, типичное описание ресурса выглядит так:
Создание ELB
"AppsElb": {
"Type": "AWS::ElasticLoadBalancing::LoadBalancer",
"Properties": {
"Scheme": "internal",
"Subnets": [
{"Ref": "Subnet1"},
{"Ref": "Subnet2"}
],
"SecurityGroups": [
{"Ref": "SG"}
],
"HealthCheck": {
"HealthyThreshold": "2",
"Interval": "5",
"Target": "TCP:80",
"Timeout": "2",
"UnhealthyThreshold": "2"
},
"Listeners": [
{
"InstancePort": "80",
"LoadBalancerPort": "80",
"Protocol": "TCP",
"InstanceProtocol": "TCP"
},
{
"InstancePort": "22",
"LoadBalancerPort": "2222",
"Protocol": "TCP",
"InstanceProtocol": "TCP"
},
{
"InstancePort": "8500",
"LoadBalancerPort": "8500",
"Protocol": "TCP",
"InstanceProtocol": "TCP"
}
]
}
}
Поскольку SparkleFormation позволяет использовать обычный ruby код внутри DSL, то переписать это можно так:
Создание ELB в SparkleFormation
resources(:AppsElb) do
type 'AWS::ElasticLoadBalancing::LoadBalancer'
properties do
scheme 'internal'
subnets [PARAMS[:Subnet1], PARAMS[:Subnet2]]
security_groups [ref!(:SG)]
# port mapping 80->80, 22 -> 2222, etc.
listeners = { :'80' => 80, :'2222' => 22, :'8500' => 8500 }.map do |k, v|
{ 'LoadBalancerPort' => k.to_s,
'InstancePort' => v,
'Protocol' => 'TCP',
'InstanceProtocol' => 'TCP' }
end
listeners listeners
health_check do
target 'TCP:80'
healthy_threshold '2'
unhealthy_threshold '2'
interval '5'
timeout '2'
end
end
end
Как можно заметить мы больше не повторяемся в описании каждого порта и добавление нового займёт у нас только одну строчку. Более того если у нам необходимо создать много почти однотипных ресурсов, но отличающихся по 1-2 параметрам, SparkleFormation предоставляет такую сущность как dynamics, где вы можете описать абстрактный ресурс, которому передают параметры:
Пример из документации
А потом мы можем вызвать этот абстрактный ресурс в шаблоне:
# dynamics/node.rb
SparkleFormation.dynamic(:node) do |_name, _config={}|
unless(_config[:ssh_key])
parameters.set!("#{_name}_ssh_key".to_sym) do
type 'String'
end
end
dynamic!(:ec2_instance, _name).properties do
key_name _config[:ssh_key] ? _config[:ssh_key] : ref!("#{_name}_ssh_key".to_sym)
end
end
А потом мы можем вызвать этот абстрактный ресурс в шаблоне:
SparkleFormation.new(:node_stack) do
dynamic!(:node, :fubar)
dynamic!(:node, :foobar, :ssh_key => 'default')
end
Таким образом мы можем повторно использовать нужные нам ресурсы и при необходимости изменения поменять все в 1 месте.
Cloud-init
Мы часто пользуемся возможностью передавать инстансу при загрузке cloud-init конфиг в виде yaml-файла и с помощью него выполнять установку пакетов, конфигурацию CoreOS, отдельных сервисов и других настроек. Проблема в том, что yaml должен передавать инстансу в user-data в CloudFormation шаблоне и выглядело это примерно так:
Безумный escaping
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"#cloud-config\n",
"\n",
"coreos:\n",
" etcd:\n",
" discovery: ", {"Ref": "AppDiscoveryURL"}, "\n",
" addr: $private_ipv4:4001\n",
" peer-addr: $private_ipv4:7001\n",
" etcd2:\n",
...
Как можно видеть это абсолютно не читаемо, уродливо и плохо поддерживаемо, не говоря уже о том что о подсветке синтаксиса можно забыть. Благодаря тому что внутри DSL можно использовать ruby код, то весь yaml можно вынести в отдельный файл и просто вызывать:
user_data Base64.encode64(IO.read('files/cloud-init.yml'))
Как видно это намного приятнее чем редактировать его внутри JSON. Вместо IO.read можно использовать и HTTP вызов для любых параметров, если вам это нужно.
CLI
У себя в проекте мы используем собственную обёртку для управления шаблонами, но эта же команда предоставляет CLI (Command Line Interface) для управления шаблонами, называемый sfn. С помощью него можно загружать, удалять и обновлять CloudFormation стеки, командами sfn create, sfn destroy и sfn update. Там так же реализована интеграция с knife.
В целом после 4 месяцев использования SparkleFormation я им доволен и надеюсь больше не вернусь на plain JSON для описания инфраструктуры. В планах попробовать весь workflow, включая sfn, предлагаемый командой Heavy Water.