CloudFormationのヘルパースクリプトcfn-initによるインスタンスの初期化

AWS

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

よく訓練されたアップル信者、都元です。みなさん、CloudFormation使ってますよね! まだの方は、ひとまずCloudFormation入門を読んでみてください。

さてところで、EC2において「何らかの役目を持ったサーバ」を立てる場合、方針は大きく分けて2つあります。1つ目は「プレーンなAMIからサーバを立ち上げ、必要なものをインストール及び設定し、そのAMIを取得。この機能特化AMIからインスタンスを立ち上げる」という方針。もう1つは「機能特化したAMIは作らず、プレーンなAMIにUserDataを与え、起動したら勝手に特化するように仕組む」という方針です。

前者は、AMIさえ作ってしまえば、あとはそれを立ち上げるだけですので、そういった面から見れば非常に運用が楽です。ただし、AMIというのはある意味ブラックボックスですので、同じAMIを再現する手順はきちんと管理しなければすぐに迷子になってしまうでしょう。

対して後者の方針は、ミドルウェアのセットアップ手順が再現可能な方法で記述(Infrastructure as Code)されますので、その辺りは安心です。今流行のChefも同じような考え方ですね。

Chefは主に、起動したサーバの内部でミドルウェアのインストール状態や設定等を再現可能な方法で記述するのに活用されています。CloudFormationはその一つ外側、AWSの世界に特化した各リソースの構成を再現可能な方法で記述するのが主な役割です。

とは言え、簡単なものであれば、Chefを使わなくても初期化・構築は可能です。そう、UserDataを使えば。

EC2のUserDataを使ったインスタンスの初期化・構築

これはCloudFormation入門でもさらっとご紹介した手法ですね。

        "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
          "#! /bin/bash -v\n",
          "yum update -y\n",
          "yum install -y httpd\n",
          "chkconfig httpd on\n",
          "service httpd start\n"
        ]]}}

これはこれで非常にお手軽な手なので、手順がシンプル場合は是非活用すべきです。しかし、ファイルをダウンロードしてzipを展開して配置する等、処理が複雑になって来ると、何かと機能的に物足りなくもなって来ます。記述も複雑になり可読性も低下しますし、例えばダウンロードに失敗した場合はスタックの作成を中止/ロールバックしたい、といった要件も出て来たりします。

実際CloudFormationをガツガツ使い始めると、構築がこの程度で済むケースは半数程度です。では、複雑になってきた場合は、どうすればいいでしょうか。

cfn-initを使ったインスタンスの初期化・構築

このような初期化・構築の仕組みとして、CloudFormationはcfn-initというヘルパースクリプトを用意しています。

解説にあたって、例となるテンプレートを作成したんですが、全文は長い *1のでgistに上げました。読者の皆さんは、まずはきっちり読む必要ありません。まずは以下のポイントに注目して、眺めてみてください。

  • PowerUser権限のIAM Roleを定義し、Webサーバに割り当てている。
  • UserDataで、何やらcfn-init等のスクリプトを起動している。
  • EC2Instanceリソースに、何やら"Metadata"という見慣れないプロパティが定義してある。この値はPropertiesの子(child)ではなく、Propertiesの兄弟(sibling)に位置している。
  • AWS::CloudFormation::WaitConditionHandleAWS::CloudFormation::WaitConditionという見慣れないリソースが定義してある。

さて、まず読む起点はここですね。UserDataに定義したスクリプトが実行されます。まずyum updateするのはお好みで。

        "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
          "#! /bin/bash -v\n",
          "yum update -y\n",

          "# Helper function\n",
          "function error_exit\n",
          "{\n",
          "  /opt/aws/bin/cfn-signal -e 1 -r \"$1\" '", { "Ref" : "WebServerWaitHandle" }, "'\n",
          "  exit 1\n",
          "}\n",

          "# Install packages\n",
          "/opt/aws/bin/cfn-init -s ", { "Ref" : "AWS::StackId" }, " -r WebServer ",
          "    --region ", { "Ref" : "AWS::Region" }, " || error_exit 'Failed to run cfn-init'\n",

          "# All is well so signal success\n",
          "/opt/aws/bin/cfn-signal -e $? -r \"WebServer setup complete\" '", { "Ref" : "WebServerWaitHandle" }, "'\n"
        ]]}}

続いてerror_exitという関数を定義しておきます。中身ではcfn-signalというスクリプトを呼んでいます。このスクリプトの-eオプションとして0を与えた場合、「リソースの生成成功」というシグナルをCloudFormationに通知します。そうではなく非0だった場合は、「リソースの生成失敗」を通知することになります。スクリプトの最後で、WebServer setup completeというメッセージと共に、成功の通知をしているのが分かりますね。

さて、一番大事なのはcfn-initスクリプトの呼び出しです。このスクリプトを呼び出したタイミングで、以下の処理が動きます。

  • yum等による各種パッケージのインストール
  • s3cmd-1.5.0-alpha3.tar.gzをダウンロードし、/opt以下に展開。
  • /var/www/html/info.phpファイルの作成
  • /var/lib/tomcat7/webapps/ROOT/index.htmlファイルの作成
  • /etc/httpd/conf.d/tomcat.confファイルの作成
  • pear install Mail_mimeDecodeコマンドの実行
  • httpd及びtomcat7の起動と、chkconfig on設定

初期化・構築に必要な基本的な要素はだいたい押さえられていますね。実際にどのようなパッケージをインストールするか等の定義は、以下の部分に書かれています。どのような記述ができるのか、詳細はドキュメントを参照して下さい。

      "Metadata" : {
        "AWS::CloudFormation::Init" : {
          "config" : {
            "packages" : {
              "yum" : {
                "httpd"        : [],
                "tomcat7"      : [],
                "mysql55"      : [],
                "php"          : [],
                "php-pear"     : [],
                "jq"           : [],
                "python-magic" : []
              }
            },
            "sources" : {
              "/opt" : "http://jaist.dl.sourceforge.net/project/s3tools/s3cmd/1.5.0-alpha3/s3cmd-1.5.0-alpha3.tar.gz"
            },
            "files" : {
              "/var/www/html/info.php" : {
                "content" : "<?php phpinfo(); ?>",
                "mode"   : "000644",
                "owner"  : "apache",
                "group"  : "apache"
              },
              "/var/lib/tomcat7/webapps/ROOT/index.html" : {
                "content" : "<html><head><title>Hello</title></head><body>Hello, cfn-init!</body></html>",
                "mode"   : "000644",
                "owner"  : "tomcat",
                "group"  : "tomcat"
              },
              "/etc/httpd/conf.d/tomcat.conf" : {
                "content" : { "Fn::Join" : ["\n", [
                  "<VirtualHost *:80>",
                  "  <Proxy *>",
                  "    Order deny,allow",
                  "    Allow from all",
                  "  </Proxy>",
                  "",
                  "  ProxyPass /tomcat ajp://localhost:8009/ keepalive=Off",
                  "  ProxyPassReverse /tomcat ajp://localhost:8009/",
                  "  ProxyPreserveHost on",
                  "</VirtualHost>"
                ]]},
                "mode"   : "000644",
                "owner"  : "root",
                "group"  : "root"
              }
            },
            "commands" : {
              "Mail_mimeDecode" : {
                "command" : "pear install Mail_mimeDecode",
                "test" : "test ! -e /usr/share/pear/Mail/mimeDecode.php"
              }
            },
            "services" : {
              "sysvinit" : {
                "httpd"   : { "enabled" : "true", "ensureRunning" : "true" },
                "tomcat7" : { "enabled" : "true", "ensureRunning" : "true" }
              }
            }
          }
        }

Chefでは繰り返し実行できる「冪等性」という性質が大事な要素ですが、そのような性質が要らない場合、つまり初回起動時に決まった形を構築するだけであれば、「cfn-initを使ってで全て定義してしまう」というのはバランスの良い選択ではないでしょうか。

さて、最後にAWS::CloudFormation::WaitConditionHandleAWS::CloudFormation::WaitConditionリソースですが、これは、いつまで経ってもcfn-signalで成功のシグナルが飛んで来なかった場合や、cfn-signalにより失敗が通知された場合にスタックの生成全体を失敗させる仕組みとして使われています。書き方はほとんどイディオムなので、このままコピペで構いません。

    "WebServerWaitHandle" : {
      "Type" : "AWS::CloudFormation::WaitConditionHandle"
    },
    "WebServerWaitCondition" : {
      "Type" : "AWS::CloudFormation::WaitCondition",
      "DependsOn" : "WebServer",
      "Properties" : {
        "Handle" : {"Ref" : "WebServerWaitHandle"},
        "Timeout" : "900"
      }
    }

ちなみにAWS::CloudFormation::WaitConditionにはCountというプロパティがありまして、デフォルト値は1となっています。つまり、成功シグナルを1回受信したら成功とする、という動きをします。この値を、AutoScalingのDesiredCapacityと一致させたりすると…。うはww夢がひろがりんぐwww

というわけで、このテンプレートを走らせてみると、TomcatとPHPが共存した不思議なサーバの出来上がりです。もっと試してみたい方は、yumに存在しないパッケージをMetadataに指定してみたりして、スタックの生成が失敗するかどうか、試してみるのも良いと思います。

cfn-init等のスクリプトは内部でCloudFormationのAPIを叩いています。と、いうことはAPIキー及びシークレットが必要ですよね。そのあたりはIAM Roleで賄われています。
IAM Role機能が登場する前の古い情報に当たった場合、cfn-initコマンド呼び出し時に、その引数にAPIキー及びシークレットを渡している例を見かけるかもしれません。さらにその情報では、この引数として渡すためのAWS::IAM::Userを作成し、そこからAWS::IAM::AccessKeyを使って…、ということをしていると思います。大変ですね…。
さらに、これらのリソースを使うと、スタックの作成時に「I acknowledge that this template may create IAM resources」というチェックボックスにチェックを入れなければならなくなり…。当時は面倒臭かったんですねー。
というわけで、今はIAM Roleを使うことによって、このような手間は一切不要になりました。素晴らしい。

参考文献

脚注

  1. CloudFormationって、本気で書いてるとすぐ数百行行っちゃうんですよねー。まぁ、もう数千行でもビビらなくなりましたが。

AWS Cloud Roadshow 2017 福岡