Laravel Octane - FrankenPHP を AWS Fargate にデプロイ・必要そうな設定を行う

[Fusic Advent Calendar 2024](https://qiita.com/advent-calendar/2024/fusic) の19日目の記事です。

7月に弊社主催イベント [Fusic Tech Live Vol.20](https://fusic.connpass.com/event/317123/) にて「[Laravel Octane を AWS Fargate で動かしてみる](https://speakerdeck.com/tutida/larvel-octane-wo-aws-fargate-dedong-kasitemiru)」という内容で発表しました。

[Laravel Octane - FrankenPHP 正式サポートになった](https://laravel-news.com/frankenphp-octane-out-of-beta)ので、改めて Fargate にデプロイしてみました。
実運用を想定した際に、行っておくと良いと考えられる設定やポイントを書き留めておきます。

## 前提
- デプロイのための ECR, タスク定義, Fargate は構築済み
    - 環境変数の設定はS3に設置し、タスク起動時にロードする
- `php artisan octane:install` で必要な設定ファイルなどはリポジトリに設定済み

## Fargate に設定する .env に必要な項目を追記

```
...
LOG_STACK=single,stderr # stderrを追加
...
OCTANE_SERVER=frankenphp
OCTANE_HTTPS=true
```

- LOG_STACK : Laravelのログを標準エラー出力し、CloudWatchLogsに転送するための設定です
- OCTANE_SERVER : FrankenPHPを利用するために追記
- OCTANE_HTTPS : ALBの背後で動かすため、LaravelはHTTPしか利用しないが、Laravelが生成でリンクをHTTPSにするため true を指定

## Dockerfile

DBは PostgreSQL を利用する想定です。

```dockerfile
FROM node:20 AS build-node
ADD . /work
WORKDIR /work
RUN npm i && npm run build && rm -rf node_modules

FROM dunglas/frankenphp:php8.3
COPY --from=build-node /work /app

RUN apt-get update \
    && apt-get install -y zip unzip git \
    && install-php-extensions \
        pcntl \
        pdo \
        pgsql \
        pdo_pgsql \
        bcmath \
        json \
        mbstring \
        tokenizer \
        xml \
        zip \
        opcache \
    && rm -rf /var/lib/apt/lists/*

COPY ./my.ini $PHP_INI_DIR/conf.d/

WORKDIR /app

ENV COMPOSER_ALLOW_SUPERUSER=1
COPY --from=composer /usr/bin/composer /usr/bin/composer

RUN composer install --no-dev
RUN php artisan octane:install --server frankenphp -n

ENTRYPOINT ["php", "artisan", "octane:start", "--host", "0.0.0.0", "--port", "80", "--admin-port", "2019", "--log-level", "info", "--caddyfile", "deploy/Caddyfile"]
```

- nodeイメージで必要なアセットをビルドした後、frankenphpが公式で提供するイメージにアセットとLaravelのコードを設置しています
- `my.ini` は各自の環境でPHPにしておきたい各設定を別ファイルで記述する想定です
- ENTRYPOINT
    - コンテナのプロセスを `octane:start` にし、アプリケーションサーバとして稼働するようにしています
    - `"--port", "80"` : 後述するタスク定義に記載するポートと合わせておく必要があります
    - `"--log-level", "info"` : デフォルトではFrankenPHPはCaddyのアクセスログを出力しません
      ログレベルを明示的に設定するとJSONでアクセスログが出力されます
      https://frankenphp.dev/docs/laravel/#laravel-octane
    - `"--caddyfile", "deploy/Caddyfile"` : ログやキャッシュの設定をしたいため、自前で用意したCaddyfile（後述）を指定しています

## タスク定義
今回はGithubActionsからデプロイを行います。
Fargateで利用するタスク定義のテンプレートは以下のようにしました。

```json
{
    "containerDefinitions": [
        {
            "name": "php",
            "image": "xxxxxxx",
            "cpu": 1024,
            "memory": 2048,
            "portMappings": [
                {
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "environment": [],
            "environmentFiles": [
                {
                    "value": "xxxxxxx",
                    "type": "s3"
                }
            ],
            "mountPoints": [],
            "volumesFrom": [],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "/fargate",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "php"
                }
            },
            "systemControls": []
        }
    ],
    "cpu": "1024",
    "memory": "2048",
    "family": "xxxxxxx",
    "taskRoleArn": "xxxxxxx",
    "executionRoleArn": "xxxxxxx",
    "networkMode": "awsvpc",
    "volumes": [],
    "placementConstraints": [],
    "tags": []
}
```

- スペック : 1Core, 2GB. ここは状況によって変えていきます。1タスク 1コンテナなのでリソース全振り出来ていいですね
- portMappings : Dockerfile で Octane に指定しているポートをマッピングしておきます
- environmentFiles : デプロイ時にロードする `.env` を置いているS3のパスを指定しておきます
- logConfiguration : awslogs を指定して、CloudWatchLogs に出力します
    - .env で LOG_STACK に stderr を追記する点と関連があります

## Caddyfile
アプリケーションサーバの設定をしたいので、自前でCaddyfileを用意しました。
`octane:start` の時に Caddyfile を指定しなければ、`vendor/laravel/octane/src/Commands/stubs/Caddyfile` のテンプレートを利用するようになっています。

テンプレートをベースに以下の対応を行いました。
- セキュリティヘッダーの追加
- 静的ファイルのキャッシュ設定
- ログに出力したくないパスを指定し除外
    - 静的ファイルへのアクセス
    - ヘルスチェックパスへのアクセス

```
{
    {$CADDY_GLOBAL_OPTIONS}

    admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}

    frankenphp {
        worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
    }
}

{$CADDY_SERVER_SERVER_NAME} {
    log {
        level {$CADDY_SERVER_LOG_LEVEL}

        # Redact the authorization query parameter that can be set by Mercure...
        format filter {
            wrap {$CADDY_SERVER_LOGGER}
            fields {
                uri query {
                    replace authorization REDACTED
                }
            }
        }
    }

    @skip path_regexp \.(js|css|png|jpe?g|gif|ico|woff|otf|ttf|eot|svg|txt|pdf|docx?|xlsx?)$
    log_skip @skip
    log_skip /up

    # Security headers
    header {
        # Prevent MIME type sniffing
        X-Content-Type-Options "nosniff"

        # Prevent iframe embedding
        X-Frame-Options "SAMEORIGIN"

        # Send the full URL for same-origin requests, only the origin for HTTPS cross-origin requests, and nothing for HTTP cross-origin requests.
        Referrer-Policy "no-referrer-when-downgrade"
    }

    @immutable_assets {
        path /build/* /vendor/*
    }
    header @immutable_assets Cache-Control "public, max-age=31536000, immutable"

    route {
        root * "{$APP_PUBLIC_PATH}"
        encode zstd br gzip

        # Mercure configuration is injected here...
        {$CADDY_SERVER_EXTRA_DIRECTIVES}

        php_server {
            index frankenphp-worker.php
            # Required for the public/storage/ directory...
            resolve_root_symlink
        }
    }
}

```

- 参考にさせていただいたドキュメント・Issue
   - https://caddyserver.com/docs/caddyfile/directives/log_skip
   - https://github.com/dunglas/frankenphp/issues/1227

## Github Actions でデプロイ

aws-actions が提供するアクションを利用してデプロイを行います。
AWSへの認証にはIAMロールを別途設定しています。
[Use IAM roles to connect GitHub Actions to actions in AWS](https://aws.amazon.com/jp/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/)

前述のDockerfile, タスク定義のパスを設定し、適宜環境に合わせて書き換えます。
指定のブランチにマージして無事デプロイが終われば、Laravel Octane がFaragate上で動いているはずです！

```yaml
name: Deploy Fargate

on:
  push:
    branches:
      - deploy

jobs:
  deploy:
    name: Deploy
    permissions:
      id-token: write
      contents: read
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: xxxxxxx
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-docker-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-docker-

      - name: Build & TAG PHP
        id: build-php
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker buildx create --use
          docker buildx build \
            --cache-from=type=local,src=/tmp/.buildx-cache \
            --cache-to=type=local,dest=/tmp/.buildx-cache \
            --file Dockerfile \
            -t $ECR_REGISTRY/xxxxxxx/php \
            --push .
          echo "image=$ECR_REGISTRY/xxxxxxx/php" >> $GITHUB_OUTPUT

      - name: Fill in the new PHP image ID in task definition
        id: render-app-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ./task_definition.json
          container-name: php
          image: ${{ steps.build-php.outputs.image }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render-app-container.outputs.task-definition }}
          service: xxxxxxx
          cluster: xxxxxxx
          wait-for-service-stability: true
```

## CloudWatchLogsでみると
FrankenPHP(Caddy)が出力したJSONのアクセスログが確認できます。
JSON形式なので Insights での検索とも相性が良く便利です。

![2024-12-18_18h41_38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74129/11f8e89f-9040-40e3-ccdd-99212e7c04ae.png)

## その他の調整
### アプリケーションログもJSON化したい
CaddyのログがJSONなので、アプリケーションログもJSONの方が後々使いやすいです
[Laravel で、エラーログを JSON で出力しつつ Stack trace を含めたい](https://hacktk.net/entries/2020-12-08-laravel-error-log-json-including-stack-trace/)
こちらの記事を参考にさせていただき stderr の出力をすべてJSONにしました


### （Inertiaの場合）ブラウザタブのアプリ名が `Laravel` のまま
特殊なことをしていなければ、Inertiaのブラウザタブは `resources/js/app.tsx` の以下の場所で設定されます。

```ts
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
```

今回の僕の作りの問題ですが、Dockerfileでビルドするタイミングでは .env がない状態のため、VITE_APP_NAMEが存在せず Laravel が出力されてしまいます。
本番稼働のアプリであれば、ここのデフォルト値にアプリ名を入れておけばいいかなと思います。

## 終わり
今回の環境でシステムテスト、負荷検証などを行い本運用に移行予定です。
追加で必要になった調整や知見があれば、追記/別記事などで共有していけれたらと思います！