Amazon ECSとの上手な付き合い方

[Ateam Lifestyle x cyma Advent Calendar 2018](https://qiita.com/advent-calendar/2018/ateam-lifestyle-cyma)の2日目は、[株式会社エイチームライフスタイル](http://life.a-tm.co.jp/) のインフラエンジニア @ihsiek が担当します。

本稿では、弊社でAmazon Elastic Container Service(以下、ECS)を導入してから本番運用を安定させるために行ったことを紹介します。
以降、Amazon Web Services(以下、AWS)の各サービスは正式名称が長いので、以降は略称を用います。
何のサービスかわからない場合は、「AWS + 略称」で調べてみてください。

# 対象者
- ECSを触り始めたばかりの人
- ECSを窓から投げ捨てたいと思い始めた人
- ECSはFargate一択だと思っている人

# TL;DR
- ECS on EC2はCloudFormationをカスタマイズすることで、ユーザーの希望に沿った使い方ができる

# 昔話(導入～運用までに感じたつらみ)
## 導入直後
ちょうど会社でECSを導入した頃、プライベートではGKE(GCPが提供するKubernetesのフルマネージドサービス)を触っていた事もあり、以下の不満からECSを窓から投げ捨てたいという話をしていました。

- インスタンスタイプなどの設定をいじりたいのにコンソールに該当項目が見つからない
    - (クラスター削除して作り直さないといけないと思っていた)
- マネージドサービスなのに人間が管理しないといけない感があって辛い
    - エージェントのバージョンアップ
    - 必要インスタンス数

## 運用開始1ヶ月
運用開始から1ヶ月ほど経った頃、ECSのインスタンスがAuto Scalingで管理されていることに気づきます。
起動設定とAuto Scalingグループを編集することで、インスタンスタイプやボリュームサイズ、セキュリティグループなど細かい設定ができるため、少しだけ運用が楽になりました。
起動設定とAuto Scalingグループで編集できる項目はそれぞれ以下のとおりです。

- 起動設定
    - インスタンスタイプやボリュームサイズ、セキュリティグループなど、EC2インスタンス作成時に指定する各項目
- Auto Scalingグループ
    - スケールアウト/インしたときのインスタンス数の上限/下限と必要数
    - スケールアウト/インに関する条件
    - その他いろいろ

## 運用開始3ヵ月
さらに3ヶ月経った頃、CloudFormationにECSクラスターのスタックを見つけます。
いろいろとマネジメントコンソールで触ったあとで、起動設定やAuto Scalingグループを汚してしまったことに対して、かなり切ない気持ちになりましたが、新しく導入するサービスからCloudFormationを使って設定するようになります。
ECSからクラスターを作成した直後のスタックテンプレートには以下の課題がありました。

- **セキュリティグループを1つしか設定できない**
    - 運用ポリシーによってはセキュリティグループが複数必要なケースがあるが対応できない
- **ルートボリュームを拡張できない**
    - Dockerの永続化領域はルートボリューム(/dev/xvda)に作られる ([参考](https://qiita.com/taishin/items/cff9c3212d0f697653e4))
    - MySQLなどを構築した場合に8GiBしかないルートボリュームが圧迫されてしまう
- **インスタンスのオートスケーリングが考慮されていない**
    - インスタンスの必要数＝オートスケールの最大数で定義されてしまう
    - インスタンスの最小数が0に設定されており、スケールインを実装するとインスタンスが全部落とされる可能性がある
- **UserDataを拡張しづらい**
    - [SSMセッションマネージャを使ったログイン](https://aws.amazon.com/jp/blogs/aws/new-session-manager/)など導入したいパッケージが増える都度、スタックテンプレートを書き換えたくない
    - 新しいバージョンのスタックテンプレートではUserDataがパラメータ化されたのに、パラメータに[シバン](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%90%E3%83%B3_(Unix))(シェルスクリプトの冒頭に書く#!から始まる1行)を含ませる設定になっている＆テキストボックスになっているせいで、追記すると改行が壊れてうまく動作しない

課題が見えているなら解消すればいい、ということでここからは対策したテンプレートを説明していきます。

# スタックテンプレートの修正概要
## セキュリティグループを複数指定できるようにする
- パラメータをカンマ区切りで定義できるように変更します。

```diff
@@ -44,10 +44,10 @@
       Optional - Specifies the Comma separated list of existing VPC Subnet
       Ids where ECS instances will run
     Default: ''
-  SecurityGroupId:
-    Type: String
+  SecurityGroupIds:
+    Type: CommaDelimitedList
     Description: >
-      Optional - Specifies the Security Group Id of an existing Security
+      Optional - Specifies the Comma separated list of Security Group Id of an existing Security
       Group. Leave blank to have a new Security Group created
     Default: ''
   VpcCidr:
```

- SecurityGroupId(String)をSecurityGroupIds(CommaDelimitedList)に書き換えます。

```diff
@@ -153,7 +153,7 @@
   SetEndpointToECSAgent:
     !Not [!Equals [!Ref EcsEndpoint, '']]
   CreateNewSecurityGroup:
-    !Equals [!Ref SecurityGroupId, '']
+    !Equals [!Select [ 0, !Ref SecurityGroupIds], '']
   CreateNewVpc:
     !Equals [!Ref VpcId, '']
   CreateSubnet1: !And
@@ -260,7 +260,7 @@
       AssociatePublicIpAddress: true
       IamInstanceProfile: !Ref IamRoleInstanceProfile
       KeyName: !If [ CreateEC2LCWithKeyPair, !Ref KeyName, !Ref "AWS::NoValue" ]
-      SecurityGroups: [ !If [ CreateNewSecurityGroup, !Ref EcsSecurityGroup, !Ref SecurityGroupId ] ]
+      SecurityGroups: !If [ CreateNewSecurityGroup, [ !Ref EcsSecurityGroup ], !Ref SecurityGroupIds ]
       BlockDeviceMappings:
       - DeviceName: !Ref DeviceName
         Ebs:
@@ -315,7 +315,7 @@
               Monitoring:
                 Enabled: true
               SecurityGroups:
-                - GroupId: !If [ CreateNewSecurityGroup, !Ref EcsSecurityGroup, !Ref SecurityGroupId ]
+                - GroupId: !If [ CreateNewSecurityGroup, !Ref EcsSecurityGroup, !Ref SecurityGroupIds ]
               SubnetId: !If
                       - CreateSubnet1
                       - !If
```

## ルートボリュームを拡張する
- ルートボリューム関連のパラメータを追加します。

```diff
@@ -106,6 +106,24 @@
       Specifies a comma-separated list of 3 VPC Availability Zones for
       the creation of new subnets. These zones must have the available status.
     Default: ''
+  EbsRootVolumeSize:
+    Type: Number
+    Description: >
+      Optional - Specifies the Size in GBs, of the newly created Amazon
+      Elastic Block Store (Amazon EBS) volume
+    Default: '0'
+  EbsRootVolumeType:
+    Type: String
+    Description: Optional - Specifies the Type of (Amazon EBS) volume
+    Default: ''
+    AllowedValues:
+      - ''
+      - standard
+      - io1
+      - gp2
+      - sc1
+      - st1
+    ConstraintDescription: Must be a valid EC2 volume type.
   EbsVolumeSize:
     Type: Number
     Description: >
```

- BlockDeviceMappingsにルートボリュームのマッピングを追加します。

```diff
@@ -262,6 +280,10 @@
       KeyName: !If [ CreateEC2LCWithKeyPair, !Ref KeyName, !Ref "AWS::NoValue" ]
       SecurityGroups: !If [ CreateNewSecurityGroup, !Ref EcsSecurityGroup, !Ref SecurityGroupIds ]
       BlockDeviceMappings:
+      - DeviceName: "/dev/xvda"
+        Ebs:
+          VolumeSize: !Ref EbsRootVolumeSize
+          VolumeType: !Ref EbsRootVolumeType
       - DeviceName: !Ref DeviceName
         Ebs:
          VolumeSize: !Ref EbsVolumeSize
```

## インスタンスのオートスケールを実装する
### インスタンスの最小数/必要数を定義する
- インスタンスの必要数を定義するパラメータを追加します。

```diff
@@ -72,6 +72,12 @@
       Specifies the number of instances to launch and register to the cluster.
       Defaults to 1.
     Default: '1'
+  AsgDesiredCapacity:
+    Type: Number
+    Description: >
+      Specifies the number of instances to launch and register to the cluster.
+      Defaults to 1.
+    Default: '1'
   IamRoleInstanceProfile:
     Type: String
     Description: >
```

- インスタンスの最小数と必要数をAsgDesiredCapacityに設定します。
    - インスタンスの最小数を必要数に合わせるのは、AutoScalingグループのスケールイン動作で0台になるのをブロックしたいからです。

```diff
@@ -305,9 +311,9 @@
           - [ !Sub "${PubSubnetAz1}" ]
         - !Ref SubnetIds
       LaunchConfigurationName: !Ref EcsInstanceLc
-      MinSize: '0'
+      MinSize: !Ref AsgDesiredCapacity
       MaxSize: !Ref AsgMaxSize
-      DesiredCapacity: !Ref AsgMaxSize
+      DesiredCapacity: !Ref AsgDesiredCapacity
       Tags:
         -
           Key: Name
```

- AutoScalingグループにスケールアウト/スケールインを設定します。
    - 配備するコンテナの状況に応じてスケールさせたいので、ECSのMemoryReservationをみてスケールするようにします。

```diff
@@ -317,6 +323,54 @@
           Key: Description
           Value: "This instance is the part of the Auto Scaling group which was created through ECS Console"
           PropagateAtLaunch: 'true'
+  EcsInstanceAsgScaleOutPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: "ChangeInCapacity"
+      PolicyType: "SimpleScaling"
+      Cooldown: "300"
+      AutoScalingGroupName: !Ref EcsInstanceAsg
+      ScalingAdjustment: 1
+  EcsInstanceAsgScaleInPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: "ChangeInCapacity"
+      PolicyType: "SimpleScaling"
+      Cooldown: "300"
+      AutoScalingGroupName: !Ref EcsInstanceAsg
+      ScalingAdjustment: -1
+  MemoryAlarmHigh:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      EvaluationPeriods: '1'
+      Statistic: Average
+      Threshold: '80'
+      AlarmDescription: Alarm if memory reservation too high
+      Period: '60'
+      AlarmActions:
+      - !Ref EcsInstanceAsgScaleOutPolicy
+      Namespace: AWS/ECS
+      Dimensions:
+      - Name: ClusterName
+        Value: !Ref EcsClusterName
+      ComparisonOperator: GreaterThanThreshold
+      MetricName: MemoryReservation
+  MemoryAlarmLow:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      EvaluationPeriods: '1'
+      Statistic: Average
+      Threshold: '30'
+      AlarmDescription: Alarm if memory reservation too low
+      Period: '60'
+      AlarmActions:
+      - !Ref EcsInstanceAsgScaleInPolicy
+      Namespace: AWS/ECS
+      Dimensions:
+      - Name: ClusterName
+        Value: !Ref EcsClusterName
+      ComparisonOperator: LessThanOrEqualToThreshold
+      MetricName: MemoryReservation
   EcsSpotFleet:
     Condition: CreateWithSpot
     Type: AWS::EC2::SpotFleet
```

## UserDataを拡張しやすくする
- UserDataパラメータの初期値に定義されているシバンとECS_CLUSTER, ECS_BACKEND_HOSTをべた書きにします。
    - UserDataパラメータには、ユーザーが実行したいコマンドを`;`区切りで入力すればよい状態になります。

```diff
@@ -295,7 +295,11 @@
          VolumeSize: !Ref EbsVolumeSize
          VolumeType: !Ref EbsVolumeType
       UserData:
-        Fn::Base64: !Ref UserData
+        Fn::Base64: !Sub |
+           #!/bin/bash
+           echo ECS_CLUSTER=${EcsClusterName} >> /etc/ecs/ecs.config
+           echo ECS_BACKEND_HOST=${EcsEndpoint} >> /etc/ecs/ecs.config
+           ${UserData}
   EcsInstanceAsg:
     Type: AWS::AutoScaling::AutoScalingGroup
     Condition: CreateWithASG
@@ -408,7 +412,11 @@
                        VolumeSize: !Ref EbsVolumeSize
                        VolumeType: !Ref EbsVolumeType
               UserData:
-                  Fn::Base64: !Ref UserData
+                  Fn::Base64: !Sub |
+                         #!/bin/bash
+                         echo ECS_CLUSTER=${EcsClusterName} >> /etc/ecs/ecs.config
+                         echo ECS_BACKEND_HOST=${EcsEndpoint} >> /etc/ecs/ecs.config
+                         ${UserData}
 Outputs:
   EcsInstanceAsgName:
     Condition: CreateWithASG
```

- UserDataパラメータに設定するコマンドのサンプル
  - 以下は、amazon-ssm-agentをインストールするシェルコマンドです。
  - ここまで書いて、UserDataパラメータにシバンを含ませたのはWindows環境と共用する意図があったということに思い至りました。

```shell
yum install -y jq; region=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region); yum install -y https://amazon-ssm-$region.s3.amazonaws.com/latest/linux_amd64/amazon-ssm-agent.rpm
```

# まとめ
ECSの運用を安定化させるために、実験と調査を繰り返してようやく安定して来ました。
Fargateのようなフルマネージドサービスを使うとこの辺の苦悩から解放されそうですが、今回のように自らの要件にあわせてカスタマイズできるのもAWSを使うメリットの一つだと思うので、ひとつずつ課題をクリアしていきます。
とはいえ、KubernetesをベースにしたEKSやGKEを使いたい話も出てるので浮気しそう・・・。

# おわりに
ECSとそこそこ上手に付き合えるようになってきたなということで振り返りの記事として書いてみましたがいかがでしたでしょうか。
ECSのようなサービスを個人で使い倒そうとすると、「何のコンテナ動かそう？」というところで詰まってしまいますが、業務上のミッションを通じてここまで深堀りさせてもらえたので　個人的には非常に満足しています。

今回紹介した内容では、スケールイン時に縮退するインスタンス上のコンテナが強制的に落とされることへの対処やSpotFleetの利用など、説明できていない部分もありますので、追って紹介できたらと思います。

[Ateam Lifestyle x cyma Advent Calendar 2018](https://qiita.com/advent-calendar/2018/ateam-lifestyle-cyma)の3日目は、JAWS FESTA 2018でエディタ戦争を仕掛けた生粋のEmacserの@tsutormが記事を書きます。
マイナーネタが好きな人なので、どんなテーマで書くのか楽しみです。

---
エイチームグループでは、一緒に働けるチャレンジ精神旺盛な仲間を募集しています。興味を持たれた方はぜひエイチームグループ採用サイトを御覧ください。
https://www.a-tm.co.jp/recruit/