PHP
laravel
docker
LocalStack

LaravelでS3への画像アップロードをLocalStackを使って開発

More than 1 year has passed since last update.

動機

AWS S3にファイルをアップロードするPHP Laravelのシステムの開発で、本物のAWSに開発用のS3環境を作るのが色々とアレなのでローカルに擬似的な環境を構築したい。
できれば、Dockerでその他の環境とまとめて管理したい。

そうだ!LocalStackを使おう!

LocalStackとは

LocalStackは、AWSのモックフレームワーク
AWSの様々なサービスをローカル環境で擬似的に利用することができる。

環境

macOS Sierra 10.12.6
PHP 7.0
Laravel 5.5
LocalStack
Docker 17.06.2-ce

前提

  • Dockerがインストールされている。
  • Laravelが動くWebサーバ用の Docker image がある。

概要

  • HTML画面から画像をアップロードしてAWS S3へ保存する処理のLaravel開発環境を構築する。
  • 基本的に全部ローカルPCのDockerを使う。
  • S3の環境は、LocalStackを使う。

環境構築

docker-compose 構成

(localstacklocalhostと、間違えやすい名前にしてしまったので注意)

docker-compose.yml
# Laravel and LocalStack
version: '2.2'

networks:
  aws_net:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.16.238.0/24
          gateway: 172.16.238.1

services:

  # LocalStack
  localstack:
    image: localstack/localstack:latest
    container_name: localstack
    environment:
      - SERVICES=s3
      - DEFAULT_REGION=us-east-1
    networks:
      - aws_net
    ports:
      - "8080:8080"
      - "4567-4578:4567-4578"

  # awscli
  awscli:
    image: xueshanf/awscli
    environment:
      - AWS_DEFAULT_REGION=ap-northeast-1
      - AWS_DEFAULT_OUTPUT=json
      - AWS_ACCESS_KEY_ID=ando
      - AWS_SECRET_ACCESS_KEY=mizue
    extra_hosts:
      - "localstack:172.16.238.1"
      - "ando-test.localstack:172.16.238.1"
    depends_on:
      - localstack
    entrypoint: aws --endpoint-url=http://localstack:4572 s3 mb s3://ando-test/

  # web - web
  web1:
    image: phpweb :latest
    container_name: web1
    environment:
      - AWS_DEFAULT_REGION=ap-northeast-1
      - AWS_DEFAULT_OUTPUT=json
      - AWS_ACCESS_KEY_ID=ando
      - AWS_SECRET_ACCESS_KEY=mizue
    volumes:
      - "./apache/conf.d/httpd-web.conf:/etc/httpd/conf.d/httpd-web.conf"
      - "../repo/localstacktest/:/var/www/project/"
    ports:
      - "8090:8090"
    extra_hosts:
      - "localstack:172.16.238.1"
      - "ando-test.localstack:172.16.238.1"
    depends_on:
      - localstack


  • Laravel(web1)からLocalStackへアクセスしようとすると、ホスト名解決で問題が起こるので、extra_hosts でhostsを追加している。
    172.16.238.1 が、LocalStackに割り当てているネットワークのIP。
    S3アップロードの時に、{バケット名}.{aws s3のエンドポイント} というホストで接続するようなので、それも入れておく。

  • environment に指定しているAWS関連の値は、特にどこかに登録してあるものではなく、適当な文字列でも大丈夫らしい。

  • サービス名 awscliは、LocalStackが起動してから、バケットを作成するコマンドを送信するためだけのサービス。image xueshanf/awscliに aws cliが入っているのでそれを利用。
    LocalStackのコンテナ起動でやろうとしたけど、うまくいかなかったので別サービスにした。

  • imageのphpweb は、CentOS7 + Apache + PHP7.0 + aws cli などが入っている独自のイメージ。

  • Laravelプロジェクトは、'web1' の /var/www/ の下で create project して作成済み。

動かしてみる

コンテナ立ち上げ

$ docker-compose up -d
Creating network "docker_default" with the default driver
Creating network "docker_aws_net" with driver "bridge"
Creating localstack ... 
Creating localstack ... done
Creating docker_awscli_1 ... 
Creating web1 ... 
Creating docker_awscli_1
Creating docker_awscli_1 ... done

立ち上がったか確認

$ docker ps -a
CONTAINER ID        IMAGE                          COMMAND                  CREATED             STATUS                     PORTS                                                                     NAMES
3612e83f6d87        xueshanf/awscli                "aws --endpoint-ur..."   6 minutes ago       Exited (0) 5 minutes ago                                                                             docker_awscli_1
53c39d930ca8        phpweb:latest           "/bin/sh -c '/usr/..."   6 minutes ago       Up 6 minutes               0.0.0.0:8090->8090/tcp                                                    web1
586886cdb457        localstack/localstack:latest   "/usr/bin/supervis..."   6 minutes ago       Up 6 minutes               0.0.0.0:4567-4578->4567-4578/tcp, 0.0.0.0:8080->8080/tcp, 4579-4582/tcp   localstack

バケットができているはずなので確認してみる。

Webサーバのコンテナで、aws cliで確認。
--endpoint-url に、今回のLocalStackのS3エンドポイントを指定するだけで、あとは通常のAWSと同じ。
(aws configure を求められたら、適当な文字列を入れておけばいいみたい)

LocalStackのエンドポイントはサービスによって決まっていて、s3は http://LocalStackのホスト:4572 になるので、今回は、http://localstack:4572

$ docker exec -it web1 /bin/bash

[root@1ac2dc002a22 project]# aws --endpoint-url=http://localstack:4572 s3 ls
2006-02-04 01:45:09 ando-test

できてる!
中身は空っぽ!

LocalStackにはブラウザで確認できるダッシュボードがあるので、それも見てみると、バケットが現れた。
locakstack.png

Laravelのコード

AWS(LocalStack)の設定

エンドポイントは 'endpoint' => env('AWS_S3_ENDPOINT'), を追加することで変更できる。

(抜粋)app/config/filesystems.php
        's3' => [
            'driver' => 's3',
            'key' => env('AWS_KEY'),
            'secret' => env('AWS_SECRET'),
            'region' => env('AWS_REGION'),
            'bucket' => env('AWS_BUCKET'),
            'endpoint' => env('AWS_S3_ENDPOINT'),
        ],

環境設定
AWS_BUCKETAWS_S3_ENDPOINT は正確に。
あとは適当で良いみたい。

(抜粋).env
WS_KEY=ando
AWS_SECRET=mizue
AWS_REGION=tokyo
AWS_BUCKET=ando-test
AWS_S3_ENDPOINT=http://localstack:4572

Route

(抜粋)routes/web.php
Route::get('photo', 'PhotoController@index');
Route::post('photo', 'PhotoController@upload');

Controller

app/Http/Controllers/PhotoController.php
<?php

namespace App\Http\Controllers;

use Storage;
use Request;

use App\Http\Requests\PhotoRequest;

class PhotoController extends Controller
{

    /**
     * ファイル一覧表示
     *
     */
    public function index()
    {
        // バケットの中のファイル名一覧取得
        $files = Storage::disk('s3')->files();

        // ファイルリスト生成
        $list = [];
        foreach ($files as $file) {
            $date = Storage::disk('s3')->lastModified($file);
            $list[$date] = [
                'name' => $file,
                'date' => date("Y-m-d H:i:s", $date),
                'type' => pathinfo($file, PATHINFO_EXTENSION),
                'size' =>  Storage::disk('s3')->size($file),
            ];
        }
        // 日付降順にソート
        krsort($list);

        // 画面表示
        return view('photo.index')->with(['list' => $list]);
    }

    /**
     * アップロードファイルをs3に保存する
     *
     */
    public function upload(PhotoRequest $request)
    {
        // アップロードされたファイルを取得
        $file = Request::file('photo');
        $image_data = file_get_contents($file->getRealPath());

        // s3へ保存
        Storage::disk('s3')->put($file->getClientOriginalName(), $image_data, 'public');

        // 一覧画面へ
        return redirect('/photo');
    }

}

app/Http/Requests/PhotoRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class PhotoRequest extends FormRequest
{

    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'photo' => ['required', 'image', 'max:1024'], // 必須, 画像のみ, 1024KBまで
        ];
    }

}

View

resources/views/photo/index.blade.php
<!doctype html>
<html lang="ja">
    <head>
        <title>LocalStack S3 test</title>
        <link rel="stylesheet" href="/bootstrap-3.3.7/css/bootstrap.min.css">
        <script src="/js/jquery-3.2.1.min.js"></script>
        <script src="/bootstrap-3.3.7/js/bootstrap.min.js"></script>
    </head>
    <body>
    <header>
        @if ($errors->any())
            <div class="alert alert-danger">
                <ul>
                    @foreach ($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif
    </header>
    <section id="form">
        <div class="panel panel-default">
          <div class="panel-body">
              <img id="preview" class="img-thumbnail" src="" width="150px">
            <form method="post" action="/photo" enctype="multipart/form-data">
              {{ csrf_field() }}
              <input id="target" type="file" name="photo" multiple>
              <input class="btn btn-primary btn-lg" type="submit" value="UPLOAD!!">
             </form>
         </div>
     </div>
    </section>
    <section id="list">
        @foreach ($list as $photo)
        <hr />
        <div class="media">
            <a class="media-left" href="#">
                {{-- 画像 --}}
                <img width="100px" class="media-object img-thumbnail" src="{{env('AWS_S3_BASE_URL')}}/{{env('AWS_BUCKET')}}/{{$photo['name']}}">
            </a>
            <div class="media-body">
                {{-- 画像情報 --}}
                <p>date : {{ $photo['date'] }}</p>
                <p>name : {{ $photo['name'] }}</p>
                <p>size : {{ ($photo['size'] > (1024*1024)) ? round($photo['size']/1024/1024,2).'MB' : round($photo['size']/1024).'KB'}} </p>
            </div>
        </div>
        @endforeach
    </section>
    <footer>
        <hr />
    </footer>
    <script>
      $(function(){
        {{-- 選択画像のプレビュー --}}
        $("#target").change( function() {
          var file = this.files[0];
          fileReader = new FileReader();
          fileReader.onload = function(event) {
              $("#preview").attr('src', event.target.result);
          };
          fileReader.readAsDataURL(file);
        });
      });
    </script>
    </body>
</html>

テストコード

Tests/Feature/PhotoTest.php
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;

class PhotoTest extends TestCase
{


    /**
     * 画像アップロード
     */
    public function testUpload()
    {
        Storage::fake('s3');

        // 正常
        $response = $this->post('/photo', [
            'photo' => UploadedFile::fake()->image('pic1.jpg')
        ]);
        Storage::disk('s3')->assertExists('pic1.jpg');


        // エラー:サイズ
        $response = $this->post('/photo', [
            'photo' => UploadedFile::fake()->image('pic2.jpg')->size(1024*2)
        ]);
        Storage::disk('s3')->assertMissing('pic2.jpg');


        // エラー:ファイルタイプ
        $response = $this->post('/photo', [
            'photo' => UploadedFile::fake()->create('pic3.pdf')
        ]);
        Storage::disk('s3')->assertMissing('pic3.pdf');

    }

}


Test

[root@1ac2dc002a22 project]# ./vendor/bin/phpunit                                                                                                                                                     

PHPUnit 6.3.0 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 2.13 seconds, Memory: 16.00MB

OK (3 tests, 5 assertions)

ここでは、実際にファイルをアップロードしているわけではないので・・

実際に画像をアップロードしてみる

アップロード前。
バケットには何もない。

[root@0e0e1473903e project]# aws --endpoint-url=http://localstack:4572 s3 ls s3://ando-test/ 

アップロードする。
up.png

アップロード後の画面。
af.png

アップした画像をブラウザ表示できてる。

aws cliでも確認。
アップしたファイルがある。

[root@0e0e1473903e project]# aws --endpoint-url=http://localstack:4572 s3 ls s3://ando-test/ 
2017-09-22 19:12:19     903944 mizue.jpg

できた!
これでファイルアップし放題!

注意

dockerを終了させるとアップしたファイルはバケットごと消えます。

参照

LocalStack(Docker Hub)
LocalStack(Git Hub)
Laravel 5.5 HTTP Tests