GitLab Runner を AWS Spotfleet で節約運用

GitLab には [GitLab CI](https://about.gitlab.com/gitlab-ci/) が統合されて gitlab.yml というファイルを入れておくだけで push 時に任意の処理を実行させることができます。その実行を [GitLab Runner](https://docs.gitlab.com/runner/) をセットアップしたサーバーで行います。沢山プロジェクトで使い出すと Runner 待ちが発生してしまいます。そこでこの Runner を必要な時にだけ必要な数を用意したい。安く。
ということで AWS Spotfleet ([スポットフリートの仕組み](http://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-fleet.html)) で平日の朝 9:00 から 20:00 までだけ起動させておくという運用を考えます。
Runner 起動時に GitLab サーバーへ登録し、shutdown 時には登録を解除させます。

実はこの環境を作った後に気づいたのですが GitLab Runner には queue の状況によって Docker Machine を使って runner をオートスケールしてくれる機能がありました。Spot Instance を使うこともできるようです :sweat_smile:
[GitLab Runner 1.1 with Autoscaling](https://about.gitlab.com/2016/03/29/gitlab-runner-1-1-released/)

## Spotfleet での EC2 Instance 起動

AWS CLI の [request-spot-fleet](http://docs.aws.amazon.com/cli/latest/reference/ec2/request-spot-fleet.html) を使います。

Spotfleet のリクエストは次のようなコマンドでできます。

```
$ aws ec2 request-spot-fleet \
    --spot-fleet-request-config file://config.json
```

リクエストの詳細は [ユーザーガイド](http://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-fleet-requests.html) にあります。


config.json は [スポットフリート設定の例](http://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/spot-fleet-examples.html) もありますが Web Console からポチポチと作って実際に動いている設定を
 `describe-spot-fleet-requests` で確認するのが近道です。

```
$ aws ec2 describe-spot-fleet-requests
```

次のように RequestId を指定すれば見たいものだけに絞れます

```
$ aws ec2 describe-spot-fleet-request \
    --spot-fleet-request-ids sfr-73fbd2ce-aa30-494c-8788-1cee4EXAMPLE
```

今回使うのはこんな JSON ファイルです。

```json:config.json.tmpl
{
  "SpotPrice": "{{SpotPrice}}",
  "TargetCapacity": {{TargetCapacity}},
  "ValidFrom": "{{ValidFrom}}",
  "ValidUntil": "{{ValidUntil}}",
  "TerminateInstancesWithExpiration": true,
  "IamFleetRole": "arn:aws:iam::{{AccountId}}:role/aws-ec2-spot-fleet-role",
  "AllocationStrategy": "lowestPrice",
  "ExcessCapacityTerminationPolicy": "Default",
  "LaunchSpecifications": [
    {
      "ImageId": "{{ImageId}}",
      "KeyName": "{{KeyName}}",
      "EbsOptimized": false,
      "BlockDeviceMappings": [
        {
          "DeviceName": "/dev/sda1",
          "Ebs": {
            "DeleteOnTermination": true,
            "VolumeSize": {{VolumeSize}},
            "VolumeType": "gp2",
            "Encrypted": false
          }
        }
      ],
      "SecurityGroups": [
        {
          "GroupId": "{{GroupId}}"
        }
      ],
      "SubnetId": "{{SubnetId}}",
      "InstanceType": "{{InstanceType}}",
      "UserData": "{{UserData}}"
    }
  ]
}
```

`{{ }}` 部分は次項で紹介する実行用 shell script の中で sed で置換します。Python やら Ruby で JSON を生成すればもうちょっと柔軟にできますが今回は Shell Script で。

`UserData` には base64 で encode した文字列を指定する必要があるため次のようにします。中身は後で。

```
UserData=$(cat userdata.txt | base64 -w 0)
```

## Spotfleet のリクエストスクリプト

先の JSON の `{{ }}` 部分を置換してリクエストを送るスクリプトです。
Ubuntu 16.04 を指定しています。CentOS でも構いません。

```bash:spotfeet.sh
#!/bin/bash

tmpfile=$(mktemp)

# ValidFrom, ValidUntil は UTC で指定するため
export TZ=UTC

# 何時間起動させるか (9:00 - 20:00 なら11時間)
hours=11

# 起動させるインスタンス数
TargetCapacity=2

# インスタンスタイプ
InstanceType=m4.large

# ストレージボリュームサイズ
VolumeSize=50

# 入札価格の上限
SpotPrice=0.08

ValidFrom=$(date +%Y-%m-%dT%H:%M:%S)
ValidUntil=$(date -d "$hours hours" +%Y-%m-%dT%H:%M:%S)

# AWS Account ID
AccountId=123456789012

# Ubuntu 16.04
ImageId=ami-c68fc7a1

# Security Group
GroupId=sg-12345678

# どの Subnet で Instance を起動させるか(複数指定可能)
SubnetId="subnet-11111111, subnet-22222222"

# SSH 用 key name
KeyName=your-key-name

UserData=$(cat userdata.txt | base64 -w 0)

sed \
  -e "s/{{AccountId}}/${AccountId}/" \
  -e "s/{{KeyName}}/${KeyName}/" \
  -e "s/{{ValidFrom}}/${ValidFrom}/" \
  -e "s/{{ValidUntil}}/${ValidUntil}/" \
  -e "s/{{ImageId}}/${ImageId}/" \
  -e "s/{{VolumeSize}}/${VolumeSize}/" \
  -e "s/{{InstanceType}}/${InstanceType}/" \
  -e "s/{{SpotPrice}}/${SpotPrice}/" \
  -e "s/{{UserData}}/${UserData}/" \
  -e "s/{{TargetCapacity}}/${TargetCapacity}/" \
  -e "s/{{GroupId}}/${GroupId}/" \
  -e "s/{{SubnetId}}/${SubnetId}/" \
  config.json.tmpl > $tmpfile

# response 保存用ファイル
fleet_response_file=$(mktemp)

aws ec2 request-spot-fleet \
  --spot-fleet-request-config file://$tmpfile > $fleet_response_file

# response から request id を取り出す
request_id=$(cat $fleet_response_file | jq -r .SpotFleetRequestId)

# status が fulfilled になるまで待つ
while :
do
    sleep 10
    status=$(aws ec2 describe-spot-fleet-requests \
                 --spot-fleet-request-id $request_id \
             | jq -r .SpotFleetRequestConfigs[].ActivityStatus)
    if [ "$status" = "fulfilled" ] ; then
        break
    fi
done

# 起動された EC2 Instance の id を取得する
instance_ids=$(aws ec2 describe-spot-fleet-instances \
                   --spot-fleet-request-id $request_id \
                   | jq -r .ActiveInstances[].InstanceId)

# EC2 instance に Tag をセットする
aws ec2 create-tags \
  --resources $instance_ids \
  --tags "Key=Name,Value=GitLab Runner" "Key=xxx,Value=yyy"

# 一時ファイルの削除
rm $tmpfile
rm $fleet_response_file
```

## GitLab Runner のインストール、登録、解除

`UserData` で渡すインスタンス作成時に実行するスクリプトでは次のようなことを行います

* Docker のインストール
* GitLab Runner のインストール
  * [Install GitLab Runner using the official GitLab repositories](https://docs.gitlab.com/runner/install/linux-repository.html)
* docker hub へ push したり private registory から pull するためにログインのための情報をセット
* 同時実行数を CPU の数に合わせる
* Runner の GitLab サーバーへの登録
  * Docker in Docker で docker build したりするので docker.sock をマウントさせる
  * キャッシュをサーバー間や日をまたいだ別のインスタンスでも共有できるように s3 に保存する
* Shutdown 時に GitLab サーバーから削除されるように設定
  * systemd の ExecStopPost を override.conf で設定

Docker Machine でオートスケールさせるよりもいろいろいじる余地があります。

```bash:userdata.txt
#!/bin/bash

# Docker のインストール
curl -sSL https://get.docker.com/ | sh

# GitLab Runner repository のインストール
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.deb.sh | sudo bash

# Debian Stretch からは同名のパッケージが Debian 側で提供されるので
# gitlab 側の repository からインストールされるようにする
cat > /etc/apt/preferences.d/pin-gitlab-runner.pref <<EOF
Explanation: Prefer GitLab provided packages over the Debian native ones
Package: gitlab-ci-multi-runner
Pin: origin packages.gitlab.com
Pin-Priority: 1001
EOF

# Install GitLab CI Runner
apt-get update
apt-get install gitlab-ci-multi-runner

# docker login
mkdir /root/.docker
echo '{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcjpob2dlaG9nZWZ1Z2FmdWdh"}}}' > /root/.docker/config.json
chmod 0600 /root/.docker/config.json

# 同時実行上限変更(CPUのスレッド数に合わせる)
sed -i "s/^concurrent.*/concurrent = $(grep -c processor /proc/cpuinfo)/" /etc/gitlab-runner/config.toml

# GitLab Server への登録
/usr/bin/gitlab-ci-multi-runner register \
 -u https://gitlab.example.com/ci \
 -n -r {RegisterToken} \
 --docker-image docker:latest \
 --tag-list docker \
 --run-untagged \
 --name "$(curl -s http://169.254.169.254/latest/meta-data/instance-id)-$(curl -s http://169.254.169.254/latest/meta-data/instance-type)" \
 --executor docker --docker-privileged \
 --docker-volumes "/root/.docker:/root/.docker:ro" \
 --docker-volumes "/var/run/docker.sock:/var/run/docker.sock" \
 --cache-type s3 \
 --cache-s3-server-address s3.amazonaws.com \
 --cache-s3-access-key AWS_ACCESS_KEY \
 --cache-s3-secret-key AWS_ACCESS_SECRET \
 --cache-s3-bucket-name s3-bucket-name \
 --cache-s3-bucket-location ap-northeast-1 \
 --cache-s3-cache-path cache \
 --cache-cache-shared true

# 停止後に GitLab サーバーから削除する設定
mkdir /etc/systemd/system/gitlab-runner.service.d
cat > /etc/systemd/system/gitlab-runner.service.d/override.conf <<EOF
[Service]
ExecStopPost=/usr/bin/gitlab-ci-multi-runner unregister -n $(curl -s http://169.254.169.254/latest/meta-data/instance-id)-$(curl -s http://169.254.169.254/latest/meta-data/instance-type)
EOF
systemctl daemon-reload
```