Active Recordのinsert_allをto_sqlする方法

背景

あるテーブルの200万件くらいのレコードを、別のテーブルに移行する必要があった。 移行バッチを実装するにあたって、find_in_batchesのバッチサイズやバルクインサートの単位を、中間オブジェクトや生成されるINSERT文のデータサイズから決めたかった。

find_in_batchesにおけるバッチサイズは移行バッチを実行するサーバーに割り当てるメモリ量を左右するし、PostgreSQLは実行可能なクエリサイズの上限を決めるオプション(MySQLで言うところのmax_allowed_packet)はなく、プロセスに割り当てられているメモリがそれにあたるため。

今回は特にPostgreSQLで実行可能なクエリサイズを上限の範囲内に収まるかどうかを検証する課程で、ActiveRecordのinsert_allメソッドからINSERT文をdumpする方法を模索したので、備忘として書いておきたい。

やったこと

find_in_batchesの各バッチの中で、insert_allメソッドを使っていたので、作成されるINSERT文を文字列として取得することにした。 参照系のクエリだとクエリの実行前にto_sqlできるが、insert_allなどの書き込み系のクエリだと呼び出し時に実行されてしまう。 それらしき公開APIなども存在しなかったので、INSERT文をdumpする方法を探してみた。

Active Recordのソースコードを見てみると、insert_allメソッドの中でInsertAllクラスのインスタンスが呼び出されており、このInsertAllクラスのprivateメソッドにto_sqlが実装されていることがわかった。

insert_allで渡す引数をそのまま、ActiveRecord::InsertAll.newとしてto_sqlメソッドを呼び出したところ、ほしかったINSERT文を出力することができた。

to_sqlする方法

以下、例

# 内部APIを使って、insert_allメソッドをto_sqlする 
query = ActiveRecord::InsertAll.new(対象のモデル, 保存対象の配列, on_duplicate: 重複時の挙動).send(:to_sql)

puts query

得たクエリ文字列を使って、byteサイズを出したい場合は以下のようにする

# 内部APIを使って、insert_allメソッドをto_sqlする 
query = ActiveRecord::InsertAll.new(対象のモデル, 保存対象の配列, on_duplicate: 重複時の挙動).send(:to_sql)

# 文字列のバイト数を取る
puts query.bytesize

GCPのリソースと認証・認可の方法

GCP上のリソースにアクセスする場合、公式から提供されているCLIやプログラムからアクセスするためのSDKなど、使用するクライアントに応じて複数の認証・認可の方法が用意されている。 どのような場合にどの認証・認可方法を採用すべきか迷うことが多いので、整理しておきたい。

まず、認証と認可について簡単に整理しておく。Auth0のドキュメントより

  • 認証: ユーザーが本人かどうかを確認するプロセスのこと
  • 認可: リソースがどの対象にアクセスすることができるか?どのアクションを実行することができるか?を確認するプロセスのこと

認証・認可の種類

操作によって認証・認可の方法が異なる

  • CLIGCPで提供されているCLIを使用する場合
  • 【プログラム】GCPAPISDKなどのプログラムを実行する場合

CLIGCPで提供されている各CLIを使用する場合

  • gcloud CLIを使用するためにはGoogleアカウントを使用した認証が必要となる
  • 認可の方式は、2通り用意されている

  • ユーザーアカウントによる認可

  • サービスアカウントによる認可

1. ユーザーアカウントによる認可

  • Google Cloudアカウント(Googleユーザー)を使用した認可方法
  • 開発者のPCからgcloud cliを使用して作業する場合にはこの方式を使用することが推奨される

認可の方法

  • gcloud init
    • 認可 + configurationsも同時に作成してくれるコマンド
    • 対象のユーザーアカウントにはじめて認可する場合に使用する
  • gcloud auth login
    • 認可のみを行うコマンド

2. サービスアカウントによる認可

  • 特定のGoogleユーザーとは関係のないサービスアカウントを使用した認可方法
  • Cloud SDK を本番環境へのマシンのデプロイの一環としてインストールして設定する場合や、すべてのユーザーが root にアクセスできる Compute Engine 仮想マシン インスタンスで使用する場合に推奨される。
    • CIやCDなど
    • 運用効率化のためのシステムから各コマンド実行したい場合は、特定のユーザーに紐づいていない形で実行したいはずなのでこっち?

認可の方法

  • gcloud auth login (--cred-fileオプションを設定)

詳しくは以下

【プログラム】GCPAPISDKなどのプログラムを実行する場合

  • Application Default Credentials (ADC)を使うことが推奨される
  • ADCに則ることで、Google Client LibraryやGoogle API Client LibraryなどのSDKを使用したアプリケーションプログラムから認可済の認証情報を自動で検索して使用することがきる
  • ADCを使用すると、同じ認証・認可方法で開発環境や本番環境でコードを実行することができるので管理もしやすい

ADCによる認可済認証情報の検索

  1. GOOGLE_APPLICATION_CREDENTIALS環境変数

  2. gcloud cliで設定されたユーザー認証情報

    • gcloud auth application-default login
    • 提供された認証情報(通常は Google アカウントからの認証情報)を含むJSON ファイルがファイル システム上に配置される
      • LinuxMacだと$HOME/.config/gcloud/application_default_credentials.json
      • 操作対象のアカウントが持つ複数のプロジェクトの認証情報がJSONファイルに書き込まれる(1アカウントで複数のプロジェクトのリソースに認証を通すことができる)
  3. 接続済のサービスアカウント

    • VMをはじめとしたGoogle Cloudのリソースに紐付けられたサービスアカウント
    • コンピューティングリソースに設定されたサービスアカウントに権限をもたせれば、わざわざアプリケーションにサービスアカウントを設定する必要はない

上から順番に検索に該当したものが選択・使用される

詳しくは以下

使い分けの判断方法

リモート環境での認証・認可方法の使い分けに関しては、公式ドキュメントに判断のためのフローチャートが用意されていた。

ローカル環境でクライアント毎にどの認証・認可の方法を採用するとよさそうか、同じようにフローチャートで整理しておく。

ローカル環境編

リモート環境編

データベース間のテーブル差分を比較するスクリプトを作成した

同じスキーマを持つ異なるデータベース間でテーブルの差分を確認したいことがあって、テーブル毎に差分を確認するのも面倒なのでデータベースの全テーブルを比較して差分を出力するスクリプトを作った。 今後もデータのマイグレーションなどで使えそうなので、備忘として残しておく。

作ったもの

#!/bin/bash

BASE_DB_URL="postgresql://user:password@host:5432/db_name" # 比較元のDBのURL
TARGET_DB_URL="postgresql://user:password@host:5432/db_name" # 比較対象のDBのURL
TABLES=`psql "$BASE_DB_URL" -t -A -F"," -c "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';"`

while IFS="," read -r TABLE_NAME
do
  echo "=== $TABLE_NAME ==="

  data-diff \
  "$BASE_DB_URL" \
  "$TABLE_NAME" \
  "$TARGET_DB_URL" \
  "$TABLE_NAME" \
  -k id \
  --json || { echo "idカラムが存在しないテーブルです"; continue; }
  # -d \ # デバッグ情報の表示
  # -v \ # 詳細情報の表示
  # -c activity \ # その他の比較対象にするカラム
  # -w "event_timestamp < '2022-10-10'" # where句を指定
done <<EOD
$TABLES
EOD

data-diffというテーブル同士の差分を比較するツールに、データベース間のテーブル同士の差分を確認する機能もあったので、それを活用した。 今回はPostgreSQLのデータベースで使用することが前提。

  1. 比較元のデータベースから比較対象のテーブル一覧を取得する
  2. テーブルごとに比較元のデータベースと比較対象のデータベースをdata-diffコマンドで比較(idカラムが存在しないテーブルは比較対象外に)
  3. 結果をjson形式で出力する

というシンプルなものを作成した。

取りたい情報や絞り込みたい条件がある場合は、コメントアウトしている各オプションを指定してやる。

参考

data-diff公式ドキュメント

2023年の振り返り

年も瀬なので、開発関連の取り組みを雑に書きなぐっておこうと思う。 また来年の年の瀬に見てやるべきことがしっかりやれたかを確認できればと。

今年やれたこと・やってみたこと

SREっぽい動き

特に組織上SREというポジションに就いているわけではないが、ソフトウェアエンジニアとして、担当しているサービスや一部その関連サービスの

などに関わることができた。 特にインフラ領域に関しては、業務委託としてお手伝いいただいている専門家の方とプロジェクトを一緒に進める中で、試行錯誤して作った既存のアウトプットが正しくよいものへブラッシュアップされていくことが嬉しかったし、そこからいろいろ学ばせてもらうことができている。

SLOに対してのボトルネック特定に関しては、HerokuのDynoが突然ダウンする問題が半年ほど問題になっていたが、PumaやPostgreSQLサーバーのメトリクスを可視化するところからAPMによる計測までを行いながら問題を切り分けていった。 結果的にSQLクエリ自体や投げ方のまずい箇所が特定と改善ができたし、HerokuのH12エラー起因の障害について何が原因になりうるかについて分かったことも多かった。

「推測するな、計測せよ」を実感した。

技術負債のコントロール

サービスのスケールに伴って、チームで技術負債とうまく付き合っていくために、以下のような取り組みをはじめてみた

  • 時間がかかる技術課題や顕在化した負債を計画的に解消していくために、プロダクトチームのOKRとして目標化を行う
  • 週次の開発振り返り会(DevOps MTG)の実施

技術課題を起票する目的や解消のための時間確保について、よりチームで意識できるようになってよかったと感じている。

技術広報

つい2,3ヶ月の間の話ではあるが、会社のテックブログの編集長になった。 一旦これまでの整理として、振り返りを行った上でこれまでのブログ運用の方針を引き直してみたりした。 また、今年は他社と共同で勉強会やLTを主催する試みを年の後半からはじめて、年内に2回開催することができた、継続してよいイベントにしていきたい。

個人開発

共有家計簿のアプリ、彼女が経理の仕事をしているということもあり、お金の管理観点で自分達の使いやすいものを作っている。 たまたま社内に同じような家計簿の管理をしたいメンバーがいたので使ってもらっていて、自分たち以外からもフィードバックが返ってくるので結構楽しく開発を続けられている。

やりきれなかったこと

開発チームのアウトカムを分かりやすく説明すること

プロダクト開発とは直接的に関わりのない部署や役割のメンバーに対して、開発チームのアウトプットやアウトカムにわかりやすく説明することがなかなか難しく感じることが多かった。

まずは、意識的に開発起点の情報にふれる接点を増やしていくこと。 徐々に各役割で追っているKPIをブレークダウンして、開発のアウトカムとしていくことが大事だと今のところ考えている。

個人としての発信

ブログ執筆を推進していく立場なので、テックブログや個人でも投稿の数・質ともに追っていく必要がある。 現状、それぞれに月1で投稿する目標がありつつも達成からは程遠い。。。

来年やっていきたいこと

  • ブログ月1投稿(テックブログ・個人ともに)
  • 勉強会・LTイベントの継続
  • 事業のアウトカムとソフトウェアエンジニアリングの貢献について、数字も交えて説明していくこと
  • SREやプラットフォームエンジニアリングについての理解や実践
  • インフラやセキュリティ領域の基礎を養うこと
  • GCPk8sなどの技術のキャッチアップと実践

GraphQL RubyでIntrospectionクエリをクライアント別に制御する方法

GraphQL Rubyを使用しているプロジェクトで、Introspectionクエリをクライアント毎に有効化、無効化の制御を行いたいことがあったので、その方法を備忘として残しておきたいと思います。

前提

graphql-rubyでは、GraphQL::Schemaの継承クラスでdisable_introspection_entry_pointsを定義することで、Intorospectionのエントリーポイント(__schema,__type)を無効化することがでできます。

公式ドキュメントより

class MySchema < GraphQL::Schema
  disable_introspection_entry_points if Rails.env.production?
end

ただ、例のように本番環境のみIntrospectionクエリを無効化するようなユースケースであれば問題ないですが、リクエスト元のクライアントを判定して、Introspectionクエリの有効、無効を細かく制御したい場合にはそのまま使うことができません。

調べてみたところ、Introspectionを拡張することでContext情報に基づいたエントリーポイントの制御ができるようでした。

また、動作環境は以下を前提とします。

Context情報に基づいたエントリーポイントの制御

GraphQL Rubyの作者のrmosolgoさんのgistでその方法が紹介されているので、参考にしつつ説明していきます。

CustomIntrospectionモジュールを定義しIntrospection名前空間内で、Introspectionクエリで出力される各オブジェクトの定義を上書きます。

  • __Schema: built-in introspection types
  • __schema: introspection entry points
  • __typename: dynamic, globally-available fields

各オブジェクトのFieldやSchema定義のvisible?メソッドをoverrideし、Context情報が特定の条件に該当する場合にのみFieldやSchemaが出力されるようにします。

app/graphql/custom_introspection.rb

module CustomIntrospection
    module HideIntrospectionByContext
      def visible?(ctx)
        super &&
          if introspection?
            ctx[:introspection_permitted]
          else
            true
          end
      end
    end

    # globally-available fields
    class IntrospectionField < GraphQL::Schema::Field
      include HideIntrospectionByContext
    end

    # dynamic fields
    class DynamicFields < GraphQL::Introspection::DynamicFields
      field_class(IntrospectionField)
      field :__typename, String, null: false
    end

    # introspection entry points
    class EntryPoints < GraphQL::Introspection::EntryPoints
      field_class(IntrospectionField)
      field :__type, GraphQL::Introspection::TypeType do
        argument :name, String
      end
    end

    # built-in introspection types
    class SchemaType < GraphQL::Introspection::SchemaType
      extend HideIntrospectionByContext
    end
  end

上記で定義した、作成したカスタムCustomIntrospectionモジュールをGraphQL::Schemaの継承クラスに定義します。

app/graphql/my_schema.rb

class MySchema < GraphQL::Schema

    introspection(CustomIntrospection)

    ...
end

出力の例

通常のクエリは問題なく実行できます(visible?メソッド内のintrospection?でIntrospectionクエリのみを制御の対象にできている)

pp MySchema.execute(things_query_str, context: {}).to_h

# {"data"=>
#   {"things"=>
#     [{"name"=>"Pogo Stick"},
#      {"name"=>"Immersion Blender"},
#      {"name"=>"Ceiling Fan"}]}}

introspection_permittedがtrueの場合はIntrospectionクエリの実行が可能です

pp MySchema.execute(GraphQL::Introspection::INTROSPECTION_QUERY, context: { introspection_permitted: true }).to_h

# {"code"=>"useAndDefineFragment", "fragmentName"=>"FullType"}}]}
# {"data"=>
#   {"__schema"=>
#     {"queryType"=>{"name"=>"Query"},
#      "mutationType"=>nil,
#      "subscriptionType"=>nil,
#      "types"=> # ...

introspection_permittedがContext情報にない場合は、Introspectionクエリに失敗します

pp MySchema.execute(GraphQL::Introspection::INTROSPECTION_QUERY, context: {}).to_h

# {"errors"=>
#   [{"message"=>"Field '__schema' doesn't exist on type 'Query'",
# ...

まとめ

  • GraphQL::Schemaの継承クラスでdisable_introspection_entry_pointsを定義することで、環境変数や定数をベースに全体のIntrospectionクエリの実行可否を制御することは可能
  • クエリを実行してくるクライアント毎にIntrospectionクエリの実行可否を制御するには、Introspection名前空間内で、visible?メソッドのオーバーライドしContextに基づいたスキーマ情報の可視、不可視の切り替えを行うことで実現することができる

参考

open-uriライブラリ10kbの罠

RakeタスクでとあるCSVファイルをダウンロードしながら(CSV.foreach)、データの加工やDBへの保存処理を行うスクリプトを作成していたところ、以下にあるような課題に当たって、ヘルパーメソッドを作成したので背景とともに残しておく

背景と課題

  1. ローカルで手軽にRakeタスクを実行したいが、HerokuでホストされているDBへのIOに時間がかかる
    • heroku runコマンドで実行するRakeタスクの引数に、GCSの公開バケットのURLを指定して、DBとスクリプトの実行環境を近づけることに
  2. GCSのバケットにあらかじめアップロードしておいたCSVファイルをURI.parse('<https://storage.google.xxx.com/sample.csv>').openを使用し、http経由でダウンロードするようにしたところ、10kb以下のファイルがStringIOで返ってくるためうまくいかない

openメソッドはOpenURI::BufferStringMax 定数(10kb)を見てバッファ(StringIO)で処理するかどうかを判断しているおり、ファイル中のデータサイズが10kb以下の場合でそれぞれ、返されるオブジェクトの種類が異なる。 一方、CSV.foreach はIO stream可能なオブジェクトを引数にする必要があるため、一工夫必要

つくったもの

  • CSV.foreachで扱えるIO stream可能なオブジェクトを返すヘルパーメソッドを作成してみた
  • ついでにパス・URL指定どちらも指定できるように
require 'open-uri'

def read_as_path_or_io(path_or_url)
  safe_uri = URI.parse(path_or_url)

  # localのファイルシステムから参照
  if safe_uri.scheme.nil?
    return path_or_url
  end

  stream = safe_uri.open

  # 10kb以上のファイルをhttp or ftpから参照
  return stream if stream.respond_to?(:path)

  # 10kb以下のファイルをhttp or ftpから参照
  Tempfile.new.tap do |file|
    file.binmode
    IO.copy_stream(stream, file)
    stream.close
    file.rewind
  end
end

# 呼び出し
temp_file = read_as_path_or_io('ローカルのファイルパス or URL')
CSV.foreach(temp_file, encoding: 'UTF-8', headers: true).do |csv|
  p csv
end

参考

https://github.com/ruby/csv/blob/master/lib/csv.rb https://github.com/gitlabhq/gitlabhq/blob/master/spec/support/helpers/rake_helpers.rb https://stackoverflow.com/questions/31527154/rails-open-returns-stringio-instead-of-tempfile https://docs.ruby-lang.org/ja/latest/library/open=2duri.html https://stackoverflow.com/questions/31527154/rails-open-returns-stringio-instead-of-tempfile https://docs.ruby-lang.org/ja/latest/class/Tempfile.html

Railsの初期化コードと置き場所

Railsでconfig配下に置く各種初期化ファイルについて、それぞれのユースケースや実行の順番について整理しておきます。

今回はRailsガイドでも挙げられている、代表的な初期化コードの配置および定義場所を扱います。

  • config/application.rb
  • config/environments 配下
  • config/initializers 配下
  • initialization events

config/application.rb

Railsアプリケーションの起動時にgemなどに依存しない、1番はじめに実行させたい共通の初期化コードを書きます。

Rails::ApplicationはRails::Railtieを間接的に継承しているため、config/application.rbでinitializerメソッドを使用して、アプリケーションの初期化処理を定義することができます。

config/environments 配下

環境毎の初期化コードが書かれた設定ファイルを置きます。 例えば、config/environments/production.rbなどが該当します。

config.active_storage.serviceを、環境毎に:local:amazonなどに変更するような使い方をイメージするとよいです。

config/initializers 配下

環境にかかわらず共通で行う初期化コードが書かれた設定ファイルを置きます。 gemやRailsフレームワークで使用する初期化コードが置かれることが多いです。

initialization events

Rails::Application, Rails::Railtie, Rails::Engineのサブクラスで使用可能な初期化時のイベントフックが5つ存在します。

  • before_configuration
  • before_initialize
  • to_prepare
  • before_eager_load
  • after_initialize

after_initialize 使用されることが多いイベントフックで - Railsアプリケーション(フレームワーク)の初期化が完了した後の初期化コード - gemに依存する初期化コード などを書きます。

実行の順番

実行の順番についてまとめておくとそれぞれ、以下となります。

初期化の順番

  1. config/application.rb
  2. config/environments 配下
  3. config/initializers 配下

また、config/initializers配下のファイルの読み込みの順番はアルファベットや数字の並び順にファイルが読み込まれるため、01,02のようなプレフィックスをファイル名につけることで、実行の順番を保証することができます。

初期化イベントの呼び出される順番

  • initialization events
    1. config/application.rbに定義
    2. config/environments 配下に定義
    3. config/initializers 配下に定義

実際に動かしてみる

実際に各初期化ファイルやコードがどの順番で実行されているかも確認しておきます。 開発者モード(RAILS_ENV=development)で検証しています。

config/application.rb

class Application < Rails::Application
    ...
    puts "config/application.rb"
    ...
end

config/environments/development.rb

Rails.application.configure do
    ...
  puts "config/environments/development.rb"
  ...
end

config/initializers/application_controller_renderer.rb

...
puts "config/initializers/application_controller_renderer.rb"
...

config/initializers/mime_types.rb

...
puts "config/initializers/mime_types.rb"
...

結果

config/application.rb
config/environments/development.rb
config/initializers/application_controller_renderer.rb
config/initializers/mime_types.rb

上記に加えて、各ファイルでafter_initializeを定義してみます。

config/application.rb

class Application < Rails::Application
    ...
    puts "config/application.rb"

    Rails.application.config.after_initialize do
        puts "config/application.rb | after_initialize"
    end
    ...
end

config/environments/development.rb

Rails.application.configure do
    ...
  puts "config/environments/development.rb"

    Rails.application.config.after_initialize do
        puts "config/environments/development.rb | after_initialize"
    end
  ...
end

config/initializers/application_controller_renderer.rb

...
puts "config/initializers/application_controller_renderer.rb"

Rails.application.config.after_initialize do  
  puts 'config/initializers/application_controller_renderer.rb | after_initialize'  
end
...

結果

config/application.rb
config/environments/development.rb
config/initializers/application_controller_renderer.rb
config/initializers/mime_types.rb
↓after_initialize↓
config/application.rb | after_initialize
config/environments/development.rb | after_initialize
config/initializers/cors.rb | after_initialize

Config gemを使う場合の注意

ここからは番外編です。 定数値を管理するためにConfig gemがよく使用されるかと思いますが、gem側で初期化のタイミングを必ず一番はじめに強制する挙動があるため注意が必要です。

特に、Config gemの初期化の前に、Rails.applicationの初期化に依存する事前処理を行いたい場合に考慮が必要となります。 initialization eventsのbefore_configurationを使用したとしても、対象の処理をConfig gemの初期化処理よりも前に持ってくることができません。

これは、Config gemがRails::Railtie継承したクラス内で、before_configurationを定義することで初期化のタイミングをトップレベルに持ってきていることによる挙動となります。

そのため、config/initializers/config.rbの初期化ブロックの一番最初の処理として、Rails.applicationの初期化に依存する処理を書くことになります。

Config.setup do |config|
    Rails.application...
    ...
end

参考

https://guides.rubyonrails.org/initialization.html https://guides.rubyonrails.org/configuring.html https://github.com/rubyconfig/config/blob/master/lib/config/integrations/rails/railtie.rb