LaravelのECS運用のベストプラクティスを考えてみた

 

kotamat

  • kotamat
  • ROXX CTO
  • サーバー、インフラ寄り
  •  🚴‍♂️

だけkotamats

最近はあんまり主催できてない…

手伝ってくれる人いたらぜひ!

転職にまつわるサービスを展開

エージェント

転職者

求人企業

話す内容

  • ベースとなるコンテナの使い分け
  • ロギング
  • ECSならではのところ
  • GitHub ActionsでCDしてみる

ベースとなるコンテナの使い分け

Laravelの主たる機能

  • WebAPI
  • キューワーカー
  • スケジュールジョブ
  • コンソール(artisanコマンド)

WebAPI

いわゆる通常の構成

  • Nginxがリバースプロキシして
  • PHP-FPMを見に行く

ここでコンテナ1プロセスポリシー

  • 基本的に一つのコンテナには一つのプロセスだけを動かす
  • そうしないと、自前でプロセス管理をしなければならない。

Nginx,PHP-FPMそれぞれのコンテナを生成する

  • Laravelは/publicディレクトリに外だしするファイルを置いているため
  • Nginx側では/publicディレクトリのみをCOPY
  • FPM側にはルートディレクトリそのままを突っ込む

sockでつなぐかポートでつなぐか

  • PHP-FPMとNginxはphpfpm.sockで繋ぐ方法とポートで繋ぐ方法がある。
  • ECSなどはボリュームマウントがあるので、sockでつなげなくもないが、コンテナの死活を考えるとポートのほうが安全

キューワーカー

Supervisorで動かすのもいいが…

  • コンテナ1プロセス管理の原則からは外れる
  • プロセスが死んだらちゃんとコンテナとして死んだ扱いをしたほうが、他の監視ツールとの相性もいい

LaravelのベースとなるDockerfileのentrypointを柔軟にする

  • CMDの指定がない場合→PHP-FPMを起動
  • CMDにartisanがある場合→artisanを起動
  • そうすることで、一つのイメージでどちらの起動方法も使える万能イメージが作れる

entrypoint.sh

#!/bin/bash

if [[ "$1" == *artisan* ]]; then
    set -- php "$@"
else
    set -- php-fpm "$@"
fi

exec "$@"

Dockerfile

# composer installとか
# apt-getとか

RUN chmod +x /app/docker/php-fpm/entrypoint.sh

ENTRYPOINT ["/app/docker/php-fpm/entrypoint.sh"]

docker-compose.ymlでは

version: '3.1'

services:
  php-fpm:
    build:
      context: .
      dockerfile: docker/php-fpm/Dockerfile
    env_file:
      - .env
  worker:
    build:
      context: .
      dockerfile: docker/php-fpm/Dockerfile
    env_file:
      - .env
    command:
      - "artisan"
      - "queue:work"
      - "sqs"
      - "--sleep=3"
      - "--tries=3"

タスク定義(ECS)では

resource "aws_ecs_task_definition" "main" {
  container_definitions = jsonencode(
    [
      // worker
      {
        image     = "baseimage:latest"
        essential = false
        name      = "laravel-worker"
        command = [
          "artisan",
          "queue:work",
          "sqs",
          "--sleep=3",
          "--tries=3"
        ],
      }, 
      // api
      {
        image     = "baseimage:latest"
        essential = true
        name      = "laravel-api"
      } 
    ]
  )
}

こんな感じで

  • サイドカーチックにworkerを動かしてみてもいいし
  • 別Service化して別途起動するコンテナ数を制御してもいい

スケジュールジョブ

ECSを使っているなら標準のタスクスケジューリングが良い

  • CloudWatchによる定期実行
  • 設定はcron形式なので、Laravel公式の通りに設定できる
  • 前に紹介した万能Dockerfileを使えばimage使いまわしでタスク定義を作れる

多重実行防止のために

  • この方法だと前のタスクが終了しているかどうかに関わらず次のタスクが実行されてしまう。
  • 多重実行を防止したいのであれば、Laravelのキャッシュをfile以外の中央集権ストレージに移した上でwithoutOverlappingを設定する

コンソール(artisan)

コンテナ内部でどうやって実行するのか

  • イメージの作り方は前述の通り
  • とはいえコンテナに入れなければタスクは実行できない
  • 実行したいartisanコマンドごとにタスク定義作るのも気が引ける

システムマネージャーのハイブリッドアクティベーションを使う

  • 従来はオンプレサーバーにもSSMできるようにする機能
    • SSM: sshポートが空いてなくてもsshっぽいことができる。AWSのリソース権限でのハンドリングができるので権限管理がらく
  • Fargateなどは通常のSSMが使えないので、こちらで代替する

1. アクティベーション生成ロールを作成

resource "aws_iam_role" "activation_create" {
  name = "${var.service_name}-for-create-activation"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "ssm.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}
resource "aws_iam_role_policy_attachment" "activation_create" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  role       = aws_iam_role.activation_create.name
}

2. 実行ロールに1のロールへのパスをつける

resource "aws_iam_policy" "activate_code" {
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "iam:PassRole",
          "ssm:CreateActivation"
        ],
        Resource = [
          aws_iam_role.activation_create.arn
        ]
      }
    ]
  })
}
resource "aws_iam_role_policy_attachment" "use_activation" {
  policy_arn = aws_iam_policy.activate_code.arn
  role       = aws_iam_role.task_role_with_ssm.name
}

3. Laravelコンテナのentrypointで登録

if [ "$SSM_ACTIVATE" = "true" ]; then
  # task実行の場合なので、厳密にエラーを補足する
  set -e
  ACTIVATE_PARAMETERS=$(aws ssm create-activation \
    --default-instance-name "$APP_NAME-ssm" \
    --description "$APP_NAME-ssm" \
    --iam-role "$APP_NAME-for-create-activation" \
    --registration-limit 5)

  export ACTIVATE_CODE=$(echo $ACTIVATE_PARAMETERS | jq -r .ActivationCode)
  export ACTIVATE_ID=$(echo $ACTIVATE_PARAMETERS | jq -r .ActivationId)
  amazon-ssm-agent -register -code "${ACTIVATE_CODE}" \
    -id "${ACTIVATE_ID}" -region "${AWS_DEFAULT_REGION}" -y
  amazon-ssm-agent
fi

注意点

  • 当然、アプリケーションの環境変数が全て見えてしまうので、アクセスできる人は最小限にする必要がある。
  • 基本定期的に発生するものであればタスク定義化しておいて、中には入れるようにしないほうが良さそう。

ロギング

laravel.logは使わない

  • コンテナに状態をもたせてもあまりうれしくない
  • laravel.logをfluentdとかで外出ししてるならまだしも、コンテナ内部に入って確認するようなフローは前述の通りあまり望ましくない。
  • ファイル容量オーバーのようなことは避けたい。

Docker log = 標準出力

  • 標準では標準エラー、標準出力を外部のログシステムに転送する機能が備わっている
  • ECSではCloudWatch Logsに転送してくれる

logging.php

<?php

return [
    'channels' => [
        'ecs' => [
            'driver' => 'stack',
            'channels' => ['stderr', 'stdout'],
        ],
        'stdout' => [
            'driver' => 'monolog',
            'level' => 'info',
            'handler' => StreamHandler::class,
            'formatter' => env('LOG_STDOUT_FORMATTER'),
            'formatter_with' => [
                'dateFormat' => '%Y-%m-%d %H:%M:%S'
            ],
            'with' => [
                'stream' => 'php://stdout',
            ],
        ],
        'stderr' => [
            'driver' => 'monolog',
            'level' => 'critical',
            'handler' => StreamHandler::class,
            'formatter' => env('LOG_STDERR_FORMATTER'),
            'formatter_with' => [
                'dateFormat' => '%Y-%m-%d %H:%M:%S'
            ],
            'with' => [
                'stream' => 'php://stderr',
            ],
        ],
        

ECS側で複数行に渡る一つのログを一つにまとめる

[
  {
    // ... 諸々のコンテナの設定
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${awslog_group}",
        "awslogs-region": "${region}",
        "awslogs-datetime-format": "%Y-%m-%d %H:%M:%S",
        "awslogs-stream-prefix": "ecs"
      }
   }
  },
]

注意点

  • PHP-FPMから直接標準出力にストリームすると、PHP-FPMの設定によっては出力内容にプロセスの表示も出てしまう。
  • PHP7.3以降であれば `decorate_workers_output` をnoにしてあげれば通常のlaravel.logのような出力にしてくれる

デプロイ(GitHub Actions)

ベースのworkflow.yml

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

worker入れたい場合はチェインさせる

jobs:
  job:
    runs-on: ubuntu-latest
    steps:
    # ...
      - name: Change Task Definition for api
        id: render-td-api
        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: Change Task Definition for worker
        id: render-td-worker
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ steps.render-td-api.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-worker.outputs.task-definition }}
          service: ecs-service
          cluster: ecs-cluster

worker入れたい場合はチェインさせる

jobs:
  job:
    runs-on: ubuntu-latest
    steps:
    # ...
      - name: Change Task Definition for api
        id: render-td-api
        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: Change Task Definition for worker
        id: render-td-worker
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ steps.render-td-api.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-worker.outputs.task-definition }}
          service: ecs-service
          cluster: ecs-cluster

GA公式でも下記でデプロイステータスは見れるが…

jobs:
  job:
    runs-on: ubuntu-latest
    steps:
    # ...
      - name: Deploy to Amazon ECS service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-td-worker.outputs.task-definition }}
          service: ecs-service
          cluster: ecs-cluster
          wait-for-service-stability: true #←これ!

GAは従量課金なのでデプロイに時間かかると辛い

// 内部実装
const waitForService = core.getInput('wait-for-service-stability', { required: false });

// Wait for service stability
  if (waitForService && waitForService.toLowerCase() === 'true') {
    core.debug(`Waiting for the service to become stable. 
Will wait for ${waitForMinutes} minutes`);
    const maxAttempts = (waitForMinutes * 60) /
          WAIT_DEFAULT_DELAY_SEC;
    await ecs.waitFor('servicesStable', {
      services: [service],
      cluster: clusterName,
      $waiter: {
        delay: WAIT_DEFAULT_DELAY_SEC,
        maxAttempts: maxAttempts
      }
    }).promise();

ECSのデプロイはCloudWatchEventで補足できるのでSNSに流して通知可能

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
      ]
    }
  })
}

まとめ

  • 本番運用におけるプラクティスを紹介
  • 表面的な紹介が多いと思うので、わからないところは聞いてもらえると
  • 自動でのマイグレーション周りは課題なので、詳しい方教えて下さい。

文章でみたい方はこちら

https://kotamat.com/post/laravel-on-ecs/

 

環境変数の管理、マイグレーションとかも言及してます

Made with Slides.com