こちらの記事は下記ブログと同じものになります。 kotamat.com
今まではEC2上でLaravelを動かしてきたが、CVEの対応など、定期的にミドルウェアをアップデートする仕組みとして、VMレベルでのプロビジョニングをするのが大変になってきたので、Dockerコンテナ上で動く仕組みを考える必要が出てきた。
Dockerコンテナ上で動かす仕組みとしてPreview環境ではk8sを採用しているものの、メンテナンス性において社内にECSを実際に運用したことのあるメンバーがいるという観点から、安全をとってECSの採用を検討した。
ECSをLaravelで採用する上で、特に運用面にていくつか考慮しなければならない点があったので、本記事でまとめる。
TL; DR
- ログ出力のため、標準出力・標準エラーへの反映を行う
- Terraform上で.envを作成、S3にあげてからGithubActionsで取り回す
- コンテナのデプロイ後を、CloudWatch Event + Lambdaで検知する
- Batchのコンテナサービス化か、Scheduled Task + On-demand migrationか
- キューワーカーはServiceのサイドカーで起動
以下上記を解説していく
ログ
Laravel側
標準の stack
ログチャンネルにおいては、エラーログをmonologを用いた stderr へのストリーミング送信、通常ログを laravel.log へのファイル出力としている。
一方ECSでは、docker log
で出力されるログをログとして収集しているため、stderrでの出力は検知できるものの、通常のログは検知する事ができない。
下記のようにファイル出力をstdoutにリダイレクトしようと思っても、PHPがファイルを見つけられずにエラーになってしまう
FROM php:fpm-alpine # ... 諸々のプロビジョニング COPY . /var/www/html RUN ln -sf /dev/stdout /var/www/html/storage/log/laravel.log
直接Laravelからstdoutに出力する方法は下記のようになる。
return [ 'default' => env('LOG_CHANNEL', 'stack'), 'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['stderr', 'stdout'], 'ignore_exceptions' => false, ], 'stdout' => [ 'driver' => 'monolog', 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ 'stream' => 'php://stdout', ], ], 'stderr' => [ 'driver' => 'monolog', 'level' => 'error', 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ 'stream' => 'php://stderr', ], ], // ... ];
こちらにすることによって、docker logに出力されるようになるのだが、php7.2以前を使っている場合、下記のように先頭にプロセスが表示されてしまう。
php_1 | [14-Jun-2020 14:14:18] WARNING: [pool www] child 8 said into stdout: "[2020-06-14 14:14:18] local.INFO: this is info log " php_1 | [14-Jun-2020 14:14:18] WARNING: [pool www] child 8 said into stderr: "[2020-06-14 14:14:18] local.ERROR: this is exception {"exception":"[object] (Exception(code: 0): this is exception at /var/www/html/routes/web.php:18)" php_1 | [14-Jun-2020 14:14:18] WARNING: [pool www] child 8 said into stderr: "[stacktrace]"
PHP7.3以降ではPHP-FPMの設定にdecorate_workers_output
というディレクティブが生えているので、こちらをnoにすることによって表示されないようになる。
decorate_workers_output = no
ECS側
ECS側では標準のままだとログが一行ごとに出力されてしまい、スタックトレースなど複数行に渡るエラーが見えづらい。 そこで、TaskDefinitionのログ定義にて、datetime formatを指定することで、datetime formatベースでログをまとめてくれるようになる
[ { // ... 諸々のコンテナの設定 "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "${awslog_group}", "awslogs-region": "${region}", "awslogs-datetime-format": "%Y-%m-%d %H:%M:%S", "awslogs-stream-prefix": "ecs" } } }, ]
デプロイ
基本
GitHub ActionsをCDで用いている場合、GitHub Actions側では下記のようにすることで、TaskDefinitionの変更とデプロイができる
jobs: job: runs-on: ubuntu-latest steps: # ... - name: Change Task Definition id: render-td uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: ${{ steps.fetch-td.outputs.task-definition }} container-name: my-container-name image: ${{ steps.built-image.outputs.image }} - name: Deploy to Amazon ECS service uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-td.outputs.task-definition }} service: ecs-service cluster: ecs-cluster
Task Definitionに環境変数をどのように注入するのか
ここで問題になるのはTaskDefinitionの環境変数をどうするかである。 ECSを使う場合、AWSの他のサービスを使うことになると思うが、 Task Definitionで記述される情報の中には当然これらの情報に対するアクセス情報を含める必要がある。
開発環境ではLaravelと同リポジトリに .env.local
のようなものを用意しておき、Dockerfile内にて
COPY .env.local .env
のようにすれば.envを適応できるが、本番環境の機微情報をバージョン管理システムにコミットするわけには行かないため、本番運用ではこの方法は使えない。
TaskDefinitionには environmentFiles
という設定項目があり、S3に存在する.envファイルを環境変数として使用することができる
{ "environmentFiles": { "value": "<S3のobjectのarn>", "type": "s3" // s3のみサポート } }
Terraformで環境を構築しているのであれば、上記ファイルを動的に作成し、Taskロールに付与すればいい
resource "aws_s3_bucket_object" "env_file" { bucket = var.env_file_bucket key = ".env" content = <<ENV DB_CONNECTION=mysql DB_HOST=${var.db_host} DB_PORT=3306 DB_DATABASE=${var.db_name} DB_USERNAME=${var.db_username} DB_PASSWORD=${var.db_password} ENV acl = "private" } resource "aws_iam_policy" "download_env_file" { policy = <<POLICY { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": "arn:aws:s3:::${var.env_file_bucket}/.env" } ] } POLICY } resource "aws_iam_user_policy_attachment" "env_file" { policy_arn = aws_iam_policy.download_env_file.arn user = var.iam_user_name }
デプロイ後の通知
上記GitHub Actionsの例を用いると、
aws-actions/amazon-ecs-deploy-task-definition@v1
ではwait-for-service-stability
をtrueにすることによって、デプロイ完了が正常に終わったかどうかを確認することができる。
ただ、GitHubActionsは実行時間の従量課金型であるため、デプロイ方法によっては必要以上の課金が発生してしまう。
CloudWatch Event + Lambdaのログを用いることによってデプロイ状況を通知することが可能なので、そちらの紹介を行う。
Terraformにて下記のようなmoduleを作成し、
tfファイル(長いので省略)
locals { base_name = "${var.base_name}-ecs-notification" } resource "aws_cloudwatch_event_rule" "main" { name = local.base_name event_pattern = jsonencode({ "source" : [ "aws.ecs" ], "detail-type" : [ "ECS Task State Change" ], "detail" : { "clusterArn" : [ var.cluster_arn ] } }) } resource "aws_cloudwatch_log_group" "lambda_log" { name = "/aws/lambda/${local.base_name}-lambda" } resource "aws_iam_role" "lambda" { name = local.base_name assume_role_policy = jsonencode({ Version = "2012-10-17" Statement : [ { Action = "sts:AssumeRole" Principal = { Service = "lambda.amazonaws.com" } Effect = "Allow" } ] }) } resource "aws_iam_policy" "lambda" { policy = jsonencode( { Version = "2012-10-17", Statement = [ { Effect = "Allow", Action = [ "logs:CreateLogStream", "logs:PutLogEvents" ], Resource = [ aws_cloudwatch_log_group.lambda_log.arn ] } ] } ) } resource "aws_iam_role_policy_attachment" "lambda" { policy_arn = aws_iam_policy.lambda.arn role = aws_iam_role.lambda.name } data "archive_file" "lambda" { source_file = "${path.module}/lambda/index.js" output_path = "${path.module}/lambda/index.zip" type = "zip" } resource "aws_lambda_permission" "from_cw_event" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.main.function_name source_arn = aws_cloudwatch_event_rule.main.arn principal = "events.amazonaws.com" } resource "aws_lambda_function" "main" { function_name = local.base_name handler = "index.handler" role = aws_iam_role.lambda.arn runtime = "nodejs12.x" filename = data.archive_file.lambda.output_path source_code_hash = data.archive_file.lambda.output_base64sha256 environment { variables = { SLACK_CHANNEL = var.slack_channel SLACK_ICON = var.slack_icon SLACK_APP_NAME = local.base_name SLACK_HOOK_URL = var.slack_hook_url } } } resource "aws_cloudwatch_event_target" "main" { arn = aws_lambda_function.main.arn rule = aws_cloudwatch_event_rule.main.name }
以下のようなLambda関数を用意すればいい
Slack通知の関数
'use strict'; const https = require('https'); const url = require('url'); // envs const SLACK_CHANNEL = process.env.SLACK_CHANNEL const SLACK_ICON = process.env.SLACK_ICON const SLACK_APP_NAME = process.env.SLACK_APP_NAME const SLACK_HOOK_URL = process.env.SLACK_HOOK_URL exports.handler = async (event, context, callback) => { const eventDetail = event.detail if (!eventDetail) { console.log("The event is not expected", event) } const message = genNotifyMessage(eventDetail) if (!message) { return } const result = await sendSlack(message) console.log(result) } function genNotifyMessage(eventDetail) { const targetStatuses = [ { from: "PENDING", to: "RUNNING", message: `${eventDetail.group} deploy started` }, { from: "RUNNING", to: "RUNNING", message: `${eventDetail.group} deploy completed` } ] const target = targetStatuses .find(({from, to}) => from === eventDetail.lastStatus && to === eventDetail.desiredStatus) return target && target.message } function sendSlack(message) { return new Promise(((resolve, reject) => { const formData = { channel: SLACK_CHANNEL, text: message, icon_emoji: SLACK_ICON, username: SLACK_APP_NAME, }; const formDataEncripted = JSON.stringify(formData) const options = { ...url.parse(SLACK_HOOK_URL), method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(formDataEncripted) } }; const req = https.request(options, function (res) { let chunks = [] res.on('data', function (chunk) { chunks.push(chunk) }); res.on('end', function () { console.log(chunks.join('')) resolve({ body: chunks.join(''), statusCode: res.statusCode, statusMessage: res.statusMessage }) }) }); req.write(formDataEncripted); req.on('error', function (e) { console.log("Sending request error: " + e.message); reject(e) }); req.end(); })) }
非同期処理系
LaravelにはSchedule taskとJob workerがある。 通常のコンテナではこれらを適切に扱うことができないので、工夫が必要
ScheduleタスクとmigrationのためのBatchサービス
他のサービスを使わない形で手っ取り早く導入するのであればこの形が一番ラク。
要件としては下記
- 起動するコンテナ数は1
- 起動時のentrypointにてmigrationの実行と、crontabの登録を行う。
- 通常のserviceと同じタイミングでデプロイ
こうすることによって、バッチコンテナが同時に複数立ち上がることがないため、withoutOverlappingでの制御が可能となり、マイグレーションもデプロイタイミングで実行されるため、他のマネージドサービスを使う必要はない。
やり方も、LaravelのDockerfileで指定しているentrypointに下記を追加するだけ。
# 環境変数で分岐させる場合 if [[ "$BATCH" -eq "1" ]]; then apk --no-cache add sudo COUNT=0 while [[ $COUNT -lt 1 ]] do # DB接続の確認。副作用を起こしたくないので、statusチェックのみ php artisan migrate:status --database 'mysql-migrate' READY=$? if [[ $READY -eq 0 ]]; then COUNT=$((COUNT + 1)) else # migrateされていないパターンかも知れないので、installを実行 php artisan migrate:install --database 'mysql-migrate' COUNT=0 fi echo "waiting for finish init process... count: ${COUNT}, ready: ${READY}" sleep 1 done echo "migrate" php artisan migrate --database 'mysql-migrate' --force echo "* * * * * sudo -u www-data php /var/www/html/artisan schedule:run" >> /etc/crontab fi
CloudWatchを使ったScheduleTaskとGithubActionsでのマイグレーション
よりマネージドで管理する場合は、Scheduleタスクを下記ドキュメントのやり方で毎分実行するようにするとよい。 https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/scheduled_tasks.html
マイグレーションを自動で行う場合はTaskDefinitionに下記を追加する。
jobs: job: runs-on: ubuntu-latest steps: # ... - name: Run migration run: aws ecs run-task --cluster ecs-cluster --task-definition migration-td #必要なオプションは適宜追加
マイグレーションにてAlter tableを用いたマイグレーションを行っている場合、テーブルロックにより実行が止まらない可能性があるので、自動化には注意したほうがいい。
Job Workerはメインのコンテナのサイドカーで実行
Queueを用いる場合、dequeueするコンテナが必要となる。 メインのコンテナとプロセスが別れていればいいので、キューの実行に厳密性が必要なかったり、FIFOで設定されていれば、サイドカーにしてしまって問題ない。
下記のようにentrypointをartisanでも実行可能な形にしておけば
if [[ "$1" == *artisan* ]]; then set -- php "$@" else set -- php-fpm "$@" fi exec "$@"
TaskDefinitionでcommandを上書きすることでqueue worker用のコンテナを実行できる
[ { "name": "${container_name}-worker", "command": [ "artisan", "queue:work", "sqs", "--sleep=3", "--tries=3" ], // ... 諸々のコンテナの設定 } }, ]
終わりに
ECS上で本番運用する上でのトピックをいくつか紹介させてもらった。 紹介した中でもっといい方法などあればFBいただけると嬉しいです。