こんにちは。インフラエンジニアの id:tsub511 です。
私は Dynamoid のメンテナではないのですが、弊社内で今回それなりに大きい問題が起きて、得た知見も大きかったため記事にしました。
TL;DR
Dynamoid にスレッドセーフではない実装があったが PR をマージしてもらって修正済み。
2019/08/13 時点ではまだリリースされていないようなので、エラーで困っている方は master ブランチをお使いください。
今回起きた問題
弊サービスでは Sidekiq 上で Dynamoid を使っています。
基本的に問題なく稼働していたのですが、デプロイ時に Sidekiq を再起動した後、Bugsnag に以下のような二種類のエラーが継続的に飛んできました。
このエラーが起きると、自然には回復しないため、Sidekiq のワーカーを再起動する必要があります。
また、NoMethodError
という一般的な例外クラスのため ActiveJob の retry_on
によるリトライ処理の考慮はしておらず、大事なジョブが実行されないままになってしまうのも問題です。
エラーをパッと見ただけでは、キャッシュの実装に考慮漏れがあるのかな?とか、インスタンスが生成されていてコード上ではメソッドが実装されているのになんで undefined method エラーが起きるんだ?などと、不思議なエラーが出ていて調査が難航しそうな印象でした。
何が原因だったのか
Sidekiq + Dynamoid でピンと来る方もいると思いますが、エラーの原因は Dynamoid にスレッドセーフではない実装があったことでした。
スレッドセーフではない実装がどこにあったのか探すために、ここで発生していたエラーをもう一度見てみます。
まず 1 つ目のエラーは nil
に対して #[]
を呼び出そうとしてエラーになっていますが、nil
になっている変数は table_cache
です。
table_cache
は以下で初期化されています。
# https://github.com/Dynamoid/dynamoid/blob/v3.2.0/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb#L66-L69 def connect! @client = Aws::DynamoDB::Client.new(connection_config) @table_cache = {} end
そして、この初期化のための Dynamoid::AdapterPlugin::AwsSdkV3#connect!
を呼び出しているのは Dynamoid::Adapter#adapter
です。
adapter.connect!
の部分に if adapter.respond_to?(:connect!)
という条件がありますが、ここが false
になっていて adapter.connect!
が実行されていないため、table_cache
の初期化処理が動いていないようです。
# https://github.com/Dynamoid/dynamoid/blob/v3.2.0/lib/dynamoid/adapter.rb#L29-L37 def adapter unless @adapter_.value adapter = self.class.adapter_plugin_class.new adapter.connect! if adapter.respond_to?(:connect!) @adapter_.compare_and_set(nil, adapter) clear_cache! end @adapter_.value end
2 つ目のエラーも見てみます。
こちらは Dynamoid::AdapterPlugin::AwsSdkV3
のインスタンス adapter
に対して query
を実行しようとしてメソッドが定義されていないというエラーです。
しかし、実際のコードにはメソッドが定義されています。
https://github.com/Dynamoid/dynamoid/blob/v3.2.0/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb#L489-L500 def query(table_name, options = {}) Enumerator.new do |yielder| table = describe_table(table_name) Query.new(client, table, options).call.each do |page| yielder.yield( page.items.map { |row| result_item_to_hash(row) }, last_evaluated_key: page.last_evaluated_key ) end end end
視点を変えて、adapter
インスタンスを定義しているコードを見てみると、やはり Dynamoid::Adapter#adapter
に行き着きます。
# https://github.com/Dynamoid/dynamoid/blob/v3.2.0/lib/dynamoid/adapter.rb#L29-L37 def adapter unless @adapter_.value adapter = self.class.adapter_plugin_class.new adapter.connect! if adapter.respond_to?(:connect!) @adapter_.compare_and_set(nil, adapter) clear_cache! end @adapter_.value end
self.class.adapter_plugin_class.new
で生成したものをメモ化しています。
メモ化には concurrent-ruby を使っていて、過去に対策されたようなのでそこは問題なさそうです。
self.class.adapter_plugin_class.new
の先が怪しそうなのでコードを見てみると、なにやら動的に require
しています。
# https://github.com/Dynamoid/dynamoid/blob/v3.2.0/lib/dynamoid/adapter.rb#L181-L187 def self.adapter_plugin_class unless Dynamoid.const_defined?(:AdapterPlugin) && Dynamoid::AdapterPlugin.const_defined?(Dynamoid::Config.adapter.camelcase) require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}" end Dynamoid::AdapterPlugin.const_get(Dynamoid::Config.adapter.camelcase) end
実はスレッドセーフではない実装はこの require
する条件の Dynamoid.const_defined?(:AdapterPlugin) && Dynamoid::AdapterPlugin.const_defined?(Dynamoid::Config.adapter.camelcase)
の部分です。
エラーを再現できるコードを Gist に用意しましたのでそちらを使って確認していきます。
Test codes to reproduce not thread-safe errors of Dynamoid · GitHub
Dynamoid に以下のような変更を加えて、unless
の中に入らず require
が実行されなかった時の状態を見てみます。
diff --git a/lib/dynamoid/adapter.rb b/lib/dynamoid/adapter.rb index f390ecf..df2a58c 100644 --- a/lib/dynamoid/adapter.rb +++ b/lib/dynamoid/adapter.rb @@ -181,6 +181,13 @@ module Dynamoid def self.adapter_plugin_class unless Dynamoid.const_defined?(:AdapterPlugin) && Dynamoid::AdapterPlugin.const_defined?(Dynamoid::Config.adapter.camelcase) require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}" + else + tmp_adapter = Dynamoid::AdapterPlugin.const_get(Dynamoid::Config.adapter.camelcase).new + puts <<~EOS + respond_to?(:connect!): #{tmp_adapter.respond_to?(:connect!)}, + respond_to?(:query): #{tmp_adapter.respond_to?(:query)}, + require: #{require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}"} + EOS end Dynamoid::AdapterPlugin.const_get(Dynamoid::Config.adapter.camelcase)
すると、スレッドによっては const_defined?
の結果が true
で、require
の結果も false
(コードがロード済み) なのに、実際のメソッドが存在しないという現象が起きていることが分かりました。
$ bundle exec ruby main.rb ... respond_to?(:connect!): false, respond_to?(:query): false, require: false ...
ここからは Ruby の require
の実装を知らないため推測になります。
おそらくマルチスレッド環境下で require
を実行すると、require
を実行したスレッド内では全てのコードがロードされた状態になりますが、別のスレッドではクラス定義などの「ガワ」だけがロードされた状態になっているのではないかと思いました。
そのため、ロードが不十分なスレッドでインスタンスを生成できるが、メソッドが定義されていない、という状態になっているのかと思われます。
解決方法
よって、全てのスレッドで確実に require
を実行することで今回のエラーが解決するという結論に至りました。
diff --git a/lib/dynamoid/adapter.rb b/lib/dynamoid/adapter.rb index f390ecf..8449e34 100644 --- a/lib/dynamoid/adapter.rb +++ b/lib/dynamoid/adapter.rb @@ -179,9 +179,7 @@ module Dynamoid end def self.adapter_plugin_class - unless Dynamoid.const_defined?(:AdapterPlugin) && Dynamoid::AdapterPlugin.const_defined?(Dynamoid::Config.adapter.camelcase) - require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}" - end + require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}" Dynamoid::AdapterPlugin.const_get(Dynamoid::Config.adapter.camelcase) end
エラーの原因と解決方法が判明したため、既に Dynamoid に PR を作りマージまでしてもらいました。
2019/08/13 時点ではまだリリースされていないようなので、エラーで困っている方は master ブランチをお使いください。
調査に苦労した点
マルチスレッドプログラミングの経験が浅いため、まずスレッドセーフではない実装があるということに気づくまでに時間がかかりました。
そして、エラーを再現しようとした時になかなか再現出来なかったのもハマりポイントでした。
エラーの再現コードを読むと分かりますが、Dynamoid のメソッドを呼ぶ直前に puts
を実行しています。
# https://gist.github.com/tsub/72e60233ed82a8a453428ea7441e6017#file-main-rb 100.times do |i| safe_thread(i.to_s) do puts 'debug' # To unlock Ruby's GVL Document.where(identifier: 'hoge').first end end
この puts
が重要で、Ruby は GVL (Giant VM Lock) という仕組みを使って、実行されるネイティブスレッドが 1 つになるように排他制御をしています。
ただし、IO 関連のメソッドを実行する際は GVL が一時的に解放されてスレッドが同時に実行されます。
ネイティブスレッドを用いて実装されていますが、 現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行される ネイティブスレッドは常にひとつです。 ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。 また拡張ライブラリから GVL を操作できるので、複数のスレッドを 同時に実行するような拡張ライブラリは作成可能です。
つまりスレッドセーフではない実装があった場合に、それを再現させるためには単純にスレッドセーフではないコードを書くだけではダメで、IO 関連のメソッドを実行して GVL を解放しないといけません。
少し古い記事ではありますが、こちらが参考になりました。
実際の本番環境ではログ出力などにより、IO 関連のメソッドは普通に実行されていることが多いかと思いますので、エラーが起きるのも納得です。