Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

マルチテナント・マルチバージョンなコンテンツ管理システムをMySQLで再構築する

サーバーサイドエンジニアの田実です!

Yappliではアプリのリリース前に下書きのコンテンツ(プレビュー)を事前に確認したり、下書きのコンテンツを本番に反映する機能があります。 stagingブランチでアプリの動作確認を行い、問題がなければstagingブランチをproductionブランチにマージして本番反映するといったGitの開発・デプロイフローがあると思いますが、こういった運用をノーコード上で実現できる機能です。

また、スナップショットと呼ばれる任意の時点でコンテンツのバックアップを取得・復元する機能もあります。 こちらもGitのコミットをリバートしてロールバックするような機能をノーコード上で実現したものです。

コードを書くエンジニアにとってGitのブランチが不可欠なツールであるのと同様に、ノーコードプラットフォームのYappliを使ってアプリを運用する非エンジニアのお客様にとって、これらの機能は運用上不可欠な機能です。このGitのブランチのような機能を実現するためには複数の状態を持つマルチバージョンなコンテンツを管理しなければなりません。

Yappliではこのマルチテナント・マルチバージョンなコンテンツ管理システムをSQLite3を使って構築しています。

tech.yappli.io

上記の記事にあるように、SQLite3はYappliの機能要件を満たす重要なデータストアである一方で課題もあります。 これらの課題を解決するため、新規開発のプロジェクトではMySQLを使った新しい手法でコンテンツ管理システムの一部を再構築しました。

本記事ではSQLite3によるマルチテナント・マルチバージョンアーキテクチャの課題と、MySQLを使った新しい手法では課題をどう解決したのかを紹介します。

SQLite3を使ったコンテンツ管理システムの課題

拡張性

SQLite3はファイルベースのRDBMSなので、複数のホストで動作させるためには各々のホストにマスタとなるDBファイルをコピーする必要があります。 YappliではSQLite3のファイルをS3にアップロードし、各ホストでS3からSQLite3のファイルをダウンロードすることでSQLite3によるリードレプリカを実現しています。 一見簡単そうに聞こえるのですが、中途半端にデータが反映されてしまうとアクセスの度に違うコンテンツが表示されてしまうことになってしまうため、RDBMSのトランザクションのようにAtomicな処理が必要です。たとえばホストAでデータの反映に成功し、ホストBでデータ反映に失敗した場合に、ホストAのロールバックを行う必要があります。

現在は一部のAPIにおいて Amazon EFS を使って拡張性や冗長構成を取れるように改善しているものの、lsyncdやバーストクレジットを始めとしたEFSの管理が必要です。

tech.yappli.io

このように拡張性を持たせるのが難しいというのが課題の1つになります。

運用性

運用性においても課題があります。Yappliではアプリ毎にSQLite3ファイルを作るような仕組みになっています。 アプリ間のデータの分離がしやすいというメリットがある一方で、クライアントのデータを横断して検索・分析することが困難です。 修正や障害発生時の影響範囲調査でこのような横断検索・分析するケースは少なくなく、現状はSQLite3からPostgreSQLにデータ移行・集約するようなETLスクリプトを書いて、PostgreSQL上で横断検索を実現しています。 しかしながら、ETLスクリプトや実行環境のメンテナンスが必要であったり、リアルタイムでのETLが現実的ではないため、運用性に関する課題を全て解決しているわけではありません。

また、テーブルのカラムを追加・変更したい場合、各テナントごとのSQLite3ファイルを横断してマイグレーションする必要があります。 クライアント数・テーブル数・バックアップ数を考慮するとマイグレーションは現実的ではないため、コード上にマイグレーションのコードを入れて対応しています。 データのマイグレーション(一括変更)に関しても同様の課題があります。 *1

可用性

単一ホストにWrite用のSQLite3ファイルが置かれているため、Writeの冗長構成を取りづらいのも課題です。 マネージドなDBサーバーであればフェイルオーバーをよしなにやってくれますが、SQLite3を利用する場合は自前で実装しなければなりません。 たとえばフェイルオーバーで別のサーバーを立ち上げる場合、最新のSQLite3ファイルを全てコピーする必要があります。 Readに関しては拡張性で記述したように複数ホストでリクエストを捌いているものの、インスタンスの追加にはSQLite3のコピーなどの手間がかかります。

Amazon EFSによって複数のホストでファイル共有を行ってWriteを冗長化する検証も行いましたが、SQLite3のようなロック制御や書き込みが激しいアプリケーションだとパフォーマンスが出ませんでした。

認知負荷

YappliではSQLite3のDBアクセス以外のスケーラビリティを持たせるため、DBアクセス用のアプリケーションをマイクロサービスとして分離しています。 そのため、マイクロサービスのインフラ管理・リリース管理が必要になり、アプリケーション構成の把握や調査等が難しくなっています。 また、APIのIDLの管理*2やDBのトランザクションが貼りづらい問題、ローカル環境構築やE2Eテストの難化、可観測性を考慮したログ設計、依存関係を考慮したリリースフローなどの課題も発生しています。かといって、DBアクセス用のマイクロサービスとして分離しておかないとYappliのアプリケーション全体の可用性が落ちてしまうため、マイクロサービス化をやめるというのも現実的では有りません。

このような認知負荷が上がることで開発速度が下がったり品質低下の一因にもなり得るため、認知負荷も無視できない課題になります。

これらの課題をどうやって解決したか

これらの課題はマルチテナント・マルチバージョンを満たすために複数のSQLite3ファイルでDBが構成されていることや、マネージドなデータストアを利用していないことに起因していると考えています。 例えばMySQLであればAWSの Amazon RDSAmazon Aurora を利用することで冗長性(フェイルオーバー)の課題が解決できますし、リードレプリカによる拡張性も簡単に得られます。 また、複数のアプリ・複数の状態のデータを1つのデータベースに集約できれば運用性も向上します。マネージドなデータストアであればマイクロサービスが不要になり認知負荷も下がります。

このように本質的な課題・原因を洗い出した上で、マネージドなRDBMSを使うことが解決の糸口になるのではないかと考え、MySQL(Amazon Aurora)を使ってコンテンツ管理システムを再構築できないかプロトタイピングを実施しました。 プロトタイピングでの開発体験や新しいコンテンツ管理方式のメリット・デメリットを踏まえて検討し、今年1月にリリースした以下の新機能においてMySQLを使った新しいコンテンツ管理システムを導入しました。

news.yappli.co.jp

実現方法

既存のSQLite3でのテーブル構成は以下のようになっています。

CREATE TABLE contents (
    `id`         TEXT NOT NULL,
    `type`       TEXT NOT NULL,
    `content`    TEXT,
    `status`     INTEGER,
    `created`    TEXT,
    `updated`    TEXT
);

プレビュー・本番・スナップショットのDBファイルはそれぞれ {アプリ識別子}/preview.db {アプリ識別子}/production.db {アプリ識別子}/backup_YYYYMMDD.db という感じでDBファイルが分かれて管理されています。

これらをMySQLに統合していきます。プレビュー用・本番用・スナップショット用といった感じで各用途でテーブルを分け、データ構造上でマルチバージョンの構造をMySQLで表現できるようになりました。 また、SQLite3ではファイル名にアプリの識別子がありテーブル上にはアプリの識別子がない状態だったので app_id というアプリの識別子を表すカラムを各テーブルに追加しました。これによってマルチテナントを実現しています。バックアップに関しても同様にファイル名にしかバックアップ日付の情報が無かったため、スナップショット用のテーブルには backup_id も追加しています。

最終形はこんな感じです。

CREATE TABLE preview_contents (
    `app_id`  varchar(255) NOT NULL,
    `id`      varchar(255) NOT NULL,
    `type`    varchar(255) NOT NULL,
    `content` json NOT NULL,
    `status`  varchar(255) NOT NULL,
    `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE production_contents (
    `app_id`  varchar(255) NOT NULL,
    `id`      varchar(255) NOT NULL,
    `type`    varchar(255) NOT NULL,
    `content` json NOT NULL,
    `status`  varchar(255) NOT NULL,
    `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE snapshot_contents (
    `backup_id`  varchar(255) NOT NULL,
    `app_id`     varchar(255) NOT NULL,
    `id`         varchar(255) NOT NULL,
    `type`       varchar(255) NOT NULL,
    `content`    json NOT NULL,
    `status`     varchar(255) NOT NULL,
    `created`    timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated`    timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

プレビューから本番への反映や、プレビューの状態をバックアップとして取得・復元する場合はMySQLの INSERT INTO ... SELECT 構文を使って一括反映しています。 例えば、プレビューから本番へ反映するときは以下のようなSQL文によって実現しています。

BEGIN;

DELETE FROM production_contents WHERE app_id = ?;
INSERT INTO production_contents SELECT * FROM preview_contents WHERE app_id = ?;

COMMIT;

テーブルを分割せずに content_status といったようにプレビュー・本番・スナップショットという区分をカラムに持たせて単一テーブルで表現する方法も有ったのですが、それぞれのアクセス特性の違いを考慮してテーブル自体を分割するようにしました。具体的にはプレビュー環境は作成・更新・削除がメイン、本番環境は閲覧がメインでスナップショットはアクセス頻度がそもそも低い、といったようなアクセス特性があり、これらを考慮したパフォーマンスチューニングができるようにしています。

MySQL化が解決している課題

拡張性に関してはMySQLのリードレプリカによって対応できます。反映方法もトランザクションによって管理されるため、リードレプリカによる多少のタイムラグはあるもののアトミックな反映が可能です。

運用性に関しては同一テーブルに格納したことでクライアントを横断したデータの管理やリアルタイムなデータ分析・調査ができるようになりました。 同一テーブルを参照しているのでスキーマやデータのマイグレーションも容易です。

Amazon Auroraを使っているため、Writeの可用性に関してはフェイルオーバーの仕組みで対応できます。

MySQL化によりマイクロサービスを介さない方式になるため、コンテンツのCRUDがSQL発行だけで完結する仕組みになり、認識負荷も下がりました。 これまでマイクロサービスを介した integration test はテストコード内でマイクロサービスのプロセスを立ち上げてデータ設定を行うなど複雑な機構になっていましたが、MySQL化によってintegration testもしやすくなりました。

tech.yappli.io

プレビューから本番への反映処理も、S3やlsyncd・EFSを使って各ホストに配信していた処理がMySQLのDB操作だけで完結するようになったため、処理を追いやすくなり保守性が上がったように思います。

MySQL化に関する懸念

一方でMySQLを使ったコンテンツ管理システムの懸念もありました。 まず1点目はSQLite3とMySQLのコンテンツ管理方式が共存することによる認知負荷の増大です。 一本化すればこの懸念はなくなるのですが、それまではSQLite3の方式とMySQLの方式の2つのコンテンツ管理方式を意識する必要があります。

また、MySQLによるコンテンツ管理の仕組みはまだ枯れていない状態で、SQLite3では満たせていた機能要件・非機能要件を満たせないといった不確実性の問題もあります。

このあたりはプロジェクトチーム内で議論を重ねていったのですが

  • 重要な機能要件・非機能要件が満たせない事象が発生したとしてもMySQLからSQLite3に移行(切り戻し)が可能なこと
  • うまくいったときに他の機能にスケールさせて、新機能だけではなくサービス全体に対してより開発を加速させていける可能性があったこと
  • プロトタイピングで実装してみてDBアクセス用マイクロサービスの変更が不要で開発体験が良かったこと
  • パフォーマンステストを実施して非機能面でも特に大きな問題が無かったこと

とメリットが懸念点を上回っていたため、MySQLによるコンテンツ管理方式を採用しました。

SQLite3の新しいツールの検証

新しいコンテンツ管理方式の検討にあたってはRDBMSだけではなくKVSやSQLite3自体をそのまま利用しつつ拡張性を上げる方法も検討しました。

例えば、SQLite3を冗長化する仕組みとしてLitestreamというOSSがあり、WALの仕組みを使ってリアルタイムなバックアップの取得・復元ができます。 検証した結果、Litestreamは動的に作られるSQLite3ファイルを同期できないことや、ロールバックジャーナルの方式をjournalからWALに切り替える必要があり、Yappliのアーキテクチャに適用が難しそうでした。 手前味噌ですが、Litestreamの仕組みや使い方に関しては以下にまとめてますのでよろしければご参考ください。

blog.freedom-man.com

LiteFSはFUSEを使ってsystem callをフックすることでリードレプリカを実現する仕組みです。 こちらはディレクトリをマウントするためSQLite3ファイルを動的に作成しても問題なく動き、LitestreamよりはYappliのアーキテクチャに適用しやすそうでした。 しかしながら、上記課題である「拡張性」「可用性」を解決するものの、「運用性」「認知負荷」に関しては解決できず銀の弾丸とはなりそうにありませんでした。

まとめ

SQLite3によるマルチテナント・マルチバージョンなコンテンツ管理システムの課題と課題解決のための試みについて紹介しました。 マルチバージョンなコンテンツ管理はノーコードの領域でユーザ体験に向き合っているYappli特有の課題だと思います。 技術的な解決が難しく事例もほとんど無い領域ですが、ユーザに価値を届けるため日々課題と向き合いながら改善を続けています。

SQLite3やノーコードのプラットフォーム、マルチテナント・マルチバージョンなコンテンツ管理システムに興味がある方、ぜひカジュアル面談で話しましょう!

open.talentio.com

*1:Yappliにおいては既存テーブルの汎用的なJSONカラムを利用するケースが多いのですが、カラム追加した方が良いケースがあってもカラム追加を選択する力学が働きにくいというのもあります

*2:IDLとしてprotobuf・gRPCを使っています