ちょっと話題の記事

CloudFormationテンプレートをYAMLで書いてみる【remarshal】

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

CloudFormation職人の朝は早い。

「まぁ好きではじめた仕事ですから」

最近は良いテンプレートが少ないと愚痴をこぼした。 まず、JSONの入念なチェックから始まる。

「やっぱり一番うれしいのはお客さんからの感謝のメールね、この仕事やっててよかったなと」 「毎日毎日AWSの機能が増える。機械では出来ない」

今日は納品日。 彼はテンプレートをS3に上げ、コンソールに向き合った。 基本的な形は決まっているが、最近のAWSのアップデートに合わせ 今ベストなものを作らなければいけないのが辛いところ、と彼は語る。

「やっぱりJSONにコメントが書けないのはキツイね、愚痴ってもしかたないんだけどさ(笑)」 「でも自分が選んだ道だからね。後悔はしてないよ」 「このテンプレートはダメだ。ほら、他のリージョンではFAILしてしまう」

彼の目にかかれば、見るだけで出来不出来が分かってしまう。 技術立国日本、ここにあり。 今、一番の問題は後継者不足であるという。 仕込みに満足できないとその日の営業をやめてしまうという。 3年前は何十ものテンプレート工房が軒を連ねたこの街だが今では職人は彼一人になってしまった。 問題はテンプレートをソラで書けるようになるのに2年はかかることだと、匠は語る。

「自分が便利なのももちろんだけど、使ってくれる人はもっと便利じゃないといけないね」 「もちろん出来上がったテンプレートは一つ一つ私自身でCreateStackしています」

ここ数ヶ月は、Terraformに押されていると言う。 「いや、ボクは続けますよ。待ってる人がいますから───」 下町のCloudFormationテンプレートの灯火は弱い。だが、まだ輝いている。

「時々ね、わざわざメールまでくれる人もいるんですよ。またお願いしますって。ちょっと嬉しいですね」 「海外からプルリクをくれるお客さんも何人もいる。体が続く限り続けようと思っとります」 「やっぱねえ、手書きだからこその味ってあるんです。機械がいくら進化したってコレだけは真似できないんですよ」

2014年、大規模なテンプレートの中でインデントを見失い、一時は店をたたむことも考えたという。

「やっぱりアレですね、たいていの若い人はすぐやめちゃうんですよ。 マネジメントコンソールからやった方が早いとか、UPDATE_ROLLBACK_FAILEDが怖いとか……。 でもそれを乗り越える奴もたまにいますよ。ほら、そこにいる望月もそう。 そういう奴が、これからのCloudFormation界を引っ張っていくと思うんですね」

最近では海外のエンジニアにも注目されているという。 額に流れる汗をぬぐいながら 「最先端に追いつき、追い越せですかね」 そんな夢をてらいもなく語る彼の横顔は職人のそれであった。

今日も彼は、日が昇るよりも早くJSONの整形を始めた。 明日も、明後日もその姿は変わらないだろう。

そう、CloudFormation職人の朝は早い。

失礼いたしました。

よく訓練されたアップル信者、都元です。言いたかったのは

  • やっぱりJSONにコメントが書けないのはキツイね
  • 大規模なテンプレートの中でインデントを見失い

辺りだけだったのですが、パロってたら止まらなくなりました。ごめんなさいごめんなさい。

いや、まぁJSONツライ時だってあるさ。ならばYAMLで書いてみたらどうだろう? ということでちょっと考えてみました。

JSON <-> YAML の相互変換

世の中には、CoffeeFormation等、CloudFormationのためのDSLが数多く存在します。が、ここはひとつCloudFormation独特の世界は忘れ、純粋にJSONとYAMLの相互変換というシンプルなアイデアで事を進めてみようと思います。これにより、CloudFormation側の機能追加や仕様変更について、一切考慮する必要がなくなります。CloudFormationテンプレートはJSONである、というところのみを拠り所にするわけです。

仕様上、正確にどうなのかは未確認ですが、私の頭でぱっと考えつく限り、JSONとYAMLには表現できるオブジェクトの状態について、事実上問題がないレベルの高い互換性があると思います。

まず。そもそもテンプレートをYAMLで書いたって、CloudFormationはそれを処理してくれはしません。従って、YAMLで書いたテンプレートはJSONに変換できる必要があります。これは当然ですね。

しかし逆に、既存資産として山ほどJSONを書いてきたので、これを新たにYAMLに変換できないとシンドイです。いつまでたってもJSONによる管理は終わりません。従って、JSONで書いたテンプレートはYAMLに変換できる必要があります。

というわけで、yaml2jsonjson2yamlコマンドをGitHub上で長らく探して *1いました。Node.js実装でyaml2jsonというのがあり、npmでインストールできるものが見つかったりもしたのですが、手持ちのテンプレートを下記のようなコマンドで相互変換を試みた結果、失敗してしまいました。

$ cat some.template | json2yaml | yaml2json

どうやら、キーにドット.が含まれると落ちてしまうようです(執筆時点)。下記のような表現が相互変換できませんでした。

"t2.micro": { "Arch" : "64HVM" },

remarshall

で、最近うまく動く実装をみつけたのですよ。dbohdan/remarshal · GitHubです。Goで書かれてますね。以下は現時点での最新バージョン v0.3.0 にて検証しました。

remarshalはMacを使っていれば、homebrewで一発インストールが可能です。

$ brew install remarshal

インストール後はyaml2json及びjson2yaml *2コマンドが使えるようになりますので、試してみます。

$ echo "{ \"foo.bar\": \"bar\" }"  | json2yaml
foo.bar: bar

$ echo "{ \"foo.bar\": \"bar\" }"  | json2yaml | yaml2json
{
  "foo.bar": "bar"
}

うん、いい感じですね。では、実際のCloudFormationテンプレートで試してみましょう。

$ wget http://cm-public-eb-applications.s3.amazonaws.com/brian-server/brian-server-0.14.template
$ cat brian-server-0.14.template | json2yaml >1.yaml
$ cat 1.yaml | yaml2json >1.json
$ cat 1.json | json2yaml >2.yaml
$ cat 2.yaml | yaml2json >2.json
$ diff 1.yaml 2.yaml
$ diff 1.json 2.json

このように、JSONとYAMLの変換を2往復して、それぞれの差異をとってみたところ、差分はありませんでした。さすがに元のテンプレートには「手書きだからこその味」がありますので、そこは一致しませんけどね :P

確認として2.jsonを使ってCloudFormationスタックを作成してみましたが、問題なくスタックの生成は完成しましたので、実用性も問題なさそうです。

まとめ

YAMLが構成管理の対象であるソース、remarshalによって生成するJSONテンプレートは中間生成物、結果できあがるCloudFormationスタックが最終成果物と考えて管理すると、いい感じに回るんじゃないかなぁ、と思っています。

先日ご紹介したS3 Streamingを組み合わせると…

$ cat foobar.template.yaml | yaml2json | aws s3 cp - s3://example-bucket/foobar.template
$ aws cloudformation create-stack --template-url http://example-bucket.s3.amazonaws.com/foobar.template ...

みたいな使い方ができますね。

最後にYAML形式のCloudFormationテンプレート例抜粋を下記に示します。コメントも自然に入れられますし、なかなかイケてませんか?(注: 2.yamlそのものではなく手直しはしてあります。)

AWSTemplateFormatVersion: 2010-09-09
Description: Brian environment demo template

Parameters:
  DBPassword:
    Type: String
    Description: Password of RDS master password
    MinLength: "4"
    NoEcho: "true"
  DBUsername:
    Type: String
    Description: The database admin account username
    AllowedPattern: '[a-zA-Z][a-zA-Z0-9]*'
    MinLength: "1"
    MaxLength: "16"
    ConstraintDescription: must begin with a letter and contain only alphanumeric
      characters.
    Default: admin
  KeyName:
    Type: AWS::EC2::KeyPair::KeyName
    Description: Name of an existing EC2 KeyPair to enable SSH access to the instances

# (略)

Resources:
################################
#### Network
################################
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: { "Fn::FindInMap": [ StackConfig, CIDR, VPC ] }
      EnableDnsHostnames: "true"
      EnableDnsSupport: "true"
      InstanceTenancy: default
      Tags:
      - Key: Name
        Value: { Ref: "AWS::StackName" }
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: { Ref: VPC }
      InternetGatewayId: { Ref: InternetGateway }
  PublicRouteTable:
    DependsOn: AttachGateway
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: { Ref: VPC }
  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: { Ref: InternetGateway }
      RouteTableId: { Ref: PublicRouteTable }
  Subnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: { Ref: VPC }
      AvailabilityZone: { "Fn::Select": [ "0",  { "Fn::GetAZs": { Ref: "AWS::Region" } } ] }
      CidrBlock: { "Fn::FindInMap": [ StackConfig, CIDR, Subnet1 ] }
      Tags:
      - Key: Name
        Value: { "Fn::Join": [ "-", [ { Ref: "AWS::StackName" }, subnet1 ] ] }
  Subnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: { Ref: VPC }
      AvailabilityZone: { "Fn::Select": [ "1",  { "Fn::GetAZs": { Ref: "AWS::Region" } } ] }
      CidrBlock: { "Fn::FindInMap": [ StackConfig, CIDR, Subnet2 ] }
      Tags:
      - Key: Name
        Value: { "Fn::Join": [ '-', [ { Ref: "AWS::StackName" }, subnet2 ] ] }
  Subnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: { Ref: Subnet1 }
      RouteTableId: { Ref: PublicRouteTable }
  Subnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: { Ref: Subnet2 }
      RouteTableId: { Ref: PublicRouteTable }

################################
#### Security Groups
################################
  SSHSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: { Ref: VPC }
      GroupDescription: Enable SSH access via port 22
      SecurityGroupIngress:
      - CidrIp: 0.0.0.0/0
        IpProtocol: tcp
        FromPort: "22"
        ToPort: "22"
      Tags:
      - Key: Name
        Value: { "Fn::Join": [ '-', [ { Ref: "AWS::StackName" }, ssh ] ] }

# (略)

脚注

  1. そんなにマジメに探していたわけではありませんが。。。
  2. ついでにjson2toml等も利用できますが、今回は触れません。