Rails初心者がActiveStorage-S3について調べてみた

## この記事について

[Rails Active Storage](https://guides.rubyonrails.org/v6.1/active_storage_overview.html) のストレージサービスとして AWS S3 を設定する場合の仕様や挙動確認の記録です。
特に、署名付きURLを利用したS3へのダイレクトアップロードとその関連部を中心に調査を実施しました。

調査の結果、S3へのファイル設置に癖があったため、その挙動の詳細と対応策についても記載します。


### 調査時環境

- Rails ~> v6.1
- ruby ~> v3.0
- aws-sdk-s3 >= v1.112, <v2.0


## セットアップ

[公式ガイド](https://guides.rubyonrails.org/v6.1/active_storage_overview.html) に従い、 config/storage.yml , config/environments/{development.rb, production.rb}, Gemfileなどを編集します。

### 設定例

production mode (RAILS_ENV=production)時のみ有効化（デフォルトは local storage 保存）する想定の設定例です。

#### storage.yml

```yml
amazon:
  service: S3
# access_key_id: "${YOUR_AWS_API_KEY_ID}"     # ※
# secret_access_key: "${YOUR_AWS_ACCESS_KEY}" # ※
  bucket: "${YOUR_BUCKET_NAME}"
  region: "ap-northeast-1"
# http_open_timeout: 0
# http_read_timeout: 0
# retry_limit: 0
# upload:
#  server_side_encryption: "" # 'aws:kms' or 'AES256'
```

※ AWS EC2 や ECS上で動作させる場合などは、IAM Roleの認証情報が読み込まれるので、`access_key_id`, `secret_access_key` の設定は不要です。（参考: https://github.com/aws/aws-sdk-ruby#configuration）
ローカル環境でのテスト時など、明示的にcredentialを指定したい場合はコメントアウトを外して値をセットしてください。

#### config/environments/production.rb

```ruby
config.active_storage.service = :amazon
```

#### Gemfile

例:  productionモード（AWS ステージング環境, 本番環境, etc）向けのビルドの時のみ、S3関連 gem を追加でインストールする設定

```ruby
group :production do
  # https://github.com/rails/rails/blob/v6.1.4.6/activestorage/lib/active_storage/service/s3_service.rb#L3
  gem "aws-sdk-s3", '~> 1.48'
end
```

## 動作確認記録

### S3へのファイル保存時のパス名（Object Key）について

デフォルトの挙動では、 ActiveStorage 用のDBテーブルに追加するレコードの ~~主キー~~ `key` 列（[SecureRandom](https://github.com/rails/rails/blob/v6.1.4.6/activestorage/app/models/active_storage/blob.rb#L130-L137)の文字列）が、そのまま S3 のファイルパス（Object Key）として使用されます。

そのため、そのまま利用するとS3バケットのルートパス直下にファイルがどしどし追加されていきます。

例: ActiveStorage-S3を有効化した際のS3バケットの様子

```
$ aws s3 ls --human-readable s3://${MY_S3_BUCKET}
2022-02-24 19:49:57   22.3 KiB 1mgskbl2m7whkupdjpjvz3snmwja
2022-02-24 19:49:56   17.4 KiB 26ls1ss28e9mnw8p01mtt4l4adtq
2022-02-28 19:33:51   39.8 KiB 2dd8z59z6h0y5lwfwg8s2o4sp6o0
2022-02-28 19:33:53   14.2 KiB 63e5ob65r5qrapcvm1zwe8fwrcn5
2022-02-24 19:49:55   39.8 KiB 871lsja4dx0m65359rx9jqbxooi6
2022-02-28 19:33:53   15.0 KiB 8f9tvx2tece48wzx7jvy79ufuyfq
...
```

また、設定ファイル（`config/storage.yml`）などで、 prefix (ローカルストレージでは `root` 属性で指定可能なディレクトリ相当) をセットすることはできません。
（2022年3月末現在、prefix設定の機能追加は予定されていないようです。[※](https://github.com/rails/rails/issues/32790#issuecomment-385947125) ）

関連issue: https://github.com/rails/rails/issues/32790

#### 対応案A: 利用者側で明示的に `key` を指定する

activestorage のファイル保存関連操作（`avatar#attach()` など）時に、明示的に key の値をセットすることで、保存先のファイルパスを指定することが可能となります。

例: https://github.com/rails/rails/blob/6-1-stable/activestorage/CHANGELOG.md#rails-610-december-09-2020

> You can optionally provide a custom blob key when attaching a new file:
```ruby
user.avatar.attach key: "avatars/#{user.id}.jpg",
  io: io, content_type: "image/jpeg", filename: "avatar.jpg"
```
> Active Storage will store the blob's data on the configured service at the provided key.

↑の例では、 例えば user.id が `12345` の時は、 `S3://{BUCKET_NAME}/avatar/12345.jpg` というパスにファイルが保存されます。

ただしこの場合、keyの一意性は利用者側で担保する必要があります。（ActiveStorageのDBレコードの主キーとしても利用されるため、非ユニークなkeyを指定した場合は、登録エラーもしくは上書きが発生する可能性があるのでご注意ください。）

また、ユーザアップロードによって受け取ったファイルを保存する場合など、ファイル名だけでは key を一意に特定できない場合が考えられます。
この場合は、key名の一部をSecureRandom文字列にする、といった対策が考えられます。

例: 疑似コード ([参考1](https://github.com/rails/rails/blob/v6.1.4/activestorage/app/models/active_storage/blob.rb#L135-L137), [参考2](https://qiita.com/kazutosato/items/c25bcdf6f7337b77960f))
```ruby
blob = new ActiveStorage::Blob(...)
blob.key = "activestorage/uploaded/#{blob.key}" # keyを指定せずにnewした場合,　key には SecureRandom 値が入っている
blob.upload
```

#### B案: 専用のS3 Bucketを用意する

keyを指定せずに（デフォルトの挙動で）利用して、ActiveStorage にS3 Bucketの管理を委ねる方式です。
ファイルパス（Object Key）がランダム文字列でありPrefixもつけられない（Bucketのルートパス直下に置かれる）ので、他のファイルと混ざらないようにStorageサービス毎に専用のBucketを用意する、といった工夫が工夫が必要かもしれません。


### その他の動作確認メモ

- S3へのファイルアップロード時に、 Meta情報として自動で Content-Type (image/jpeg, etc) がセットされる
    - 「The Content-Type header is set on image variants when they're uploaded to third-party storage services.」(v6.1~, [CHANGELOG](https://github.com/rails/rails/blob/6-1-stable/activestorage/CHANGELOG.md#rails-610-december-09-2020)) 


## ダイレクトアップロード

ActiveStorageでは、各種オブジェクトストレージ（AWS S3, GCS, etc）への署名付きURLによるダイレクトアップロードをサポートしています。
-> https://guides.rubyonrails.org/v6.1/active_storage_overview.html#direct-uploads

ダイレクトアップロード自体は、[AWS sdk for ruby](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/UsingTheMPRubyAPI.html) と JavaScript の組み合わせでも実現可能です。ただ、ActiveStorageは専用のJavaScript（[activestorage.js](https://github.com/rails/rails/blob/6-1-stable/activestorage/app/assets/javascripts/activestorage.js)）も提供してくれているので、特に理由がなければそちらの利用が便利かと思います。

### ダイレクトアップロード処理概要

1. ファイルをアップロードしたいクライアント側（Webブラウザなど）が、Rails サーバのAPIを呼んでダイレクトアップロード用のS3署名付きURL情報をリクエスト
    - デフォルトのAPI path `/rails/active_storage/direct_uploads`
2. Rails サーバ側で、アップロード予定のファイル情報を保持する ActiveStorage::BlobのDBレコードとS3署名付きURLを発行
3. クライアント側が受け取った署名付きURLを使ってS3にファイルをアップロード 

参考: https://edgeguides.rubyonrails.org/active_storage_overview.html#usage
 

### 改造: ダイレクトアップロード時にもファイルに独自prefixをつける

標準のダイレクトアップロードAPIを利用する場合、保存時のBlob keyを指定できません（=SecureRandom値になる）。

ただ、サービスのサーバ構成と運用の都合でどうしても prefix をつけて保存できるようにする必要がありました。そこで、ActiveStorage::DirectUploadsController を override して、 所定のprefixをkeyにつける方法を考えました。

「とりあえず動いた」コード例を以下に記載します。
（Rails初心者なので至らないコードだと思います。改善点があればご指摘いただけると幸いです。）

**Server（Rails）側**

まず、Rails本体の [activestorage/app/controllers/active_storage/direct_uploads_controller.rb](https://github.com/rails/rails/blob/v6.1.4/activestorage/app/controllers/active_storage/direct_uploads_controller.rb) をコピーし、 `ActiveStorage::DirectUploadsController` クラスを改造します。 

改造例:

```ruby
# frozen_string_literal: true

# based on https://github.com/rails/rails/blob/v6.1.4.6/activestorage/app/controllers/
class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
  # override
  def initialize
    # このController経由でアップロードされたファイルに設定する共通prefix
    @prefix = 'activestorage/blobs/uploaded' 
  end

  # override
  # ActiveStorage::Blob.create_before_direct_upload!() に設定する key を明示的に指定するようにする
  def create
    key = File.join(@prefix, ActiveStorage::Blob.generate_unique_secure_token)
    blob = ActiveStorage::Blob.create_before_direct_upload!(key: key, **blob_args) # key は引数などで外から指定されない想定
    # blob.key = File.join(@prefix, blob.key) # create_before_direct_upload! の時点でDBレコードが作成されてしまうので、ここでkeyを上書きしても遅い
    render json: direct_upload_json(blob)
  end

  private
    def blob_args
      params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys
    end

    def direct_upload_json(blob)
      blob.as_json(root: false, methods: :signed_id).merge(direct_upload: {
        url: blob.service_url_for_direct_upload,
        headers: blob.service_headers_for_direct_upload
      })
    end
end
```

これを自身のRails appディレクトリの  `app/controllers/active_storage/direct_uploads_controller.rb` に保存すると、Railsの不思議な力により、Rails標準のダイレクトアップロードAPIの挙動をoverride可能です。
社内のエキスパートの方に伺ったところ、「モンキーパッチ」と呼ばれる手法だそうです。

**View側**

file input form に `data-direct-upload-url` 属性をセットしておくと、 activestorage.js がよしなにsubmit event handlingとRails API呼び出し、署名付きURLを利用したS3へのファイルアップロードを行ってくれます。

```html
<script src="activestorage.js"></script>
<script>
  ActiveStorage.start()
</script>
...
<input type=file data-direct-upload-url="<%= rails_direct_uploads_url %>" />
...
```

### View側でのダイレクトアップロードURLの指定について

デフォルトだと、システム共通の `rails_direct_upload_url` エンドポイント= `/rails/active_storage/direct_uploads` が利用されます。
（参考: https://github.com/rails/rails/blob/v6.1.4/activestorage/config/routes.rb#L15 )

ただし、formごとにダイレクトアップロード時挙動（key prefix 分け、など）を変更したい場合が考えられます。例えば、 user view 配下のフォームからのダイレクトアップロードに対して `user_direct_uploads_url` を割り当てた場合、 input タグの `data-direct-upload-url` を下記のように切り替えることで対応可能です。

```html
<input type=file data-direct-upload-url="<%= user_direct_uploads_url %>" />
```

#### Rails の Form クラスを利用する場合

Rails ガイドの[サンプル](https://guides.rubyonrails.org/v6.1/active_storage_overview.html#usage) にあるように、 `direct_upload: true` を指定すると、 form HTML要素に `data-direct-upload-url` 属性をセットしてくれます。

```erb
<%= form.file_field :attachments, multiple: true, direct_upload: true %>
```

なお、この時セットされるURLは、 標準の`rails_direct_upload_url` になります。
参考: https://github.com/rails/rails/blob/v6.1.4.6/actionview/lib/action_view/helpers/form_tag_helper.rb#L916

もしここを切り替えたい場合は、

-  `direct_upload` オプションを使わずに、自前で `data: 'direct-upload-url'` をセットする
    - 例: `<%= form.file_field data: { 'direct-upload-url': user_direct_uploads_url(), ... }, ... %>`

-  ActionView::FormTagHelper#[convert_direct_upload_option_to_url()](https://github.com/rails/rails/blob/v6.1.4.6/actionview/lib/action_view/helpers/form_tag_helper.rb#L914) などをオーバライドする
- Rails Formを諦めて、erb.html 自分でformタグ (<input> など) を書く

といった対策が考えられます。

### その他: 細かい留意点

#### Blobレコード作成タイミング

標準の ActiveStorage::DirectUploadsController では、 `Blob.create_before_direct_upload!()` 関数を使っているので、リクエストがきたタイミングでまず Blob RecordがDB上に作成 (INSERT & COMMIT) されます。
レコード作成に成功すると、クライアント側（Webブラウザなど）に実際にダイレクトアップロードを行うためのS3署名付きURLが返ります。

その後、仮にクライアント側でのS3へのファイルアップロードが失敗したとしても、先に作成されたActiveStorage Blob DB レコードは削除されることなくDB上に残り続けます。
（以降どこからも参照されないので放置しても無害なはずですが、DB肥大化を避けるためにも定期的な掃除などの対策を講じるのがベターかと思われます。）


## RitchText Editor (ActionText) 連携 

ActionText + trix によるRichText Editorの想定で、添付ファイルつきでコメントなどを投稿する場合の動作確認のメモです。

v6.1.4 では、[actiontext/attachment_upload.js](https://github.com/rails/rails/blob/v6.1.4.6/actiontext/app/javascript/actiontext/attachment_upload.js#L1) が activestorage.js をimportして利用しています。

ActionTextの添付ファイル付き投稿を submit するイベントをHookすると、以降は activestorage.js がやっているように、 `data-direct-upload-url` 属性がセットされていればよしなにダイレクトアップロードを行ってくれます。

v7.0.x 以降では、 [actiontext.js](https://github.com/rails/rails/blob/v7.0.0/actiontext/app/assets/javascripts/actiontext.js#L678) に置き換わっているようです。ただ、こちらも activestorage.js と元の[actiontext/attachment_upload.js](https://github.com/rails/rails/blob/v6.1.4.6/actiontext/app/javascript/actiontext/attachment_upload.js#L1) を合体させたような内容のようです。ですので、ダイレクトアップロード周りはv6.1.4と同様にやってくれるものと思われます。（未検証）

## Variant

https://qiita.com/zagvym/items/bd9ce7bdf951e87ecef0

## ファイルのダウンロード

ActiveStorage-S3 で管理するファイルを表示・取得する場合、 デフォルトでは Rails 側で  S3の署名付きURLを発行し、それを View に出力してくれます。
（正確には、 `/active_storage/representations/redirect/...` のようなURLが出力され、ファイル毎のS3署名付きURLにリダイレクトされる。）

このままでもブラウザ上でのファイルの表示やダウンロードは可能です。
ただし、毎回S3へのアクセスが発生するのはレイテンシや費用の問題があるので、 本番運用時にはCloudFront CDNなど経由での配信ができるようにするのが望ましいです。

### CloudFront CDNとの連携

https://qiita.com/zagvym/items/5bfabf1d8a261d143b48


## まとめ

ActiveStorage-S3 について調べ、具体的な利用方法や細かいハマりどころについて記載しました。

いかがだったでしょうか