VisasQ Dev Blog

ビザスク開発ブログ

必要に迫られて Google Cloud NDB ライブラリにパッチを当てたという話

こんにちは。プラットフォーム開発グループのくまがいです。

現在、弊社では大掛かりなシステム刷新のプロジェクトが進行しているのですが、その中で旧システムで利用している Cloud Datastore を移行中の新システム側からもアクセスする必要性が生じたことから、割と泥臭い対応を行ったのでご紹介したいと思います。

前提

旧システムは弊社の創業から今日に至るまでビジネスの急成長に追従するべく一貫して Google App Engine スタンダード環境 第1世代(以下、GAE と表記)で動作しており、ランタイムは Python2.7 です。 Python2.7 は すでに EOL を迎えている ため目下のところシステム刷新プロジェクトで Cloud Run & Python3.9 環境への移行と分割を絶賛推進中です。

今回対象となっている Datastore は以前にも紆余曲折を経て古い GAE 環境付属の NDB から Datastore (DatastoreモードのFirebase)に移行したのだそうですが、 今回の新システム移行では影響範囲が複数チームに跨って分散しているため短期間での一括移行は難しく、並行稼働しつつ旧システムと新システムの双方からの読み書きを実現する必要が出てきていました。

要件

対応の要件として上がっていたのは次の2点です:

  • 旧システム(Python2系)側と新システム(Python3系)側の双方からデータの破損等なく書き込み・読み出しが行えること
  • 既存の実装コードの箇所はできる限り変更しないで済む範囲で実現すること

一つ目については MUST、二つ目については SHOULD 寄りの NICE TO HAVE くらいの温度感です。 これらの要件を満たす方法を見つけるべく、まずは調査を始めます。

調査

利用している対象のライブラリはこちら google-cloud-ndb です。 実環境で利用しているのは DatastoreモードのFirestore なのですが、旧システムが GAE 組み込みの NDB を利用していたという過去の経緯からこのライブラリを継続して利用していたようです。

まず利用している Datastore のモデルを洗い出します。

調べていくとほとんどのプロパティは String や Int などの型であり、扱いに一定の注意は必要ではあるものの読み出しができない非互換性が含まれると思しき箇所は見当たりませんでしたが、ひとつだけ気になる型のプロパティが目に付きました。

PickleProperty です。

ご存知でない方のために補足しておくと Pickle とは Python が標準ライブラリとして備えているオブジェクトのシリアライズ・デシリアライズ用バイナリプロトコル実装で、 Pythonオブジェクトをそのままシリアライズ・デシリアライズできるのでデータ構造を保存するフォーマットとして重宝します。

Pickle はPythonのバージョンが上がっていくに従ってより効率的にシリアライズ・デシリアライズが可能になるようにプロトコルのバージョンが追加されているのですが、 高いバージョンのプロトコルを使ってシリアライズされた pickle を読み込むためには同じかそれ以上のプロトコルを解釈できる Python のバージョンが要求されます。

PickleProperty はシリアライズ時に 実行ランタイムバージョンにおける最新のプロトコルバージョンを使ってシリアライズする ように実装されていました。 このため Python2 系で実行した場合、使われるプロトコルバージョンは バージョン2 となる一方、Python3 系で実行すると、(Python 3.9 で実行した場合)使われるプロトコルバージョンは バージョン5 となります。

以下は PickleProperty のシリアライズ機能の実装コードです:

    def _to_base_type(self, value):
        """Convert a value to the "base" value type for this property.
        Args:
            value (Any): The value to be converted.
        Returns:
            bytes: The pickled ``value``.
        """
        return pickle.dumps(value, pickle.HIGHEST_PROTOCOL)

この より新しいプロトコルバージョンが意図せずに使われる 動作によって Python3 系でシリアライズすると、Python2 系では扱えないプロトコルバージョンのためデシリアライズ不可、という事象が発生していたことが分かりました。

実際に datastore-emulator を使って検証してみると Python2 で書き込み -> Python3 で読み出し は問題なく処理できましたが、同じデータで Python3 で書き込み -> Python2 で読み出しの順に処理を行うと読み出しのタイミングでエラーが発生することが確認できました。

対策検討

ではこれらの調査結果を踏まえてどのように相互の読み書きを実装すればよいでしょうか。

ひとつは、そもそも Datastore モードの Firestore を使っているのだから google-cloud-datastore ライブラリにすればよいのでは?という案が考えられると思います。

しかし既存コードからの実装変更はできればやりたくないという要件があるので、あまり積極的に取れる手段では無さそうでした。 また、システムの移行を機に遠くない将来に Datastore を脱却するつもりなのでそこにコストを掛けたくない(近々で捨てるコードを書くことになる)という思いもありました。

もうひとつは google-cloud-ndb ライブラリに直接パッチを適用して利用する、という案です。

google-cloud-ndb ライブラリ自体にパッチを適用してしまえば、既存の実装を一切いじる必要はありません。 サードパーティが提供するライブラリにパッチを適用するという手段の是非はともかく、既存コードをコピペで新システム側に持っていったとしても動作することは期待できます(もちろん十分な動作検証は避けられませんが...)。

このため今回弊社では google-cloud-ndb ライブラリにパッチを適用して互換性を維持する、を選択しました。 賢明な判断ではないかもしれませんが、移行作業を実際行っている開発者の負担にならない方法を選択することも、時には必要です。

実施

さて、方針が決まったらあとは実装と動作検証が必要です。

実装

まず google-cloud-ndb ライブラリを弊社の GitHub Organization 配下に fork します。 次に現時点で利用している google-cloud-ndb のバージョンのタグからtopicブランチを作成してパッチを適用します。

最初に、初期化の際に Pickle のプロトコルバージョンを明示的に指定できるようにします。

    _protocol_version = None

    @utils.positional(2)
    def __init__(
        self,
        name=None,
        compressed=None,
        indexed=None,
        repeated=None,
        required=None,
        default=None,
        choices=None,
        validator=None,
        verbose_name=None,
        write_empty_list=None,
        protocol_version=None,
    ):
        super(PickleProperty, self).__init__(
            name=name,
            compressed=compressed,
            indexed=indexed,
            repeated=repeated,
            required=required,
            default=default,
            choices=choices,
            validator=validator,
            verbose_name=verbose_name,
            write_empty_list=write_empty_list,
        )
        if (
            protocol_version is not None
            and 0 <= protocol_version
            and protocol_version <= pickle.HIGHEST_PROTOCOL
        ):
            self._protocol_version = protocol_version
        else:
            self._protocol_version = pickle.HIGHEST_PROTOCOL

ついで、初期化時に指定されたプロトコルバージョンを、シリアライズ時に指定して実行するように変更します。

    def _to_base_type(self, value):
        """Convert a value to the "base" value type for this property.
        Args:
            value (Any): The value to be converted.
        Returns:
            bytes: The pickled ``value``.
        """
        return pickle.dumps(value, self._protocol_version)

こうすることで任意のプロトコルバージョンを明示した場合にはそのバージョンでシリアライズされ、プロトコルバージョンを指定しない場合は従来と同じ挙動をする PickleProperty のカスタムクラスが定義できました。 実際にはライブラリの中で用意されているテストも上記の変更に合わせて修正し、テストが通るようにしておきます。

ライブラリ自体へのパッチ適用作業は以上です。

動作検証

パッチ済みライブラリの用意はできたので、つぎはそのライブラリを利用して動作検証を行ないます。

調査の段階で行ったものと同じ動作確認を実環境で利用している全てのエンティティのクラスに対して、疑似データを使って実施しました。 読み書き先の datastore は調査の際と同じく datastore-emulator を利用しました。

結果は Python2 で書き込み -> Python3 で読み出し と Python3 で書き込み -> Python2 で読み出し のどちらもエラーなく動作することが確認できました。

ここまでで最低限の動作確認まではできましたがさすがに擬似データと datastore-emulator を使った検証だけでは「これで大丈夫です」と言われても説得力がありません。 そこで Datastore に格納したデータを主に扱うチームのリーダーと相談してどういう検証をすれば必要十分になるかを話した結果、 本番やステージング環境に実際に格納されている PickleProperty を利用したエンティティのデータ全件をつかって読み書きとその結果の突合を行ってエラーが無ければいいんじゃないか、ということになりました。

では具体的に突合処理をどうやって実現するかを考えます。

PickleProperty を利用したエンティティは2種類あり、本番環境とステージング環境にはそれぞれ18万〜20万件ほどのデータが蓄積されています。 この規模の実データ全件の読み書きと突合なので検証はバッチ処理が良さそうです。 Batchサービスを使うことも考えたのですが、Cloud Runでやりたいことが実現できそうだったのでそちらを選択しました。 これもあまり筋の良い選択とは言えないかも知れませんが、こういう選択肢もあるという一例だと思って頂ければと思います。

さて、ここで一つ大きな課題が出てきました。ステージング環境や本番環境は当然日々稼働しています。 その状況下で稼働しているシステムに影響を与えずにどうやって実データセットを使って検証をすれば良いでしょうか。

実は Cloud Datastore というサービスはひとつの GCP プロジェクト内に一つ存在するだけで、インスタンスという概念がありません。 つまり利用用途によってインスタンスを分ける、という手法が使えないのです。 その代わり、というわけではないとは思いますがエンティティにアクセスする際に Datastore クライアントにはオプションとして名前空間を指定することができます。 名前空間が異なるエンティティはキーが同一であっても別データとして扱われるので、お互いに対する操作が相互に影響する心配はありません。

ということで検証用に名前空間を定義して、検証用名前空間に元データの複製を作って検証を行うことにします。

実際の動作確認は次に示す順序で行ないました。

  1. Python3 で読み出して検証用名前空間へ書き込み、JSON形式に変換後ハッシュ値を記録
  2. Python2 で検証用名前空間から読み出し、JSON形式に変換後ハッシュ値を記録
    1. と 2. で記録したハッシュ値同士を突合して差異が無いことを確認する

結果

本番環境、ステージング環境共に Python3 と Python2 で相互書き込みを行ってもデータの不整合などは発生しないことが確認できました。 バッチ処理による突合作業自体はほぼ一日がかりの作業でしたが、大きな問題も発生することなく淡々と進んだのは印象的でした。

おわりに

必要に迫られて Google Cloud NDB ライブラリに手を入れた話をご紹介しました。いかがでしたでしょうか?

後半の動作検証の部分で実際の結果をお見せしていない(できない)ので、ともすれば創作では?と疑念をお持ちになられる方もいらっしゃるかも知れませんが、調査から検証完了まで足掛け1ヶ月半程を要した実話です。

記事中には書いていないもののバッチ処理を実際のデータに対して実行してみたら発生した想定外の事象もいくつかあったにはあったのですが、実データに関する内容なので本記事ではオフトピックとさせて頂きました。 あしからずご了承ください。

長くなりましたが最後までお付き合い頂きありがとうございました。

弊社ではこのような泥臭い仕事ばかりということはなく、面白い仕事がたくさんあります(たくさんありすぎて人が足りていません)!

ご興味・関心をお持ちの方はぜひこちらからご連絡頂ければと思います。

developer-recruit.visasq.works