行動すれば次の現実

テック中心の個人ブログ

CarrierWaveでAWS S3 にファイルをアップロードしようとするときに発生する403エラーについて | Rails

carrierwaveとfog-awsを使用してファイルをアップロードしようとしたところ以下のエラーが発生しました。

 Excon::Error::Forbidden:
       Expected(200) <=> Actual(403 Forbidden)

このエラーの原因と対処法を整理しましたので同様のことでお悩みの方はぜひ参考にしてみてください。

エラーの原因

AWS S3のバケットには「ブロックパブリックアクセス」について設定する項目があります。 ブロックパブリックアクセスとは、リソースへのアクセスについてセキュリティ高めることができる設定です。 説明すると長くなるので、この記事では説明は省略します。

ブロックパブリックアクセスについてはこのの記事が参考になると思います。 aws s3のパブリックアクセスについてまとめる - Qiita

デフォルトではブロックパブリックアクセスは全てオンになっています。

f:id:furu07yu:20211105143552j:plain
ブロックパブリックアクセス

ブロックパブリックアクセスが全てオンなのにも関わらず、carrierwave.rbにはfog_public = trueが設定されています。 fog_publicとはリソースを公開する場合はtrue、公開しない場合はfalseを設定します。(デフォルトはtrueが設定されます)

S3バケットのブロックパブリックアクセスとcarrierwaveの設定内容が矛盾しているためエラーが発生しています。

require 'carrierwave/storage/abstract'
require 'carrierwave/storage/file'
require 'carrierwave/storage/fog'

CarrierWave.configure do |config|
  config.storage :fog
  config.fog_provider = 'fog/aws'
  config.fog_directory = ENV['S3_BUDGET_NAME']
  # アップロードしたリソースを公開する場合はtrue
  config.fog_public = true
  config.fog_credentials = {
    provider: 'AWS',
    aws_access_key_id: ENV['S3_BUDGET_ACCESS_KEY'],
    aws_secret_access_key: ENV['S3_BUDGET_SECRET_ACCESS_KEY'],
    region: 'ap-northeast-1',
    path_style: true
  }
end

対処方法

対処方法は2つあります。いずれかの方法を採ると良いでしょう。

fog_public = falseにする

fog_public = falseにすることで、リソースを非公開状態でアップロードできます。 ブロックパブリックアクセスは有効なままですのでバケット内のセキュリティが保たれます。

デメリットとしては、S3上のリソースをブラウザ等からURLを指定して直接ダウンロードすることが出来ないことです。 そのため、ダウンロード用のアクションを配置するなどして、サーバー上でダウンロードしてからクライアントに返却する必要があります。

しかしながら、fog-awsではfog_publicがfalseの場合、fog_authenticated_url_expirationというオプションが自動で有効になります。 600秒間有効な署名付きURLを自動で発行するという機能です。

これを活用することで「URLを指定して直接ダウンロードできない」というデメリットがほとんど解決されるのではないかと思います。

ブロックパブリックアクセスをオフにする(非推奨)

もう一つの方法はブロックパブリックアクセスをオフに変更することです。 オフにすることで、fog_public = trueとの矛盾がなくなり、エラーが発生しなくなります。

しかし、セキュリティホールが発生してしまうリスクが伴いますので個人的にはオススメしません。 セキュリティ要件に応じて、アップロードリソース毎にパブリックアクセスの可否を設定する必要が生じるので管理コストも増えてしまいます。

参考

carrierwave/fog.rb at master · carrierwaveuploader/carrierwave · GitHub

Heroku Postgresで発生する「sql_error_code = 28000 FATAL: no pg_hba.conf entry for host "xxx.xxx.xx.xx", user "xxxx", database "yyyy", SSL off」について

Heroku PostgresをHobbyプランからStandardにアップグレードしたところ以下のログが頻発するようになりました。

sql_error_code = 28000 FATAL: no pg_hba.conf entry for host "xxx.xxx.xx.xx", user "xxxxx", database "xx", SSL off

FATALレベルなのですが、アプリケーションからDBには正常に接続できており、特に影響はないように見えます。

弊社システムではPapertrailを使用してエラー系のログを吸い上げているのですが、当該ログが高頻度で出力されるため、ノイズになってしまい困っていました。

ログの原因

「sql_error_code = 28000」データベースへのログイン施行に失敗したことを意味するようです。また、その際のアクセスがSSL通信でないことに対するアラートになります。

Heroku Postgresはインターネットというパブリックな場所に存在するため、ある程度の不正アクセスは発生してしまいます。 不正アクセスと言っても、Heroku Postgresのユーザー名、パスワード、データベース名の組み合わせはランダムに生成されます。不正なログイン施行はほとんど無駄なアクセスになりますので過度に心配する必要はなさそうです。

対処方法

「sql_error_code = 28000をアラート対象から外す」などの対応が有効かと思います。

また、類似のログとして「sql_error_code = 0A000」というものもあります。 sql_error_code = 0A000 FATAL: unsupported frontend protocol x.x: server supports 2.0 to 3.0

これはサポート外のフロントエンドプロトコルによるアクセスの場合に発生します。nmapなどのツールがそれに該当されます。 これも同様にアラート対象から除外するのが良さそうです。

参考

Seeing "FATAL: no pg_hba.conf entry" errors in Postgres - Heroku Help

Why am I seeing connection errors for my Heroku Postgres database from an unexpected IP address? What are these unauthorized connection attempts? - Heroku Help

RubyでS3にある巨大なファイルをストリーム処理する方法

最近の案件で、AWS S3に配置された1GB程度のCSVファイルを処理することがありました。 そのままメモリに載せてしまうとすぐにメモリオーバーになってしまうので、ストリーム処理で実装することにしました。 その時の内容を整理してみましたので、同様のことで悩んでいる方の参考になれば嬉しいです。

前提

S3クライアントとして「aws-sdk-s3」を使用します。

github.com

ローカルにS3のファイルダウンロードする

まずは、S3にあるファイルをローカルにダウンロードします。

S3から直接ストリーミングしながら処理をしていくことも可能なのですが、途中でネットワーク等に異常があった場合のリカバリを考慮する必要があるので、ローカルにダウンロードしてから処理することをオススメします。

実装

事前にS3のクライアントをインスタンス変数で作成しておきます。

Aws.config.update(
  region: 'ap-northeast-1',
  credentials: Aws::Credentials.new(ENV['S3_BUDGET_ACCESS_KEY'], ENV['S3_BUDGET_SECRET_ACCESS_KEY'])
)
@bucket = ENV['S3_BUDGET_NAME']
@s3_client = Aws::S3::Client.new

ローカル上の「tmp/temp_file.csv(任意の名前)」というファイルに、S3上の@bucket内にある「s3_file.csv(任意の名前)」の内容をコピーします。

コピー時に大量のデータ転送が発生しますので、ある程度の時間を要します。

File.open('tmp/temp_file.csv', 'wb') do |file|
  @s3_client.get_object({ bucket: @bucket, key:'s3_file.csv' }, target: file)
end

ファイルをストリーム処理する

ローカル上のファイルをCSV.newでCSVファイルに変換して、eachで一行ずつ読み込みます。 こうすることでストリーム処理でファイルを読み込むことができます。

メモリに乗るのは一行ごとのデータになりますので、メモリに優しく処理をすることができます。

File.open('tmp/temp_file.csv', 'r') do |file|
  CSV.new(file).each do |row|
    # ここに処理を書いていきます
  end
end

参考:

Downloading Objects from Amazon S3 using the AWS SDK for Ruby | AWS Developer Tools Blog

Heroku PostgresのHobbyプランが異常な遅さだった話

Herokuを使ったシステム開発をしているときにDBのアクセスが異様に遅いことがありました。 その時の調査内容を記事にまとめてみました。同じ境遇の方がいるかもしれないので、ぜひ参考にしてみてください。

ランダムに発生するH12エラー

Heroku PostgresのHobby Basicプランは最大1000万レコードまでという制約はありますが、小規模な社内システムなどの開発であれば今まで問題なく使えていました。

今回の開発もHobby Basicを使用して実装を進めていたのですが、あるテーブルの一覧画面において頻繁にH12エラー(Request Timeout)が発生することがわかりました。

その画面はkaminariとransackを用いた極めて一般的な一覧画面です。

該当のテーブルは約60万件のレコード数を保持しており、外部キー以外はインデックスを貼っていませんでした。

実行計画を確認してみるとSeq Scanが発生していたのでsort keyにIndexを貼っていないことが原因かと思い、インデックスを貼ってみました。しかし解決されません。

H12エラーは毎回発生するわけではなく数時間に一回程度の頻度でした。一度発生するとしばらく発生しないという特徴がありました。

アプリのキャッシュが関係しているのかと思い、dynoを再起動させてキャッシュをクリアしてみましたが、H12エラーは発生せず、再現方法がわかりませんでした。

Hobbyプランはインメモリデータベースでない

dynoを再起動しても再現しないことからアプリが原因であるという線は薄くなりました。 となるとDBサーバーが原因なのではないかと思い、改めてHobby Basic Planの仕様を確認してみました。

Hobby Basic

  • Row Limit 10,000,000
  • Storage Capacity 10 GB
  • RAM 0 Bytes

確認するとメモリが0バイト(RAM 0 Bytes)であることがわかりました。要するにインメモリデータベースではないということです。

データーベースはディスクアクセスを避けるためにメモリにデータをキャッシュさせておき、高速化を図ります。 しかしHobby basicの場合はインメモリを使用しないので、高頻度にディスクにアクセスをするということです。

インメモリデータベースにアップグレード

インメモリデータベースでないことが原因なのかを確かめるために、Standard 0プランにアップグレードしてみます。 Standard 0プランの仕様は以下のとおりです。

Standard 0

  • Row Limit none
  • Storage Capacity 64 GB
  • RAM 4 GB

アップグレードしてアクセスしてみると、H12エラーは発生しなくなりました。平均的なアクセス時間も格段に短縮されました。

適切なプランを選択することが大事

今回の事象を通じてインメモリデータベースの重要性を思い知らされました。

Hobby basicは安価で魅力的なプランですが、扱うデータ数によっては力不足なケースがあります。 レコード数があまり多くない、パフォーマンスをそこまで追求しないシステムであればHobby basicでも十分だと思います。

私も今後の開発においてもHobby basicを使っていくことはあるかと思います。 システム要件やデータ数を元に適切なプランを採択することが重要なのだと感じました。

Hashを使って大量のActiveRecordをキャッシュに載せる方法 | Ruby on Rails

ループ処理の中で、あるテーブルに対してアクセスしなければならない場合、ループ毎にテーブルをfindするのは非効率かもしれません。事前に必要なテーブルデータを抽出しておき、そのキャッシュを使って処理するほうが効率的な場合があります。

しかし、このような実装方法をとると、取り扱うデータ量によってはメモリを逼迫する要因になり兼ねません。

そこで、なるべくメモリを消費せずに大量データをキャッシュさせる方法を考えてみようと思います。

ActiveRecordオブジェクトをHashにする

一般的な方法は対象オブジェクトをHashにする方法です。

keyを対象オブジェクトのid、valueを対象オブジェクトとしたHashにします。

この方法は実装がシンプルなので使われるケースが多いかと思います。

ただし、ActiveRecordオブジェクト自体がキャッシュに乗ることになるので、大量データを扱う場合は多くのメモリを消費してしまいます。

# 対象オブジェクトをHashにする
users = User.all.map {|d| [d.id, d] }.to_h

# または以下でも同様の結果が得られます。
users = User.all.index_by(&:id)

# 取得する場合
user = users[user_id]

ActiveRecordオブジェクトから特定の項目のみHashにする

データベースから取得した結果をmapで必要な項目のみ抽出して、Hashにする方法です。

サンプルコードのようにキャッシュに乗るのはid、 first_name、last_name、ageのみに限定されるように見えます。

項目を絞っているので、オブジェクト自体を取得するよりもメモリの消費を抑えることができます。

ただし、この方法だとallで取得したデータを一度キャッシュに乗せる必要があるので、一時的にメモリ消費量が大きくなります。 メモリ使用量は時間とともに徐々に落ち着いてきますが、メモリリークの引き金になるかもしれません。

users = User.all.map do |user 
  [user.id, 
  {
    first_name: user.first_name,
    last_name: user.last_name,
    age: user.age
  }]
end.to_h

ActiveRecordオブジェクトをpluckしてからHashにする

pluckを使用することでSELECT句レベルで項目が絞られた状態からHashにすることができます。 直接all.mapするよりも取得するデータ量が減るのでメモリ消費が抑えられます。

pluckすることにより可読性は低下しますが、パフォーマンスを重視したい場合はこの方法を取ると良いでしょう。

users = User.all.pluck(:id, :first_name, :last_name, :age).map do |user 
  [user[0], 
  {
    first_name: user[1],
    last_name: user[2],
    age: user[3]
  }]
end.to_h

Rubyで文字列がURL形式かどうか正規表現でチェックする | Rails

Rubyで文字列がURL形式であるかチェックする場合は URI::DEFAULT_PARSER.make_regexp.match を使用します。

これを使用することで、引数に渡された文字列がURI形式であるかどうかをチェックすることができます。

引数の文字列がURI形式であればMatchDataオブジェクト、URI形式でない場合はnilが返却されます。

> URI::DEFAULT_PARSER.make_regexp.match('https://example.com')
=> #<MatchData "https://example.com" 1:"https" 2:nil 3:nil 4:"example.com" 5:nil 6:nil 7:nil 8:nil 9:nil>

> URI::DEFAULT_PARSER.make_regexp.match('example.com')
=> nil

URL以外の文字列が含まれる場合は注意が必要

URL以外の文字列が含まれる場合、regexp.matchで最初に一致した結果がMatchDataとして返却されますので、その点は注意が必要です。

> match = URI::DEFAULT_PARSER.make_regexp.match('サイトURLはhttps://example.comです https://example.jp')
=> #<MatchData "https://example.com" 1:"https" 2:nil 3:nil 4:"example.com" 5:nil 6:nil 7:nil 8:nil 9:nil>

to_sすることで最初に一致したURLを取得できます。

> match.to_s
=> "https://example.com"

URL判定メソッドの実装例

以下のようなメソッドを実装してURL形式であるか判定することが可能です。

def url?(url_string)
  URI::DEFAULT_PARSER.make_regexp.match(url_string).present?
end

とても便利なメソッドなのでぜひ使用してみてください。

HerokuのDBを有料プラン(Hobby)に切り替える方法

Herokuのposgresql DB無料版から有料版(Hobby以上)に移行する手順をまとめます。

1. 有料版DB(Hobby)を作成する

Herokuの管理画面からAdd-onsを追加するか、下記コマンドを実行します。

heroku addons:create heroku-postgresql:hobby-basic your-app-name

2. 現状の構成を確認

環境変数DATABASE_URLはHobby-devに当たっていることが確認できます。

$ heroku pg:info
=== DATABASE_URL
Plan:                  Hobby-dev
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:24 UTC
Data Size:             9.0 MB/1.00 GB (In compliance)
Tables:                11
Rows:                  30/10000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-aerodynamic-111111

=== HEROKU_POSTGRESQL_IVORY_URL
Plan:                  Hobby-basic
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:31 UTC
Data Size:             8.9 MB/10.00 GB (In compliance)
Tables:                11
Rows:                  30/10000000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-symmetrical-222222

DBをコピーする

DATABASE_URLのデータをHEROKU_POSTGRESQL_IVORY_URLにコピーします。

heroku pg:copy DATABASE_URL HEROKU_POSTGRESQL_IVORY_URL

DBを切り替える

HEROKU_POSTGRESQL_IVORY_URLに切り替えます。

heroku pg:promote HEROKU_POSTGRESQL_IVORY_URL

切り替え後の状態を確認

HEROKU_POSTGRESQL_IVORY_URLとDATABASE_URLの環境変数には同じ値に変更され、旧DBはHEROKU_POSTGRESQL_PUCE_URLに置き換わっていることが確認できます。

heroku pg:info

=== HEROKU_POSTGRESQL_IVORY_URL, DATABASE_URL
Plan:                  Hobby-basic
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:31 UTC
Data Size:             8.9 MB/10.00 GB (In compliance)
Tables:                11
Rows:                  30/10000000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-symmetrical-222222

=== HEROKU_POSTGRESQL_PUCE_URL
Plan:                  Hobby-dev
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:24 UTC
Data Size:             9.0 MB/1.00 GB (In compliance)
Tables:                11
Rows:                  30/10000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-aerodynamic-111111

旧DBを削除する

HEROKU_POSTGRESQL_PUCE_URLは不要なので削除しておきます。

heroku addons:destroy HEROKU_POSTGRESQL_PUCE_URL