行動すれば次の現実

テック中心の個人ブログ

【Carrierwave+fog-aws】特定バケットへのアクセス権限を持ってS3にアップロードさせる方法

RailsアプリでS3にファイルをアップロードする場合、Carrierwaveとfog-awsを使用することが多いと思います。複数アプリをS3バケットを切り替えて使用する際に、ユーザーごとにバケットのアクセス権限を与えることで、思わぬ事故を防いだり、セキュリティを向上させることができます。

今回は、特定バケットのみアクセスできる権限を持ったCarrierwaveとfog-aws周り環境設定を解説いたします。

インストールとアプリ側の設定

Gem

  • carrierwaveとfog-awsをインストールします
  • 環境変数管理用のdotenvもインストールします
gem 'carrierwave'
gem 'fog-aws'
gem 'dotenv'

Carrierwave設定ファイル

  • Carrierwaveの設定ファイルを作成します

config/initializers/carrierwave.rb

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

CarrierWave.configure do |config|
  config.storage :fog
  config.fog_provider = 'fog/aws'
  config.fog_directory = ENV['S3_BUCKET_NAME']
  config.fog_public = true
  config.fog_credentials = {
    provider: 'AWS',
    aws_access_key_id: ENV['S3_ACCESS_KEY'],
    aws_secret_access_key: ENV['S3_SECRET_ACCESS_KEY'],
    region: 'ap-northeast-1',
    path_style: true
  }
end

Modelへのマウント

  • 例として、Paymentモデルのreceipt_fileにReceiptUploaderをマウントします

app/models/payment.rb

class Payment < ApplicationRecord
  mount_uploader :receipt_file, ReceiptUploader
end

app/uploaders/receipt_uploader.rb

class ReceiptUploader < CarrierWave::Uploader::Base

  def extension_white_list
    %w[pdf]
  end
end

AWSの設定

AWSではS3とIAMのサービスを使用します。

S3の設定

S3バケットの作成を作成します。

S3バケットの作成

  • 任意の名前でバケットを作成します。

  • パブリックアクセスはデフォルトのオフのままにしておきます。それ以外の項目は要件に応じて任意で変更してください。私の場合はデフォルトのままであることが多いです。

IAMの設定

huzzah-bucketのみにアクセスできる権限(ポリシー)を作成して、ユーザーに付与します。

ポリシーの作成

  • ポリシーを使用すると、ユーザーに対して権限を割り当てることができます。JSONエディターで、特定バケットに対してアクセスを許可する操作を設定します。

JSONエディターの内容

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket"
            ],
            "Resource": "arn:aws:s3:::huzzah-bucket"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::huzzah-bucket/*"
        }
    ]
}
  • 任意のポリシー名を入力してポリシーを作成します

ユーザーの作成

  • 任意のユーザー名を設定します。コンソールへのアクセスは無効のままにしておきます。

  • 「ポリシーを直接アタッチする」を選択して、先程作成したポリシーにチェックしてユーザーを作成します。

ユーザーに対してアクセスキーの設定

  • ユーザーの作成が完了したら、再度ユーザーの詳細画面を開きます。「セキュリティ認証情報」タブに移動して「アクセスキーを作成」をクリックします。

  • 「コマンドラインインターフェース」にチェックを入れてアクセスキーを作成します。

  • アクセスキーとシークレットアクセスキーが作成されますので、忘れないように控えておきます。

環境変数の設定

.envに先程控えたアクセスキーとシークレットアクセスキーとバケット名を登録します。

S3_BUCKET_NAME=huzzah-bucket
S3_ACCESS_KEY=xxxxxxx
S3_SECRET_ACCESS_KEY=zzzzzz

お疲れ様でした

作業は以上となります。お疲れ様でした!

HerokuのPostgreSQLをバージョン11から14にアップグレードしてみた

Herokuから以下の通知が来ました。

  • PostgreSQL 11 reaches End of Life on 2023-Nov-09. Due to security and operational concerns, Heroku cannot run unsupported software as a service. Therefore, the following database will need to be updated before 2023-Nov-09:

  • PostgreSQL 11は2023-11-09に終了を迎えます。Herokuでは、セキュリティや運用の観点から、サポート対象外のソフトウェアをサービスとして運用することができません。

PostgreSQL 11の終了の期限が迫ってきたので、重い腰を上げてバージョンを上げることにしました。実際やってみると思っていたより簡単に終わったので思いとどまっている方の参考になれば幸いです。

アップグレード方法の選定

基本的には公式のアップグレードガイドに沿った説明になっていますが、実際作業してみての所感などを追記していますので、合わせて確認するとより理解ができるかと思います。

PostgreSQLをアップグレードする方法は2つの選択肢があります。

  1. pg:upgradeを使用する方法
  2. pg:copyを使用する方法

私はpg:copyを使用する方法を選択しました。 理由は以下のとおりです。

  • pg:copyの方が作業がシンプルであるため
  • DBは10GBを超えているがダウンタイムを許容できるため
  • Database Bloat(使用されなくなったレコードによって占有される余分な領域)を解消したいため

アップグレード手順

1. 新しいデータベースを作成する

アップグレード後のデータベースを構築します。--versionを指定しない場合、2023/01/21時点ではversion 14が適用されます。

heroku addons:create heroku-postgresql:premium-2 -a your-appname

2. アプリをメンテナンスモードをオンにする

データベースへの書き込みを停止させるためにメンテナンスモードにします。

heroku maintenance:on -a your-appname

3. 新しいデータベースにコピーする

既存のデータベースの内容を新しいデータベースにコピーします。 HEROKU_POSTGRESQL_CYAN_URLの部分は新しいデータベースの名称です。PostgreSQLの管理画面やheroku pg:infoコマンドなどで確認できます。

heroku pg:copy DATABASE_URL HEROKU_POSTGRESQL_CYAN_URL -a your-appname

ちなみに私の環境の場合は20GB程度のコピーに15分ほど掛かりました。また、肥大化の部分がカットされて容量が8GB程度に下がりました。

4. 新しいデータベースに切り替える

新しいデータベースを使用するように切り替えます。

heroku pg:promote HEROKU_POSTGRESQL_CYAN_URL -a your-appname

5. アプリをメンテナンスモードをオフにする

メンテナンスモードをオフにします。以後アプリは新しいデータベースを使用するようになります。

heroku maintenance:off -a your-appname

6. 古いデータベースを削除する

アプリが安定稼働していることを確認したら、下記のコマンドまたは管理画面から古いデータベースを削除します。

heroku addons:destroy HEROKU_POSTGRESQL_LAVENDER -a your-appname

終わりに

以上で作業は終了です。思っていたよりも簡単にアップグレードできたので、一安心しました。 細かいことを意識しなくても手順に沿えばアップグレードできてしまうあたりは、さすがのHerokuだと改めて感心しました。

Groverで非同期にPDFファイルを生成する方法 | Rails

PDFファイル生成GemとしてGroverはとても優秀なのですが、処理するデータ量によっては処理が重くなり、メモリ負荷やレスポンスタイムの懸念が付き纏います。

弊社プロダクトでも、もともと非同期処理でGroverを実装していなかったため、度々レスポンスが極端に遅くになりHeroku上でH12(タイムアウト)が発生することが有りました。

そこで今回は、Sidekiqを用いたGroverの非同期処理の実装例を紹介したいと思います。

実装例

下記は非同期処理の全体図になります。

請求書(Invoiceモデル)の内容をもとにPDFファイルを非同期で生成して、画面からダウンロードするというシナリオで実装イメージを説明していきます。

前提として、Invioceモデルにはpdf_fileというカラムを持っており、pdf_fileにはCarrierWaveをマウントしておきます。 aws-fogを使用してS3にPDFファイルを格納してクライアントからダウンロードさせるという構図になります。

Grover非同期処理の全体図

① クライアントからPDF生成アクション(POST)を実行します

クライアントは請求書を作成するために画面からPDF生成アクションを実行します。

後述するenumステータスによって、画面上に「PDF生成ボタン」「PDF作成中ローディング」「PDFダウンロードボタン」の出力を切り替えるなどすると良いかと思います。

② PDF生成アクションがPDF生成ワーカー(Sidekiq)にリクエストします

PDF生成アクションではWorkerに対してPDF生成処理のみを要求して、即座にレスポンスを返します。 Invoiceモデルには必要に応じてenumで処理状態のステータスを管理すると良いでしょう。

# endpoint
#    invoices/:invoice_id/pdf_files(post)
# action
#    invoices::pdf_files_controller#create

# スタータスの例
# - requested: 処理をリクエスト済み
# - creating: PDF作成中
# - created: PDF作成済み
# - failure: PDF作成失敗

def create
  invoice = Invoice.find(params[:invoice_id])
  invoice.requested!
  CreateInvoicePdfWorker.perform_async(invoice.id)
  redirect_to invoices_url
end

③ PDF生成ワーカーがGroverでPDFを生成してS3に配置します

ワーカーでは、PDFファイルをローカルに作成して、CarrierWave経由でS3にアップロードさせます。 コメントで処理の内容を記載していますので、詳しくはそちらを参照ください。

class CreateInvoicePdfWorker
  include Sidekiq::Worker
  def perform(invoice_id)
    # 対象のInvoiceを取得してステータスを作成中に変更します
    invoice = Invoice.find(invoice_id)
    invoice.creating!
    # html文字列を生成します
    # 生成処理は簡略化のためにダミーのHTML文字列としています
    html = "<div>請求書</div>"
    temp_file_name = "/tmp/invoice_#{invoice.id}_#{Time.zone.today.strftime('%Y%m%d%S')}.pdf"
    # pathを指定すると任意のローカルファイルにPDFファイルを書き出しをしてくれます
    Grover.new(html, path: temp_file_name).to_pdf
    File.open(temp_file_name) do |f|
      # 事前にInvoiceモデルのpdf_file項目にCarrierWaveをマウントしておきます
      # (aws-fogを使用してS3に配置します)
      # PDFファイルの内容をinvoice.pdf_fileに反映します
      invoice.pdf_file = f
    end
    # ステータスを作成済みに更新します
    invoice.created!
    # 一時ファイルは削除しておきます
    File.delete(temp_file_name)
  end
end

④ クライアントがPDF取得アクション(GET)を実行します

クライアントは請求書が「作成済み」になったら、PDF取得アクションでファイルをダウンロードします。 CarrierWaveでpdf_fileをS3にマウントしていますので、S3のパスは隠蔽したままダウンロードさせることができます。

# endpoint
#    invoices/:invoice_id/pdf_files(get)
# action
#    invoices::pdf_files_controller#index

def index
  invoice = Invoice.find(params[:invoice_id])
  send_data(invoice.pdf_file.read, filename: invoice.pdf_file_identifier)
end

【CarrierWave + aws-fog】S3上のファイルをローカルにダウンロードする方法

CarrierWaveとaws-fogを用いてS3上にアップロードしたファイルをローカルにダウンロードする方法を説明します。 サイズの大きいファイルを一旦ローカルにダンロードしてから処理したいケースなどに活用できるかと思います。

実装

モデルの定義

class Invoice < ApplicationRecord
  mount_uploader :csv_data, CsvUploader
end
  • Invoiceモデルのcsv_dataという項目にCarrierWaveをマウントします

ダウンロード処理

user = User.find(params[:id])

temp_file = Tempfile.new
# ダウロード対象がバイナリの場合は下記も実行
# temp_file.binmode

URI.parse(user.csv_data.file.url).open do |file|
  temp_file.write(file.read)
  temp_file.close
end
  • user.csv_data.file.urlを実行するとS3オブジェクトの署名付きURLが返却されます
  • Tempfileを作成して、URI.openでS3オブジェクトを取得してファイルに書き込みます。

ダウンロードしたファイルを使う

例えば、ダウンロードしたCSVファイル使って取込処理を実装する場合は以下のようになります。

File.open(temp_file) do |file|
  CSV.new(file).each do |row|
    # 取り込み処理を実装する
  end
end

Asset Pipelineの本番モードと開発モードの挙動の違い

アセットパイプラインは本番環境と開発環境で挙動が違うと言われていますが、具体的にどのように違うのかを深堀りしてみました。

そもそも本番モード、開発モードとは?

アセットパイプラインでは本番モード、開発モードで挙動が違うというのはRails開発者なら誰しもが知っていることかと思います。しかし厳密には本番モード、開発モードという個々のモードが存在するわけではありません。Railsガイドの説明上、明示的に別の表現が用いられているにすぎません。

本番モードとはconfig.assets.compile=falseの状態であることを指します。

config.assets.compileとは動的コンパイルをするかどうか制御するオプションです。 trueの場合は動的コンパイルをする、falseの場合は動的コンパイルをしません。 本番環境の場合はデフォルトでfalseが設定されます。

動的コンパイルとは、リクエストを受けたときに、最新のアセットがコンパイルされていなかった場合に、その場でコンパイルを実行してアセットを生成する仕組みのことです。アセットのコンパイルには多くのメモリを消費するため、本番環境ではconfig.assets.compile=falseにすることが原則とされています。

反対に開発環境では、動的コンパイルを有効にすることで多少メモリを消費したとしても開発作業の効率を優先して、動的コンパイルを有効にするケースが一般的です。 開発環境の場合はデフォルトでtrueが設定されます。

本番モードの仕組み

本番モードの仕組みを簡単に説明します。本番モードでは以下のようなアセットパイプラインの設定になっているのが一般的です。

config.assets.compile=false
config.assets.digest=true

config.assets.digest=trueに設定することで、コンパイルされたのファイル名に以下のようなフィンガープリントが付与されます。

/assets/application-47140618c97a4b155143c9a405b56b34e8ed3c3aeb621fb1110dc1f41c99c34c.js

javascriptの場合、アセットパイプラインはjavascript_include_tagでリンクさせます。

<%= javascript_include_tag "application" %>

このように記載することで、出力されたHTMLには以下のようなアセットがリンクされます。

ここで出力された/assets/というディレクトリの実態はデフォルトでは、pubic/aseets配下になります。

本番モードでは、アセットコンパイルは手動で行う必要がありので、以下のコマンドを実行します。

RAILS_ENV=production bin/rails assets:precompile

コマンドを実行すると、pubic/aseetsにapplication.jsのフィンガープリント付きのアセットが生成されることが確認できます。

このフィンガープリントはアセットパイプラインがコンパイルする対象のディレクトリにある全てのファイルを連結させたファイルに対してSHA-256でハッシュ化された文字列が付与されます。

アセットの内容が少しでも変更されるとフィンガープリントも変更されます。これによりWebサーバーやCDN等のキャッシュ破棄が正常に動作するようになります。

開発モードの仕組み

次に開発モードの仕組みを説明します。開発モードでは以下の設定になっているのが一般的です。

config.assets.compile=true
config.assets.digest=true

本番モードとの違いはconfig.assets.compile(動的コンパイル)がtrueであるかどうかです。

開発モードの場合、assets:precompileのような明示的なコンパイルを実行しません。動的コンパイルが働きますので、あまり意識せずともアセットパイプラインがよしなに動いてくれます。どのようにコンパイルが実行されているかは良い意味でブラックボックス化されています。

詳細は省きますが、簡単に動的コンパイルの流れを説明すると以下のようになります。(Railsのバージョンによって多少の違いがありますのでご容赦ください)

  1. /assets/**へのリクエストをSprocketsが受け取る
  2. config.assets.compile=trueなので、初回リクエストの場合はアセットをコンパイルしてキャッシュに保存して返却する
  3. 以降のリクエストではアセットに変更がないことを確認する
  4. 変更がなければキャッシュされた内容を返却する
  5. 変更がある場合は再度コンパイルした結果をキャッシュに保存して返却する

ここでいうキャッシュとはSprocketsキャッシュストアを指します。デフォルトでは、tmp/cache/assetsのパスが設定されています。

開発モードの場合、アセットが最新であるかどうかの確認やコンパイルが実行されるため、本番モードと比べてメモリ消費が多く、パフォーマンスも低いという特徴があります。

また、Rails6.0の場合下記に該当するassetsはプロジェクトディレクトリのどこにも存在しません。

このことから、動的コンパイルモードの場合はアセットそのモノを作成せずに、キャッシュを使って動的にアセットを生成していることが伺えます。コンパイル対象となるアセットに変更がある場合は、キャッシュの中身を更新した後に、動的にアセットを生成しているようです。

参考

アセットパイプライン - Railsガイド

config.assets.compile=true in Rails production, why not? - Stack Overflow

inputファイル経由ではなく、アプリ内で作成したファイルをCarrierWaveで管理する方法

CarrierWaveの使用例として、inputファイルをアップロードしてModelとリンクさせるというケースが多いかと思います。

本記事では、inputファイルではなくRailsアプリケーションで作成したファイルをModelとリンクさせる方法を紹介します。

実装例

「Reportモデルのoutput_fileという項目にCarrierWaveをマウントしている」という体での実装イメージです。

# reportオブジェクトを取得します
report = Report.find(params[:id)

# tmp/hello.txtにファイルを作成します
file_path = 'tmp/hello.txt'
File.open(file_path, 'wb') do |file|
  file.puts('hello wolrd!')
end

# reportオブジェクトのoutput_fileにファイルをリンクさせます
File.open(file_path) do |file|
  report.output_file = file
end

# ファイルを削除します
FileUtils.rm_rf file_path

CarrierWaveを使用することで、Modelとファイルのライフサイクルを一体化することが出来ますので、とても保守が楽になります。

使用できる箇所では積極的に使用していくと良いでしょう。

参考

https://github.com/carrierwaveuploader/carrierwave#activerecord

FTPに配置されたファイルを取り込む処理をRSpecでテストする方法

外部サービスとデータ連携する際に、FTPに配置されたファイルをローカルにダウンロードして、データを取り込むような処理を実装するケースは少なくないと思います。

当該処理のユニットテストを実装する場合、毎回リアルなFTPサーバーを使ってテストするのは現実的ではありません。 本記事では、FTP通信の実装例とモックを使用したRSpecのテストコードを紹介したいと思います。

net/ftpをラップしたFtpClientクラスを用意

net/ftpはRuby標準のFTPライブラリです。そのまま使うには自由度が高すぎるので、専用のラッパークラスを介してnet/ftpを扱うようにします。

下記クラスはあくまでもサンプルなので作りが甘い部分があります。実際にはエラーハンドリングなどを考慮する必要があります。

lib/utils/ftp_client.rb

require 'net/ftp'

class FtpClient

  def initialize(host, user, password)
    @host = host
    @user = user
    @password = password
  end

  def connect
    @ftp = Net::FTP.new
    @ftp.connect(@host)
    @ftp.login(@user, @password)
  end

  def close
    @ftp.close
  end

  def copy_to_local(remote_filepath, local_filepath)
    @ftp.get(remote_filepath, local_filepath)
  end
end

FtpClientの使用例

FtpClientの使用例は以下の通りです。 ファイル取込用のサービスクラス(ImportDataSerice)で、FtpClientを使用する例を記します。

app/services/import_data_service.rb

class ImportDataSerice

  def call
    # ログイン
    # 実際にはログイン情報は環境変数などで渡したほうが良いです
    ftp_client = FtpClient.new('111.111.111.111', 'ftp-user', 'password')
    # 接続開始
    ftp_client.connect
    # ファイルダウンロード
    ftp_client.copy_to_local('ftp_dir/data.csv', 'tmp/data.csv')
    # 接続終了
    ftp_client.close

    # 以下に取込処理を実装する
  end
end

RSpecの実装例

ImportDataSericeをRSpecでテストしていきます。

FtpClientを実際に接続することを避けるためにFtpClientの各メソッドをallow_any_instance_ofでモック化します。 *1

connectとcloseメソッドは単純にtrueを返す実装に変更させます。

copy_to_localメソッドは、spec/data/services/import_data_service/data.csvにあるテストデータをtmp/data.csvにコピーするという処理に置き換えます。

こうすることで、ImportDataSericeの取込処理では、tmp/data.csvに配置された状態で動作することになります。

テストデータはFTPにアップロードすることなく、ローカルのspec/data/services/import_data_service/data.csvを直接編集すれば良いので楽にテストすることが出来ます。

require 'rails_helper'

RSpec.describe ImportDataService, type: :service do
  describe 'call' do
    example 'ファイルが取り込まれること' do
        allow_any_instance_of(FtpClient).to receive(:connect) do
          true
        end
        allow_any_instance_of(FtpClient).to receive(:close) do
          true
        end
        allow_any_instance_of(FtpClient).to receive(:copy_to_local) do
          FileUtils.cp('spec/data/services/import_data_service/data.csv', 'tmp/data.csv')
        end

        ImportDataService.new.call
        # 以下で取込結果を検証していく
    end
  end
end

*1:※ allow_any_instance_ofを使用していますが、別のメソッドでもモック化出来るかもしれません