ジョブスケジューラを ECS と Rundeck で構築

[前回の記事](http://qiita.com/amrael/items/04cbb0d5add996479365)の予告どおり今回は具体的なECSとRundeckの構成についてお話しします。

まずECSの用語について念のために予め簡単に説明します。公式ドキュメントには抽象的で分かりにくく記載されていますが設定をいじっているうちに自然と相互関係が理解できます。

## ECS用語

- Task - Docker Container
- Task Definition - Taskを起動する際に必要となる情報をこれに定義します。 docker run のオプションにあたるものです。
- Service - 常時起動タスクを定義してECSに管理させるものです。今回は使いません。
- Cluster - 物理サーバのクラスタ管理だけではなく前述のServiceの設定や起動中タスク一覧など運用フェーズになったら通常こちらを参照します。
- ECR - EC2 Container Registry の略で AWS版 Docker Hub です。

## 構成概略図

![image](https://qiita-image-store.s3.amazonaws.com/0/7843/917382d0-9f59-2572-5534-fc1e745ded4e.png)

この図のECS部分は全般的なもので用語集をよりイメージしやすくするための補足です。Travis-CIからECRの関係も一般的ですが、特殊なのはテストとdocker buildが成功してもデプロイは行わずイメージをアップロードして終了します。
Rundeckから`aws ecs run-task`で実行されたジョブはECRから最新のイメージを取得してECS内で実行し、終了したと同時にコンテナが破棄されます。このためデプロイという概念がなく、常に最新のコードでジョブが起動されつつも実行中のジョブは自然に終了するまでは何事もなく動き続けられます。

# ジョブサーバの構成

## Rundeckとは？
[Rundeck](http://rundeck.org/)は簡単に言うとWebUIを備えたジョブ管理ソフトウェアです。他の候補は Chronos, Dkron などがありますが Chronos はHA周りがoverkillであること、DkronはLGPLv3を謳っているもののCopyrightに個人名が記載されていたのと実績がほとんどないので候補から外しました。Jenkinsでも一応同様なことはできますが元々DevOpsのためのものなのでこれも除外しました。
Rundeckは自前のHA機能は備えていませんが、その代わりにこのプロセスもDocker化してService定義、ELBと接続することでプロセス監視とサービス監視をそれぞれECSとELBに任せてしまい、常に1コンテナだけ起動するようにします。初期設定ではローカルファイルとしてデータ保存するのでこれをDB(PostgreSQL)に切り替えることでコンテナやインスタンスが自動でフェイルオーバーしても問題のないようにしています。

### Rundeck Dockerfile
alpine linuxをベースイメージとし、ベースディレクトリを `/opt/rundeck` と定義したものが以下です。ポート4440を使います。
また、上の図のとおりRundeckからは`aws ecs`コマンドを使ってECSタスク起動するため`awscli`をインストールしています。

```Dockerfile
FROM alpine:latest

# This line has to be separated from other definitions because $RDECK_BASE becomes empty if it's in the same line.
ENV RDECK_BASE=/opt/rundeck
ENV RDECK_JAR=$RDECK_BASE/app.jar \
    PATH=$PATH:$RDECK_BASE/tools/bin

RUN apk --no-cache add openjdk7-jre openssl bash curl py-pip \
    && mkdir -p $RDECK_BASE \
    && wget -O $RDECK_JAR http://dl.bintray.com/rundeck/rundeck-maven/rundeck-launcher-2.6.9.jar \
    && pip install awscli
COPY entrypoint.sh /entrypoint.sh
EXPOSE 4440
VOLUME /opt/rundeck
ENTRYPOINT ["/entrypoint.sh"]
```

### Rundeck entrypoint

上のDockerfileで定義したとおり、起動時に必ず実行されるスクリプト、`/entrypoint.sh`が以下の通りです。
簡単な流れとしては共通鍵方式で暗号化された`RUNDECK_*`の環境変数を復号化し、インストールと各種セットアップ(admin, user それぞれのアカウントを作成、DBアカウント、ホスト名)の後、プロセス起動しています。SSLは今回ELBに任せるのでHTTP前提の設定になっています。
環境変数の暗号化については後ほど紹介します。

```bash
#!/bin/bash
set -e

# decode variables
# expected variables are RUNDECK_DEFAULT_ADMIN_USER RUNDECK_DEFAULT_ADMIN_PASSWORD RUNDECK_DEFAULT_USER RUNDECK_DEFAULT_PASSWORD RUNDECK_DB_URL RUNDECK_DB_USER RUNDECK_DB_PASSWORD RUNDECK_HOSTNAME RUNDECK_PROTOCOL RUNDECK_AWS_SECRET_ACCESS_KEY
for line in $(printenv | grep RUNDECK_)
do
    key=$(echo "$line" | cut -d '=' -f1)
    val=$(printenv $key | openssl aes-256-cbc -d -a -A -k '暗号/復号キー')
    if [ $key == "RUNDECK_AWS_SECRET_ACCESS_KEY" ]; then
        export AWS_SECRET_ACCESS_KEY="$val"
    else
        export $key="$val"
    fi
done

function install_rundeck() {
    config_properties=$RDECK_BASE/server/config/rundeck-config.properties
    java -jar $RDECK_JAR --installonly
    echo "$RUNDECK_DEFAULT_ADMIN_USER:$RUNDECK_DEFAULT_ADMIN_PASSWORD,user,admin" > $RDECK_BASE/server/config/realm.properties
    echo "$RUNDECK_DEFAULT_USER:$RUNDECK_DEFAULT_PASSWORD,user" >> $RDECK_BASE/server/config/realm.properties

    # Update `rundeck-config.properties` and configure the datasource:
    driver="dataSource.driverClassName = org.postgresql.Driver"
    datasource="dataSource.url = jdbc:postgresql://${RUNDECK_DB_URL}/rundeck"
    username="dataSource.username = ${RUNDECK_DB_USER}"
    password="dataSource.password = ${RUNDECK_DB_PASSWORD}"
    extra="rundeck.projectsStorageType=db\nrundeck.storage.provider.1.type=db\nrundeck.storage.provider.1.path=keys"
    sed -i "s,^dataSource\.url.*\$,${datasource}," $config_properties
    echo $driver >> $config_properties; echo $username >> $config_properties; echo $password >> $config_properties
    echo -e $extra >> $config_properties

    # Set server URL
    sed -i "s,^grails\.serverURL.*\$,grails\.serverURL=${RUNDECK_PROTOCOL}://${RUNDECK_HOSTNAME}," $config_properties
}

install_rundeck

exec java -Xmx1024m \
       -XX:MaxPermSize=256m \
       -Drundeck.jetty.connector.forwarded=true \
       -Dserver.hostname=$RUNDECK_HOSTNAME \
       -jar $RDECK_JAR --skipinstall
```

### 環境変数の暗号化

Task Definition にパスワード等の秘匿情報を直接設定することももちろん可能ですが、公式ドキュメントではセキュリティ上の問題により非推奨となっています。APIやdocker inspectで丸見えになってしまいます。
そのためAES-256方式を使って暗号化済みの値をTask Definitionに設定することにしました。

![image](https://qiita-image-store.s3.amazonaws.com/0/7843/c3edea00-9b6c-1886-5c8f-5516686692d5.png)

暗号化コマンドは以下のとおりです。

```sh
echo '暗号化したい値' | openssl aes-256-cbc -e -a -A -salt -k '暗号/復号キー'
```

暗号化に使用したキーはコンテナイメージに復号のため埋め込んでしまいますがECRのREAD権限を絞ることで、ECSのみのアクセス権限では実際の値が読めず、最小限のセキュリティは確保しました。

### ジョブの登録

ジョブサンプルをYAML形式で出力すると以下のようになります。
Rundeckは元々SSHなどで各サーバにログインしてコマンド実行するように作られていますが、今回はそのNodeという概念は使わずローカルサーバ上で`aws ecs run-task`を実行するだけです。ただしジョブ毎に実行するコマンドを変えるために `--overrides` というオプションを使って`command`, Dockerfile でいうところの `CMD` を上書きしています。

```yaml
- description: ''
  executionEnabled: true
  id: 96b668e9-e682-4dd5-a898-499c10c4d74c
  loglevel: INFO
  name: タスク名
  schedule:
    month: '*'
    time:
      hour: 0-23/6
      minute: '17'
      seconds: '0'
    weekday:
      day: '*'
    year: '*'
  scheduleEnabled: true
  sequence:
    commands:
    - script: 'aws ecs run-task --region ap-northeast-1 --cluster クラスタ名 --task-definition
        TaskDefinition名 --overrides ''{"containerOverrides": [{"name": "コンテナ名",
        "command": ["任意のコマンド"]}]}'''
    keepgoing: false
    strategy: node-first
  uuid: 96b668e9-e682-4dd5-a898-499c10c4d74c
```

# バックエンド側の構成

## Travisの設定

下記を入れ込むことでmasterかstagingブランチにマージされたときに`docker build`, `docker push` を行います。また、master,stagingそれぞれ別のECR Repositoryを定義しています。

#### .travis.yml
```yaml
after_success:
  - if [ "$TRAVIS_BRANCH" == "master" ] || [ "$TRAVIS_BRANCH" == "staging" ]; then scripts/docker_push.sh; fi
```

#### scripts/docker_push.sh (上記で呼ばれるスクリプト)

```bash
#!/bin/bash

IMAGE_NAME="イメージ名"
REMOTE_IMAGE_URL="ECRイメージへのURL"

# Push only if it's not a pull request
if [ -z "$TRAVIS_PULL_REQUEST" ] || [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
  # use different image for staging.
  if [ "$TRAVIS_BRANCH" == "staging" ]; then
    IMAGE_NAME="${IMAGE_NAME}-staging"
    REMOTE_IMAGE_URL="${REMOTE_IMAGE_URL}-staging"
  fi
  # This is needed to login on AWS and push the image on ECR
  pip install awscli
  export PATH=$PATH:$HOME/.local/bin
  eval $(aws ecr get-login --region ap-northeast-1)
  # Build and push
  docker build -t $IMAGE_NAME -f カスタムDockerfile名 .
  echo "Pushing $IMAGE_NAME:latest"
  docker tag $IMAGE_NAME:latest "$REMOTE_IMAGE_URL:latest"
  docker push "$REMOTE_IMAGE_URL:latest"
  echo "Pushed $IMAGE_NAME:latest"
else
  echo "Skipping building and pushing container image because it's a pull request."
fi
```
※ .travis.yml 内でAWS_SECRET系の環境変数定義とそのアカウントのパーミッション設定をしておかないと権限エラーで失敗します。

## Credentials

Rundeckと同様に暗号化してTask Definitionに定義します。

## Logging

td-agentを元々使っていますが、ECSの1インスタンスにつき1つのtd-agentプロセスを起動しておく必要があるため以下をAuto Scaling Launch Configuration detailsのUser dataにtextとして貼り付けます。(ECS_CLUSTERの設定は常に必要なのでそれも含んでいます)
前提として、このプロセスもECSで管理させるためTask DefinitionやCluster定義、docker image登録など予めやっておく必要があります。また、コンテナ同士で通信するためにジョブとLoggerともにTask DefinitionのNetwork ModeをHostに設定します。

```
Content-Type: multipart/mixed; boundary="==BOUNDARY=="
MIME-Version: 1.0

--==BOUNDARY==
MIME-Version: 1.0
Content-Type: text/text/x-shellscript; charset="us-ascii"

#!/bin/bash
# Specify the cluster that the container instance should register into
cluster="クラスタ名"

# Write the cluster configuration variable to the ecs.config file
# (add any other configuration variables here also)
echo ECS_CLUSTER=$cluster >> /etc/ecs/ecs.config

# Install the AWS CLI and the jq JSON parser
yum install -y aws-cli jq
--==BOUNDARY==
MIME-Version: 1.0
Content-Type: text/text/upstart-job; charset="us-ascii"

#upstart-job
description "Amazon EC2 Container Service (start task on instance boot)"
author "Amazon Web Services"
start on started ecs

script
	exec 2>>/var/log/ecs/ecs-start-task.log
	set -x
	until curl -s http://localhost:51678/v1/metadata
	do
		sleep 1
	done

	# Grab the container instance ARN and AWS region from instance metadata
	instance_arn=$(curl -s http://localhost:51678/v1/metadata | jq -r '. | .ContainerInstanceArn' | awk -F/ '{print $NF}' )
	cluster=$(curl -s http://localhost:51678/v1/metadata | jq -r '. | .Cluster' | awk -F/ '{print $NF}' )
	region=$(curl -s http://localhost:51678/v1/metadata | jq -r '. | .ContainerInstanceArn' | awk -F: '{print $4}')

	# Specify the task definition to run at launch
	task_definition="loggerプロセス用のTaskDefinition名"

	# Run the AWS CLI start-task command to start your task on this container instance
	aws ecs start-task --cluster $cluster --task-definition $task_definition --container-instances $instance_arn --started-by $instance_arn --region $region
end script
--==BOUNDARY==--
```

ここでの`aws ecs`コマンドはIAM Roleの`ecsInstanceRole`、もしくは自分で個別にアサインしたRole権限で実行されるため、そのRoleに以下の権限を追加しておく必要があります。

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecs:StartTask"
      ],
      "Resource": "*"
    }
  ]
}
```

これでインスタンス起動時にインスタンス毎に1つのloggerプロセスのコンテナが起動し、さらにECS上でそのコンテナを管理できるようになります。

## 古いdocker imagesの削除

通常はdocker imageをアップデートするたびにインスタンス上にタグがないゴミイメージが溜まっていってしまいディスクが溢れますが、ecs-agent 1.13.0 以降が動作しているインスタンスでは使われていないlatest以外のイメージを[自動削除してくれるようになりました](https://github.com/aws/amazon-ecs-agent/issues/118)。

## Auto Scaling Policy

メモリが不足しそうになったらスケールアウトするのが望ましいため `MemoryReservation` が閾値を超えたら、というAuto Scaling GroupのScaling Policyを定義します。CloudWatchからMemoryReservation Alarmを定義してからAuto Scaling GroupのScaling Policyを設定しないと閾値基準がCPUしか選択できない点が注意です。

# まとめ

以上、要点だけに絞りつつもかなり長い投稿となってしまいましたが、このような構成でスケジュールジョブシステムとして分割、移行し、順調に稼働しています。極端な話、スケジューラーはcronでもよかったのですが、せっかくならWebUIで誰でも簡単にジョブ登録と編集ができたら便利だろうということで主に海外で評判の良いRundeckを採用し、今回の使い方ではややオーバースペックながらも安定していて使いやすいです。

--
trippieceではエンジニアを募集しています。
https://www.wantedly.com/projects/38518