Edited at

HerokuでCarrierWaveを使う場合に注意すること

More than 5 years have passed since last update.


update


記事中ではCarrierWaveのキャッシュ機能を使用できないと記載していますが、v0.10.0よりキャッシュの保存先をS3などに指定できるようになっているようです。

詳しくは以下の記事などを参照してください。

CarrierwaveでS3にアップロードさせるとき、キャッシュもS3に置く - Qiita

現時点でキャッシュストレージをS3に変更してテストをしていませんが、パフォーマンス上の問題が発生しないようであれば、こちらを選択するのもありかなと思います。

ファイル名長さの制限はherokuではなくS3(ファイルパス含め1000バイト?)のものになり、かなり緩和されることになります。


Ruby/on Rails には CarrierWave というファイルアップロード用の Gem があります。

人気の Gem なので使っている方も多いと思いますが、同じくRailsの実行環境として人気のHeroku で使用する場合には、いくつかの注意点がありました。


キャッシュディレクトリを ./tmp に変更する


CarrierWave はファイルアップロードの際、最終的な保存先であるストアディレクトリへ保存する前に、まずキャッシュディレクトリへ一時保存します。

ストアディレクトリは多くの方が Amazon S3などを利用するよう設定していると思いますが、キャッシュディレクトリもデフォルトから変更した方が良いです。

CarrierWave はデフォルトのルートを ./public にしているので、キャッシュディレクトリは ./public/tmp/uploads/になってしまいます。

当然ながらここは公開ディレクトリ。サービスの性格によっては一般に公開したくないようなファイルをアップロードする場合もあるでしょう。

安全に運用するためにも、ここは通常エンドユーザからアクセスできない領域にキャッシュディレクトリを設定しておきたいものです。

キャッシュディレクトリを変更するためには、下記のうちいずれかを、config/initializers/carrierwave.rb の本番環境用の設定に追加すると良いです。

config.root = Rails.root.join('tmp')

config.cache_dir = "#{Rails.root}/tmp/uploads"

あまりやらないとは思いますが、./app/uploaders/ に配置されるアップローダクラスでもキャッシュディレクトリの定義を行うことができるので、carrierwave.rb の設定を上書きしないように注意する必要があります。

実際のところ、キャッシュディレクトリに置かれたアップロードファイルはストアディレクトリへ保存後削除される(デフォルト動作。変更可能)うえ、一時保存のために生成されるサブディレクトリ名は時刻やプロセスIDから決定されますし、最大でも24時間経てば強制的に消されることになります。

サービスの性格上あまり気にしなくても良い、というのであれば設定の変更は行わなくても良いと思います。



※ キャッシュディレクトリの設定変更について誤った記述をしていたため、修正しています。

以前は CarrierWave 公式の下記記述を根拠に、この設定変更は必須だと書いていました。

https://github.com/jnicklas/carrierwave/wiki/How-to%3A-Make-Carrierwave-work-on-Heroku

要約すると「Heroku は ./tmp と ./log 以外の書き込みを禁止しているので、設定変更しないと正常に動作しない」といったことが書かれています。

これは Bamboo スタックという Heroku の過去の環境の話としては正しいのですが、現在は Cedar スタックがデフォルトなので、多くの場合当てはまりません。

CarrierWave 公式は下記 Heroku 公式サイトの情報を元にしているのですが、アップデートしてほしいところですね。

https://devcenter.heroku.com/articles/read-only-filesystem

Cedarスタックでの説明はこちらです。

https://devcenter.heroku.com/articles/dynos#ephemeral-filesystem

Bamboo スタックの書き込み制限を撤廃する代わり、WebDyno のリサイクルのタイミングで最新の deploy 内容のコピーで上書き更新されるようになっています。

そのため、一時保存はどこにでも可能ですが最長24時間で消えることになります。

この仕組みを heroku は "ephemeral filesystem" と呼んでいるようです。

ephemeral は一日限りとか短命とかいう意味です。


アップロードファイル名を255バイト以内に制限する


上に書いたように、CarrierWave を使用してファイルアップロードする場合、一度キャッシュディレクトリに一時保存されてからストアディレクトリへコピーされる仕組みになっています。

この仕組み自体は設定変更などで変えられない、CarrierWave の仕様です。

このため、アップロードファイルはキャッシュディレクトリのファイルシステムの制限を受けることになります。

※ 実際にはストアディレクトリの制限も受けますが、キャッシュディレクトリ=heroku のファイルシステムの制限の方がより強いです。

明確に仕様として公開されていないようですが、Heroku のファイルシステムは Linux で一般的な ext3 あたりのようで、ファイル名に255バイトまでしか使用できません。

エンドユーザの使用OSとして一般的な Windows/Mac だと255文字なので、255バイトを超えるファイル名のアップロードファイルが送られてくる可能性があるわけです。

何の対策も行ない状態でこのようなファイルがアップロードされると、エンドユーザが見るのは Internal Server Error の画面。

heroku は例外をスローし、以下のようなログを吐きます。

Mar 01 11:42:12 ****** app/web.1: File name too long - /app/tmp/uploads/20130301-0242-2-3952/あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやいゆえよらりるれろわいうえをあいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめ.rtf

まあそのまんまな例外ですね。

255バイトのファイル名はUTF-8であればざっくり言うと80文字ちょい。短く見積もっても40文字強なので、この例外が実運用で発生する機会はあまりないと思います。

が、やはりエンドユーザに500番を返すのはできるだけ避けたいので、できればファイル名の切り詰め処理を入れておきたいです。

それまでは個々のアップローダがそれぞれ直接 CarrierWave::Uploader::Base を継承していましたが、それをやめて、プロジェクトで使用するアップローダのベースクラスを作成。

以下のようにオーバーライドして切り詰め処理を追加しています。

  # override

def filename
@original_filename
end

# override
def original_filename=(filename)
@original_filename = super(filename)

if(@original_filename.bytesize > configatron.uploadfilename.bytesize)
original_file_extension = File.extname(@original_filename)

@original_filename.force_encoding("ascii-8bit")
@original_filename = @original_filename[0, (configatron.uploadfilename.bytesize - original_file_extension.bytesize)]
@original_filename.encode!("UTF-16BE", "UTF-8", :invalid => :replace, :undef => :replace, :replace => '')
@original_filename.encode!("UTF-8")

@original_filename += original_file_extension
end

@original_filename
end

configatron.uploadfilename.bytesize は255。

設定値として持つ必要はあまりない気もしますが、マジックナンバーぽいのも嫌だなと。

UTF-8文字列の切り詰め処理については、下記の別記事で説明しています。

UTF-8文字列を指定バイトに収まるように短くする



※ ファイル名の長さの制限について誤った記述をしていたため、修正しています。

こちらもお恥ずかしながら間違っていました。パスを含めて125文字なのかなーとか思っていまして。

それを確かめるために、実際にサポートにも聞いてみました。

I use Ruby Gem CarrierWave for My Rails Application to upload files.

I configured CarrieWave to make cache files to ./tmp directory.
And i tried many files which have long filename over 125 letters, but i saw HTTP 500 Internal Server Error.
I checked log files, heroku returned "file name too long" error message.
So my question is that Does heroku ./tmp directory have limitation of filename length ?
when I tried to upload files with 125 letters filename, i can upload them.
But when with 126 letters, i can't.
I searched your help for this, but i could not find any article about it.
Please tell me URL of article about it if you told about this problem.
thank you

・・・そこ、英語下手とか笑わない。

返事は即日で返ってきました。数時間くらいだったかな?

日本時間で金曜の夕方過ぎくらいだったので、ちょうどいいタイミングだったのかもしれません。

Hey Ohkubo,

There are limits to filename length, the filename max length should be 255 but it might change if you are using a different character set.
Cheers, ****

ファイル名の最大長は255だけど、キャラクタセットによっても変わるよって返事ですね。

この返事で Linux の255バイトの制限に思い至った訳でして(遅)。

そうそう、Herroku のサポートは結構親切な上に英語下手でもなんとか理解しようとしてくれるので、積極的に使いましょう。


ファイルの再アップロードで、CarrierWave のキャッシュを使用しない


何度も言及している通り、CarrierWave ではファイルアップロード時にキャッシュディレクトリに一時保存を行います。

このキャッシュはフォームのヴァリデーションエラーが発生した場合にも活用できるようになっています。

ふつう、ファイルアップロードフォームでヴァリデーションエラーが発生した場合は、ファイルをもう一度アップロードしなおす必要がありますが、再アップロードする代わりにすでに保持しているキャッシュを利用できるんですね。

特にこの機能に名前はついていないようなんですが、個人的にかなり嬉しい機能だなあと思いました。

でもこの機能、WebDynoを二つ以上使っている場合は利用できません。

なぜかっていうと、WebDyno は各々独立して動いているから。

公式での記述はこの辺ですね。

https://devcenter.heroku.com/articles/dynos#isolation-and-security

例えばエンドユーザがファイルアップロードを実行しようと一回目に POST してヴァリデーションエラーが発生したとして、リクエストを処理するのが web.1 の Dyno だったとします。

この時、web.1 のキャッシュディレクトリにはアップロードファイルが存在します。

でも、web.1 と独立している web.2 のキャッシュディレクトリにはファイルはありません。

この状態からエンドユーザがエラー内容を回復して再度 POST を実行したとき、そのリクエストを処理するのは web.1 でしょうか?それとも web.2 でしょうか?

答えはランダム。運よく web.1 が処理してくれると正常にファイルアップロードが完了しますが、web.2 が担当した場合、CarrierWave で例外が発生して Internal Server Error が発生します。

正直諦めるには惜しい機能だったので、例えばキャッシュディレクトリを何らかの共有ストレージに設定できないかなとかいろいろ調べてはみたんですが、最終的には難しそうって結論に達しました。

CarrierWave のキャッシュディレクトリの設定はストアディレクトリと違い、S3 などの外部ストレージを利用するようにはできておらず、かなりの改編が必要になりそうで。

heroku が共有ディレクトリの Adds-On 用意してくれたり、CarrierWave がキャッシュディレクトリを外部ストレージ利用可能ないように対応してくれたりしないかぎりは、しばらく避けて通るしかなさそうです。

mongoDBあたりをキャッシュディレクトリとして使えると良さそうなんですけどね。

この問題はステージング環境(WebDynoは1個)では構造上再現しないので、なかなか気づけないハマりどころでした。

heroku じゃなくてもキャッシュディレクトリが独立している環境って結構ありそうなので、CarrierWave 側がそのうち対応してくれるかな、と淡い期待をしています。

何か耳寄りな情報をご存じの方は教えてくださいね。



※ 再アップロード時にキャッシュを利用できない問題を加筆しました。