行動すれば次の現実

テック中心の個人ブログ

RailsでPostgreSQLの配列型の項目に対して検索をする

PostgreSQLの配列型に対して「〜を含む」という検索したかったので調べました。

配列型はアンチパターンなのですが、そこは目を瞑ってください。。

環境

  • ruby 2.7.4
  • rails 5.2.5
  • PostgreSQL 13.4

Migration

ブログ(Blogs)に複数のタグ(tags)を付与できるテーブルを作成します

class CreateBlogs < ActiveRecord::Migration[5.2]
  def change
    create_table :blogs do |t|
      t.references :user, foreign_key: true
      t.string :title, null: false
      t.text :detail, null: false
      t.text :tags, array: true

      t.timestamps
    end
  end
end

実装

railsというタグを保持しているブログを検索する場合

  • SQLで書くと
select * from blogs where 'rails' = ANY(tags)
  • Railsで書くと
Blog.where("'rails' = ANY(tags)")

AWS SDK for Rubyを使ってS3上のファイル存在チェックをする方法

AWS SDK for Rubyを使用して、S3上に特定のファイルが存在するかどうかチェックする方法をまとめました。

前提

AWS SDK for Ruby Version 3のaws-sdk-s3を使用します。

github.com

実装

# 事前に初期化しておく
@s3_client = Aws::S3::Client.new

def exists?(key)
  @s3_client.head_object(key)
  true
rescue StandardError
  false
end

Aws::S3::Client#head_objectというメソッドを使用します。 head_objectとはオブジェクトのメタ情報のみを取得するメソッドです。 get_objectだとオブジェクト自体を取得してしまいますが、head_objectだと取得するのはファイルのメタ情報のみですので軽量でオススメです。

rescue StandardErrorしているのは、ファイルが存在しない場合にAws::S3::Errors::NotFoundが発生するためです。権限エラー時は別の例外クラスが発生したりしますので、一律StandardErrorとしてキャッチしています。

迷ったら公式リファレンスをチェックしよう

「Aws::S3::Clientを使用して他のことをやりたいんだけど、サンプルソースが見つからない」という場合は公式リファレンスをチェックすると良いでしょう。

https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html にアクセスすると、使用できるメソッドの一覧が表示されます。単語でページ内検索をして、やりたいことに近いメソッドを検索しましょう。

AWS SDKはS3:Client以外のもさまざまなクラスが存在します。他のクラスの仕様を確認したい場合は、URLのAws/S3/Client.htmlのところを該当のクラスに変更すればAPIリファレンスが確認できます。

RubyのAWS SDKを使用してS3からファイルをダウンロードするときのエンコーディング問題

S3 bucketにあるファイルをダウンロードしてTempfileに書き込みしようとしたところ以下のエラーが発生しました。

 Encoding::UndefinedConversionError:
       "\xE5" from ASCII-8BIT to UTF-8

その時のコードは以下の通りです

s3_client = Aws::S3::Client.new
Tempfile.open(file_name) do |file|
  s3_client.get_object({ bucket: 'your-bucket', key: 'your-key' }, target: file)
  file
end

ファイルはCSV形式でエンコーディングはUTF-8なのですが、ASCII-8BITとして見なされているようです。 原因と対処方法を整理したので、同様のことで悩んでいる方はぜひ参考にしてみてください。

エラーの原因

ASCII-8BITとはバイトストリームを取り扱う際に使用される特殊なエンコーディングです。S3上のファイルはエンコーディングがUTF-8のCSVファイルなのですが、S3からget_objectする際はバイナリデータとしてダウンロードされます。

バイナリデータをTempfileに焼き付けようとしたためEncoding::UndefinedConversionErrorのエラーが発生したようです。

Tempfileは画像などのバイナリデータをIO.binmodeとしてバイナリモードにする必要があります。

対処方法

コードを下記に変更することで解決しました。

s3_client = Aws::S3::Client.new
Tempfile.open(file_name) do |file|
  s3_client.get_object({ bucket: 'your-bucket', key: 'your-key' }, target: File.open(file, 'wb'))
  file
end

バイナリ形式の書き込みモード(wb)でFile.openしてあげてからファイルを書き込むようにします。 こうすることでTempfileへのバイナリストリームでの書き込みが可能となります。

もう一つの方法として、以下のようにTempfileをbinmodeにしたものを使用して書き込む方法もあります。 今回はタイプ数の少ないFile.openの方を採用しましたが、こちらの対応のほうが一般的のようです。

temp = Tempfile.new('temp.csv')
temp.binmode
File.read(temp.path, encoding: "bom|utf-8")

参考

ruby - File encoding issue when downloading file from AWS S3 - Stack Overflow

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を使っていくことはあるかと思います。 システム要件やデータ数を元に適切なプランを採択することが重要なのだと感じました。