Webアプリケーションコンテナ（Laravel）からLocalStackコンテナのS3にファイルをアップロードできるようにする

今参画しているプロジェクトでは、ファイルアップロードの機能があり、そのアップロード先が`S3`でした。

その機能を実装した人はもうプロジェクトにいないので、ローカル環境でどのように動作確認をしていたのかは謎なのですが、「ローカル環境でも`S3`アップロードの動作ができるようにしたいな～」と思い立ったので`LocalStack`を使ってあれこれやってみたらできたので、自分のための備忘録として記載しておきます。

https://www.localstack.cloud/

ちなみに`AWS`、`LocalStack`に関してはズブの素人です。`chatGPT`さんのお世話になりながらやったらできました（小並）。

以下の環境で動作確認を行いました。

```
(WSL)
WSL バージョン: 1.2.5.0
カーネル バージョン: 5.15.90.1
Ubuntu 20.04.6 LTS
(Docker)
Docker version 24.0.2
Docker Compose version v2.18.1
(PHP)
PHP 8.2.8
Laravel Framework 10.16.1
```

# コンテナの用意
## Webアプリケーションコンテナを用意する

Webアプリケーションコンテナを用意します。今回は自分が使い慣れている`Laravel`が動くコンテナを用意します。

以下の`Docker`環境を利用させていただきました:bow_tone1:

https://github.com/ucan-lab/docker-laravel

この時点の`docker-compose.yml`は以下です。

<details><summary>docker-compose.yml</summary>

`web`コンテナのポート80番がぶつかったので、`8080`と変更した以外は参照リポジトリのままです。

```yaml:docker-compose.yml
volumes:
  db-store:
  psysh-store:

configs:
  db-config:
    file: ./infra/docker/mysql/my.cnf

services:
  app:
    build:
      context: .
      dockerfile: ./infra/docker/php/Dockerfile
      target: ${APP_BUILD_TARGET:-development}
    volumes:
      - type: bind
        source: ./src
        target: /workspace
      - type: volume
        source: psysh-store
        target: /root/.config/psysh
        volume:
          nocopy: true
    environment:
      - APP_DEBUG=${APP_DEBUG:-true}
      - APP_ENV=${APP_ENV:-local}
      - APP_URL=${APP_URL:-http://localhost}
      - LOG_CHANNEL=${LOG_CHANNEL:-stderr}
      - LOG_STDERR_FORMATTER=${LOG_STDERR_FORMATTER:-Monolog\Formatter\JsonFormatter}
      - DB_CONNECTION=${DB_CONNECTION:-mysql}
      - DB_HOST=${DB_HOST:-db}
      - DB_PORT=${DB_PORT:-3306}
      - DB_DATABASE=${DB_DATABASE:-laravel}
      - DB_USERNAME=${DB_USERNAME:-phper}
      - DB_PASSWORD=${DB_PASSWORD:-secret}

  web:
    build:
      context: .
      dockerfile: ./infra/docker/nginx/Dockerfile
    ports:
      - target: 8080
        published: ${WEB_PUBLISHED_PORT:-8080}
        protocol: tcp
        mode: host
    volumes:
      - type: bind
        source: ./src
        target: /workspace

  db:
    build:
      context: .
      dockerfile: ./infra/docker/mysql/Dockerfile
    ports:
      - target: 3306
        published: ${DB_PUBLISHED_PORT:-3306}
        protocol: tcp
        mode: host
    configs:
      - source: db-config
        target: /etc/my.cnf
    volumes:
      - type: volume
        source: db-store
        target: /var/lib/mysql
        volume:
          nocopy: true
    environment:
      - MYSQL_DATABASE=${DB_DATABASE:-laravel}
      - MYSQL_USER=${DB_USERNAME:-phper}
      - MYSQL_PASSWORD=${DB_PASSWORD:-secret}
      - MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-secret}

  mailpit:
    image: axllent/mailpit
    ports:
      - target: 8025
        published: ${MAILPIT_PUBLISHED_PORT:-8025}
        protocol: tcp
        mode: host
```

</details>

また、ディレクトリ構成はこのようになっています。`infra/docker`ディレクトリ以下に各コンテナごとの設定ファイルがあります。
![screenshot_02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961694/db12e6aa-5762-b47e-5947-5c146685b4d5.jpeg)


## LocalStackのコンテナを追加する

先程の`docker-compose.yml`に[公式](https://docs.localstack.cloud/getting-started/installation/#docker-compose)を参考にしつつ、`LocalStack`のコンテナを追加します。

```diff_yaml:docker-compose.yml(追記)
+  aws:
+    build: 
+      context: .
+      dockerfile: ./infra/docker/aws/Dockerfile
+    ports:
+      - "127.0.0.1:4566:4566"            # LocalStack Gateway
+      - "127.0.0.1:4510-4559:4510-4559"  # external services port rang
+    environment:
+      - DEBUG=${DEBUG-}
+    volumes:
+      - "./infra/docker/aws/s3/scripts:/etc/localstack/init/ready.d"
+      - "${LOCALSTACK_VOLUME_DIR:-./infra/docker/aws/volume}:/var/lib/localstack"
```

なお`Lambda`を使用する場合は`/var/run/docker.sock`をボリュームとしてマウントする必要がある[^1]、とのことですが今回`Lambda`は使用しないためマウントしていません。

`volumes`の`./infra/docker/aws/s3/script`については後述します。

[^1]: Mounting the Docker socket `/var/run/docker.sock` as a volume is required for the Lambda service. 「[Getting Started
 / Installation / Starting LocalStack with Docker-Compose](https://docs.localstack.cloud/getting-started/installation/#docker-compose)」より

### LocalStack用のディレクトリを用意

先程の`infra/docker`ディレクトリ以下に、`aws`ディレクトリを用意します。

```sh
cd infra/docker
mkdir aws
```

`aws`ディレクトリには`s3/scripts`と`volume`ディレクトリを用意しておきます。また、`s3/scripts`には`init.sh`も用意します。

```sh
cd aws
mkdir -p s3/scripts
mkdir volume
touch s3/scripts/init.sh
# init.shが実行権を持っているか確認してください
ls -al s3/scripts/init.sh
# 実行権を持っていなかった場合は実行権が必要です（以下はrootユーザー権限で実行）
chmod u+x s3/scripts/init.sh
```

今ディレクトリはこのようになっています。赤枠が追加した部分です。

![screenshot_03.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961694/d99b5f2c-19cf-278f-9cb6-3fd27d0caced.jpeg)

### AWSリソースの初期化スクリプトを作成する

以下で紹介されていたやりかたを参考にし、AWSリソース（S3）の初期化スクリプトを作成します。

https://zenn.dev/horitaka/articles/localstack-docker

先程の作成した`init.sh`にこのように追記しました。
今回はバケット直下ではなく、その下にディレクトリ（キー）を作ってアップロードしたいためその設定も追加しています。

```sh:init.sh
#!/bin/bash

readonly BUCKET_NAME="sample-bucket"
# S3にバケットを作成してファイルを初期化
awslocal s3 mb "s3://${BUCKET_NAME}"
awslocal s3 ls "s3://${BUCKET_NAME}"

# ファイルアップロード先作成
awslocal s3api put-object --bucket "${BUCKET_NAME}" --key uploads/

# AWS CLIのプロファイルを設定
aws configure --profile localstack <<EOF
dummy          # AWS Access Key ID
dummy          # AWS Secret Access Key
us-east-1      # Default region name
json           # Default output format
EOF
```

`docker-compose.yml`の`app`コンテナの`environment`に`AWS`関連の設定も追加しておきます。

```diff_yaml:docker-compose.yml(追記)
services:
  app:
    # 省略
    environment:
      # 省略
      - DB_PASSWORD=${DB_PASSWORD:-secret}
+      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-dummy}
+      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-dummy}
+      - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
+      - AWS_BUCKET=${AWS_BUCKET:-sample-bucket}
+      - AWS_USE_PATH_STYLE_ENDPOINT=${AWS_USE_PATH_STYLE_ENDPOINT:-true}
```

`docker compose`コマンドで`build`と`up`に成功し、`localstack`（今回は`aws`と指定したので`aws`と付く名前のコンテナ）コンテナが起動していたらひとまず成功です:smile:

[こちらのREADME.md](https://github.com/ucan-lab/docker-laravel/blob/main/README.md)を参考に、`Laravel`をインストールし、ブラウザから`Laravel`のwelcomeページが確認できるようにしておきます。

# ファイルをLocalStackコンテナのS3にアップロードできるようにする

以降は`Laravel`のwelcomeページが確認できるようになっている前提で進みます。

## AWS SDK for PHP のインストール

まず`AWS SDK for PHP`をインストールします。

```sh
composer require aws/aws-sdk-php
```
<details>
<summary>インストールされたSDKのバージョンが2系だった場合</summary>

自分が↑このコマンドだけでインストールを試みたところ、SDKのバージョンが2系だったので、`composer.json`でバージョンを指定し（`guzzlehttp`もバージョンの指定が必要でした）、`composer update`したら成功しました:rolling_eyes:

```diff_json:composer.json
{
    "require": {
        "php": "^8.1",
+        "aws/aws-sdk-php": "^3.0",
+        "guzzlehttp/guzzle": "^6.0|^7.0",
+        "guzzlehttp/psr7": "^1.0|^2.0",
+        "guzzlehttp/promises": "^1.4.0",
        "laravel/framework": "^10.10",
        "laravel/sanctum": "^3.2",
        "laravel/tinker": "^2.8"
    },
}
```

```sh
composer update
```
</details>

## `.env`ファイルの編集

ローカル環境でアップロードができるように、`.env`のAWS関連の環境変数を以下のようにします。

```.env
AWS_ACCESS_KEY_ID=dummy
AWS_SECRET_ACCESS_KEY=dummy
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=sample-bucket
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_ENDPOINT=http://aws:4566
```

`AWS_BUCKET`の値は自身が設定したバケット名にし、`AWS_ENDPOINT`の`aws`の部分は、`LocalStack`コンテナを他の名前にしていたら、その名前にしてください（`http://localhost:4566`だと上手くいかないです。ちなみにそれになかなか気付けずに私は沼りました:sweat:）。

また`config/aws.php`を作成し、これらの環境変数をセットして`config`から読み込めるようにしておきます。

<details>
<summary>config/aws.php</summary>

```php:config/aws.php
<?php

return [
    'access_key_id' => env('AWS_ACCESS_KEY_ID'),
    'secret_access_key' => env('AWS_SECRET_ACCESS_KEY'),
    'default_region' => env('AWS_DEFAULT_REGION'),
    'bucket' => env('AWS_BUCKET'),
    'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT'),
    'endpoint' => env('AWS_ENDPOINT'),
];
```

Laravelでは`config:cache`コマンドを使用して設定ファイルをキャッシュします。
一度設定がキャッシュされると`.env`ファイルを読み込まなくなるため、`env`関数は`config`の中でのみ使用しなくてはいけません[^2]。

[^2]:[Configuration - Laravel 10.x - The PHP Framework For Web Artisans](https://laravel.com/docs/10.x/configuration#configuration-caching)


</details>

## フォームの用意

ファイルをアップロードするだけの`view`を用意しておきます。

![screenshot_04.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961694/97e8427f-bc9c-50a3-4ae2-55181e8ac0ed.jpeg)

```php:fileUpload.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    {{-- CSS部分は省略 --}}
    <title>Document</title>
</head>
<body>
    <h1>ファイルをアップロードする</h1>
    <form action="{{ route('upload') }}" method="post" enctype="multipart/form-data" name="uploadForm">
        @csrf
        <input type="file" name="uploadFile">
        <button type="submit">送信</button>
    </form>
</body>
</html>
```

アップロード時の処理をするURLも設定しておきます。
```php:web.php
Route::post('upload', [FileUploadController::class, 'create'])->name('upload');
```

## アップロード処理を行うController

とりあえずのサンプルなのでバリデーションチェックなし、アーキテクチャとかも気にせず`Controller`で全部やっちゃうスタイルになっています。あくまでアップロードの処理だけ参考に...。

（`S3`に慣れていなかったので、最初`Key`が何を指すのか分からん！となって困惑しました。）

```php:FileUploadController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;

class FileUploadController extends Controller
{
    // ファイルアップロード先
    private const S3_KEY = 'uploads/';

    private S3Client $s3Client;

    public function __construct()
    {
        $this->s3Client = new S3Client([
            'version'                 => 'latest',
            'region'                  => config('aws.default_region'),
            'endpoint'                => config('aws.endpoint'),
            // パス形式のエンドポイントを使用するかどうか(bool)
            // ローカル環境ではtrueにしないと上手くいきませんでした
            'use_path_style_endpoint' => config('aws.use_path_style_endpoint')
        ]);
    }

    /**
     * 初期表示
     */
    public function index()
    {
        return view('fileUpload');
    }

    /**
     * ファイルアップロード
     */
    public function create(Request $request)
    {
        if (!$request->hasFile('uploadFile')) {
            return;
        }

        $file = $request->file('uploadFile');

        $originalName = $file->getClientOriginalName();
        // 今回はアップロード時のファイル名のままでS3にアップロードしています
        $uploadFileName = self::S3_KEY . $originalName;

        try {
            $this->s3Client->putObject([
                'Bucket'     => config('aws.bucket'),
                'Key'        => $uploadFileName,
                'SourceFile' => $file->getRealPath(),
            ]);
            return redirect()->action([self::class, 'index']);
        } catch (S3Exception $e) {
            echo $e->getAwsErrorMessage() . "\n";
        }
    }
}
```

ファイルを指定して送信ボタンを押すと...

![screenshot_07.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961694/ac8c9d67-11b2-f223-0c4e-01b3b6d7ab4c.jpeg)

ファイルのアップロードができました！
上のサンプルでは割愛していますが、viewでアップロードファイルの一覧を表示するようにしています。
コウテイペンギンはかわいい。:penguin:

![screenshot_05.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961694/aa7d153e-fd6a-baa8-28db-c84b4ec04171.jpeg)

ローカル環境で`S3`にアップロードっぽくできてよかったです。


色々分からない中での手探りだったので、もし「こうした方が良いよ」ということがありましたら教えていただけると幸いです。