Linkers Tech Blog

リンカーズ株式会社の開発者ブログです。

S3互換ストレージMinIOの開発環境(Rails)への導入

はじめに

この記事は、Ruby on Railsの既存プロジェクトにMinIOを実際に環境に導入し、その際つまずいたところを記録した記事です。

MinIO導入の経緯から、ローカルの開発環境にコンテナとして導入して、最後はCIが緑になって開発ブランチにマージされるまでを書いています。

対象読者

  • Railsプロジェクトの環境にMinIOの導入を検討している人(どんな障壁があったか知りたい人)
  • RailsプロジェクトでMinIOを導入しようとして詰まっている人
  • MinIOをDockerのコンテナ上で動かしたい人
  • MinIOを知らない人

MinIOとは何か

MinIO Object Storage(以下MinIO)はMinIO, Inc.によって開発されているOSSです。Apache 2.0ライセンスで公開されています。

元々プライベートクラウド上で使えるオブジェクトストレージとして設計されていましたが、AWS S3のAPIと互換性があるため、例えば、本番環境はS3を使って、ローカルやCIの環境はMinIOのコンテナで……というように、ローカルやCIでS3の環境を用意したい人にとっても便利に使うことができます。

GitHubでコードを公開しているだけではなく、公式でDockerイメージまでDockerHubに用意してくれており、大変便利に使うことができます。MinIO, Inc.に足を向けて寝れません。

下の画像は、ローカルで起動しているMinIOにアクセスした画面です。シンプルなデザインがお洒落で素敵ですね。

f:id:jesus_isao:20200227102752p:plain:w500
ログイン画面

f:id:jesus_isao:20200227102817p:plain:w500
管理画面

導入のきっかけと目的

これまでの開発環境/本番環境(概略)

私達のプロジェクトのMinIO導入前の本番環境/開発環境について、今回関わる部分のみ簡単にご説明します。

本番環境

f:id:jesus_isao:20200227102500p:plain:w500
本番環境

本番環境は上記のような一般的な構成です。EC2上にRailsのアプリケーションが起動しており、ユーザーが画像やPDFをアップロードすると、Railsがそれを受け取って、Shrineというgemに後は任せます。Shrineは、ファイルをS3やAPサーバー内に保存してくれるgemです。アップロードされたファイルのダウンロードは、後からユーザーがRailsでレンダリングされたWebページを見て、そこにあるS3へのリンクをクリックしダウンロードする、という構成です。

開発環境

そしてMinIO導入以前のローカルの開発環境は、下記のような構成でした。

f:id:jesus_isao:20200227102541p:plain:w500
開発環境

この画像には3つのローカル端末が描かれていますが、全て同一のMacBook Proです。弊社の開発者にはMacBook Proが支給されます。

この画像を見て分かる通り、本番環境と明らかに違うところがあります。本番環境ではS3が画像やPDF等のファイルを返していましたが、この開発環境ではRailsがファイルを返しています。開発環境と本番環境でそれぞれ、S3を見に行くか、それとも自分自身のサーバー内を見に行くかはShrineが抽象化しているため、ソースコード自体はシンプルです。しかし本番環境と開発環境の仕組みの違いが、後々問題を引き起こします。

本番のみ出てしまう挙動

例えば、実際にあった例としてこんな問題がありました。 「自分でアップロードしたCSVファイルを後からダウンロードしようとすると外部サイト移動の警告が出る」という不具合です。

画像は実際にそれが出ていた時のスクリーンショットです。

f:id:jesus_isao:20200227102929p:plain:w500
不具合

開発環境のURLは./bucket/hoge.csvのような相対パスで、本番環境のURLはhttps://aws.s3.domain/bucket/hoge.csvのような異なるURLが生成されていたため、開発環境中では判定の誤りに気づけず、リリースがされてしまった……。という悲しい不具合でした。

環境の差異によって見過ごされていた不具合は他にも出てくる可能性は十分に考えられるため、少しでもローカルで本番環境に近い構成にしたい、というのがMinIOを導入した動機です。

以上、前置きでした。

MinIOが動くまで

[開発環境] docker-compose.ymlに追加

色々と省略していますが、MinIOに関連するところだけ抽出するとこのようになりました。

# docker-compose.yml
version: "3.3"

services:
  minio:
    image: minio/minio:latest
    networks:
      - local_dev
    ports:
      - "9000:9000"
    volumes:
      - ./docker/volume/minio:/data
    command: server /data
    environment:
      MINIO_ACCESS_KEY: minio_access
      MINIO_SECRET_KEY: minio_secret

networks:
  local_dev:

そして、MinIOに初期処理を行う部分は別のymlに切り出しました。

ここに出てくるmcはminio-clientの略で、起動しているminioのコンテナに対してアクセスし、hogeという名前のバケットを削除したり新規作成したりしています。ここに出てくるminio/mcというイメージも、公式から提供されています。

# 別のファイル docker-compose.createbuckets.yml
version: "3.3"

services:
  createbuckets:
    image: minio/mc
    networks:
      - local_dev
    depends_on:
      - minio
    entrypoint: >
      /bin/sh -c "
      until (/usr/bin/mc config host add myminio http://minio:9000 minio_access minio_secret) do echo '...waiting...' && sleep 1; done;
      /usr/bin/mc rm -r --force myminio/hoge;
      /usr/bin/mc mb myminio/hoge;
      /usr/bin/mc policy download myminio/hoge;
      echo '-----------------------------------------';
      echo 'バケットの作成を完了しました。';
      echo 'Ctrl + Cでdocker-composeを終了してください……';
      echo '-----------------------------------------';
      exit 0;
      "

networks:
  local_dev:

普段の開発をする際のdocker-compose upをする前に、初回のみ下記のコマンドを実行します。

docker-compose -f docker-compose.yml -f docker-compose.createbuckets.yml up

複数のファイルを-fで指定してdocker-compose upすると、ファイルの中身がマージされて実行されます。

このコマンドを初回だけ実行することで、初期処理が行われてバケットが作成され、MinIOに書き込み・読み込みができるようになります。以降は普通のdocker-compose upで開発環境が起動します。

ハマった:foreman、突然の死

これはdocker-compose.createbuckets.ymlにMinIOの初期処理を分離する前、docker-compose.ymlにバケットの作成の処理を詰め込んでいた時の話です。

私達のチームではforemanというプロセス管理ツールを使っていました。これはHerokuのProcfileをもとにプロセスを実行してくれるツールで、私達はこれで、docker-compose upしたりrails sしたりしていたのですが、バケットの作成の処理が入っていた場合にbundle exec foreman startしても停止してしまうという問題が発生しました。

理由はforemanが、「全てのプロセスが起動し続ける」前提で動いているツールであったことでした。 docker-compose.createbuckets.ymlに書いてある内容を読めば分かるのですが、ここでexit 0;しています。これによってこのminio-clientのコンテナは停止しますが、それをforemanが検知するとforemanは全てのプロセスを終了しようとします。よって、docker-compose.createbuckets.ymlに書いてあるような内容は、少なくともforemanの外で実行する必要があります。

[CI環境] gitlab.ymlに追加

私達のチームはGitLab CIを使用しています。E2Eテストでもまた、MinIOを使いたいので、Rspecが動くJobなどでMinIOも動かせるよう設定をする必要があります。

# .gitlan-ci.yml

# 予め作っておいたimage。この中にminio-clientも入っている。
# これを各Jobで使う
image: gitlab.example.com/my/image:latest

rspec:
  services:
    - name: minio/minio
      command: ["server", "/data"]
      alias: "minio.example.com"
  script:
    - sh -c "until (/usr/bin/mc config host add myminio http://minio.example.com:9000 minio_access minio_secret) do echo '...waiting...' && sleep 1; done; /usr/bin/mc rm -r --force myminio/hoge; /usr/bin/mc mb myminio/hoge; /usr/bin/mc policy download myminio/hoge; exit 0;"
    - bundle exec rspec

It works!

動きました! 後は、赤くなっているテストを修正します。

テスト(Rspec)が通るまで

素直な修正

これまで"rspec用"として書いていたコードが、より本番環境に近いコードに修正されることになります。

例えば、これまではhref='/bucket/6f4648d.png'を期待していたところを、href='http://localhost:9000/bucket/6f4648d.png'に直すような修正です。より本番環境に近い状態でテストができるようになったため、こうしたテストを修正するのは当然のことでしょう。

Aws::S3::Errors::RequestTimeTooSkewed

Timecopというgemがあります。これによって、Time.nowなどをした時の時間を操作することができます。 しかしRspec内の時間が固定されていたことによって、Minioとの時間差が大きくなりすぎてアップロードできなくなるケースが出てきました。

0 byteのファイルアップロードできないと発覚

MinIOに変えたことで0 byteのファイルをアップロードできない不具合が見つかりました。自動テストで0 byteのダミーファイルをアップロードしていて、そこがErrorになったことで判明しました。S3を使っている本番環境でも同様の問題が発生していると分かったため、MinIOを入れたことで隠れていた問題が浮き上がりました。

調べると、これはShrineの不具合でした。issue #440で報告がされており、2020/01/16にリリースされたShrine v3.2.1だと問題なくアップロードができると分かりました。

ActionController::RoutingError

これまで、開発環境や自動テスト環境ではファイルをダウンロードする時に、相対パスにあるファイルをRspecが読みに行っていました。

例えば、下記は「test.ppt」というパワポのファイルへのリンクが画面に表示されていて、それをクリックしたらパワポのファイルをダウンロードができることをテストしているRspecです。

click_on 'test.ppt'
expect(page.response_headers['Content-Type']).to eq('application/vnd.ms-powerpoint')

しかし、このプロジェクトではファイルをMinIOが返すように変更しました。つまり、Railsがファイルを返すわけではなくなったため、pageで中身を取得することはできなくなりました。

この問題に対してはKernel#openでファイルをダウンロードし、その中身をテストするという方針に変更しました。

System Specのhave_linkでリンクが突然探し出せなくなった

下記のようなテーブルの構造で、hrefの中身のURLが適切に生成されているかをテストしていました。

<table>
  <tbody>
    <tr>
      <td>
        <div>
          <span>
            <a href="./bucket/hogehoge.csv">hogehoge.csv</a>
            <p>something</p>
          </span>
        </div>
      </td>
    </tr>
  </tbody>
</table>

これまでは、withinで<tr>のところまで降りてきて、下記のコードでテストできていました。

expect(td[0]).to have_link 'hogehoge.csv', href: something_expected_url

しかし、MinIOはS3と互換性があり、URLにもS3と同様の長いクエリがついて、最終的には長大なURLになります。たとえば、X-Amz-Credentialや、X-Amz-Signatureなどのパラメータが付与されます。それが原因なのか、have_linkで深いところにあるURLをいきなり探りあてることができなくなりました。 そのため、URLのあるタグまでfindして判定することにしました。

expect(td[0].find('div > span > a')[:href]).to eq something_expected_url

修正完了

テストを修正し、CIの全てのテストが緑になりました。

導入完了

GitLabのマージリクエストで他の開発者にレビュー依頼をし、LGTMをもらい、開発ブランチに変更をマージしました。これにて導入が完了しました。

本流とは関係のないところですが、MinIOとはあまり関係のないところで課題となったところもいくつかありました。

  • ローカル環境でランダムで発生するrspecのエラーが表出した(元々のテストにバグが仕込まれていた)
  • 開発ブランチへのマージ後、ある開発者のみMinIOでアップロードができない状態に陥った(何かの拍子でローカルのgem、aws-sdk-s3の中身が書き換わっていたことが判明した)

MinIOを導入することで、これまで潜んでいた色々な問題が表出する可能性もあるため、既存プロジェクトに導入する際にはそれなりの時間を見積もっていた方が安全かもしれません。

とはいえ、これでやっと導入は完了しました。お疲れ様でした!🎉 最終的に、開発環境は画像のようになりました。

f:id:jesus_isao:20200227103014p:plain:w500
最終的な開発環境

良い開発ライフを!