michimani.net

ECS タスクを作って実行して CloudWatch でログを確認するまでを AWS CLI だけでやってみた

2021-02-18

前に AWS Copilot を使って ECS タスクを実行する環境 (VPC とか サブネットとか) をサクッと作ったのですが、今回はなんとなく AWS CLI で全部作ってみました。気軽に始めた割には結構なボリュームになったので、備忘録として残しておきます。

AWS Copilot を使ってスケジュールされた ECS タスクをデプロイする - michimani.net

目次

はじめに

今回は、 AWS アカウント内の S3 バケットの数を出力する ECS タスク を作成して実行します。

実行するまでに必要な諸々のリソースを作成して、実際に実行し、CloudWatch Logs に出力されたログを確認するところまでを AWS CLI でやってみます。必要になる諸々の値は変数に設定しながら進めているので、すべて同一のセッションで続けて実行することを想定しています。

AWS CLI のバージョンは、実行時点で最新だった 2.1.26 を使います。

$ aws --version
aws-cli/2.1.26 Python/3.7.4 Darwin/19.6.0 exe/x86_64 prompt/off

作成するリソース

作成するリソースは下記の通りです。

作っていく

では、前項で挙げたリソースを順に作っていきます。今回作成するタスク (アプリケーション) の名前は esc-task-test とします。

$ APP_NAME="esc-task-test"

また、 AWS アカウント ID も必要になるので、先に変数に設定しておきます。

$ AWS_ACCOUNT_ID=$( \
aws sts get-caller-identity \
--query 'Account' \
--output text ) \
&& echo "${AWS_ACCOUNT_ID}"

また、コマンド内で漏れているかもしれませんが、グローバルなリソースを除いてすべて東京 ap-northeast-1 リージョンに作成します。

ECR

まずは ECR のリポジトリです。

$ ECR_REPO_URI=$( \
aws ecr create-repository \
--repository-name ${APP_NAME} \
--region ap-northeast-1 \
--query "repository.repositoryUri" \
--output text ) \
&& echo "${ECR_REPO_URI}"

リポジトリが作成できたら、 docker イメージを作成して push しておきます。今回は AWS アカウント内の S3 バケットの数を標準出力に出力するだけのタスクなので、 Amazon Linux をベースイメージとして、中で AWS CLI をインストールし、 s3api list-buckets コマンドを実行するようなイメージを作成します。 Dockerfile は下記の通りです。

FROM amazonlinux:2.0.20210126.0-with-sources
RUN yum install -y unzip less \
&& curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install \
&& aws --version
CMD BUCKET_CNT=$(\
aws s3api list-buckets --query "Buckets[] | length(@)" --output text) \
&& echo "There are ${BUCKET_CNT} S3 buckets"

ビルドして、作成した ECR リポジトリに push します。

# ビルド
$ docker build -t ${APP_NAME} .

# タグ付け
$ docker tag ${APP_NAME}:latest ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${APP_NAME}:latest

# ECR にログイン
$ aws ecr get-login-password \
--region ap-northeast-1 \
| docker login \
--username AWS \
--password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com

# push
$ docker push ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${APP_NAME}:latest

VPC

続いて、 VPC などのネットワーク関連リソースを作成します。今回は ECR からイメージを取得して実行するので、 ECS タスクを実行するサブネットからは外部ネットワークに接続できる必要があります。実際のアプリケーションでは、プライベートサブネットで実行して NAT ゲートウェイ経由で接続する方法が一般的かと思いますが、今回はパブリックサブネットで実行することにします。(NAT ゲートウェイ高いので…)

なので、諸々のリソースの作成順序としては、下記の通りです。

  1. VPC 作成
  2. インターネットゲートウェイ (IGW) 作成、VPC にアタッチ
  3. カスタムルートテーブル作成、IGW へのルートを作成
  4. サブネットを作成
  5. ルートテーブルとサブネットを関連付け
  6. Public IP の自動付与設定

では順番にやっていきます。

1. VPC 作成

$ VPC_ID=$( \
aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--query "Vpc.VpcId" \
--output text )\
&& echo "${VPC_ID}"

2. インターネットゲートウェイ (IGW) 作成、VPC にアタッチ

# IGW を作成
$ IGW_ID=$(\
aws ec2 create-internet-gateway \
--query "InternetGateway.InternetGatewayId" \
--output text )\
&& echo "${IGW_ID}"

# VPC にアタッチ
$ aws ec2 attach-internet-gateway \
--vpc-id "${VPC_ID}" \
--internet-gateway-id "${IGW_ID}"

3. カスタムルートテーブル作成、IGW へのルートを作成

# カスタムルートテーブルを作成
$ ROUTE_TABLE_ID=$( \
aws ec2 create-route-table \
--vpc-id "${VPC_ID}" \
--query "RouteTable.RouteTableId" \
--output text )\
&& echo "${ROUTE_TABLE_ID}"

# IGW へのルートの作成
$ aws ec2 create-route \
--route-table-id "${ROUTE_TABLE_ID}" \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id "${IGW_ID}"

4. サブネットを作成

$ SUBNET_ID=$( \
aws ec2 create-subnet \
--cidr-block 10.0.0.0/24 \
--vpc-id "${VPC_ID}" \
--query "Subnet.SubnetId" \
--output text )\
&& echo "${SUBNET_ID}"

5. ルートテーブルとサブネットを関連付け

$ aws ec2 associate-route-table \
--subnet-id "${SUBNET_ID}" \
--route-table-id "${ROUTE_TABLE_ID}"

6. Public IP の自動付与設定

$ aws ec2 modify-subnet-attribute \
--subnet-id "${SUBNET_ID}" \
--map-public-ip-on-launch

どうでもいいんですが、 VPC 関連のリソースを扱う際にも AWS CLI のコマンドは ec2 なんですよね。最近はもう VPC は EC2 だけのものではない気もするのですが、過去の経緯からもこうなっているんでしょうか。

ECS

続いては ECR のリソースです。作成するのは下記のリソースです。

  1. クラスター
  2. タスク定義

クラスター

$ aws ecs create-cluster \
--cluster-name ${APP_NAME}-cluster \
--region ap-northeast-1

タスク定義

タスク定義を作成する際に、 タスク実行ロールタスクロール が必要になるので、それぞれ作成します。

タスク実行ロール

ロールにアタッチするポリシーは、予め用意されている AmazonECSTaskExecutionRolePolicy を使います。

# AssumeRole Policy Document 作成
$ cat <<EOF > assume-role-policy-doc-for-task-exec-role.json
{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# IAM Role 作成
$ TASK_EXEC_ROLE_ARN=$( \
aws iam create-role \
--role-name ${APP_NAME}-task-exec-role \
--assume-role-policy-document file://assume-role-policy-doc-for-task-exec-role.json \
--query "Role.Arn" \
--output text ) \
&& echo "${TASK_EXEC_ROLE_ARN}"

# アタッチする IAM Policy の ARN 取得
$ POLICY_ARN=$(\
aws iam list-policies \
--path-prefix "/service-role/" \
--max-items 1000 \
--query "Policies[?PolicyName == \`AmazonECSTaskExecutionRolePolicy\`].Arn" \
--output text )\
&& echo "${POLICY_ARN=$}"

# Role に Policy をアタッチ
$ aws iam attach-role-policy \
--role-name ${APP_NAME}-task-exec-role \
--policy-arn ${POLICY_ARN}

タスクロール

今回のタスクでは S3 バケットのリストを取得しているので、 S3 に対する読み取り権限があれば十分です。なので、ロールにアタッチするポリシーは、予め用意されている AmazonS3ReadOnlyAccess を使用します。

# AssumeRole Policy Document 作成
$ cat <<EOF > assume-role-policy-doc-for-task-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# IAM Role 作成
$ TASK_ROLE_ARN=$( \
aws iam create-role \
--role-name ${APP_NAME}-task-role \
--assume-role-policy-document file://assume-role-policy-doc-for-task-role.json \
--query "Role.Arn" \
--output text ) \
&& echo "${TASK_ROLE_ARN}"

# アタッチする IAM Policy の ARN 取得
$ TASK_ROLE_POLICY_ARN=$(\
aws iam list-policies \
--max-items 1000 \
--query "Policies[?PolicyName == \`AmazonS3ReadOnlyAccess\`].Arn" \
--output text )\
&& echo "${TASK_ROLE_POLICY_ARN}"

# Role に Policy をアタッチ
$ aws iam attach-role-policy \
--role-name ${APP_NAME}-task-role \
--policy-arn ${TASK_ROLE_POLICY_ARN}

タスク定義

必要なロールが揃ったので、タスク定義を作成します。タスク定義の JSON は下記のコマンドでテンプレートを確認して、今回は必要最低限の項目のみ指定します。

# タスク定義用の JSON フォーマット確認
$ aws ecs register-task-definition \
--generate-cli-skeleton
# 必要最低限の内容で task-definition.json を作成
$ cat <<EOF > task-definition.json
{
  "family": "${APP_NAME}",
  "networkMode": "awsvpc",
  "containerDefinitions": [
    {
      "name": "${APP_NAME}-app",
      "image": "${ECR_REPO_URI}",
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/dev/${APP_NAME}-task",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "dev"
        }
      }
    }
  ],
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "taskRoleArn": "${TASK_ROLE_ARN}",
  "executionRoleArn": "${TASK_EXEC_ROLE_ARN}",
  "cpu": "256",
  "memory": "512"
}
EOF

# タスク定義を作成
$ TASK_DEF_ARN=$( \
aws ecs register-task-definition \
--cli-input-json file://task-definition.json \
--query "taskDefinition.taskDefinitionArn" \
--output text) \
&& echo "${TASK_DEF_ARN}"

CloudWatch

最後に、 CloudWatch Logs のロググループを作成しておきます。

$ aws logs create-log-group \
--log-group-name /dev/${APP_NAME}-task

実行する

諸々準備が整ったので、タスクを実行します。

$ TASK_ARN=$(\
aws ecs run-task \
--cluster ${APP_NAME}-cluster \
--task-definition "${TASK_DEF_ARN}" \
--network-configuration "awsvpcConfiguration={subnets=[${SUBNET_ID}],assignPublicIp=ENABLED}" \
--launch-type FARGATE \
--query "tasks[0].taskArn" \
--output text )\
&& echo ${TASK_ARN}

確認する

実行状況を確認しつつ、実行が終わったら CloudWatch Logs でログを確認します。

ECS タスクの実行状況

# タスクの実行状況確認
$ aws ecs describe-tasks \
--tasks ${TASK_ARN} \
--cluster ${APP_NAME}-cluster \
--query "tasks[0].lastStatus" \
--output text

STOPPED になっていればタスクの実行は終わっています。

CloudWatch Logs にイベントが出力されていないとか、その他結果がおかしい場合には --query オプションを外して確認します。

CloudWatch Logs のイベント

# 最新のログストリーム名を取得
$ LOG_ST_NAME=$(\
aws logs describe-log-streams \
--log-group-name /dev/${APP_NAME}-task \
--query 'max_by(logStreams[], &lastEventTimestamp).logStreamName' \
--output text) \
&& echo "${LOG_ST_NAME}"

# ログイベント取得
$ aws logs get-log-events \
--log-group-name /dev/${APP_NAME}-task \
--log-stream-name "${LOG_ST_NAME}" \
--limit 5 \
--query 'events[].[join(``, [ to_string(timestamp) ,`: `,message])]' \
--output text
1613656782996: There are 80 S3 buckets

のような出力がされれば OK です。

まとめ

ECS タスクを作って、実行して CloudWatch でログを確認するまでを AWS CLI だけでやってみた話でした。

楽さでいうと Copilot のほうが圧倒的です。が、ネットワーク周りのリソースとか、その他 ECS でタスク実行するときに必要なリソースとその関係について理解するには、やっぱり AWS CLI は最適でした。

ECS まわりは最近になってちゃんと触ってるんですが、とりあえず動くものを Copilot でサクッと作って、その後で CLI で一つずつ作っていくっていう方法が個人的にはかなりしっくりきてます。ECS よくわからんっていう方は同じような順序で触ってみると理解しやすいかもしれません。

以上、よっしー (michimani) でした。