前回の記事の予告どおり今回は具体的なECSとRundeckの構成についてお話しします。
まずECSの用語について念のために予め簡単に説明します。公式ドキュメントには抽象的で分かりにくく記載されていますが設定をいじっているうちに自然と相互関係が理解できます。
ECS用語
- Task - Docker Container
- Task Definition - Taskを起動する際に必要となる情報をこれに定義します。 docker run のオプションにあたるものです。
- Service - 常時起動タスクを定義してECSに管理させるものです。今回は使いません。
- Cluster - 物理サーバのクラスタ管理だけではなく前述のServiceの設定や起動中タスク一覧など運用フェーズになったら通常こちらを参照します。
- ECR - EC2 Container Registry の略で AWS版 Docker Hub です。
構成概略図
この図のECS部分は全般的なもので用語集をよりイメージしやすくするための補足です。Travis-CIからECRの関係も一般的ですが、特殊なのはテストとdocker buildが成功してもデプロイは行わずイメージをアップロードして終了します。
Rundeckからaws ecs run-task
で実行されたジョブはECRから最新のイメージを取得してECS内で実行し、終了したと同時にコンテナが破棄されます。このためデプロイという概念がなく、常に最新のコードでジョブが起動されつつも実行中のジョブは自然に終了するまでは何事もなく動き続けられます。
ジョブサーバの構成
Rundeckとは?
Rundeckは簡単に言うと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
をインストールしています。
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前提の設定になっています。
環境変数の暗号化については後ほど紹介します。
#!/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に設定することにしました。
暗号化コマンドは以下のとおりです。
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
を上書きしています。
- 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
after_success:
- if [ "$TRAVIS_BRANCH" == "master" ] || [ "$TRAVIS_BRANCH" == "staging" ]; then scripts/docker_push.sh; fi
scripts/docker_push.sh (上記で呼ばれるスクリプト)
#!/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に以下の権限を追加しておく必要があります。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecs:StartTask"
],
"Resource": "*"
}
]
}
これでインスタンス起動時にインスタンス毎に1つのloggerプロセスのコンテナが起動し、さらにECS上でそのコンテナを管理できるようになります。
古いdocker imagesの削除
通常はdocker imageをアップデートするたびにインスタンス上にタグがないゴミイメージが溜まっていってしまいディスクが溢れますが、ecs-agent 1.13.0 以降が動作しているインスタンスでは使われていないlatest以外のイメージを自動削除してくれるようになりました。
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