ECS Exec で実行した作業ログを CloudWatch Logs へリアルタイム転送したい

記事タイトルとURLをコピーする

みなさん、こんにちは。AWS CLI が好きなテクニカルサポート課の市野です。

お客様からいただいたお問い合わせへの返答の前に検証環境で試して確認した内容を記載します。

お問い合わせの背景

実施されたいこと

ECS タスクに対し、ECS Exec を利用し接続した際に、nohup(& 付与) を用いてバックグラウンドで実行するアプリケーションのログを CloudWatch Logs へリアルタイムで出力できないか?
ちなみに、お客様内部で、/dev/stdout に出力するようにバックグラウンドで実行させたログは CloudWatch Logs への転送が行われなかったことを確認しているとのこと。

背景

ECS Exec では、aws ecs execute-command サブコマンドを実行時に払い出されるセッション ID ごとに CloudWatch Logs へログが転送され、セッションの終了(exit で抜ける)までログの転送ができない点が求めている要件と異なっている。

そもそも、ECS Exec とは?

ECS Exec については、弊社島村が詳しく書いてくれているブログがあるので合わせてご紹介します。

blog.serverworks.co.jp

まとめ

少しエントリが長くなりますので、結論から先に…。

  • 可否で言えば、「できなくはない」

    • 実行としては nohup ${COMMAND} > /proc/1/fd/1 & の要領で /proc/1/fd/1 にログ出力を行うことで実現はできる。
      /dev/stdout/proc/self/fd/1 にシンボリックリンクされているが、おそらくコンテナ内部でログドライバーが収集対象としているログは PID 1 で動作しているプロセスのみを対象としていることが推察され、ECS Exec で発生したセッションでは、プロセス ID が 1 とならないため、単純に /dev/stdout に対してログ出力を行うようにバックグラウンドで処理を実行しても CloudWatch Logs へ転送されなかったものと推察される。)
  • 是非で言えば、「うーん」と言ったところ。

    • あくまで緊急的な調査などでは実行できなくはないですが、本来、タスクで動かしているプロセス用のログ出力に相乗りさせてしまう形となる(apache だったらアクセスログと混在する)ため、本来用途のログが汚染される(ノイズとなる)懸念がある。
    • CloudWatch Logs ではログ出力行ごとに課金が発生してしまうため、コストがかかりそう。

検証(まずは通常の ECS Exec が実行できるまで)

0. 前提(環境)

実行環境は AWS CloudShell を利用しました。

OS

cat /etc/os-release 
NAME="Amazon Linux"
VERSION="2"
ID="amzn"
ID_LIKE="centos rhel fedora"
VERSION_ID="2"
PRETTY_NAME="Amazon Linux 2"
ANSI_COLOR="0;33"
CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2"
HOME_URL="https://amazonlinux.com/"

シェル

bash --version
GNU bash, version 4.2.46(2)-release (x86_64-koji-linux-gnu)
Copyright (C) 2011 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

0. (事前準備)変数の設定

以降の作業で利用する変数を設定しておきます。

# コンテナのイメージとして公式の httpd を利用します。その他命名部分などでそのまま利用します。
APP_NAME=httpd

# CloudWatch Logs に環境を付与する想定です。
ENV=test

# 実行する AWS アカウント ID を取得します
AWS_ACCOUNT_ID=$( \
  aws sts get-caller-identity \
    --query '[Account]' \
    --output text)

# ECS タスクを実行するサブネットID(今回は決め打ちで直接サブネット ID を変数に入れています。)
SUBNET_ID=subnet-xxxxxxxxxxxxxxxxx

# タスク実行ロールに関連する変数
TASK_EXEC_ROLE=${APP_NAME}-task-exec-role
TASK_EXEC_TRUSTED_ENTITY_DOCUMENT=${TASK_EXEC_ROLE}-trusted-entity.json

# タスクロールに関連する変数
TASK_ROLE=${APP_NAME}-task-role
TASK_TRUSTED_ENTITY_DOCUMENT=${TASK_ROLE}-trusted-entity.json
TASK_ROLE_POLICY_DOCUMENT=${TASK_ROLE}.json

# タスク定義作成用ドキュメント
TASK_DEFINITION=${APP_NAME}-task-definition.json

1. ECS クラスター作成

aws ecs create-cluster \
  --cluster-name ${APP_NAME}-cluster \
  --configuration executeCommandConfiguration="{ \
    logging=OVERRIDE, \
    logConfiguration={ \
      cloudWatchLogGroupName=/${ENV}/${APP_NAME}, \
      s3BucketName=${APP_NAME}-${AWS_ACCOUNT_ID}, \
      s3KeyPrefix=${ENV} \
    } \
  }"

2. タスク実行ロール作成

信頼されたエンティティ登録用設定ファイル作成

cat << EOF > ${TASK_EXEC_TRUSTED_ENTITY_DOCUMENT}
{
    "Version": "2008-10-17",
    "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
    ]
}
EOF

タスク実行ロールの作成

TASK_EXEC_ROLE_ARN=$( \
  aws iam create-role \
  --role-name ${TASK_EXEC_ROLE} \
  --assume-role-policy-document file://${TASK_EXEC_TRUSTED_ENTITY_DOCUMENT} \
  --query "Role.Arn" \
  --output text)

マネージドポリシー AmazonECSTaskExecutionRolePolicy の ARN を取得

POLICY_ARN=$(\
  aws iam list-policies \
    --path-prefix "/service-role/" \
    --max-items 1000 \
    --query "Policies[?PolicyName == \`AmazonECSTaskExecutionRolePolicy\`].Arn" \
    --output text)

タスク実行ロールへのポリシーのアタッチ

aws iam attach-role-policy \
  --role-name ${TASK_EXEC_ROLE} \
  --policy-arn ${POLICY_ARN}

3. タスクロール作成

信頼されたエンティティ登録用設定ファイル作成

cat << EOF > ${TASK_TRUSTED_ENTITY_DOCUMENT}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

タスクロール作成

TASK_ROLE_ARN=$( \
  aws iam create-role \
    --role-name ${TASK_ROLE} \
    --assume-role-policy-document file://${TASK_TRUSTED_ENTITY_DOCUMENT} \
    --query "Role.Arn" \
    --output text )

IAM ポリシーの作成

cat << EOF > ${TASK_ROLE_POLICY_DOCUMENT}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetEncryptionConfiguration"
      ],
      "Resource": "arn:aws:s3:::${APP_NAME}-${AWS_ACCOUNT_ID}"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::${APP_NAME}-${AWS_ACCOUNT_ID}/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:DescribeLogGroups"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogStream",
        "logs:DescribeLogStreams",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:ap-northeast-1:${AWS_ACCOUNT_ID}:log-group:/${ENV}/${APP_NAME}:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ssmmessages:CreateControlChannel",
        "ssmmessages:CreateDataChannel",
        "ssmmessages:OpenControlChannel",
        "ssmmessages:OpenDataChannel"
      ],
      "Resource": "*"
    }
  ]
}
EOF

タスクロール用のポリシー作成

TASK_ROLE_POLICY_ARN=$( \
  aws iam create-policy \
  --policy-name "${APP_NAME}-task-role-policy" \
  --path "/sample/" \
  --policy-document file://${TASK_ROLE_POLICY_DOCUMENT} \
  --query "Policy.Arn" \
  --output text)

タスクロールを作成

aws iam attach-role-policy \
  --role-name ${APP_NAME}-task-role \
  --policy-arn ${TASK_ROLE_POLICY_ARN}

4. タスク定義作成

タスク定義設定ファイルの作成

cat << EOF > ${TASK_DEFINITION}
{
  "family": "${APP_NAME}",
  "networkMode": "awsvpc",
  "containerDefinitions": [
    {
      "name": "${APP_NAME}-app",
      "image": "${APP_NAME}",
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/${ENV}/${APP_NAME}-task",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "${ENV}"
        }
      }
    }
  ],
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "taskRoleArn": "${TASK_ROLE_ARN}",
  "executionRoleArn": "${TASK_EXEC_ROLE_ARN}",
  "cpu": "256",
  "memory": "512"
}
EOF

タスク定義の作成

TASK_DEFINITION_ARN=$( \
  aws ecs register-task-definition \
    --cli-input-json file://${TASK_DEFINITION} \
    --query "taskDefinition.taskDefinitionArn" \
    --output text)

5. アプリケーションログ用の ロググループ作成

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

5. ExecuteCommand ログ用のロググループ、 S3 バケット作成

S3 バケットの作成

aws s3 mb "s3://${APP_NAME}-${AWS_ACCOUNT_ID}"

CloudWatch Logs ロググループの作成

aws logs create-log-group \
  --log-group-name "/${ENV}/${APP_NAME}"

6. タスク実行

ECS Exec を実施したいので --enable-execute-command オプションを付けて ecs run-task を実行します。

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

7. タスクへの接続試行

タスクに割り当てられた パブリック IP の取得します。

TASK_GLOBAL_IP_ADDRESS=$(\
  aws ec2 describe-network-interfaces \
  --network-interface-ids $( \
    aws ecs describe-tasks \
    --tasks ${TASK_ARN} \
    --cluster ${APP_NAME}-cluster \
    --query "tasks[0].attachments[0].details[?name=='networkInterfaceId'].value" \
    --output text) \
  --query "NetworkInterfaces[0].PrivateIpAddresses[0].Association.PublicIp" \
  --output text)

curl コマンドで httpd タスクのレスポンスを確認

curl ${TASK_GLOBAL_IP_ADDRESS}
# 以下のように返却されることを確認する
<html><body><h1>It works!</h1></body></html>

ECS Exec でコマンド実行し ECS タスクへ接続する

aws ecs execute-command \
  --cluster "${APP_NAME}-cluster" \
  --task "${TASK_ARN}" \
  --command "/bin/bash" \
  --interactive
コマンド実行結果
# 以下のような表示になり、セッション ID の表示後、対象のタスクのプロンプトとなることを確認する
The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.

Starting session with SessionId: ecs-execute-command-xxxxxxxxxxxxxxxxx
root@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-xxxxxxxxxx:/usr/local/apache2#

タスク内でコマンドを実行してみます。

ls コマンドを実行して httpd の index.html を確認し、index.html の中身を書き換えてみます。

root@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-xxxxxxxxxx:/usr/local/apache2# ls -l htdocs/index.html
-rw-r--r-- 1 501 staff 45 Jun 11  2007 htdocs/index.html
root@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-xxxxxxxxxx:/usr/local/apache2# echo "<html><body><h1>Hello World</h1></body></html>" > htdocs/index.html
root@3e7fa3f2dde84d9b8c34a393835816e5-1546680777:/usr/local/apache2# exit
exit


Exiting session with sessionId: ecs-execute-command-xxxxxxxxxxxxxxxxx.

curl コマンドで httpd タスクのレスポンスを再確認

curl ${TASK_GLOBAL_IP_ADDRESS}
# 以下のように返却されることを確認する
<html><body><h1>Hello World</h1></body></html>

8. 最新のログストリーム名を取得

以下のようにコマンドを実行すると、先ほどの ECS Exec セッション ID 名そのままのログストリーム名が取得されます。

LOG_STREAM_NAME=$( \
  aws logs describe-log-streams \
  --log-group-name /${ENV}/${APP_NAME} \
  --query 'max_by(logStreams[], &lastEventTimestamp).logStreamName' \
  --output text) \
  && echo ${LOG_STREAM_NAME}
ecs-execute-command-0162a52befb2593fc

9. ログストリームを指定してログイベントを取得

aws logs get-log-events \
  --log-group-name /${ENV}/${APP_NAME} \
  --log-stream-name "${LOG_STREAM_NAME}" \
  --limit 5 \
  --query 'events[].[join(``, [ to_string(timestamp) ,`: `,message])]' \
  --output text

上記コマンド実行後、以下のようにログイベントが取得できます。
出力内容からも分かるとおり、ECS Exec で COMMAND として投げられた処理中で起こった処理内容がひとまとめに出力されていることが確認できました。

1696320732442: Script started on 2023-10-03 08:12:11+00:00 [COMMAND="cat /var/lib/amazon/ssm/12e0012e89cd4b7ab2402b88e9fadae2-1546680777/session/orchestration/ecs-execute-command-0162a52befb2593fc/InteractiveCommands/ipcTempFile.log" <not executed on terminal>]
root@12e0012e89cd4b7ab2402b88e9fadae2-1546680777:/usr/local/apache2# ^M^Mroot@12e0012e89cd4b7ab2402b88e9fadae2-1546680777:/usr/local/apache2# ls -l htdocs/index.html
^M-rw-r--r-- 1 501 staff 45 Jun 11  2007 htdocs/index.html
root@12e0012e89cd4b7ab2402b88e9fadae2-1546680777:/usr/local/apache2# echo "<html><body><h1>Hello World</h1></body></html>" > htdocs/index.html
^Mroot@12e0012e89cd4b7ab2402b88e9fadae2-1546680777:/usr/local/apache2# exit
^Mexit

Script done on 2023-10-03 08:12:11+00:00 [COMMAND_EXIT_CODE="0"]

ExecuteCommand ログ用の S3 バケットを確認してみても、ECS Exec セッション ID 名がそのままオブジェクト名となっているログが格納されていることを確認できました。

aws s3 ls "s3://${APP_NAME}-${AWS_ACCOUNT_ID}"/${ENV}/
2023-10-03 08:12:12        820 ecs-execute-command-0162a52befb2593fc.log

検証(ここからが本番)

冒頭で述べたように、--enable-execute-command オプションをつけて起動した ECS タスクに対し、aws ecs execute-command サブコマンドで接続した場合、発生したセッションが閉じられてから、CloudWatch Logs へのログ転送が行われることが上記までの検証でも確認ができました。

ここからお客様のご要件に合うようなリアルタイムの出力ができるか試してみます。

検証10. リアルタイムで CloudWatch Logs へ出力できるのか?

再び ECS Exec を用いてタスクの中に入って確認をしてみます。

ls -l /dev/stdout
lrwxrwxrwx 1 root root 15 Oct  3 08:09 /dev/stdout -> /proc/self/fd/1

Docker のドキュメントでも、httpd では STDOUT や STDERR に出力するとありますが、/dev/stdout/proc/self/fd/1 のシンボリックリンクとなっていることが確認できました。

docs.docker.com

The official httpd driver changes the httpd application's configuration to write its normal output directly to /proc/self/fd/1 (which is STDOUT) and its errors to /proc/self/fd/2 (which is STDERR). See the Dockerfile.

ls -l /proc/self/fd/1
lrwx------ 1 root root 64 Oct  3 08:23 /proc/self/fd/1 -> /dev/pts/0

そして、/proc/self/fd/1/dev/pts/0 のシンボリックリンクとなっていることを確認しました。
ps コマンドがないため、インストールして tty の状況を確認してみます。

apt update && apt install -y procps
ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.1   5844  4744 ?        Ss   08:09   0:00 httpd -DFOREGROUND
www-data     8  0.0  0.1 1265904 4080 ?        Sl   08:09   0:00 httpd -DFOREGROUND
www-data     9  0.0  0.0 1265848 3600 ?        Sl   08:09   0:00 httpd -DFOREGROUND
www-data    10  0.0  0.1 1265904 4072 ?        Sl   08:09   0:00 httpd -DFOREGROUND
root        92  0.0  0.3 1255116 15240 ?       Ssl  08:09   0:00 /managed-agents/execute-command/amazon-ssm-agent
root       106  0.0  0.6 1416492 24252 ?       Sl   08:09   0:00 /managed-agents/execute-command/ssm-agent-worker
root       144  0.2  0.5 1335052 23536 ?       Sl   08:22   0:00 /managed-agents/execute-command/ssm-session-worker ecs-execute-command-0f6b205264ba2e432
root       153  0.0  0.0   4608  3756 pts/0    Ss   08:22   0:00 /bin/bash
root       351  0.0  0.1   8480  4200 pts/0    R+   08:28   0:00 ps aux

ps コマンドの結果から、ECS Exec で実行した /bin/bash は検証時点では PID 153 となっていることを確認しました。

合わせて amazon-ssm-agentssm-session-worker がそれぞれ動いていて、ECS Exec で発生しているセッションは ssm-session-worker が担っていてこのセッションが切れた時に、ExecuteCommand ログ用のロググループに転送されるものと推察しました。

ここで、コンテナのアプリケーションログは CloudWatch Logs へ出力されているのだから、httpd のプロセスのファイルディスクプリタに送ってみたらどうなるか試してみることとして PID 1 のファイルディスクリプタへログを出力するように実行をしてみます。

nohup tail -f /var/log/amazon/ssm/amazon-ssm-agent.log > /proc/1/fd/1 &

すると、仮説の通り、httpd のログと混在する形とはなりましたが、CloudWatch Logs へリアルタイムでのログ転送ができることを確認しました。

おわりに

冒頭のまとめにも記載しましたが、/proc/1/fd/1 にログ出力を行うことで実現はできるため、事前にコンテナ内部で特別な構築をしていない状況下の緊急時に CloudWatch Logs へリアルタイムにログを流すことはできなくはないと思われます。

ただ、/proc/1/fd/1 には本来用途のログ(今回の検証では httpd のアクセスログ)が出力されるため、双方のログが混在してしまう状況になるかとは考えられます。

常用することは望ましくないように思われますが、今回のエントリがどなたかの参考になれば幸いです。

ではまた。

市野 和明 (記事一覧)

マネージドサービス部・テクニカルサポート課

お客様から寄せられたご質問や技術検証を通じて得られた気づきを投稿していきます。

情シスだった前職までの経験で、UI がコロコロ変わる AWS においては GUI で手順を残していると画面構成が変わってしまって後々まごつくことが多かった経験から、極力変わりにくい AWS CLI での記事が多めです。

X(Twitter):@kazzpapa3(AWS Community Builder)