今までは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がファイルを見つけられずにエラーになってしまう

1
2
3
4
5
6
FROM php:fpm-alpine

# ... 諸々のプロビジョニング

COPY . /var/www/html
RUN ln -sf /dev/stdout /var/www/html/storage/log/laravel.log

直接Laravelからstdoutに出力する方法は下記のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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以前を使っている場合、下記のように先頭にプロセスが表示されてしまう。

1
2
3
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にすることによって表示されないようになる。

1
decorate_workers_output = no

ECS側

ECS側では標準のままだとログが一行ごとに出力されてしまい、スタックトレースなど複数行に渡るエラーが見えづらい。 そこで、TaskDefinitionのログ定義にて、datetime formatを指定することで、datetime formatベースでログをまとめてくれるようになる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[
  {
    // ... 諸々のコンテナの設定
    "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の変更とデプロイができる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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内にて

1
COPY .env.local .env

のようにすれば.envを適応できるが、本番環境の機微情報をバージョン管理システムにコミットするわけには行かないため、本番運用ではこの方法は使えない。

TaskDefinitionには environmentFiles という設定項目があり、S3に存在する.envファイルを環境変数として使用することができる

1
2
3
4
5
6
{
  "environmentFiles": {
    "value": "<S3のobjectのarn>",
    "type": "s3" // s3のみサポート
  }
}

Terraformで環境を構築しているのであれば、上記ファイルを動的に作成し、Taskロールに付与すればいい

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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ファイル(長いので省略)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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通知の関数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
'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に下記を追加するだけ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 環境変数で分岐させる場合
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に下記を追加する。

1
2
3
4
5
6
7
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でも実行可能な形にしておけば

1
2
3
4
5
6
7
if [[ "$1" == *artisan* ]]; then
    set -- php "$@"
else
    set -- php-fpm "$@"
fi

exec "$@"

TaskDefinitionでcommandを上書きすることでqueue worker用のコンテナを実行できる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[
  {
    "name": "${container_name}-worker",
    "command": [
      "artisan",
      "queue:work",
      "sqs",
      "--sleep=3",
      "--tries=3"
    ],
    // ... 諸々のコンテナの設定
   }
  },
]

終わりに

ECS上で本番運用する上でのトピックをいくつか紹介させてもらった。 紹介した中でもっといい方法などあればFBいただけると嬉しいです。