CloudFormationのtemplateをRubyで作成 – 「SparkleFormation」を触ってみた。

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、せーのです。個人的には2015年に入ってから4つめのCloudFormationネタとなります。どうしたんでしょう。
今日はRubyのDSLでCloudFormationのtemplateが書けるrubyist垂涎のツールをご紹介します。名前は「SparkleFormation」といいます。

SparkleFormation - github | document

CloudFormation templateに対する根本的な不満

やっぱりなんと言っても特徴は「CloudFormationのtemplateをRubyのコードで書ける」という部分にあります。逆に言うと「CloudFormationのtemplateをRubyのコードで書けるだけのツール」とも言えます。CloudFormationのtemplateというのは本来はJSONで書かれています。JSONはRubyに比べれば決して難しくない言語です。Rubyを覚える1/50くらいの学習コストといえるでしょう。先日ご紹介した「knife-cloudformation」もknifeでCloudFormationを動かす「だけ」のツールといえるかと思います。ではなぜわざわざRubyで書くのでしょうか。なぜわざわざknifeで動かすのでしょうか。
それは皆がCloudFormationのtemplateに対して思っているうっすらとした不満の現れかと思います。SparkleFormationの作者はなぜJSONだけで書かないのか、という問いに対して

逆にみんな、本当に本心からこのテンプレートを全てJSONで書きたいなんて思ってるのか?

という回答をしています。なんでしょう、胸が抉られるようです。
CloudFormationは構成がコード化出来て便利だから、今流行りのInfrastructure as a codeだから、色々な言語に対して汎用的でテキストベースだから扱いやすいから、jqとかですぐに加工できるから、だからこれでいいんだ。JSONが正しい姿なんだ。使いづらくない、めんどくさくなんか無い
そう思い込もうとしていた私の心にグッサリ刺さりました。もう無理しなくていいんだよ。泣いてもいいんだよ。心にダムはあるのかい

特徴 = Rubyに出来てJSONに出来ないこと全て。

 ということでこのツールの強みとしては「JSONで表現出来ない書き方が出来る」というところにあります。
CloudFormationのtemplateは地味にコメントが使えません(正確にはDescriptionアトリビュートに書けば使えると言えば使えるのですが所謂プログラムコードに書く「コメント」が書けません)。これは地味にイタいです。何の目的でこういうものを作るのか、どうしてこういうセキュリティグループなのか、という説明が書けなかったのです。でもSparkleFormationは書けます。だってRubyだもの
他にもループ処理が書けたりよくあるパターンをまとめてComponent化して使いまわす等CloudFormationをしばらく使っていると「ああ、これが出来ればなあ」というような機能が詰まっています。例えるならCloudFormationのフレームワーク、と言った感じでしょうか。
またrubyを使っているということで色々な連携が使えます。上に書かれているknife-cloudforamtionのテンプレートとしても使えるため

$ knife cloudformation test --file templates/sparkleformationtest.rb --defaults

みたいな書き方ができます。他にもRedis, RabbitMQ, Sensu Enterprise, Uchiwa等との連携、またChefとの連携にも使えます。

中の人も注目している?

皆さんは「AWS Pop-Up Loft」という名前を聞いたことがあるでしょうか。これはサンフランシスコにあるAWSを使ったスタートアップ等があつまるコミュニティースペースです。こちらには軽食も完備されていて、みんなリラックスした雰囲気で色々な情報交換や勉強会が行われています。 この「SparkleFormation」は先日の1月8日になんとこのAWS Pop-Up Loftにて勉強会が行われており、AWSテクニカルチームも注目のツールになっています。日本ではまだ知らない人も多いこのツールをいち早くマスターすると、後々AWS系で一歩先を行けること間違いなし、ですね!

インストール

では早速インストールしてみましょう。githubからダウンロードしてgem、もしくはgemのBundlerでインストールします。私はgemそのままで入れてみました。

Tsuyoshis-Air:dev Tsuyoshi$ git clone https://github.com/sparkleformation/sparkle_formation.git sparkleformation
Cloning into 'sparkleformation'...
remote: Counting objects: 1714, done.
remote: Compressing objects: 100% (12/12), done.
remote: Total 1714 (delta 2), reused 0 (delta 0)
Receiving objects: 100% (1714/1714), 1.14 MiB | 557.00 KiB/s, done.
Resolving deltas: 100% (1078/1078), done.
Checking connectivity... done.
Tsuyoshis-Air:dev Tsuyoshi$ cd sparkleformation/
Tsuyoshis-Air:sparkleformation Tsuyoshi$ ls -l
total 80
-rw-r--r--   1 Tsuyoshi  staff    898  1 12 03:08 CHANGELOG.md
-rw-r--r--   1 Tsuyoshi  staff    558  1 12 03:08 CONTRIBUTING.md
-rw-r--r--   1 Tsuyoshi  staff     97  1 12 03:08 Gemfile
-rw-r--r--   1 Tsuyoshi  staff  10850  1 12 03:08 LICENSE
-rw-r--r--   1 Tsuyoshi  staff   8433  1 12 03:08 README.md
drwxr-xr-x   5 Tsuyoshi  staff    170  1 12 03:08 bin
drwxr-xr-x  11 Tsuyoshi  staff    374  1 12 03:08 docs
drwxr-xr-x   4 Tsuyoshi  staff    136  1 12 03:08 examples
drwxr-xr-x   4 Tsuyoshi  staff    136  1 12 03:08 lib
-rw-r--r--   1 Tsuyoshi  staff    706  1 12 03:08 sparkle_formation.gemspec
drwxr-xr-x   4 Tsuyoshi  staff    136  1 12 03:08 test
Tsuyoshis-Air:sparkleformation Tsuyoshi$ gem install sparkle_formation
Fetching: sparkle_formation-0.2.6.gem (100%)
ERROR:  While executing gem ... (Gem::FilePermissionError)
    You don't have write permissions for the /Library/Ruby/Gems/2.0.0 directory.
Tsuyoshis-Air:sparkleformation Tsuyoshi$ sudo gem install sparkle_formation
Password:
Successfully installed sparkle_formation-0.2.6
Parsing documentation for sparkle_formation-0.2.6
1 gem installed
Tsuyoshis-Air:sparkleformation Tsuyoshi$

動かしてみる

早速サンプルを動かしてみましょう。サンプルとしてセットされている「ec2_example.rb」を見てみましょう。

SparkleFormation.new('ec2_example') do
  description "AWS CloudFormation Sample Template EC2InstanceSample..."

  parameters do
    key_name do
      description 'Name of an existing EC2 KeyPair to enable SSH access to the instance'
      type 'String'
    end
  end

  mappings.region_map do
    _set('us-east-1', :ami => 'ami-7f418316')
    _set('us-east-1', :ami => 'ami-7f418316')
    _set('us-west-1', :ami => 'ami-951945d0')
    _set('us-west-2', :ami => 'ami-16fd7026')
    _set('eu-west-1', :ami => 'ami-24506250')
    _set('sa-east-1', :ami => 'ami-3e3be423')
    _set('ap-southeast-1', :ami => 'ami-74dda626')
    _set('ap-northeast-1', :ami => 'ami-dcfa4edd')
  end

  resources do
    my_instance do
      type 'AWS::EC2::Instance'
      properties do
        key_name _cf_ref(:key_name)
        image_id _cf_map(:region_map, 'AWS::Region', :ami)
        user_data _cf_base64('80')
      end
    end
  end

  outputs do
    instance_id do
      description 'InstanceId of the newly created EC2 instance'
      value _cf_ref(:my_instance)
    end
    az do
      description 'Availability Zone of the newly created EC2 instance'
      value _cf_attr(:my_instance, :availability_zone)
    end
    public_ip do
      description 'Public IP address of the newly created EC2 instance'
      value _cf_attr(:my_instance, :public_ip)
    end
    private_ip do
      description 'Private IP address of the newly created EC2 instance'
      value _cf_attr(:my_instance, :private_ip)
    end
    public_dns do
      description 'Public DNSName of the newly created EC2 instance'
      value _cf_attr(:my_instance, :public_dns_name)
    end
    private_dns do
      description 'Private DNSName of the newly created EC2 instance'
      value _cf_attr(:my_instance, :private_dns_name)
    end
  end
end

Rubyを書いたことがある方ならなんとなく書こうとしている意図がわかると思います。ParameterとしてSSH鍵の名前、MappingとしてAMIのID、ResourceとしてEC2インスタンスが一つありOUTPUTが色々指定されている、という感じですね。では実行してみましょう。コンパイルしてJSON Templateを吐くスクリプトを書いて流します。

require 'sparkle_formation'
require 'json'

puts JSON.pretty_generate(
  SparkleFormation.compile('ec2_example.rb')
)
Tsuyoshis-Air:sparkleformation Tsuyoshi$ cd examples/allinone/cloudformation/
Tsuyoshis-Air:cloudformation Tsuyoshi$ chmod 700 test.rb
Tsuyoshis-Air:cloudformation Tsuyoshi$ ruby test.rb
{
  "Description": "AWS CloudFormation Sample Template EC2InstanceSample...",
  "Parameters": {
    "KeyName": {
      "Description": "Name of an existing EC2 KeyPair to enable SSH access to the instance",
      "Type": "String"
    }
  },
  "Mappings": {
    "RegionMap": {
      "Us-east-1": {
        "Ami": "ami-7f418316"
      },
      "Us-west-1": {
        "Ami": "ami-951945d0"
      },
      "Us-west-2": {
        "Ami": "ami-16fd7026"
      },
      "Eu-west-1": {
        "Ami": "ami-24506250"
      },
      "Sa-east-1": {
        "Ami": "ami-3e3be423"
      },
      "Ap-southeast-1": {
        "Ami": "ami-74dda626"
      },
      "Ap-northeast-1": {
        "Ami": "ami-dcfa4edd"
      }
    }
  },
  "Resources": {
    "MyInstance": {
      "Type": "AWS::EC2::Instance",
      "Properties": {
        "KeyName": {
          "Ref": "KeyName"
        },
        "ImageId": {
          "Fn::FindInMap": [
            "RegionMap",
            {
              "Ref": "AWS::Region"
            },
            "Ami"
          ]
        },
        "UserData": {
          "Fn::Base64": "80"
        }
      }
    }
  },
  "Outputs": {
    "InstanceId": {
      "Description": "InstanceId of the newly created EC2 instance",
      "Value": {
        "Ref": "MyInstance"
      }
    },
    "Az": {
      "Description": "Availability Zone of the newly created EC2 instance",
      "Value": {
        "Fn::GetAtt": [
          "MyInstance",
          "AvailabilityZone"
        ]
      }
    },
    "PublicIp": {
      "Description": "Public IP address of the newly created EC2 instance",
      "Value": {
        "Fn::GetAtt": [
          "MyInstance",
          "PublicIp"
        ]
      }
    },
    "PrivateIp": {
      "Description": "Private IP address of the newly created EC2 instance",
      "Value": {
        "Fn::GetAtt": [
          "MyInstance",
          "PrivateIp"
        ]
      }
    },
    "PublicDns": {
      "Description": "Public DNSName of the newly created EC2 instance",
      "Value": {
        "Fn::GetAtt": [
          "MyInstance",
          "PublicDnsName"
        ]
      }
    },
    "PrivateDns": {
      "Description": "Private DNSName of the newly created EC2 instance",
      "Value": {
        "Fn::GetAtt": [
          "MyInstance",
          "PrivateDnsName"
        ]
      }
    }
  }
}
Tsuyoshis-Air:cloudformation Tsuyoshi$

綺麗にJSONが出てきました。

実践的なサンプルを作ってみる

もう少し実践的なサンプルを動かしてみましょう。こちらもドキュメントに載っていたサンプルです。

SparkleFormation.new('website') do

  set!('AWSTemplateFormatVersion', '2010-09-09')

  description 'Supercool Website'

  resources.cfn_user do
    type 'AWS::IAM::User'
    properties.path '/'
    properties.policies _array(
      -> {
        policy_name 'cfn_access'
        policy_document.statement _array(
          -> {
            effect 'Allow'
            action 'cloudformation:DescribeStackResource'
            resource '*'
          }
        )
      }
    )
  end

  resources.cfn_keys do
    type 'AWS::IAM::AccessKey'
    properties.user_name ref!(:cfn_user)
  end

  parameters.web_nodes do
    type 'Number'
    description 'Number of web nodes for ASG.'
    default 2
  end

  resources.website_autoscale do
    type 'AWS::AutoScaling::AutoScalingGroup'
    properties do
      availability_zones({'Fn::GetAZs' => ''})
      launch_configuration_name ref!(:website_launch_config)
      min_size ref!(:web_nodes)
      max_size ref!(:web_nodes)
    end
  end

  resources.website_launch_config do
    type 'AWS::AutoScaling::LaunchConfiguration'
    properties do
      image_id 'ami-123456'
      instance_type 'm3.medium'
    end
  end

  resources.website_elb do
    type 'AWS::ElasticLoadBalancing::LoadBalancer'
    properties do
      availability_zones._set('Fn::GetAZs', '')
      listeners _array(
        -> {
          load_balancer_port '80'
          protocol 'HTTP'
          instance_port '80'
          instance_protocol 'HTTP'
        }
      )
      health_check do
        target 'HTTP:80/'
        healthy_threshold '3'
        unhealthy_threshold '3'
        interval '5'
        timeout '15'
      end
    end
  end
end

ELBに80ポートでEC2を2つAuto Scalingで並べるサンプルですね。先程より少し複雑になりました。このサンプルをコンパイルするスクリプトはこちらになります。

require 'sparkle_formation'
require 'json'

puts JSON.pretty_generate(
  SparkleFormation.compile('website.rb')
)
Tsuyoshis-Air:examples Tsuyoshi$ chmod 700 test2.rb
Tsuyoshis-Air:examples Tsuyoshi$ ruby test2.rb
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "Supercool Website",
  "Resources": {
    "CfnUser": {
      "Type": "AWS::IAM::User",
      "Properties": {
        "Path": "/",
        "Policies": [
          {
            "PolicyName": "cfn_access",
            "PolicyDocument": {
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": "cloudformation:DescribeStackResource",
                  "Resource": "*"
                }
              ]
            }
          }
        ]
      }
    },
    "CfnKeys": {
      "Type": "AWS::IAM::AccessKey",
      "Properties": {
        "UserName": {
          "Ref": "CfnUser"
        }
      }
    },
    "WebsiteAutoscale": {
      "Type": "AWS::AutoScaling::AutoScalingGroup",
      "Properties": {
        "AvailabilityZones": {
          "Fn::GetAZs": ""
        },
        "LaunchConfigurationName": {
          "Ref": "WebsiteLaunchConfig"
        },
        "MinSize": {
          "Ref": "WebNodes"
        },
        "MaxSize": {
          "Ref": "WebNodes"
        }
      }
    },
    "WebsiteLaunchConfig": {
      "Type": "AWS::AutoScaling::LaunchConfiguration",
      "Properties": {
        "ImageId": "ami-123456",
        "InstanceType": "m3.medium"
      }
    },
    "WebsiteElb": {
      "Type": "AWS::ElasticLoadBalancing::LoadBalancer",
      "Properties": {
        "AvailabilityZones": {
          "Fn::GetAZs": ""
        },
        "Listeners": [
          {
            "LoadBalancerPort": "80",
            "Protocol": "HTTP",
            "InstancePort": "80",
            "InstanceProtocol": "HTTP"
          }
        ],
        "HealthCheck": {
          "Target": "HTTP:80/",
          "HealthyThreshold": "3",
          "UnhealthyThreshold": "3",
          "Interval": "5",
          "Timeout": "15"
        }
      }
    }
  },
  "Parameters": {
    "WebNodes": {
      "Type": "Number",
      "Description": "Number of web nodes for ASG.",
      "Default": 2
    }
  }
}
Tsuyoshis-Air:examples Tsuyoshi$

こちらを更に環境の環境の初期定義分として使いまわす目的のbase, パラメータ値を定義するcomponents, 引数を持たせて1テンプレート内で複数回使えるように設定するdynamics、と役割別にファイルを分けると

SparkleFormation.build do
  set!('AWSTemplateFormatVersion', '2010-09-09')

  resources.cfn_user do
    type 'AWS::IAM::User'
    properties.path '/'
    properties.policies _array(
      -> {
        policy_name 'cfn_access'
        policy_document.statement _array(
          -> {
            effect 'Allow'
            action 'cloudformation:DescribeStackResource'
            resource '*' 
          }
        )
      }
    )
  end

  resources.cfn_keys do
    type 'AWS::IAM::AccessKey'
    properties.user_name ref!(:cfn_user)
  end
end

<

pre>

SparkleFormation.dynamic(:elb) do |_name, _config={}|
  resources("#{_name}_elb".to_sym) do
    type 'AWS::ElasticLoadBalancing::LoadBalancer'
    properties do
      availability_zones._set('Fn::GetAZs', '')
      listeners _array(
        -> {
          load_balancer_port _config[:load_balancer_port] || '80'
          protocol _config[:protocol] || 'HTTP'
          instance_port _config[:instance_port] || '80'
          instance_protocol _config[:instance_protocol] || 'HTTP'
        }
      )
      health_check do
        target _config[:target] || 'HTTP:80/'
        healthy_threshold _config[:healthy_threshold] || '3'
        unhealthy_threshold _config[:unhealthy_threshold] || '3'
        interval _config[:interval] || '5'
        timeout _config[:timeout] || '15'
      end
    end
  end
end

これらをコンパイルするにはこのようなテンプレートを書きます。

SparkleFormation.new(:website).load(:base).overrides do

  description 'Supercool Website'

  parameters.web_nodes do
    type 'Number'
    description 'Number of web nodes for ASG.'
    default 2
  end

  resources.website_autoscale do
    type 'AWS::AutoScaling::AutoScalingGroup'
    properties do
      availability_zones({'Fn::GetAZs' => ''})
      launch_configuration_name ref!(:website_launch_config)
      min_size ref!(:web_nodes)
      max_size ref!(:web_nodes)
    end
  end

  resources.website_launch_config do
    type 'AWS::AutoScaling::LaunchConfiguration'
    properties do
      image_id 'ami-123456'
      instance_type 'm3.medium'
    end
  end

  dynamic!(:elb, 'website')
end

なかなかわかりにくくなってきましたね。baseの部分には使い回ししやすいIAM Userについてまとめてあり、dynamicsはELBが入っています。こちらにEC2系を入れてしまうのも手かと思います。オブジェクト指向プログラミングが身についている方であればbaseがabstractな感じに見えるかと思います。そう見えれば何を入れたら良いかも大体わかってきますね。

まとめ

いかがでしたでしょうか。上でご紹介したのはこのライブラリのほんのさわりの部分です。例えばこのテンプレートをknife-cloudformationで流しやすいようにまとめてあるsparkle starter kitというツールがあったり、octets = ENV['VPC_SUBNET].split('.').slice(0,2).join('.')">みたいな書き方をすることで既存のVPCに対してサブネットを追加する、等この世界はまだまだ奥が深そうです。まずはドキュメントを読んでみてください。

参考サイト