Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

gokart の環境変数周りでバグを発見したので、修正 PR を出したら爆速でリリースされた話

こんにちは。技術本部 R&D 研究員の青見です。

4月で社会に出て1年になりました。 この時期は花粉症が辛くて記憶がくしゃみにかき消されがちですが、入社式のやっていきを思い出して2年目も頑張っていきます。

さて、 R&D では積極的にパイプラインツールを使って開発しようという流れになってきており、その1つとして gokart を利用しています。 gokart は Luigi という Spotify が開発している Python のパイプラインライブラリのラッパです。 特徴として機械学習利用のパイプラインに特化しており、再現性の担保という観点では非常に Luigi と比べて使いやすく感じます。 gokart と Luigi について詳しくは同じチームの髙橋が連載で紹介しているので、ぜひそちらを御覧ください。 buildersbox.corp-sansan.com

今回は実際に使用していた gokart においてバグを発見したので修正の PR を出したところ、マージ後高速でリリースされ、即座に業務で利用することが出来たという話をします。

バグの発見

Luigi や gokart では処理の一つひとつをタスクという単位でまとめ、パイプラインとして実行します。 このタスクやパイプライン全体に対してパラメータをうまく扱う仕組みがあり、これらのパラメータを設定ファイル (.ini ファイルなど) として管理することが出来ます。 パラメータに関する設定ファイルは、 Python built-in の configparser に準拠しており、以下のようにセクション単位で機能やタスクのパラメータを管理します。

[TaskOnKart]
workspace_directory=./resources
local_temporary_directory=./resources/tmp

[core]
logging_conf_file=./conf/logging.ini

[TaskA]
task_a_parameter=hoge

gokart では髙橋が過去の記事で述べている通り、各タスクの中間結果を pickle ファイルで保存し再現性を担保する仕組みを持っていますが、この保存先として S3 のようなクラウドストレージを指定することができます。 上記設定ファイルの workspace_directorys3:// から始まる S3 の保存先 uri を指定することで簡単に設定が可能です。 今回私は、gokart を用いてパイプラインを作成し、 AWS 上で構築されるサービスをローカルで検証できる LocalStack 上での検証を試みました。 LocalStack を利用するに当たりエンドポイントを明示的に指定する必要がありますが、 S3 へのアクセスを行うライブラリ boto3 を Luigi 側で管理しており、Luigi のラッパである gokart も当然それに準じています。

こういった理由から直接 Luigi が持っている boto3 のクライアントオブジェクトを呼び出すことは困難ですが、ここで Luigi では設定ファイルで簡単に boto3 の設定ができることを知ります。 上記設定ファイルに [s3] セクションを追加することで、 Luigi が持っている boto3.resource() のパラメータとして値を渡すことができるのです。

[s3]
endpoint_url=http://localhost:4566

[TaskOnKart]
workspace_directory=s3://bucket_name

パラメータ管理の観点でも非常に便利です。 しかし、早速このように設定し gokart のパイプラインを実行してみると、どうしてかエラーが発生します。

[2022/02/01 06:59:38][luigi-interface][ERROR](s3.py:141) resource() got an unexpected keyword argument 'PYTHONUNBUFFERED'
[2022/02/01 06:59:38][luigi-interface][WARNING](worker.py:651) Will not run sample.Sample() or any dependencies due to error in complete() method:
Traceback (most recent call last):
  File "/opt/pysetup/.venv/lib/python3.9/site-packages/luigi/contrib/s3.py", line 135, in s3
  self._s3 = boto3.resource('s3',
  File "/opt/pysetup/.venv/lib/python3.9/site-packages/boto3/__init__.py", line 102, in resource
     return _get_default_session().resource(*args, **kwargs)
TypeError: resource() got an unexpected keyword argument 'PYTHONUNBUFFERED'

確認すると、 endpoint_url しか渡していないはずの boto3.resource()PYTHONUNBUFFERED が渡されていて、そんな引数は取ることができないとエラーになっているようです。 一体どういうことでしょうか。

原因の調査

PYTHONUNBUFFERED という変数の正体は、 Dockerfile 内で設定した環境変数でした。 試しに Dockerfile 内から PYTHONUNBUFFERED を削除すると、別の環境変数が boto3.resource() に渡されてしまいます。 どうやら環境変数が何らかの形で Luigi もしくは gokart で渡されているようです。

ところで、Luigi では設定ファイル内で ${ENVVAR} で指定した環境変数を読み込むことができ、さらに gokart は %(envvar)s で指定した環境変数を読み込むことが出来ます。 調べていくうちに、 gokart の %(envvar)s が想定とは異なる挙動をしていそうだというアタリが付きました。

そもそも %()s とは何かというと、この記法でパラメータを指定することで built-in の configparser が他のパラメータの値を展開できる仕組みを持っています。

[section_name]
hoge=hogehoge
fuga=%(hoge)s  # -> fuga=hogehoge

では、gokart はどうしてこれで環境変数が展開出来ていたのかというと、設定ファイルに存在する全てのセクションに全ての環境変数をパラメータとして先に挿入していました。 こうすると、環境変数を %(envvar)s の形式で指定すると読み込めるという理屈です。

例えば、こういった設定ファイルに対し、

[section_name]
hoge=%(ENVVAR)s

ENVVAR=foo が環境変数として設定されている場合は、configparser は以下のような状態になるということです。

[section_name]
ENVVAR=foo
hoge=%(ENVVAR)s  # -> hoge=foo

すなわち、 PYTHONUNBUFFERED が渡された状況とは、 configparser は以下のような状態になっていたから起きたものでした。

[s3]
PYTHONUNBUFFERED=1
endpoint_url=http://localhost:4566

一方で、Luigi の ${ENVVAR} 記法では別途環境変数を展開するコードが書かれており、こういった問題はありませんでした。 configparser の機能をうまく使えてしまったことで起きたバグだったようです。

修正 PR

不必要な環境変数まで configparser に取り込まれてしまう状況は、今回のような思わぬバグを引き起こします *1 。 そこで今回は m3dev/gokart に修正の PR を出しました。

大きくわけて修正の提案は 2種類です。 実際の PR はこちらになります。

github.com

gokart 独自の環境変数の展開コードを実装する

1つ目の解決策は、 Luigi が ${ENVVAR} を展開できる仕組みと同じように、 gokart でも独自の変数展開を行う仕組みを追加し、現在の環境変数が全て展開されてしまう仕組みを置き換えることです。 built-in の configparser は Interpolation という文字列を補間するオブジェクトを持っており *2 、 Luigi は独自の EnvironmentInterpolation を実装し、独自の方法で環境変数が展開される仕組みを用意していました。

gokart でも同様に独自の Interpolation を実装すれば問題は解決します *3。 注意としては、 configparser が元から持っている BasicInterpolation によって変数が評価される前に gokart 独自の Interpolation で環境変数を展開し、環境変数ではない %()s を残しておく点が挙げられました。

興味のある方は、実際の実装が以下の commit にありますので御覧ください。 https://github.com/m3dev/gokart/pull/272/commits/13d9b9e0f399eb4deb8d033167b2fb2e4a9f1376 https://github.com/m3dev/gokart/pull/272/commits/9cc41dd8ee3c339de36743a4d3cafa911e4741b5

gokart 独自の環境変数展開を行うことはやめる

2つ目は、そもそも副作用が大きいこの方式をやめることです。 該当の環境変数が全て展開されるコードは同様に取り去り、 現状の %(envvar)s による展開を完全に廃止します。 Luigi の ラッパである gokart は ${ENVVAR} によって環境変数が展開できる上に、本来の用途と少し異なる %(envvar)s による変数の展開は危険性をはらんでおり、こちらのほうがリスクが低いと考えていました。

結果として、こちらが採用されマージされました。 詳しい経緯は PR と、そのコメントで述べられている資料を御覧ください。

まとめ

PR がマージされたあとは、爆速でリリースされ、業務ですぐに使うことが出来ました。 maintainer の方々に感謝です。

f:id:nersonu:20220322194116p:plain

github.com

gokart 1.1.0 のリリースに伴い環境変数絡みのバグが無くなり、部内での gokart 活用がより推進されそうです。 今後も組織として円滑に業務ができるよう、積極的にさまざまな改善に取り組んでいきたいです。

*1:R&D の中では、プロンプト表示で用いられる PS1, _OLD_VIRTUAL_PS1 などの環境変数内でシェルが展開する値を configparser が うまく扱えずにエラーが出てしまう問題が別途起きており、今回の調査で原因が同一であったことがわかりました。

*2:この Interpolation (BasicInterpolation) が %()s を展開するという仕組みです。

*3:別途 Interpolation を実装せずに configparser の変数を iterator などで取得しようとすると、そのタイミングで BasicInterpolation が %(envvar)s を展開しようとしてエラーになるという罠がありました。

© Sansan, Inc.