ActiveStroage + S3 でファイルを扱う

### はじめに

S3使用時のActiveStorageについて、シンプルな構成で動作の流れと実装方法をまとめました。今回はRailsとDBはHerokuにデプロイしています。

### 全体概要

ファイルアップロード時の流れは下記になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895236/a6931067-be9d-b47f-c9f8-cf3b4e24791a.png)


1. クライアントからRailsにファイルを送信(POST)します
1. ActiveStorageがS3にファイルをPutします
1. ActiveStorageがDBにメタ情報を保存します



### 動作確認環境

- Apple M1
- macOS 14.1.2
- Ruby 3.2.1
- Rails 7.0.6
- gem aws-sdk-s3 1.142.0

## 環境構築

構築時の手順は下記のような流れになります。

1. AWS
    1. ファイルを保存するS3のバケットを作成
    1. バケットにアクセスするためのIAM Policy(権限）を作成
    1. 権限を使用するためのIAM Userと認証情報を作成
1. Rails
    1. 環境設定
    1. ActiveStorageを使用するための実装
1. Heroku
    1. 手順に沿ってデプロイ
    1. 必要に応じて環境削除


### AWSのリソース作成

アカウントは存在する前提で進めます。

#### S3 Bucket用意

ファイルを保存するBucketを作成します。
__Amazon S3 > バケット > バケットを作成__ と進みます。
全てデフォルトでOKです。一応設定例を載せておきます。

|項目|設定値|
|--|--|
|リージョン|ap-northeaset-1|
|バッケットタイプ|汎用|
|バケット名|好きな名前|
|オブジェクト所有者|ACL無効|
|パブリックアクセス|全てをブロック|
|バケットのバージョニング|無効|
|暗号化タイプ|SSE-S3|
|バケットキー|有効にする|

設定が確認できたら「バケットを作成」を選択します。


#### IAM Policyの作成

上記で作成したBucketにアクセスするたの権限を作成します。
手っ取り早く`AmazonS3FullAccess`を設定する場合もありますが、それでは権限が広すぎるのでここでは最小権限のポリシーを作成します。
（権限の広すぎる認証情報が漏洩すると他のバケットのデータ流出消失、または勝手に使われてコストが生じるなどのリスクが発生します。）
__IAM　> ポリシー > ポリシーの作成__ と進みます。
JSONを選択して下記を記述します。
```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::作成したバケット名"
        }
    ]
}
```

AcriveStorageに必要な権限は`Action`に記載したものだけで十分です。また`Resouce`を指定することで権限の範囲を作成したバケットに限定しています。

確認ができたら適当な名前を付与して作成します。
ちなみに個人的には一覧で表示させやすようにアンダースコアを付与することが多いです。この場合だと`_ActiveStorageSampleApp`のようにしています。

#### IAM Userとアクセスキーの作成

ActiveStorageに認証情報を与えるためのIAM Userを作成します。
こちらも他のユーザーを流用する場合はありますが、ここでは最小権限のために専用のユーザーを作成します。
__IAM　> ポリシー > ポリシーの作成__ と進みます。

|項目|設定値|
|--|--|
|ユーザー名|任意|
|AWS マネジメントコンソールへのユーザーアクセスを提供する|チェックを外す|
|許可のオプション|ポリシーを直接アタッチする|
|許可ポリシー|先ほど作成したポリシー名のみをアタッチする|

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895236/22eb57dd-1af7-39c6-b3d6-f62b473119d1.png)

その他はデフォルトで作成します。
ユーザーが作成できたら __IAM > ユーザー test > アクセスキーを作成__ と進み、 __コマンドラインインターフェイス (CLI)__ でアクセスキーとシークレットアクセスキーを作成します。


### Railsの設定と実装

アプリ、モデル、データベースがある前提で進めます。
変更するファイルが多いです。

#### 環境設定

ActiveStorageでS3を使用するための設定を追記します。認証情報は直接記載せずにdotenvのgemを用いて環境変数から取得します。

```yml:config/storage.yml
amazon:
  service: S3
  access_key_id: <%= ENV['AWS_ACCESS_KEY'] %> # アクセスキー
  secret_access_key: <%= ENV['AWS_SECRET_KEY'] %> # シークレットアクセスキー
  region: <%= ENV['AWS_REGION'] %> # Bucketを作成したリージョン
  bucket: <%= ENV['AWS_BUCKET'] %> # Bucket名
```

上記で追記したActiveStorageの設定を本番環境で使用するように指定します。

```ruby:config/environments/production.rb
config.active_storage.service = :amazon
```

s3へuploadするために必要なgemを追加します。

```ruby:gemfile
gem 'aws-sdk-s3', require: false # S3へアップロードするためのgem
gem 'dotenv-rails' # シークレット情報を扱うためのgem
```

gemを追加したので更新をお忘れなく

```sh
$ bundle install
```

環境変数にAWSで作成した値を追加しておきます。

```sh:.env
# 自分の環境の値を入力します
AWS_ACCESS_KEY='AKIHOGEHOGE'
AWS_SECRET_KEY='secrethogehoge'
AWS_REGION='ap-northeast-1'
AWS_BUCKET='my-bucket-name-hogehoge'
```

必要に応じてサンプルファイルを用意してリポジトリで管理しておくと運用がしやすくなります。

```sh:.env.sample
# 空の値になるように注意
AWS_ACCESS_KEY=''
AWS_SECRET_KEY=''
AWS_REGION=''
AWS_BUCKET=''
```

.envを誤ってリポジトリに含めないようにします。

```sh:.gitignore
# 追加
.env
```

ActiveStorage使用のためのコマンドを実行します。

```sh
# ActiveStorage用テーブル作成のためのmigrateファイルが作成される
$ bundle exec rails active_storage:install
# migrate実行
$ bundle exec rails db:migrate
```

環境設定としては以上になります。これでActiveStorageを使用するための環境が整いました。

#### ActiveStorageの使用

ActiveStorageを使用するための実装を行なっていきます。関連付けはmodelで行います。ここでは既に存在しているitemというmodelに対し、imageという属性名で画像ファイルを追加するケースとして進めます。

itemモデルに下記、`has_one_attached`を追記します。
なお、`image`というカラムは元々存在していたカラムではなくここで初めて追加したものです。このように記述することでmodelとActiveStorageが関連付けされます。
これにより他の属性と同じようにcreate, find, update, destroyといった操作が可能になります。

```ruby:app/models/item.rb
class Item < ApplicationRecord
  has_one_attached :image # この行を追加
end
```

##### 動作確認用の補足情報

細かいですが動作確認のためのサンプルコードを載せておきます。

最初はseedファイルで動作確認を行うと躓きにくいかもしれません。その場合の記載例は下記になります。`attach`メソッドを使用することでファイルが属性に追加されます。S3に、メタデータがデータベースに保存されます。

```ruby:db/seeds.rb
# 適当に8つ同じ画像を登録する場合
(1..8).each do |i|
  Item.find_or_create_by!(name: "item#{i}") do |item|
    # 必要な属性名を記述します
    item.name = "name#{i}"
    # modelsで関連付けしたものにファイルを読み込みます（事前にファイルを配置しておく必要があります）
    item.image.attach(io: File.open(Rails.root.join('db/seeds/images/your-file-name.png')),
                      filename: 'your-file-name.png')
  end
end
```

seedは下記のコマンドで実行できます。

```sh
# seed実行
$ bundle exec rails db:seed
```

下記のようにviewで`モデル名.属性`とすると画像を表示することができます。

```erb:app/views/items/show.html.erb 
<%= image_tag item.image %>
```

formの場合、file_fieldで指定すれば添付されます。

```erb:app/views/items/_form.html.erb 
<div class="container">
  <%= form_with model: item, url: url do |f| %>
    <div class="mb-3">
      <%= f.label :image, class: "form-label" %>
      <%= f.file_field :image, accept: 'image/*', class: "form-control"  %>
    </div>
    <div class="mb-3">
      <%= f.submit "Submit", class: "btn btn-dark"%>
    </div>
  <% end %>
</div>
```

上記のerbで下記のように表示されます。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895236/7dfa7e83-3b9f-4977-0a56-84249d637d94.png">


### Herokuへのデプロイ

アカウントは取得している前提で始めます。

基本的には[公式手順](https://devcenter.heroku.com/ja/articles/getting-started-with-rails7
)の通りですが、ActiveStorage関連の環境変数を追加する手順が必要です。サンプルコマンドを下記に記載しました。ここでは.envの内容を読み込んで一つずつHerokuアプリの環境変数に設定しています。また、デプロイするブランチは現在のブランチとしています。


```sh:heroku-deploy.sh
# デプロイ
heroku login
heroku apps:create
heroku addons:create heroku-postgresql:mini
# 環境変数設定
source .env
heroku config:set AWS_ACCESS_KEY="$AWS_ACCESS_KEY"
heroku config:set AWS_SECRET_KEY="$AWS_SECRET_KEY"
heroku config:set AWS_REGION="$AWS_REGION"
heroku config:set AWS_BUCKET="$AWS_BUCKET"
# target_branchをherokuへpushする
## 現在のブランチ名
CURRENT_BRANCH=$(git branch --contains | cut -d " " -f 2):main
# deploy
git push heroku $CURRENT_BRANCH
# setup
heroku run rake db:migrate
heroku run rails db:seed
heroku ps:scale web=1
heroku open
heroku logs --tail
```

反対にアプリを停止する処理を記載します。

:::note warn
下記を実行するとアプリとデータが削除されて復元できなくなります
:::

```shell:heroku-destory.sh
# アプリを削除する
heroku addons:destroy heroku-postgresql
heroku apps:destroy
heroku addons --all
heroku apps --all
```

## おわりに

ActiveStorageでS3を使う際の環境構築や実装についてまとめました。これだけの記載でStorageが扱えるのは便利ですね。ここでは最低限のことしか触れませんでしたが複数ファイルの取り扱いやバリアントなど便利な機能がたくさんあります。間違っているところやもっと良い方法があれば指摘していただけると助かります。

### 参考文献

- [Rails Edge Guides](https://edgeguides.rubyonrails.org/active_storage_overview.html)
- [Railsガイド Active Storage の概要](https://railsguides.jp/active_storage_overview.html)
- [Heroku スターターガイド (Rails 7.x)](https://devcenter.heroku.com/ja/articles/getting-started-with-rails7)