AWS Systems Manager Run Command を使ってデプロイしてみる


個人的に使っている Web サービスや規模の小さいサービスでは git pull でリリースしている場合もあるかと思います。わざわざ ssh でログインして git pull するのは面倒なので、 CodePipeline と Systems Manager Run Command を使って自動化してみます。

Systems Manager Run Command とは

AWS Systems Manager Run Command では、マネージドインスタンスの設定をリモートかつ安全に管理することができます。マネージドインスタンスは、Systems Manager のために設定されたハイブリッド環境の Amazon EC2 インスタンスまたはオンプレミスコンピュータです。

AWS Systems Manager Run Command

とありますが、今回は、 EC2 内でコマンドを実行する という使い方をします。

やること

  1. 対象の EC2 インスタンスに SSM エージェントをインストールする
  2. 対象の EC2 インスタンスに、必要な IAM ロールを割り当てる
  3. Run Command を実行するための Lambda 関数を実装する
  4. CodePipeline でデプロイのパイプラインを作成する

1. 対象の EC2 インスタンスに SSM エージェントをインストールする

今回の対象は EC2 インスタンスで、 OS は Amazon Linux です。
下記の手順で SSM エージェントをインストールします。

  1. 一時ディレクトリを作成、移動

    $ mkdir /tmp/ssm
    $ cd /tmp/ssm
  2. SSM エージェントをインストール

    $ sudo yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm
  3. SSM エージェントのステータスを確認

    $ sudo status amazon-ssm-agent

    起動していない場合は、下記コマンドで起動します。

    $ sudo start amazon-ssm-agent

他の環境、OS の場合については公式リファレンスを確認してください。

2. 対象の EC2 インスタンスに、必要な IAM ロールを割り当てる

対象となる EC2 インスタンスには、 AmazonEC2RoleforSSM ポリシーをアタッチしたロールを割り当てます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:DescribeAssociation",
                "ssm:GetDeployablePatchSnapshotForInstance",
                "ssm:GetDocument",
                "ssm:GetManifest",
                "ssm:GetParameters",
                "ssm:ListAssociations",
                "ssm:ListInstanceAssociations",
                "ssm:PutInventory",
                "ssm:PutComplianceItems",
                "ssm:PutConfigurePackageResult",
                "ssm:UpdateAssociationStatus",
                "ssm:UpdateInstanceAssociationStatus",
                "ssm:UpdateInstanceInformation"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ssmmessages:CreateControlChannel",
                "ssmmessages:CreateDataChannel",
                "ssmmessages:OpenControlChannel",
                "ssmmessages:OpenDataChannel"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2messages:AcknowledgeMessage",
                "ec2messages:DeleteMessage",
                "ec2messages:FailMessage",
                "ec2messages:GetEndpoint",
                "ec2messages:GetMessages",
                "ec2messages:SendReply"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:PutMetricData"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstanceStatus"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ds:CreateComputer",
                "ds:DescribeDirectories"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:DescribeLogGroups",
                "logs:DescribeLogStreams",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:PutObject",
                "s3:GetObject",
                "s3:GetEncryptionConfiguration",
                "s3:AbortMultipartUpload",
                "s3:ListMultipartUploadParts",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Resource": "*"
        }
    ]
}

3. Run Command を実行するための Lambda 関数を実装する

  • ランタイム : Python 3.6
  • 実行ロール : 下記のようなポリシーを作成して、ロールにアタッチします

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "VisualEditor0",
                "Effect": "Allow",
                "Action": [
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                ],
                "Resource": "arn:aws:logs:*:*:*"
            },
            {
                "Sid": "VisualEditor1",
                "Effect": "Allow",
                "Action": [
                    "codepipeline:ListPipelineExecutions",
                    "codepipeline:PutJobFailureResult",
                    "codepipeline:PutJobSuccessResult",
                    "ssm:*",
                    "ec2:*",
                    "codepipeline:GetPipelineExecution"
                ],
                "Resource": "*"
            },
            {
                "Sid": "VisualEditor2",
                "Effect": "Allow",
                "Action": "logs:CreateLogGroup",
                "Resource": "arn:aws:logs:*:*:*"
            }
        ]
    }

実際のソースは下記のとおりです。

import boto3
import json
import logging
import os
import traceback

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from datetime import datetime

SLACK_CHANNEL = 'channel_name'
TARGET_INSTANCE_ID = '{対象の EC2 インスタンスのインスタンス ID}'
PIPELINE_NAME = 'laravel-deploy-pl'
HOOK_URL = 'https://hooks.slack.com/services/*********'

pipeline = boto3.client('codepipeline')

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):

    job_id = event["CodePipeline.job"]["id"]

    try:
        ec2 = boto3.client('ec2')
        ssm = boto3.client('ssm')
        job_id = event["CodePipeline.job"]["id"]

        target_content_info = get_content_info()
        if target_content_info['can_deploy'] != True:
            print('Nothing to do.')
            put_job_failer(job_id, 'This pipeline Not in progress.')
            return {'status': 200}

        cmd_res = ssm.send_command(
            InstanceIds = [TARGET_INSTANCE_ID],
            DocumentName = "AWS-RunShellScript",
            Parameters = {
                "commands": [
                    "cd /var/www/laravel-test",
                    "git pull origin master:master",
                    "/usr/local/bin/composer install",
                    "php artisan migrate",
                    "php artisan cache:clear",
                    "php artisan view:clear"
                ],
                "executionTimeout": ["3600"]
            },
        )

        if cmd_res['ResponseMetadata']['HTTPStatusCode'] != 200:
            print('Failed to execute ssm.send_command().')
            put_job_failer(job_id, 'Some errors occered in execution ssm.send_command().')
            return {'status': 200}

        put_job_success(job_id, target_content_info['message_for_slack'])

    except Exception:
        print(traceback.format_exc())
        put_job_failer(job_id, traceback.format_exc())
        return {'status': 500}


"""
for CodePipeline.
To get target content info in current execution pipeline.
"""
def get_content_info():

    try:
        exe_pipelines = pipeline.list_pipeline_executions(pipelineName=PIPELINE_NAME, maxResults=1)
        exe_pipeline = exe_pipelines['pipelineExecutionSummaries'][0]

        exe_id = exe_pipeline['pipelineExecutionId']
        exe_detail = pipeline.get_pipeline_execution(pipelineName=PIPELINE_NAME, pipelineExecutionId=exe_id)

        print(exe_detail)

        exe_status = exe_pipeline['status']
        git_summary = {
            'revision_id': exe_pipeline['sourceRevisions'][0]['revisionId'],
            'commit_message' : exe_pipeline['sourceRevisions'][0]['revisionSummary'],
            'revision_url' : exe_pipeline['sourceRevisions'][0]['revisionUrl'],
            'message_for_slack' : "Commit <{}|{}> \n{}".format(
                exe_pipeline['sourceRevisions'][0]['revisionUrl'],
                exe_pipeline['sourceRevisions'][0]['revisionId'],
                exe_pipeline['sourceRevisions'][0]['revisionSummary']
                )
        }

        if exe_status == 'InProgress':
            git_summary.update({'can_deploy': True})
        else:
            git_summary.update({'can_deploy': False})
            print('Current pipeline has finished with status {}'.format(exe_status))

        return git_summary
    except Exception as e:
        raise e

"""
for CodePipeline.
To finish lambda function with status "SUCCESS".
"""
def put_job_success(job_id, message):
    try:
        pipeline = boto3.client('codepipeline')
        pipeline.put_job_success_result(
            jobId=job_id,
            currentRevision={
                'revision': 'revision_string',
                'changeIdentifier': 'changeIdentifier_string'
            }
        )
        post_to_slack(message)
    except Exception:
        print(traceback.format_exc())
        post_to_slack(traceback.format_exc(), False)

"""
for CodePipeline.
To finish lambda function with status "FAILER".
"""
def put_job_failer(job_id, message):
    try:
        pipeline = boto3.client('codepipeline')
        pipeline.put_job_failure_result(
            jobId=job_id,
            failureDetails={
                'type': 'JobFailed',
                'message': message
            }
        )
        post_to_slack(message, False)
    except Exception:
        print(traceback.format_exc())
        post_to_slack(traceback.format_exc(), False)

"""
for notification.
To post message with attachments to Slack.
"""
def post_to_slack(message, is_ok=True):
    status = 'ok'
    state_color = '#00FF00'
    icon_emoji = ':codepipeline:'
    author_name = 'CodePipeline'

    if is_ok != True:
        status = 'ng'
        state_color = '#FF0000'

    title = 'deploy test [{}]'.format(status)

    slack_message = {
        'channel': SLACK_CHANNEL,
        'icon_emoji': icon_emoji,
        'attachments': [
            {
                'author_name': author_name,
                'title': title,
                'color': state_color,
                'fields': [
                    {
                        'value': message
                    }
                ]
            }
        ]
    }

    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

長々と書いてありますが、 Run Command を使用しているのは下記の部分です。

cmd_res = ssm.send_command(
    InstanceIds = [TARGET_INSTANCE_ID],
    DocumentName = "AWS-RunShellScript",
    Parameters = {
        "commands": [
            "cd /var/www/laravel-test",
            "git pull origin master:master",
            "/usr/local/bin/composer install",
            "php artisan migrate",
            "php artisan cache:clear",
            "php artisan view:clear"
        ],
        "executionTimeout": ["3600"]
    },
)

対象のインスタンス ID のリストと、実行したいコマンドをリストで指定します。
今回は対象が Laravel のプロジェクトなので、 git pull したあとに DB のマイグレーション、キャッシュの削除を実行しています。

実行結果は Slack に通知するようにしていて、実際の通知はこんな感じになります。

Slack Notification

ちなみに Slack のカスタマイズアイコンとして :codepipeline: の名前で CodePipeline のアイコンを登録しています。(それっぽくしたかった)

4. CodePipeline でデプロイのパイプラインを作成する

最後に、ローカルからの git push を検知して、上記の Lambda を実行するためのフローを CodePipeline で作成します。
構成は下図のとおりです。

Deploy CodePipeline

Source

まず Srouce ステージですが、ここは git リポジトリの情報を設定します。
今回はアクションプロバイダに Github を指定、監視するブランチを mater で指定しました。

Staging

名前は Staging となっていますが、ここは適宜変更してください。
やっている内容は、上で実装した Lambda 関数を呼んでいるだけです。

注意点

新たにパイプラインを作成するときには、Build または Deploy のステージを必ず入れる必要があり、その設定画面では Lambda を選択することが出来ません。
なので、一旦ダミーのビルド/デプロイを CodeBuild/CodeDeploy で作成しておいて、それを指定しておきます。
その後、不要なステージを削除して、 Lambda 呼び出しのステージを追加する という手順になります。

まとめ

以上、Systems Manager Run Command を使って EC2 に web サービスをデプロイする方法でした。
外から任意のコマンドを実行できるということは、これまた何でも出来てしまうと思うので、例えば yum パッケージのアップデートとかも自動で出来るかなと思いました。

comments powered by Disqus