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


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

**そうだ！LocalStackを使おう！**

#LocalStackとは
[LocalStack](https://github.com/atlassian/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 構成

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


```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のエンドポイントはサービスによって決まっていて](https://github.com/localstack/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](https://yumemi.qiita.com/files/1c0a6a82-af82-2a94-b783-25146f6a3810.png "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_BUCKET` と `AWS_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/ 

```


アップロードする。
<img width="150" alt="up.png" src="https://qiita-image-store.s3.amazonaws.com/0/152105/c9ad14d0-3aba-841f-01d5-dd718fbcc874.png">


アップロード後の画面。
<img width="150" alt="af.png" src="https://qiita-image-store.s3.amazonaws.com/0/152105/a6b42965-af14-aef0-e6ad-e86adcb59ac7.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)](https://hub.docker.com/r/localstack/localstack/)
[LocalStack(Git Hub)](https://github.com/localstack/localstack)
[Laravel 5.5 HTTP Tests](https://laravel.com/docs/5.5/http-tests#customizing-request-headers)