概要
AWSを使ってサービス開発していると,「EC2上でしか動かせない」みたいなのがちょいちょい出てきます.代表的なのは権限絡み(InstanceProfile)や,SNS/SQS等のマネージドサービスを使いたい場合でしょうか.ローカルマシンでやろうとすると,やれIAMだのアクセスキーだのと言われますし,キーを発行すると漏洩しないようにちゃんと管理しなければなりません.
また,本番はEC2で動かすのに開発はMac...だと環境依存の何かがあってローカルでは動くのにデプロイすると動かん..みたいなことも発生しがちです.ですので開発の段階からEC2を使えると何かと便利です.
しかし,EC2はインスタンスを起動しておくとそれだけでお金を吸いとられるのでなるべく安いインスタンスを使いたい...,かといって開発に利用となるとあまりに貧弱なインスタンスでも困る...さらに開発用なので別に深夜動いてなくてもいいし...でもインスタンスが起動したらすぐに開発を始められるようにしておきたいし...など,いくつか追加の条件も考えられます.
というわけで上記のあれやこれやを実現する仕組みを,せっかくなのでサーバーレス1に作ってみました.
やりたいこと
- 開発はそれなりに良いインスタンスを使いたい → spotインスタンスを使う
- spotインスタンスは強制終了のリスクがある → Spot Fleet Requestを使う
- 業務開始時間前ぐらいに自動で起動してほしい → CloudWatch EventsとLambdaを使う
- インスタンス起動時のセットアップに時間食いたくない → EBSを使いまわす
- 業務終了後は不要.けどEBS内を見たいことがある → t2.nanoを使う
- インスタンスが再作成されてもIP調べたりするのはめんどくさい → Route53を使う
とりあえずは上記ぐらいでしょうか.それぞれ説明していきます.
開発はそれなりに良いインスタンスを使いたい
EC2の料金体系はオンデマンド,リザーブド,スポットの3種類です.オンデマンドは定価,リザーブドは一定期間継続して使うのを約束するかわりに割引価格にしてくれるやつ,です.それぞれの料金体系についての詳細は割愛しますが,どちらも開発用に使いたいという今回の用途には合わないでしょう.
そこでスポットです.スポット価格はユーザが入札することによって決まるので,(1)余剰インスタンスがどれぐらいあるか,(2)インスタンスを使いたいユーザがどれぐらいいるのか,で変動します.なるべく余剰がたくさんあるインスタンスタイプで,使いたい人が少ない(競争率の低い)インスタンスタイプが使えれば,オンデマンドで使うよりも劇的に安くあがります.
オンデマンドとスポットでどれぐらい変わってくるかの例(月20営業日として1日10時間起動する) [Tokyoリージョン 2016/10/07時点]
type | price(on-demand) | price(spot) |
---|---|---|
m4.4xlarge | $278.2 | $30.2 |
c3.4xlarge | $204.2 | $34.26 |
r3.4xlarge | $319.2 | $35.92 |
d2.4xlarge | $675.2 | $78.58 |
スポット価格は変動するのでこの通りにはなりませんが,価格差のイメージはつくかと思います.普段はなかなか手が出せなさそうな高スペックインスタンスでもちょっとやれるかも...?ぐらいの価格になってる気がする...2?
デメリットは,スポット価格がいつのまにか高騰して自分の入札価格を上回ってしまうと,強制ターミネートを食らう3可能性があることです.
スポットインスタンスを使って本番サービスを運用するのであればいろいろ考えることは多いでしょうが,今回の用途では,仮に落とされたとしてもそこまで大問題にはなりません.(コード書いててノッてるときに落とされたらテンションは下がるでしょうが...)
では実際に強制ターミネートを食らった場合について考えてみます.
殺されたら作り直さないといけませんが,方法はいくつか考えられます.
- 手動でがんばる
- AutoScalingGroup
- SpotFleetRequest
強制ターミネートを食らったということはスポット価格が高騰したということなので,同じタイプのインスタンスを起動するためにはスポット価格が落ちつくのを待つ必要があります.すぐに落ちつくこともあるし,なかなか落ちつかないこともあるし,高騰したり下落したりを短期間で繰り返すこともあります.
手動でがんばる
正直しんどい...
メタデータ4を見て通知されたら次のインスタンスを起動...というのは可能でしょうが,ターミネート通知は来ないことがある(らしい)5 and ターミネート通知が来てもターミネートされないことがある(らしい)5 ので確実に対応するのはなかなか難しそうです.
AutoScalingGroup
AutoScalingGroupはインスタンスを指定の数だけ維持してくれる仕組みです.AutoScalingGroupでスポットインスタンスを起動するようにしておけば,強制ターミネートを食らってもインスタンスを作り直そうとしてくれます.しかし,ここでちゃんと作り直せるかは前述の通りスポット価格が下落している場合に限ります.価格が下がらなければ作り直しはいつまでたっても成功しません.また,高騰/下落が短時間で繰り返された場合,インスタンスが頻繁に起動/終了される可能性もあり,こうなるとまともに開発なんてやってられません.
SpotFleetRequest
SpotFleetRequestもインスタンスを指定数(キャパシティ)維持する仕組みです.AutoScalingGroupと異なるのはインスタンスタイプの候補をいくつか指定できることです.複数のAutoScalingGroupを組合せて目的のインスタンスを起動する仕組みを自前で構築することも可能そうですが,似たようなことはSpotFleetRequestがやってくれます.
つまり,タイプAで起動→高騰して死んだらタイプB→...ということが可能6なのです.
いったんスポット価格が高騰すると戻るまでに数分〜数十分はかかることが多いですし,その間開発を止めるよりは次の候補でさっさと起動してくれたほうが開発は続けやすいです.
注意点は,候補の選択が難しいことです.タイプA,Bの2種類のインスタンスを候補にしていたとして,「通常はタイプAを使いたい,が,タイプBのほうが安い」みたいな状況だとSpotFleetRequestはタイプBを起動させてしまいます.似たようなスペックのものを選んでおく7...ぐらいしかないかもしれません.
まとめると,SpotFleetRequestを使ってスポットインスタンスを起動することで,強制ターミネートの恐怖をある程度緩和8でき,そこそこ安定して開発することができるのです.
業務開始時間前ぐらいに自動で起動してほしい
CloudWatch Eventsというそのものズバリなサービスがあるので使います.cron式でスケジュールを定義できるので,9時始業であれば毎週月〜金の8:50にSpotFleetRequestを投げるLambdaを起動,というルールを組めば実現できます.
インスタンス起動時のセットアップに時間食いたくない
ユーザデータはすべて別途作成したEBSに入れておき,インスタンスにアタッチすれば良いだけにしておきます.
注意点は,EBSはAZをまたいだインスタンスにはアタッチできないため,結果的にスポットインスタンスがAZ固定になってしまうことです.スポットインスタンスはAZによって価格がぜんぜん違うことが往々にしてあるのでちょっと辛いところ...EFSが使えるようになれば解決すると信じてます.
業務終了後は不要.けどEBS内を見たいことがある
SpotFleetRequestには有効期限を設定することができ,有効期限が切れたらリクエスト内で起動されたインスタンスを強制終了させることができます.
なので,17時終業なのであれば朝8:50に作られるSpotFleetRequestに有効期限として当日17:00を指定しておけば勝手に終了してくれます.
あとは同時にCloudWatch Eventsでt2.nano9を起動して宙ぶらりんになったEBSをくっつければOKです.
スポットインスタンス上で作業していて,ターミネート時刻までに作業内容をgithub等にあげられれば良いですが,たまたま離席してたりすることもあるので毎回キッチリできるかは分かりません.t2.nanoにくっつけておけばスポットインスタンスが死んだ後はローカルで作業を継続したいという場合でもsshして同期すれば良くなります.
SpotFleetRequestの期限が切れる(=スポットインスタンスが終了する)直前にスポットインスタンス内からt2.nanoを起動すれば良いと思うかもしれませんが,その時にスポットインスタンスが生きている保証がないので,外部から起動してやるのが一番確実です.
インスタンスが再作成されてもIP調べたりするのはめんどくさい
IPを固定にして起動することもできますが,若干制御がめんどうです.なのでできるだけIP固定はしないほうが良いと思います.
そのかわりにRoute53に適当なゾーンを作ってインスタンス起動時にリソースレコードを更新してやることでIPが変わってもドメイン名でアクセスできるようにしておきます.
やったこと
使用したいスポットインスタンスタイプの選定
これはお財布と相談になります.
- https://aws.amazon.com/jp/ec2/spot/bid-advisor/
- 価格履歴
- 必要なスペック
等々を見て選ぶことになるでしょう.Ephemeral Diskのありなしも重要な評価ポイントです.
Spot Fleet Requestをマネコンで作成してみる
ReviewまでいけばJSONの設定ファイルをダウンロードすることができ,あとでLambdaを作るときに捗ります.(SpotFleetRequestのパラメータを手で作るのはしんどい...10)
Target capacity = 1 instances
にしておけばインスタンスタイプの候補内から1インスタンスを良い感じに維持してくれます.
Lambdaを作る
ちょっと長いのですがソースまるごと.(インスタンスタイプ,入札額は実際とは違います)
from __future__ import print_function
from datetime import datetime, timedelta
import sys
import boto3
import base64
VOLUME_ID='vol-xxxxxxxxxxxxxxxxx'
def sunrise(hours = 10):
ec2 = boto3.client('ec2')
# request spot fleet and attach volume
initcmd = b"""#!/bin/bash
set -x
export AWS_DEFAULT_REGION=ap-northeast-1
me=$(curl -s 169.254.169.254/latest/meta-data/instance-id)
aws ec2 create-tags --resources $me --tags Key=Name,Value=super-dev-env
while :; do
aws ec2 wait volume-available --volume-ids {0} && break
done;
aws ec2 attach-volume --volume-id {0} --instance-id $me --device /dev/xvdb
aws ec2 wait volume-in-use --volume-ids {0}
until [ -e /dev/xvdb ]; do
sleep 1
done
mkdir -p /workspace/home /workspace/global
mount /dev/xvdb /workspace/home
mkfs.ext4 /dev/xvdca
mount /dev/xvdca /workspace/global
useradd -u 501 -d /workspace/home/uraura uraura
/workspace/home/setup/setup.sh
/workspace/home/setup/changeset-gen.sh
aws route53 change-resource-record-sets --hosted-zone-id XXXXXXXXXXXXXX --change-batch file:///workspace/home/setup/changeset
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
""".format(VOLUME_ID)
return ec2.request_spot_fleet(
DryRun = False,
SpotFleetRequestConfig = {
"IamFleetRole": "arn:aws:iam::xxxxxxxxxxxx:role/aws-ec2-spot-fleet-role",
"AllocationStrategy": "lowestPrice",
"TargetCapacity": 1,
"SpotPrice": "1.0",
"ValidFrom": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"),
"ValidUntil": (datetime.now() + timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"TerminateInstancesWithExpiration": True,
"LaunchSpecifications": [
{
"ImageId": "ami-xxxxxxxx",
"InstanceType": "m4.10xlarge",
"KeyName": "foo",
"UserData": base64.b64encode(initcmd).decode('UTF-8'),
"IamInstanceProfile": {
"Arn": "arn:aws:iam::xxxxxxxxxxxx:instance-profile/SuperDevelopmentEnvironmentForEC2"
},
"BlockDeviceMappings": [
{
"DeviceName": "/dev/xvda",
"Ebs": {
"DeleteOnTermination": True,
"VolumeType": "gp2",
"VolumeSize": 8,
"SnapshotId": "snap-xxxxxxxx"
}
}
],
"NetworkInterfaces": [
{
"DeviceIndex": 0,
"SubnetId": "subnet-xxxxxxxx",
"DeleteOnTermination": True,
"AssociatePublicIpAddress": True,
"Groups": [
"sg-xxxxxxxx"
]
}
]
},
{
"ImageId": "ami-xxxxxxxx",
"InstanceType": "r3.8xlarge",
"KeyName": "xxxxxxxxxxxx",
"UserData": base64.b64encode(initcmd).decode('UTF-8'),
"IamInstanceProfile": {
"Arn": "arn:aws:iam::xxxxxxxxxxxx:instance-profile/SuperDevelopmentEnvironmentForEC2"
},
"BlockDeviceMappings": [
{
"DeviceName": "/dev/xvda",
"Ebs": {
"DeleteOnTermination": True,
"VolumeType": "gp2",
"VolumeSize": 8,
"SnapshotId": "snap-xxxxxxxx"
}
},
{
"DeviceName": "/dev/xvdca",
"VirtualName": "ephemeral0"
},
{
"DeviceName": "/dev/xvdcb",
"VirtualName": "ephemeral1"
}
],
"NetworkInterfaces": [
{
"DeviceIndex": 0,
"SubnetId": "subnet-xxxxxxxx",
"DeleteOnTermination": True,
"AssociatePublicIpAddress": True,
"Groups": [
"sg-xxxxxxxx"
]
}
]
}
],
"Type": "maintain"
}
)
def sunset():
ec2 = boto3.client('ec2')
#
initcmd = b"""#!/bin/bash
set -x
export AWS_DEFAULT_REGION=ap-northeast-1
me=$(curl -s 169.254.169.254/latest/meta-data/instance-id)
aws ec2 create-tags --resources $me --tags Key=Name,Value=dev-env
while :; do
aws ec2 wait volume-available --volume-ids {0} && break
done
aws ec2 attach-volume --volume-id {0} --instance-id $me --device /dev/xvdb
aws ec2 wait volume-in-use --volume-ids {0}
until [ -e /dev/xvdb ]; do
sleep 1
done
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
mkdir -p /workspace/home
mount /dev/xvdb /workspace/home
useradd -u 501 -d /workspace/home/uraura uraura
echo 'AWS_DEFAULT_REGION=ap-northeast-1' > /var/spool/cron/root
echo '*/10 * * * * aws ec2 wait instance-running --filters Name=tag:Name,Values=super-dev-env Name=instance-state-code,Values=16 && umount -d /dev/xvdb && aws ec2 detach-volume --volume-id {0} && /sbin/init 0' >> /var/spool/cron/root
service crond restart
yum install -y git
/workspace/home/setup/changeset-gen.sh
aws route53 change-resource-record-sets --hosted-zone-id XXXXXXXXXXXXXX --change-batch file:///workspace/home/setup/changeset
""".format(VOLUME_ID)
return ec2.run_instances(
DryRun = False,
ImageId = 'ami-xxxxxxxx',
MinCount = 1,
MaxCount = 1,
KeyName = 'xxxxxxxx',
UserData = base64.b64encode(initcmd).decode('UTF-8'),
InstanceType = 't2.nano',
InstanceInitiatedShutdownBehavior = 'terminate',
IamInstanceProfile = {
"Arn": "arn:aws:iam::xxxxxxxx:instance-profile/SuperDevelopmentEnvironmentForEC2"
},
BlockDeviceMappings = [
{
"DeviceName": "/dev/xvda",
"Ebs": {
"DeleteOnTermination": True,
"VolumeType": "gp2",
"VolumeSize": 8,
"SnapshotId": "snap-xxxxxxxx"
}
}
],
NetworkInterfaces = [
{
"DeviceIndex": 0,
"SubnetId": "subnet-xxxxxxxx",
"DeleteOnTermination": True,
"AssociatePublicIpAddress": True,
"Groups": [
"sg-xxxxxxxx"
]
}
]
)
def lambda_handler(event, context):
print(event)
try:
if event['mode'] == 'sunrise':
#backup
sfr = sunrise(hours=event['hours'])
print(sfr)
elif event['mode'] == 'sunset':
#backup
i = sunset()
print(i)
else:
print('Invalid mode: {}'.format(event['mode']))
except:
print('failed!')
print(sys.exc_info())
else:
print('passed!')
return event['mode']
finally:
print('complete at {}'.format(str(datetime.now())))
1つのLambdaで,起動時の引数(CloudWatch Eventsのルールで指定)によってsunrise
かsunset
のどちらかを呼ぶようにしています.
基本的にはsunrise
内でSpotFleetRequestを作成,sunset
内でt2.nanoを起動しているだけなのですが,userdata内でゴチャゴチャやっているのでそこだけ説明します.
まずはsunrise
内のuserdata
#!/bin/bash
set -x
export AWS_DEFAULT_REGION=ap-northeast-1
me=$(curl -s 169.254.169.254/latest/meta-data/instance-id)
aws ec2 create-tags --resources $me --tags Key=Name,Value=super-dev-env
while :; do
aws ec2 wait volume-available --volume-ids {0} && break # 使い回しEBSが使用可能になるまで待機
done;
aws ec2 attach-volume --volume-id {0} --instance-id $me --device /dev/xvdb # 使い回しEBSを自分にアタッチ
aws ec2 wait volume-in-use --volume-ids {0} # 使い回しEBSが使用中になるまで待機
until [ -e /dev/xvdb ]; do
sleep 1 # OSからもアタッチしたEBSが見えるようになるまで待機
done
mkdir -p /workspace/home /workspace/global # EBS, ephemeral diskのマウントポイント作成
mount /dev/xvdb /workspace/home # EBSをマウント
mkfs.ext4 /dev/xvdca # ephemeral diskをフォーマット
mount /dev/xvdca /workspace/global # ephemeral diskをマウント
useradd -u 501 -d /workspace/home/uraura uraura # EBS上にユーザのHOMEを置いておく(チームで使うならメンバー全員分)
/workspace/home/setup/setup.sh # 任意のセットアップスクリプト
/workspace/home/setup/changeset-gen.sh # Route53の更新情報を作成
aws route53 change-resource-record-sets --hosted-zone-id XXXXXXXXXXXXXX --change-batch file:///workspace/home/setup/changeset # Route53のリソースレコードを更新
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
- 使い回しEBSを自分にアタッチして
- ephemeral diskを使えるようにして11
- DNSを更新
DNSの更新はたぶんLambda内でrequest_spot_fleetの結果を辿ればできると思うんですが,Lambdaはさっさと終了してほしい12のでuserdataに寄せました.
続いてsunset
内のuserdata
#!/bin/bash
set -x
export AWS_DEFAULT_REGION=ap-northeast-1
me=$(curl -s 169.254.169.254/latest/meta-data/instance-id)
aws ec2 create-tags --resources $me --tags Key=Name,Value=dev-env
while :; do
aws ec2 wait volume-available --volume-ids {0} && break # 使い回しEBSが使用可能になるまで待機
done
aws ec2 attach-volume --volume-id {0} --instance-id $me --device /dev/xvdb # 使い回しEBSを自分にアタッチ
aws ec2 wait volume-in-use --volume-ids {0} # 使い回しEBSが使用中になるまで待機
until [ -e /dev/xvdb ]; do
sleep 1 # OSからもアタッチしたEBSが見えるようになるまで待機
done
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
mkdir -p /workspace/home # EBSのマウントポイント作成
mount /dev/xvdb /workspace/home # EBSをマウント
useradd -u 501 -d /workspace/home/uraura uraura # EBS上にユーザのHOMEを置いておく(チームで使うならメンバー全員分)
echo 'AWS_DEFAULT_REGION=ap-northeast-1' > /var/spool/cron/root # スポットインスタンスが起動されたら自分からEBSをデタッチして死ぬためのcron
echo '*/10 * * * * aws ec2 wait instance-running --filters Name=tag:Name,Values=super-dev-env Name=instance-state-code,Values=16 && umount -d /dev/xvdb && aws ec2 detach-volume --volume-id {0} && /sbin/init 0' >> /var/spool/cron/root
service crond restart
yum install -y git
/workspace/home/setup/changeset-gen.sh # Route53の更新情報を作成
aws route53 change-resource-record-sets --hosted-zone-id XXXXXXXXXXXXXX --change-batch file:///workspace/home/setup/changeset # Route53のリソースレコードを更新
ほとんどsunrise
と同様ですが,ephemeral diskが無いことと,インスタンスを監視するためのcronが仕込まれるのが違います.
sunset
で起動するインスタンス(t2.nano)はオンデマンドなので強制終了リスクはないため,サーバ内から監視するようにしています.
CloudWatch Eventsのルールを作る
sunrise
を呼び出すためのルール
sunset
を呼び出すためのルール
注意点は,cron式はUTCで書く必要があるので,日本時間にしたい場合は9時間調節が必要です.
あと,Lambdaを呼びだすときの引数を指定できるのでそこにmode
を指定しています.
ここまでくれば,あとは下記がループするだけです.
- CloudWatch Eventsが指定時間に
sunrise
を呼びだす- スポットインスタンスが起動する
- スポットインスタンスが起動したのを見てt2.nanoが終了する
- スポットインスタンスがEBSを奪って開発開始
- SpotFleetRequestが有効な間は強制ターミネート後のリスタートは面倒みてくれる
- SpotFleetRequestが期限切れとなりスポットインスタンス終了
- 同時にCloudWatch Eventsが指定時間に
sunset
を呼びだす- t2.nanoが起動する
- t2.nanoが宙ぶらりんになったEBSを奪いとる
- t2.nanoが,スポットインスタンスが起動するのを待つ
時間軸を整理すると以下のようになります.(図の時刻はテキトーです)
まとめ
- スポットインスタンスを使えば普段はなかなか手が届かないインスタンスも怖くない
- ビッグデータじゃなくてもSpotFleetRequestつかえた
- EFSはやく来て...
- サーバーレスはいいものだ
以上です.
社内共有のために書いてたけど社内限にする必要もなかったので公開😶
-
インスタンスを管理するインスタンスが...となるとまた話がややこしくなるし,費用も余計にかかるので. ↩
-
高スペックインスタンスを使うことで作業のスピードが上がるのはもちろんですが,時間を無駄にできないというプレッシャーもかかってより集中できる...かもしれません. ↩
-
スポットインスタンスを1〜6時間強制終了されないようにする仕組みもあります.お値段は若干上昇しますが,6時間だけで良いというのであればアリかもしれません. ↩
-
http://169.254.169.254/latest/meta-data/spot/termination-time ↩
-
http://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-interruptions.html ↩ ↩2
-
正確には優先順位を付けられるわけではなく,Allocation Strategy(価格の低いものから優先する,など)で決まります. ↩
-
似たようなスペックなら似たような価格になりやすい気がします.人気のあるc4とかは辛いかもですが... ↩
-
全候補のスポット価格が高騰したら...諦めましょう... ↩
-
t2.nanoが選ばれた理由は最安だからです.将来的にt2.pico...とか出てきたら変更します.しらんけど. ↩
-
https://boto3.readthedocs.io/en/latest/reference/services/ec2.html#EC2.Client.request_spot_fleet ↩
-
ephemeral diskが複数付いてても今のところ1つしか使ってません...もったいないのでRAID 0にして使うのがよいかも. ↩
-
http://qiita.com/uraura/items/8020989e79a6985b0c29#slack%E3%81%8B%E3%82%89sql%E6%8A%95%E3%81%92%E3%82%8B%E4%BB%95%E7%B5%84%E3%81%BF%E6%94%B9 ↩