CloudFormationでコンパイルサーバー

背景
---
以下のようなサーバー構成でコンパイルに時間がかかるアプリケーションをデプロイすることになり、コンパイルサーバーを導入することで時間が短縮できたので方法をメモします。

* ロードバランサー x1
* アプリケーションサーバー x2
	* AWS::AutoScaling::AutoScalingGroupで定義
* DBサーバー x1

そもそもCloudFormationでデプロイ？
---
CloudFormationはデプロイ用のサービスではないですが、ソースコードを外部から取得し、アプリケーションを実行させればデプロイツールの代わりになります。

具体的にはAutoScalingGroupでアプリケーションサーバーを定義していれば、そのUserDataが更新されればアプリケーションサーバーのインスタンスは再生成されるため、ソースコードのバージョンをパラメーターで指定できるようにしておいて、それをUserDataに埋め込んでおけばCloudFormationの画面からバージョン指定でデプロイできるので楽です。

```json:AutoScalingGroupのUserDataの一部
"UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
（省略）
  "git clone --recursive https://github.com/foo/bar.git .\n",
  "git checkout ", { "Ref" : "SourceVersion" }, "\n",
（省略）
}
```

Immutable Infrastructureとも言えるのでしょうか。

また、AutoScalingGroupはUpdatePolicyをAutoScalingRollingUpdateにすればローリングデプロイも簡単です。

そのような訳でCloudFormationを環境構築以外にデプロイツールとして使っていました。

※ 公式ドキュメントに[AWS CloudFormation によるアプリケーションのデプロイ](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/deploying.applications.html)というページがあることにこの記事を書いてるときに気づきました。特に邪道という訳ではないんですね。


問題発生
---
アプリケーションはPlayFramework 2.2(java)で書いていました。

最初のうちは気になりませんでしたが、コード量が増えるにつれてコンパイル時間も長くなりました。各アプリケーションサーバーでコンパイルをし、1台ずつローリングアップデートしていたので、

    コンパイル時間 * アプリケーションサーバー台数

がデプロイ毎にかかっていました。

また、サーバー毎のコンパイルはAssetファイルのEtagの不一致にもつながりました。
（`play stage`を実行したタイミングが異なると同じ内容のAssetでもEtagが一致しません。CDNも使っていませんでした。）

コンパイルサーバーの導入
---
そこで、それらの問題を解決するためにコンパイルサーバーを導入しました。コンパイルサーバーでコンパイルしたファイルを各アプリケーションサーバーにデプロイすればコンパイル回数は1回で済むし、AssetのEtagも同じになります。

CloudFormationのテンプレートにコンパイルサーバーとなるEC2インスタンスを追加しました。UserDataで指定したバージョンのソースコードを取得してコンパイルするところまでは今まで通りで、その後、コンパイル後のファイルをアーカイブしてS3にアップロードするようにしました。
各アプリケーションサーバーのUserDataではS3からコンパイルされたファイルを取得し、サーバーアプリケーションを実行します。

※ この記事を書いているときにMetadata属性を指定すればアプリケーションサーバーのUserDataにいろいろ書かなくても良さそうなことに気づきました。
  検証はできていないのでこの記事ではUserDataに書く方式で進めます。

### アプリケーションサーバーをコンパイルサーバーに依存させる
CloudFormationは可能な限りリソースを並列に準備しようとします。よって、このままではコンパイルサーバーでコンパイル中にアプリケーションサーバーがコンパイル後のファイルを取得しようとしてしまいます。

そこで、アプリケーションサーバーをコンパイルサーバーに依存する設定にすることでコンパイルが終わるのを待つようにします。
設定の仕方は次の通りです。

* アプリケーションサーバー側
	* AutoScalingGroupのDependsOnでコンパイルサーバーを指定
* コンパイルサーバー側
	* CreationPolicyでResourceSignalを指定し、コンパイル時間に余裕を持った時間をTimeoutで指定
	* UserDataで、コンパイル後のファイルをアップロード後、シグナルを送る
	
    	```json:アップロードコマンドの直後
	    "cfn-signal -e $? --stack ", { "Ref" : "AWS::StackName" }, " --resource CompileServer --region ", { "Ref": "AWS::Region" }, "\n",
	    ```

これでアプリケーションサーバーの生成はコンパイル後になります。

### コンパイル後のコンパイルサーバー
コンパイルサーバーはコンパイル中だけ必要で、コンパイル後は不要です。よって、UserDataの最後で自己停止するようにしました。

```bash
shutdown -h +1
```

InstanceInitiatedShutdownBehaviorはstopにします。terminateにするとCloudFormationの管理外でインスタンスが破棄されるため、以降のUpdateStack等ができなくなります。

これでお金も節約できます。


UpdateStackに対応する
---

### コンパイルサーバーの再生成
ここまででCreateStack時はうまく動作すると思います。

しかし、UpdateStackで新しいバージョンを指定して更新しても、コンパイルサーバーはUserDataが変更されるだけでスクリプトが実行されません。
これはCloudFormationが極力リソースの再生成をさけるためです。

そこで苦肉の策ですが変える必要のないパラメーターも一緒に更新することで強制的に再生成することにしました。

今回は[AWS::EC2::Instanceのドキュメント](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html)で「更新に伴う要件」が「置換」になっているものの中で、影響の少なそうなものとしてAvailabilityZoneを変更することにしました。

AvailabilityZoneをテンプレートパラメーターに追加し、UpdateStack時にソースコードバージョンと共に前回と違う値を指定するようにします。具体的には、Tokyoリージョンであればap-northeast-1aとap-northeast-1cを交互に指定するようにします。

※ ここは自動的に交互になるようにしたかったのですが、テンプレートでそのようにしている方法が思いつきませんでした…

#### AvailabilityZoneの変更を忘れたら
UpdateStack時にAvailabilityZoneの変更を忘れるとコンパイルサーバーは再生成されず、アプリケーションサーバーの再生成は行われます。S3からコンパイル後のファイルをダウンロードできる場合、前回と同じバージョンをデプロイしてしまうでしょう。

S3にアップロードするファイルのファイル名等にバージョンを含めるようにすれば未コンパイルのバージョンはダウンロードできなくなるのでより安全です。

### アプリケーションサーバーの再生成
アプリケーションサーバーもコンパイルサーバーのコンパイルしたファイルを取得する必要があるため、強制的に再生成させます。

AWS::EC2::Instanceと違ってAWS::AutoScaling::AutoScalingGroupのUserDataは更新に伴う要件」が「置換」なので、UserData内のコメント等にソースコードバージョンを埋め込むようにすればOKです。


これでUpdateStack時にコンパイルサーバーが再生成されるようになり、コンパイルを待ってからアプリケーションサーバーも再生成されます。

最終的なテンプレートの全体像
---
今までの設定をまとめると次のようなテンプレートになります。
ダミーの値にしていたり関係の無いリソースや属性を省略したりしていますので参考程度にどうぞ。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": 省略,
  "Parameters" : {
    "SourceVersion": {
      "Description": "Source code version (git checkout SourceVersion). DON'T FORGET TO CHANGE CompileServerAvailabilityZone!!",
      "Type": "String"
    },
    "MinInstancesInService": {
      "Description": "Minimum number of Instances in the service",
      "Type": "Number",
      "Default": "2"
    },
    "MaxInstancesInService": {
      "Description": "Maximum number of Instances in the service",
      "Type": "Number",
      "Default": "3"
    },
    "KeyPairName" : {
      "Description" : "EC2 Key pair name",
      "Type" : "String",
      "Default" : "mykey"
    },
    "AMI" : {
      "Description" : "EC2 AMI",
      "Type" : "String",
      "Default" : "ami-********"
    },
    "SecurityGroups" : {
      "Description" : "EC2 Security groups",
      "Type" : "CommaDelimitedList",
      "Default" : "sg-********"
    },
    "DBInstanceClass" : {
      "Description" : "RDS Instance class. db.m3.medium or db.m1.small",
      "Type" : "String",
      "AllowedValues" : ["db.m3.medium", "db.m1.small"],
      "Default" : "db.m1.small"
    },
    "CompileServerAvailabilityZone" : {
      "Description" : "CompileServerAvailabilityZone. ap-northeast-1a or ap-northeast-1c",
      "Type" : "String",
      "AllowedValues" : ["ap-northeast-1a", "ap-northeast-1c"],
      "Default" : "ap-northeast-1a"
    }
  },
  "Resources": {
    "CompileServer": {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {
        "AvailabilityZone": { "Ref" : "CompileServerAvailabilityZone" },
        "IamInstanceProfile": "CompileServerProfile",
        "ImageId": { "Ref": "AMI" },
        "InstanceInitiatedShutdownBehavior": "stop",
        "InstanceType": "m1.medium",
        "KeyName": { "Ref": "KeyPairName" },
        "SecurityGroupIds" : { "Ref" : "SecurityGroups" },
        "Tags": [
          {
            "Key": "Name",
            "Value": { "Fn::Join" : ["", [ {"Ref": "AWS::StackName"}, "-Compile" ] ] }
          }
        ],
        "BlockDeviceMappings": [
          {
            "DeviceName" : "/dev/sda",
            "Ebs" : { "VolumeSize" : "20", "VolumeType" : "gp2", "DeleteOnTermination" : "true" }
          }
        ],
        "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
          "#!/bin/sh\n",

          "cd /path/to/app_dir\n",
          "git clone --recursive https://github.com/foo/bar.git .\n",
          "git checkout ", { "Ref" : "SourceVersion" }, "\n",

          "# Helper function\n",
          "function error_exit\n",
          "{\n",
          "  cfn-signal -e 1 --stack ", { "Ref" : "AWS::StackName" }, " --resource CompileServer --region ", { "Ref": "AWS::Region" }, "\n",
          "  exit 1\n",
          "}\n",

          "/opt/playframework/default/play clean stage", " || error_exit\n",
          "zip -r compiled.zip target/universal/stage", " || error_exit\n",

          "aws s3 cp compiled.zip s3://bucket-name/deploy/", {"Ref": "AWS::StackName"}, "/", " || error_exit\n",
          "cfn-signal -e $? --stack ", { "Ref" : "AWS::StackName" }, " --resource CompileServer --region ", { "Ref": "AWS::Region" }, "\n",
          "sudo su - -c \"shutdown -h +1\"\n"
        ]]}}
      },
      "CreationPolicy" : {
        "ResourceSignal" : {
          "Timeout" : "PT20M"
        }
      }
    },
    "LoadBalancer": {
      省略
    },
    "AppServerGroup": {
      "Type": "AWS::AutoScaling::AutoScalingGroup",
      "UpdatePolicy" : {
        "AutoScalingRollingUpdate" : {
          "MaxBatchSize" : "1",
          "MinInstancesInService" : {
            "Ref" : "MinInstancesInService"
          },
          "WaitOnResourceSignals" : "true"
        }
      },
      "Properties": {
        "AvailabilityZones": 省略,
        "VPCZoneIdentifier" : 省略,
        "HealthCheckType" : "ELB",
        "HealthCheckGracePeriod" : "60",
        "Cooldown" : "300",
        "LaunchConfigurationName": { "Ref": "LaunchConfig" },
        "MinSize": { "Ref": "MinInstancesInService" },
        "MaxSize": { "Ref": "MaxInstancesInService" },
        "DesiredCapacity": { "Ref": "MinInstancesInService" },
        "LoadBalancerNames": [ { "Ref": "LoadBalancer" } ],
        "Tags" : [
          {
            "Key" : "Name",
            "Value" : {"Ref": "AWS::StackName"},
            "PropagateAtLaunch" : "true"
          }
        ]
      },
      "CreationPolicy" : {
        "ResourceSignal" : {
          "Count" : { "Ref": "MinInstancesInService" },
          "Timeout" : "PT10M"
        }
      },
      "DependsOn" : "CompileServer"
    },
    "LaunchConfig": {
      "Type": "AWS::AutoScaling::LaunchConfiguration",
      "Properties": {
        "KeyName": { "Ref": "KeyPairName" },
        "ImageId": { "Ref": "AMI" },
        "AssociatePublicIpAddress": "true",
        "BlockDeviceMappings": [
          {
            "DeviceName" : "/dev/sda",
            "Ebs" : { "VolumeSize" : "20", "VolumeType" : "standard", "DeleteOnTermination" : "true" }
          }
        ],
        "SecurityGroups" : { "Ref" : "SecurityGroups" },
        "InstanceType": 省略,
        "IamInstanceProfile": "AppServerProfile",
        "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
          "#!/bin/sh\n",

          環境構築の準備とか、省略

          "# deploy\n",
          "cd /path/to/app_dir\n",
          "aws s3 cp s3://bucket-name/deploy/", {"Ref": "AWS::StackName"}, "/compiled.zip .\n",

          "# force replace instance(modify LaunchConfiguration::UserData)\n",
          "# We expect source version of the compiled.zip is ", { "Ref" : "SourceVersion" }, ".\n",

          "# Helper function\n",
          "function error_exit\n",
          "{\n",
          "  cfn-signal -e 1 --stack ", { "Ref" : "AWS::StackName" }, " --resource AppServerGroup --region ", { "Ref": "AWS::Region" }, "\n",
          "  exit 1\n",
          "}\n",

          "unzip compiled.zip || error_exit\n",

          "# start service\n",
          "sudo /etc/init.d/play start || error_exit\n",

          "cfn-signal -e $? --stack ", { "Ref" : "AWS::StackName" }, " --resource AppServerGroup --region ", { "Ref": "AWS::Region" }, "\n"
        ]]}}
      }
    },
    "RDS": {
      "Type": "AWS::RDS::DBInstance",
      "Properties": {
        "AutoMinorVersionUpgrade": "true",
        "DBInstanceClass": { "Ref": "DBInstanceClass" },
        "Port": "3306",
        "DBParameterGroupName": "mysql55",
        "AllocatedStorage": 省略,
        "BackupRetentionPeriod": "3",
        "Engine": "mysql",
        "EngineVersion": "5.5",
        "LicenseModel": "general-public-license",
        "MasterUsername": "****",
        "MasterUserPassword": "*********",
        "PreferredBackupWindow": "17:00-18:00",
        "PreferredMaintenanceWindow": "sun:18:00-sun:19:00",
        "VPCSecurityGroups": { "Ref": "SecurityGroups" }
      },
      "DeletionPolicy": "Snapshot"
    }
  },
  "Outputs": {
    省略
  }
}
```