Amazon S3 の署名バージョン 4 に合わせて gem をアップデートした話

少し前になりますが、AWS から衝撃の発表がありました。

[Amazon S3 の AWS 署名バージョン 2 がオフ (廃止)](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/UsingAWSSDK.html#UsingAWSSDK-sig2-deprecation)

2019/6/24 以降、署名バージョン 2 を廃止するため、各種 CLI や SDK をアップグレードしなければ、その日以降 Amazon S3 へのアクセスが出来なくなってしまうというものでした。

私の会社ではインフラ管理も含めてお仕事をいただくことが多く、本件についても各プロジェクトでの対応が求められました。今回はその中の１つのシステムについて知見をご紹介出来ればと思います。

# 該当システムの概要

ご紹介するシステムは Rails で開発された、主に画像／動画を扱う web サービスです。ファイルのアップロード／ダウンロード／ストリーミング再生などが可能です。ファイルの管理は下記の gem を利用して実現されていました。

- paperclip: 4.3.6
- aws-sdk: 1.64.0

実はこのシステムはすでに数年稼働しているもので、別会社さまが初期開発やその後の運用を担当されていたものを、ある時期から引き継いだものになります。Paperclip, aws-sdk 共に少し古めのバージョンになっていますが、昔 Paperclip は aws-sdk の v2 に対応されていなかったという過去があったり、機能開発や日々の運用もあり、アップデートするタイミングがなかなか掴めなかったのではないかと思います。

今回はちょうど良い機会ですので、思い切って双方とも最新版（Paperclip: 6.1.0, aws-sdk: 3.0.1）へのアップデートを目指しました。

# アップデート作業へ入る前に

AWS から、署名バージョン 2 廃止後のデフォルトとなる署名バージョン 4 に対応するためにどのバージョンまでアップグレードしなければならないかという情報が出ていますので、まずはそちらを確認してみます。

[署名バージョン 2 から署名バージョン 4 への移行](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/UsingAWSSDK.html#UsingAWSSDK-move-to-Sig4)

- AWS SDK for Ruby v1 --> Ruby V3 にアップグレード。

とあるので、aws-sdk を v3 までアップグレードすれば大丈夫そうです。

また、ご存知の方も多いと思いますが、ActiveStorage の台頭により Paperclip の開発は止まってしまっています。（**※1**）そのため ActiveStorage への移行も検討したのですが、データ移行なども含めると少し重厚なプロセスとなりそうだったため、そちらは今後の課題として今回の作業とは切り離しました。

なお、Paperclip から ActiveStorage への移行を検討されている方は公式の手順が公開されていますので参考にされると良いと思います。

- github：[paperclip/MIGRATING.md](https://github.com/thoughtbot/paperclip/blob/master/MIGRATING.md)
- 日本語訳：[Rails: PaperclipからActiveStorageへの移行ガイド by thoughtbot（翻訳）](https://techracho.bpsinc.jp/hachi8833/2018_05_21/56668)

**※1**
https://thoughtbot.com/blog/closing-the-trombone

本文中で下記のように語られています。メンテナンスしていくためのリソースを確保できなくなってきたことが１つの要因のようです。

```:本文
In late 2017 we began the internal discussions of deprecating Paperclip.
ActiveStorage was under heavy development, Paperclip was not, and another security bug report came in.
We came up with a plan, caused one or two mistakes along the way, and now here we are.
```
```:訳
2017 年の終わりごろに Paperclip を廃止することについて社内で話し合いました。
ActiveStorage は開発中なのに対して Paperclip は開発中ではなく、そのタイミングでセキュリティバグレポートが発表されました。
我々は計画を立ててその対応に臨みましたが、１つ２つミスを起こしてしまいました。それが現状です。
```

# Paperclip と aws-sdk の最新化

Paperclip, aws-sdk について、バージョン固定している部分を削除して `bundle update` することでそれぞれの gem を最新化します。

```diff:Gemfile
- gem 'paperclip', '4.3.6' 
+ gem 'paperclip'

- gem 'aws-sdk', '~> 1.6'
+ gem 'aws-sdk'
```

```sh
$ bundle update paperclip aws-sdk
```

最新の aws-sdk では各サービスごとに gem が分かれている（aws-sdk-s3, aws-sdk-rds, etc...）ため、本来的には Paperclip に必要な aws-sdk-s3 のみを取り込むという選択をすべきだと思います。今回のケースでは、初期開発から担当していなかったため aws-sdk の利用用途が完全には絞り切れなかったこと、該当システムではそれなりに多くのユースケースを有しているが、その全ケースを検証するだけのリソース確保が難しかったことなどがあり、より安全な方向へ倒す方針で aws-sdk をそのままアップデートすることにしました。

# アップデート後の確認作業

## spec の実行

```shell
$ RAILS_ENV=test bundle exec rails db:migrate:reset
$ bundle exec rails spec
```

確認作業として１番はじめにやることは・・・そう、テストの実行ですね。該当システムでは RSpec を採用していますので、そちらを実行して既存の機能が担保できていることを確認しました。

なお、全体のテストを通す際は、先に `RAILS_ENV=test bundle exec rails db:migrate:reset` を実行し、DB を１度真っさらな状態にしてから実施することをお勧めします。時々「**初回でのみエラーとなり、２回目以降はうまくいく**」という事象に出会うことがあるので、そういう不具合を見つけるのに有用だと思います。

さて、テストは問題無く完了したのですが、よく見てみると `db/schema.rb` に差分が出ていました。

```diff

$ git status
	modified:   db/schema.rb

$ git diff db/schema.rb
       :

-    t.integer  "picture_file_size",    limit: 4
+    t.integer  "picture_file_size",    limit: 8
       :

-    t.integer  "movie_file_size",      limit: 4
+    t.integer  "movie_file_size",      limit: 8
       :

```

Paperclip(6.1.0) から、これまで **int(4 バイト)** で扱われていた `file_size` が **bigint(8 バイト)** で扱われるように変更されたようです。

https://github.com/thoughtbot/paperclip/commit/34ec355e43e91c63288aab956a604f17471d4e59#diff-ea01ca461ea6204a96f865ebabb2d994R8

これは受け入れたい変更ではあるのですが、システム仕様としては **int(4 バイト)** のままでも問題無いこと、該当箇所が多いためマイグレーションに時間がかかってしまう（＝その間サービスを止めてしまう）かもしれないこと（**※2**）などを考慮して、既存の DB 構成を維持するようパッチを当てました。

```rb:config/initializers/paperclip.rb 
module Paperclip
  module Schema
    COLUMNS = {:file_name    => :string,
               :content_type => :string,
               # :file_size    => :bigint,
               :file_size    => :integer,
               :updated_at   => :datetime}
  end
end
```

**※2**
過去に数百万レコードのデータが投入されているテーブルに対してマイグレーションを仕掛けた際、その処理が終了するのに１時間ほどかかってしまったことがありました。今回はそこまでのレコード数ではなかったのですが、対象のテーブル数がそれなりにあったため、より安全な方向で調整することにしました。

## 実際にファイルをアップロード

次にローカル環境で実際に数種類のファイルのアップロードを試してみました。その後のダウンロードやストリーミング再生等も問題なく動いているようでした。

# 満を持してステージング環境へデプロイ

プログラム的にも実際の動作的にも確認が取れたため、満を持してステージング環境へデプロイ、QA を開始してもらいました。QA からの返答は「**いくつかのファイルがアップロード出来ませんでした**」というものでした。

## ローカル環境で再確認

アップロード出来なかったファイルをいただき、ローカル環境で試してみると・・・問題なくアップロード出来てしまいました。。。

システム開発の現場では、こういう「環境差による動きの違い」というものにはよく出くわしますので、慌てることなく「ローカル環境では動いた」という１つの情報だと捉えて次に進むことにしました。

## ステージング環境のログを調査

アップロードできなかったのは mp4 のファイルでしたが、ログを調査してみたところ、content-type のバリデーションに引っかかっているようでした。該当箇所では下記のように mp4 のファイルを許容するように実装されています。

```rb
validates_attachment_content_type :movie, content_type: %w(video/mp4 ...)
```

実際にどういう状態だったのかを確認するために、バリデーションエラーとなった際の content-type をログ出力するようにしてみたところ

```sh
content-type: 'video/3gpp'
```

という結果が得られました。`'video/3gpp'` は許容されているものではないためエラーとなっていたようです。そもそもアップロードしようといているファイルの形式がおかしいのかも（？）とも考えましたが、同ファイルは旧環境では `'video/mp4'` のファイルとして取り扱えているとのことでした。（3gpp は mp4 互換のファイル形式であるため、その動き自体は納得できるものでした。）

少し手詰まりになってしまいましたが、調査を進めるために各環境で同ファイルの content-type がどのように扱われるのかを調べてみることにしました。

```sh:新環境：Paperclip(6.1.0)
$ file -b --mime invalid.mp4
video/3gpp; charset=binary
```
```sh:旧環境：Paperclip(4.3.6)
$ file -b --mime invalid.mp4
video/3gpp; charset=binary
```
```sh:ローカル環境：Paperclip(6.1.0)
$ file -b --mime invalid.mp4
video/mp4; charset=binary
```

状況を整理すると下記のようになります。

<table>
<tr>
  <th colspan=2>gem アップデート前</th>
  <th colspan=2>gem アップデート後</th>
</tr>
<tr>
  <th>アップロード</th>
  <th>content-type</th>
  <th>アップロード</th>
  <th>content-type</th>
</tr>
<tr>
  <td style="text-align: center">○</td>
  <td>video/mp4</td>
  <td style="text-align: center">○</td>
  <td>video/mp4</td>
</tr>
<tr>
  <td style="text-align: center">○</td>
  <td>video/3gpp</td>
  <td style="text-align: center">×</td>
  <td>video/3gpp</td>
</tr>
</table>

gem をアップデートしたことによって、より厳密に content-type を判定するようになったのかな？と思いつつ、仮にそうだとしても**これまで扱えていたファイルが扱えなくなるというのは利用者視点では損失でしかない**ため、どうにか対応する必要があるなと思いながら Paperclip の内部へ侵入することにしました。

# Paperclip の content-type を決定するロジックを調査

Paperclip には `Paperclip::ContentTypeDetector#detect` というメソッドが存在していて、そこで content-type を判定しています。

https://github.com/thoughtbot/paperclip/blob/master/lib/paperclip/content_type_detector.rb#L28

きっとここのロジックに手が入って content-type をより厳密に扱うようにしたのだろうと思い、さっそく新旧の環境で試してみました。

```rb:新環境：Paperclip(6.1.0)
> detector = Paperclip::ContentTypeDetector.new('invalid.mp4')
> detector.detect
# => "video/3gpp"
```

「よしよし、想定通り」

```rb:旧環境：Paperclip(4.3.6)
> detector = Paperclip::ContentTypeDetector.new('invalid.mp4')
> detector.detect
# => "video/3gpp"
```

「え？旧環境でも同じ？？？」

その結果、旧環境：Paperclip(4.3.6) でも該当のファイルは同じように `'video/3gpp'` として判定されているようでした。ではなぜそのファイルが `'video/mp4'` として保存されていたのでしょう・・・？どこかに「**mp4 互換のものは mp4 として扱う**」というようなマッピング処理がされているのかな？と推測しながら調査を進めました。

# Paperclip と 3gpp / mp4

Paperclip と 3gpp / mp4 についてググってみたり、[thoughtbot/paperclip](https://github.com/thoughtbot/paperclip) のリポジトリ内を検索してみましたが、有用な情報は得られませんでした。どこかでマッピングしているのだろうと思っていたのですが、どうもそれは検討違いだったようです。。。

# 該当システムでファイルを添付するときの動きを再現する

Paperclip のロジックに当たりを付けるのが難しくなってきたため、実際の動きを再現する方向で調査を進めることにしました。該当システムでは

- （１）JavaScript で見た目を演出しながらモデルを仮保存
- （２）↑ にファイル情報を付与して update

という動きになっていて（２）でエラーとなっていたので、その動きを試してみました。該当システムは Rails で開発されているため、ファイルアップロード時は `ActionDispatch::Http::UploadedFile` のインスタンスを受け取ることになります。

```rb:新環境：Paperclip(6.1.0)
> model = Model.create!(temporary: true)
> uploaded_file = ActionDispatch::Http::UploadedFile.new({ tempfile: File.open('invalid.mp4'), filename: 'original_filename.mp4', type: 'video/mp4' })
> model.movie = uploaded_file
> m.movie
# => #<Paperclip::Attachment:0x00005639fe4158f0
@instance=
  #<Model:0x00005639fe7967b8
   movie_content_type: "video/3gpp",  # <= やはり "video/3gpp" となっている
```

```rb:旧環境：Paperclip(4.3.6)
> model = Model.create!(temporary: true)
> uploaded_file = ActionDispatch::Http::UploadedFile.new({ tempfile: File.open('invalid.mp4'), filename: 'original_filename.mp4', type: 'video/mp4' })
> model.movie = uploaded_file
> m.movie
# => #<Paperclip::Attachment:0x000055a4136138e0
@instance=
  #<Model:0x000055a414446ca0
   movie_content_type: "video/mp4",  # <= "video/mp4" となっている!!!!!
```

ここでようやく差分を見つけることが出来ました。

旧環境：Paperclip(4.3.6) では `uploaded_file.content_type` をそのまま採用して `'video/mp4'` となっているのに対して、新環境：Paperclip(6.1.0) ではその他の方法（`Paperclip::ContentTypeDetector#detect`？）で判定して `'video/3gpp'` となっているように見えました。

# Paperclip のファイル添付ロジックを深掘り

Paperclip ではさまざまな種類のオブジェクトを添付できるように対応されています。それらは `IO Adapters` と呼ばれ `lib/paperclip/io_adapters` というディレクトリ配下にまとめられています。

[IO Adapters](https://github.com/thoughtbot/paperclip#io-adapters)
[lib/paperlip/io_adapters](https://github.com/thoughtbot/paperclip/tree/master/lib/paperclip/io_adapters)

旧環境：Paperclip(4.3.6) と新環境：Paperclip(6.1.0) を見比べていたところ、 `lib/paperclip/io_adapters/uploaded_file_adapter.rb` に差分を見つけることが出来ました。

```rb:Paperclip(4.3.6)
def content_type_detector
  self.class.content_type_detector
end
```

```rb:Paperclip(6.1.0)
def content_type_detector
  self.class.content_type_detector || Paperclip::ContentTypeDetector
end
```

Paperclip:6.1.0 ではデフォルトで `Paperclip::ContentTypeDetector` が利用されるように対応されています。この修正が入ったことによって旧環境との差分が生まれてしまっていたようです。

こちらの修正が入った経緯を調べてみると、`uploaded_file.content_type` をそのまま採用するとどんな形式のファイルでもアップロード出来てしまうために対応が入れられたようでした。

https://github.com/thoughtbot/paperclip/pull/2270

一見するとこちらの処理を外すとセキュリティ的に問題がありそうに見えますが、同時期に Paperclip では添付ファイルの content-type 指定が必須になっていたり、spoof 対策（**※3**）が入っていたり、該当システムは登録制であるため悪意のあるユーザーがあまり想定されないことなどを考慮して、処理を以前の形に戻すことにしました。

```rb:config/initializers/paperclip.rb
module Paperclip
  class UploadedFileAdapter < AbstractAdapter
    def content_type_detector
      # self.class.content_type_detector || Paperclip::ContentTypeDetector
      self.class.content_type_detector
    end
  end
end
```

これで無事、利用者へ影響を与えることなく Amazon S3 の署名バージョン 4 に対応することができました。

**※3**
 Paperclip では `file` コマンドで `mime type` を取得し、その形式と実際のファイル形式が合致するか否かのチェックを実施しています。

# まとめ

これまでもさまざまな開発現場に関わってきましたが、それらは全て唯一無二のものでした。今回も単純な gem のアップデートを目指しましたが、それでさえ「現行サービスへの影響度」「対応リソース」「堅牢性」「容易性」などいくつもの要素と向き合い、パッチを必要とするものとなりました。

どんな開発でも杓子定規に進められるものではないということを再度心に刻みつつ、今後もその時々の状況に合わせてより良いプロダクトを生む一助となっていければと思います。