AWS
EC2
AmazonLinux

7 Days to Dieサーバをスポットインスタンスで構築して激安運用を目指す

​ゲームのマルチプレイサーバをAWS EC2 スポットインスタンスで構築した話です。
マイクラサーバの構築にも流用できると思います。
基本的に構築の備忘録も兼ねた内容です。ゲームの紹介等はしませんので、気になる方はニコニコ大百科あたりをご参照ください。

要件

  • 仲間内で使うサーバ(最大3人しか使わない)
  • 利用時間帯は基本的に夜(早くて21時〜遅くて1時半)
  • 毎日稼働させる必要はない(毎日SlackでやりたいゲームをEmojiで表明して決めており、それによってプレイしない日があるため)
  • 月500円以下で抑える(ちょっとでも高いと使うのを自分も仲間も躊躇いそうなので) 2017-10-28.png

スポットインスタンスとは

詳しく知りたい方はこちらのスライドをどうぞ。
AWSスポットインスタンスの真髄

ざっくり掻い摘んで説明するとこんな感じでしょうか。

  • 余剰リソースのディスカウント
  • 激安(常に激安とは言ってない)
  • 強制削除されるリスクあり
    • 削除されれば当然ストレージにしまったデータはすべて消える
  • インスタンスの停止はできない(手動での削除、rebootはできる)

今回はコストを抑えてサーバを建てたいので、オンデマンドインスタンスではなくこちらを必要な際に都度立ち上げてして使います。
もちろん毎回ぼうけんのしょ消滅の仕様ではまずいので、その対策も行います。

構築

インスタンスを作成

まずはWebのAWSコンソールからスポットインスタンスのリクエストを発行して、スポットインスタンスを立ててもらいます。
立ち上げるインスタンスの設定は以下。この辺はVPC/サブネットの設定やその他お好みに合わせて調整してください。

  • インスタンスイメージ : Amazon Linux AMI 2017.03
  • インスタンスタイプ : c3.xlarge
  • インスタンスの個数 : 1
  • VPC/サブネット : デフォルト
  • IPv4 アドレス : 有効

セキュリティグループ、キーペアは適当に1つ作っておきます。
c4ではなくc3にしたのは、後述する価格高騰が起こっていないことと、スポットだと若干ゃお値段が安いためです。
参考までに、c3.largeでもサーバの立ち上げは出来ましたが、2人ログインしただけでかなりのラグが発生しました。
c3.xlargeは2-3人ならそれなりに耐えてくれました。(それでもたまにラグったりしますが…。)

入札価格について

1ページ目のいちばん下にある入札価格とは、「1時間単価で支払いを許容する最高金額」です。
要は「この金額までなら払えます」(=値段がこれを超えたらインスタンスを潰してください)というボーダーラインです。お財布事情に応じて設定しましょう。
今までのスポットインスタンスの価格推移は「価格設定履歴」から確認することができます。インスタンスタイプに設定されていた価格を過去3ヶ月分まで確認できます。

2017-10-28 (1).png

今回使用するc3.xlargeは、全く価格高騰を起こしていませんので、かなり安めの入札価格でも大丈夫でしょう。今回は$0.05/hと設定しました。

ゲームサーバのインストール

SSHログイン後、ゲームサーバをインストールします。
詳細はこちらを頑張って読んでください。
ざっくりした手順としては以下のような感じです。

  • steamCMD をマニュアルインストール
    • libgccが必要らしいので yum install する
  • steamCMD実行用にユーザ steam を作成
  • steamCMDのshを叩いてログイン、 app_update 294420 と打つとサーバがインストールされる

セキュリティグループの設定

7 Days to Dieで使用するポートをセキュリティグループ設定で開けます。
デフォルト設定のままでいくのであれば、以下のポートを開けてあげればOKです。

  • 26900 TCP
  • 26900-26903 UDP

その他に、コントロールパネルのインタフェースも提供しており、8080 TCP(HTTP)と8081 TCP(telnet)があるのですが、いずれも接続パスワードやコマンドを平文でやりとりするので、セキュリティが気になる方は塞いでしまったほうが良いでしょう。
今回は、Slack通知ができるようになるまではブラウザ上でのサーバの起動チェックも兼ねてもらうため、HTTPのみフルオープンにしました。

最初の接続チェック

ここまで設定が済めば、ゲームクライアントからEC2のIPアドレスに繋いで、とりあえずログインとプレイはできるようになります。
ただ、前述の通りサーバを止めるとデータが吹き飛ぶので、次はデータの退避機構を作り込みます。

S3自動バックアップ

S3に退避用の適当なバケットを一つ作成し、起動および停止時にS3 syncすることで退避と復旧を実現します。
※事前に、EC2につけたIAMロールに S3にアクセスできるようポリシーをつけておきます。
init.dを利用して、起動時および終了時にs3 syncを行います。

/etc/rc.d/init.d/svr_startstop
#!/bin/sh
# chkconfig: 2345 99 10
# description: STARTSTOP SHELL

DATA_LOCAL="/path/to/steamcmd/steamapps/Data"
DATA_SERVER="s3://7dtd-bk/Data"
WORLD_LOCAL="/path/to/7DaysToDie/Saves"
WORLD_SERVER="s3://7dtd-bk/Saves"
LOGFILE="/var/log/startstop"
SUBSYS="/var/lock/subsys/svr_startstop"


echo "[$(date)] : $1" >> ${LOGFILE}
case "$1" in
 start)
        su steam -c "/usr/bin/aws s3 sync --exact-timestamps ${DATA_SERVER} ${DATA_LOCAL}" >> ${LOGFILE}
        su steam -c "/usr/bin/aws s3 sync --exact-timestamps ${WORLD_SERVER} ${WORLD_LOCAL}" >> ${LOGFILE}

        touch ${SUBSYS}
       ;;
 stop)
        su steam -c "/usr/bin/aws s3 sync --exact-timestamps ${DATA_LOCAL} ${DATA_SERVER}" >> ${LOGFILE}
        su steam -c "/usr/bin/aws s3 sync --exact-timestamps ${WORLD_LOCAL} ${WORLD_SERVER}" >> ${LOGFILE}

        rm -f ${SUBSYS}
       ;;
  *) break ;;
esac

とりあえずセーブファイルっぽい場所のデータをS3 syncさせます。
自分の環境の場合、DATA_LOCALはsteamcmdインストール場所の配下に、WORLD_LOCALはユーザsteamのホームディレクトリ配下の.localとしています。
どちらも対象のディレクトリのオーナーはsteamなので、su を噛ませています。
subsys配下にファイルをtouchさせないと終了時のスクリプトが走らないので注意。

なお、スポット価格高騰による強制削除時にこれがうまくいくかは未検証です…。

ゲームサーバのデーモン化

インスタンスを立ち上げた時にゲームサーバも起動して欲しいので、デーモン化します。
こちらは、簡単にデーモン化ができるというUpstartを使いました。confを一つ書くだけというお手軽さ。

/etc/init/7daystodie.conf
description "7 days to die server"

start on runlevel [2345]

respawn

exec `su steam -c "/path/to/steamcmd/steamapps/startserver.sh -configfile=serverconfig.xml"`

これをinitctlに読み込ませて自動起動をONにして完了です。

AMIイメージ作成

一度インスタンスのstop/startをして、S3バックアップとデーモン起動ができることが確認できたら、ここでAMIイメージをとります。
次回起動時のAMIイメージにこれを指定することで、現在の状態のインスタンスと同じ環境で起動させることができるようになります。

完成!…完成?

必要最低限の環境が整いました!
スポットインスタンスを立ち上げる際、ここで作ったAMIから立ち上げれば勝手にS3とのワールドデータの同期をとってくれます。
あとはWeb上のコンソールで今回と同じ設定でスポットインスタンスを起動して、起動したスポットインスタンスのIPアドレスを確認して、友達を呼ぶ場合は毎回これを教えて…
あっ、まだ手順複雑ですね…

より便利に使うために

Lambda 関数作成

毎回起動時にコンソールでぽちぽちするのもだるいので、Lambda関数にしてしまいます。

index.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import boto3
import datetime

# Spot request Specification
spot_price        = "0.05"
instance_count    = 1
request_type      = "request"
duration_minutes  = 360     # 360分経過で終了させる
valid_until       = datetime.datetime.now() + datetime.timedelta(minutes = duration_minutes)
image_id          = "ami-xxxxxxxx"
security_group_id = "sg-xxxxxxxx"
instance_type     = "c3.xlarge"
availability_zone = "ap-northeast-1c"
subnet_id         = "subnet-xxxxxxxx"
iam_profile_arn   = "arn:aws:iam::xxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
iam_fleet_arn     = "arn:aws:iam::xxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

client            = boto3.client('ec2')


def lambda_handler(event, context):

    # 起動するぞう
    response = client.request_spot_fleet(
        DryRun = False,
        SpotFleetRequestConfig = {
            "SpotPrice"           : spot_price,
            "TargetCapacity"      : instance_count,
            "Type"                : request_type,
            "ValidUntil"          : valid_until.replace(microsecond=0),
            "TerminateInstancesWithExpiration" : True,
            "IamFleetRole"        : iam_fleet_arn,
            "LaunchSpecifications"  : [
                {
                    "ImageId"          : image_id,
                    "SecurityGroups" : [{
                        "GroupId" : security_group_id
                    }],
                    "InstanceType"     : instance_type,
                    "Placement"        : {
                        "AvailabilityZone" : availability_zone
                    },
                    "IamInstanceProfile": {
                        "Arn": iam_profile_arn
                    },
                    "SubnetId"         : subnet_id
                }
            ]

        }
    )

(ほんとはグローバル変数でガシガシ置いている値は環境変数に持たせるべきです…。)

つまづきポイント: valid_until.replace(microsecond=0)

6時間経過で自動的に終了してもらうため、ValidUntilに現在時刻の6時間後の時刻を指定させていたのですが、これがうまく動かず、小一時間ほどハマりました。
調べたところ同様のIssue(EC2 RequestSpotFleet ValidFrom / ValidUntil timestamp issue)が見つかり、「ValidUntilにマイクロ秒が含まれていたこと」が原因っぽかったため、書かれていたとおりにreplaceしてあげた所バッチリ動作しました。

Slackから起動する

毎回起動時にコンソール覗きに行くのもだるいので、SlackのwebhookからAPI Gatewayを経由してこのLambdaを叩けるようにします。
このあたりの実現方法は以下の記事が大変参考になりました。ありがとうございます。
SlackからAWS API Gatewayを通してLambdaを起動するまで

あとは、WebhookにくっつくTokenの認証をLambdaの頭に仕込み…

index.py
    # Token authorize
    if event["token"] != os.environ['token']:
        return { "error": "Invalid token" }

SlackにpostするメッセージをreturnするようにしてあげればOK。

index.py
    return {
        "username": u"ねぷねぷ@7D2D Server",
        "text": u"おっけー!今準備してるから、ちょっとだけ待ってね!"
    }

起動時にIPアドレスをSlackにpostする

毎回起動時にIPアドレスを確認しに行って、それを毎回友達に教えるのもだるいので、webhookでpostさせちゃいましょう。
これには、先程作ったinit.dの起動時のスクリプトに以下を埋め込めば実現できます。

/etc/rc.d/init.d/svr_startstop
INSTANCE_ID=`/usr/bin/curl -s http://169.254.169.254/latest/meta-data/instance-id`
IPADDRESS=`/usr/bin/curl -s http://169.254.169.254/latest/meta-data/public-ipv4`

NAME="ねぷねぷ@7D2D Server"
WEBHOOK_URL="https://hooks.slack.com/services/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
START_MESSAGE="よーしっ、サーバ起動したよ! 「${IPADDRESS}」に繋いでね!"
/usr/bin/curl -X POST --data-urlencode "payload={\"username\": \"${NAME}\", \"text\": \"${START_MESSAGE}\" }" ${WEBHOOK_URL}

Incoming webhookを使い、curlでJSONをWebhook URL宛にPOSTしています。
インスタンスID、IPアドレスはインスタンスメタデータから取得しています。

AMIイメージを取り直して、Lambdaに仕込んだAMI IDを更新してあげれば、次回からはインスタンスが立ち上がったタイミングでSlackにIPアドレスを通知してくれるようになります。

DynamoDBを使って状態管理

この状態では、ビスケットが増える不思議なポケットよろしく、Slackからサーバを何個も起動させることができてしまいます。
そこで、現在の起動状態をDynamoDBのテーブルに乗せておくことで多重起動を防ぎます。
(DynamoDBはAWSアカウント作成から1年を経過してても失効しない無料利用枠があります。素晴らしいですね…!)

index.py
dynamodb          = boto3.resource('dynamodb')
table_name        = os.environ['table_name']

def lambda_handler(event, context):

    # Token authorize
    if event["token"] != os.environ['token']:
        return { "error": "Invalid token" }

    table = dynamodb.Table(table_name)

    # 現在状態が停止であれば起動する
    info = table.get_item(
        Key={
             "Id": '1'
        }
    )['Item']

    if info['state'] == 'stop':
        # 起動するぞう

        response = client.request_spot_fleet(
            DryRun = False,
            SpotFleetRequestConfig = {
                "SpotPrice"           : spot_price,
                "TargetCapacity"      : instance_count,
                "Type"                : request_type,
                "ValidUntil"          : valid_until.replace(microsecond=0),
                "TerminateInstancesWithExpiration" : True,
                "IamFleetRole"        : iam_fleet_arn,
                "LaunchSpecifications"  : [
                    {
                        "ImageId"          : image_id,
                        "SecurityGroups" : [{
                            "GroupId" : security_group_id
                        }],
                        "InstanceType"     : instance_type,
                        "Placement"        : {
                            "AvailabilityZone" : availability_zone
                        },
                        "IamInstanceProfile": {
                            "Arn": iam_profile_arn
                        },
                        "SubnetId"         : subnet_id
                    }
                ]

            }
        )

        table.update_item(
            Key={
                'Id': '1'
            },
            AttributeUpdates={
                'state': {
                    'Action': 'PUT',
                    'Value': 'startup'
                },
                'expire': {
                    'Action': 'PUT',
                    'Value': valid_until.isoformat()
                }
            }
        )

        return {
            "username": u"ねぷねぷ@7D2D Server",
            "text": u"おっけー!今準備してるから、ちょっとだけ待ってね!"
        }
    else:
        if info['state'] == 'startup':
            return {
                "username": u"ねぷねぷ@7D2D Server",
                "text": u"時間かかってるみたいかな……。もうちょっとで起動するはずだから、待っててね!"
            }

        if info['state'] == 'running':
            return {
            "username": u"ねぷねぷ@7D2D Server",
            "text": u"ねぷぅ……?サーバはもう起動してるみたいだよ?"
        }

起動・停止させる時のスクリプトにもDynamoDBの状態を更新させるようにします。

/etc/rc.d/init.d/svr_startstop
aws dynamodb update-item \
        --table-name ${TABLE_NAME} \
        --key '{"Id": {"S": "1"}}' \
        --attribute-updates '{"state": { "Value":{ "S": "running" }, "Action": "PUT" }}'

仕込んだらAMIのイメージを取り直し、LambdaのAMI IDも書き換えて…。

もっと真面目にやるならDBのロック等も必要ですが…実現方法も思いつかず、さすがに労力に釣り合わないと思ってやめました。興味はあるのでなにかいい方法があったら教えてください。

AMIイメージを外部から指定する

7 Days to Dieはまだアルファ版で、頻繁にアップデートが入ります。
(つい先日も新しいアップデートがリリースされました)
しかしこの状態では、上記のシェルスクリプト書き換えの際に毎回行っていたように、アップデートのたびにAMIイメージを取り直し、Lambdaに抱えさせているAMI IDの書き換えが必要になります。
アップデート作業自体はひとまず置いておき、ちょうどさきほどDynamoDBに専用のテーブルを作りましたので、最新のAMI IDをこちらに持たせるようにします。

index.py
response = client.request_spot_fleet(
            DryRun = False,
            SpotFleetRequestConfig = {
                "SpotPrice"           : spot_price,
                "TargetCapacity"      : instance_count,
                "Type"                : request_type,
                "ValidUntil"          : valid_until.replace(microsecond=0),
                "TerminateInstancesWithExpiration" : True,
                "IamFleetRole"        : iam_fleet_arn,
                "LaunchSpecifications"  : [
                    {
                        "ImageId"          :  info['latest_ami'], # <- ここ!
                        "SecurityGroups" : [{
                            "GroupId" : security_group_id
                        }],
                        "InstanceType"     : instance_type,
                        "Placement"        : {
                            "AvailabilityZone" : availability_zone
                        },
                        "IamInstanceProfile": {
                            "Arn": iam_profile_arn
                        },
                        "SubnetId"         : subnet_id
                    }
                ]

            }
        )

これで少なくともアップデートの際にLambdaに手を入れる必要はなくなりました。

ひとまずの完成(完成ではない)

今現在はこの状態でゲームサーバを運用しています。
ただ、まだまだ課題がありまして…

  • SlackにIPが通知されてから、実際にゲームサーバに繋がるようになるまで3-4分かかる
  • 遊び終わった後始末(サーバ停止)をまだコンソール上での手動操作で行なっている
  • アップデート作業も自動化したい…。

このあたりは、まだいい解決策が思い付かなかったり、単純にまだやる気ゲージが溜まってなかったりで、棚上げになってます。
近いうちに手をつけたいですね。

気になるお値段

500円でおさまるかな、どうかな、と思ってましたが…

2017-10-28 (4).png

10月28日現在の時点で予測コストは1.85$(200円ちょっと)です。半額以下に抑えられそうですね!
EC2が10月から秒単位課金に変わったことや、一時期インスタンスをc3.largeに落としていたこと、単純に内輪で集まれる機会が減ったこと等が要因としてありますが、それでも予想以上に安くなりました。
これなら、もうちょっとお金と時間をかけてみて、Lex等を使ってSlackをチャットボットな感じにしてみるなどの遊びを取り入れてみても面白いなー、と思ってます。