Amazon EC2 Spot FleetのスケールアウトにAWS CodeDeployを連携する

805

sawanobolyです。たまに技術的な話を書き込みます。

CodeDeployではアプリケーションデプロイのターゲットとしてAuto Scaling Group(ASG)を指定することができます。
その場合、スケールアウトの際新規インスタンスにアプリケーションをデプロイするため、ASGのLifeCycleHook(CodeDeploy-managed-automatic-launch-deployment-hook)を自動的に登録してくれる仕組みです。
このため、ASGのインスタンスにはcodedeploy-agentを起動する仕組みを入れておけばよく、楽ちんです。

詳しくはAWSのブログ、 Under the Hood: AWS CodeDeploy and Auto Scaling Integration | AWS DevOps Blog に。

Spot Fleet(所属のインスタンス群)もCodeDeployのターゲットとして指定できますが、ASGと違いLifeCycleHook連携ができません。

おそらくSpot Fleetのスケール操作がApplication Autoscalingによるもので、ASGとはちょっと出どころが違うからかと思います。

DeploymentGroupのフィルタには引っかかるので、codedeploy-agentを起動さえしておけば、次回のデプロイからはちゃんとターゲットとなるためアプリケーションをデプロイすることができます。

自動連携は(まだ)ないので、仕掛けで対応

せっかくのSpot Fleet、スケールアウトや入れ替えで新規投入したインスタンスも直後からアプリケーションをホストしてほしいですね。
CodeDeployとの連携なしにUser-Scriptでデプロイ&ELB参加、のようなことも可能ですが、appspecによるライフサイクル管理が使えないとなると同じようなことを別に用意する必要があります。
これは二度手間となり、あまり好ましくありません。

AWSのフォーラムでもこの件について質問と回答がありました。

こちらログインしていないと参照できないため、必要部分を引用します。

In your lambda you could do the following in order:

1. Tag your new instance by calling ec2 create-tags ( https://docs.aws.amazon.com/cli/latest/reference/ec2/create-tags.html )
2. Create a CodeDeploy deployment group for just that host by calling CodeDeploy’s create-deployment-group ( https://docs.aws.amazon.com/cli/latest/reference/deploy/create-deployment-group.html )
3. Create a deployment to that deployment group.
4. Delete your deployment group when the deployment is finished – This is important because we have a limit to the number of deployment groups per application.

なるほど以下の手順でなんとかなりそうです。

  1. 新しいインスタンス(アプリケーションが自動デプロイされていない)にタグをつけて
  2. 専用のDeployment Groupを作成
  3. 2に(カレントのアプリケーションを)Deploy
  4. 2のグループを削除

あとから追いついて帳尻を合わせる感じでしょうか。今のCodeDeployでは、Deployment Groupのフィルタの仕様の都合で特定のインスタンスID(resource_id)指定、のようなことはできないのでタグをつけるという手順なんですね。
案内では『Lambdaファンクションとかを使うとよいよ』となっています。今回はインスタンスに自分でやってもらうようにしてみます。

インスタンス起動時の処理

では、インスタンスが起動した時にどこを確認しながらCodeDeployすればよいかを検討していきます。必要に応じてインスタンスロールのポリシーも調整する必要がありますね。

 

  • application-nameとdeployment-group-nameはとりあえずSpot Fleetインスタンスのタグに埋め込む
  • デプロイメントグループにlastSuccessがあれば、デプロイされているリビジョン, ELB情報, サービスロールのARNを保存
  • 自身に特定できるタグ(値にID)を付与
  • そのタグでフィルタするDeploymentGroupを作成
  • DeploymentGroupに保存したリビジョンをデプロイ

このサンプルではapplication-nameとdeployment-group-nameをタグに入れる都合で、作成順か命名規則に注意ですね。

 

Amazon Linuxのサンプル

では先ほど羅列した処理をAmazon LinuxのUser-Dataで実現してみます。aws-cliのバージョンがかなり新しめでないとDeploymentGroupのlastSuccessfulDeploymentが取得できないのは少しネックですね。

コメント部分はブログ用の追記です。

#!/bin/bash
set -eo pipefail
export PATH=/opt/aws/bin:$PATH

MD_ENDPOINT="http://169.254.169.254/latest/meta-data/"

yum -y update
yum -y install ruby wget jq
pip install --upgrade awscli

instance_id=`curl -s $MD_ENDPOINT/instance-id`
a_zone=`curl -s $MD_ENDPOINT/placement/availability-zone`
region=${a_zone:0:-1}
AWSCLI="aws --region $region"

## codedeploy-agentをスタート
wget https://aws-codedeploy-$region.s3.amazonaws.com/latest/install -O /tmp/install
chmod +x /tmp/install
/tmp/install auto
service codedeploy-agent start

## ついでにSSM
yum install -y https://s3-$region.amazonaws.com/amazon-ssm-$region/latest/linux_amd64/amazon-ssm-agent.rpm

##  失敗した時インスタンスを止めるなどしたければTrapで止めるなどの準備
emage () {
  echo error...
  ##  一時利用DeploymentGroupを作ってしまった後ならば消す
  # $AWSCLI deploy delete-deployment-group --application-name ${APP_NAME} --deployment-group-name $instance_id
  # poweroff
}
trap emage ERR

## インスタンスのタグからApplicationNameとDeploymentGroupNameを取得
APP_NAME=`$AWSCLI ec2 describe-tags --filters "Name=resource-id,Values=$instance_id" | jq -r '.Tags[] | select(.Key == "APP_NAME").Value'`
DEPLOYG=`$AWSCLI ec2 describe-tags --filters "Name=resource-id,Values=$instance_id" | jq -r '.Tags[] | select(.Key == "DEPLOYG").Value'`

## 自身の所属するスポットフリートのIDをとるサンプル、今回ApplicationNameとDeploymentGroupNameにタグを使うので不要
# sfr_id=`$AWSCLI ec2 describe-tags --filters "Name=resource-id,Values=$instance_id" | jq -r '.Tags[] | select(.Key == "aws:ec2spot:fleet-request-id").Value'`

## 所属するDeploymentGroupにデプロイ済みのアプリケーションがあるかチェック、なければ終わり
has_deploy=`$AWSCLI deploy get-deployment-group --application-name ${APP_NAME} --deployment-group-name ${DEPLOYG} | jq -r '.deploymentGroupInfo.lastSuccessfulDeployment'`
if [  "null" == "$has_deploy" ] ; then exit 0 ; fi

## Deploymentに必要な情報を一時保存
role_arn=`$AWSCLI deploy get-deployment-group --application-name ${APP_NAME} --deployment-group-name ${DEPLOYG} | jq -r '.deploymentGroupInfo.serviceRoleArn'`
elbinfo=`$AWSCLI deploy get-deployment-group --application-name ${APP_NAME} --deployment-group-name ${DEPLOYG} | jq -r '.deploymentGroupInfo.loadBalancerInfo'`
revision_info=`$AWSCLI deploy get-deployment-group --application-name ${APP_NAME} --deployment-group-name ${DEPLOYG} | jq -r '.deploymentGroupInfo.targetRevision'`

## 一時利用DeploymentGroupのために、タグTMP_CDPLOYにインスタンスIDをセット
$AWSCLI ec2 create-tags \
  --resources $instance_id \
  --tags Key=TMP_CDPLOY,Value=$instance_id

## タグTMP_CDPLOYがこのインスタンスIDをターゲットにした一時利用DeploymentGroupを作成する
$AWSCLI deploy create-deployment-group --application-name $APP_NAME --deployment-group-name $instance_id --service-role-arn $role_arn \
  --ec2-tag-filters Key=TMP_CDPLOY,Value=$instance_id,Type=KEY_AND_VALUE \
  --load-balancer-info "$elbinfo"

## Deploymentする
deployment_id=`$AWSCLI deploy create-deployment --application-name $APP_NAME --deployment-group-name $instance_id --revision "$revision_info" | jq -r .deploymentId`

## Successを待ち、こけたらそれ用の処理。
if ! $AWSCLI deploy wait deployment-successful --deployment-id $deployment_id ; then
  $AWSCLI deploy delete-deployment-group --application-name ${APP_NAME} --deployment-group-name $instance_id
  emage
fi

## 一時利用DeploymentGroupを削除する
$AWSCLI deploy delete-deployment-group --application-name ${APP_NAME} --deployment-group-name $instance_id

bashはどうしてもjq頼りになりますね。

WindowsServer 2016のサンプル

WindowsServer2016でもできそうなのでやってみます。こちらもAWSPowerShellのモジュールが新しめでないとlastSuccessfulDeploymentがとれません。ギャラリーから入れてしまいます。

と、このスクリプトは途中で、初回起動時のAWSPowerShell更新と同じセッション中は`LastSuccessfulDeployment`の中身に`Amazon.CodeDeploy.Model.LastDeploymentInfo`がはいりませんでした。

そのため、処理を一旦ファイルに書き出し最後にpowershell.exeから呼び出すというちょっと無理矢理感のあるスクリプトに。
ファイル書き出しをしない場合、`LastSuccessfulDeployment`を利用するのではなく他の手段でデプロイ対象を決定するなどの対応が必要そうです。モジュール更新済みのAMIを用意でもよいですね。

とりあえず単発では動くので置いておきます。

<powershell>
Set-ExecutionPolicy RemoteSigned -Force

# REM install IIS
Import-Module -Name ServerManager
Install-WindowsFeature Web-Server


# REM update AWSPowerShell
Install-PackageProvider -Name NuGet -Force
Install-Module -Name AWSPowerShell -Force
Import-Module AWSPowerShell -Force


# REM install codedeploy-agent
New-Item -Path "c:\temp" –ItemType "directory" -Force

$instance_id=Get-EC2InstanceMetadata -Category InstanceId
$az=Get-EC2InstanceMetadata -Category AvailabilityZone
$region=$az.Substring(0, $az.Length - 1 )

Read-S3Object -BucketName aws-codedeploy-$region	-Key latest/codedeploy-agent.msi -File c:\temp\codedeploy-agent.msi

c:\temp\codedeploy-agent.msi /quiet /l c:\temp\host-agent-install-log.txt

# REM Deploymentの設定を取得するなど

echo @'
$instance_id=Get-EC2InstanceMetadata -Category InstanceId
$az=Get-EC2InstanceMetadata -Category AvailabilityZone
$region=$az.Substring(0, $az.Length - 1 )
$filter = New-Object Amazon.EC2.Model.Filter
$filter.Name = "resource-id"
$filter.Values = $instance_id
$mytags = Get-EC2Tag -Filter @( $filter )

$APP_NAME=($mytags | Where-Object Key -Match "APP_NAME").Value
$DEPLOYG=($mytags | Where-Object Key -Match "DEPLOYG").Value

## REM +++WIP+++ `Get-CDDeploymentGroup`以降は何らかの対策が必要
$mydepg=Get-CDDeploymentGroup -ApplicationName $APP_NAME -DeploymentGroupName $DEPLOYG

# REM LastSuccessfulDeploymentがなければ終了
if (-Not $mydepg.LastSuccessfulDeployment) { Exit }


# REM 以降Linuxと同様にDeployment作成
$tag_filter = New-Object Amazon.CodeDeploy.Model.EC2TagFilter
$tag_filter.Type = 'KEY_AND_VALUE'
$tag_filter.Key = 'TMP_CDPLOY'
$tag_filter.Value = $instance_id

New-CDDeploymentGroup -ApplicationName $APP_NAME -DeploymentGroupName $instance_id -Ec2TagFilters @( $tag_filter ) -ServiceRoleArn $mydepg.ServiceRoleArn

$s3loc = $mydepg.TargetRevision.S3Location

$deployment_id = New-CDDeployment -ApplicationName $APP_NAME -DeploymentGroupName $instance_id -Revision_RevisionType $mydepg.TargetRevision.RevisionType.Value  -Revision_S3Location_Key $s3loc.Key -Revision_S3Location_Bucket $s3loc.Bucket -Revision_S3Location_ETag $s3loc.ETag -Revision_S3Location_BundleType $s3loc.BundleType

Write-Host "Wait for", $deployment_id

$timeout = new-timespan -Minutes 30
$sw = [diagnostics.stopwatch]::StartNew()
:LOOP while ($sw.elapsed -lt $timeout){
  switch ( (Get-CDDeployment -DeploymentId $deployment_id).Status.Value ) {
    "Succeeded" {
      # REM Groupを消しておく
      Remove-CDDeploymentGroup -ApplicationName $APP_NAME -DeploymentGroupName $instance_id -Force
      exit
    }
    "InProgress" { start-sleep -seconds 3 }
    "Failed" {
      Break LOOP
    }
  }
}

# REM Fail, Timeoutはここにくるので何かインスタンスの処遇を決めて処理する
Remove-CDDeploymentGroup -ApplicationName $APP_NAME -DeploymentGroupName $instance_id -Force
'@ > "c:\temp\deploy.ps1"

powershell.exe 'c:\temp\deploy.ps1'

</powershell>

普段PowerShellは使ってませんでしたが、モデルが揃っていて良い感じがしました。

New-CDDeploymentにRevisionのモデルをそのまま渡せないので、S3とGithubかを意識しないといけない点だけはbashの方が楽です。

最後に+注意点

CodeDeploy+ASGでもよく見かける話ですが、スケールアウトと同時に新しいリビジョンをデプロイをする際、新規インスタンスには以前取得できた`lastSuccessfulDeployment`に基づいたバージョンがデプロイ中のことがあります。
デプロイ前はスケールトリガをdisableにしておくなど、何かしら対策しないとリビジョンが混在しそうです。

それと、DeploymentGroupが消えるとそれに紐づいたDeploymentも参照できなくなるのがちょっと不便(というか不安)ですね。

最後に、そのうち公式で対応してくれると嬉しいなあ。