booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

“プロジェクトを前に進める役”をやってみて気づいたこと

1. はじめに

初めまして、コミック ROLLY サービスでサーバーサイドエンジニアをしている伊藤です。
今回私からは、「今までチームリードや PM の経験はなかった人間が、“プロジェクトを前に進める役”をやって気づいたこと」についてお伝えします。

コミック ROLLY の開発チームは、スクラム開発を導入しています。
PM という役職自体はないものの、メンバー各自が自律的に動くことをよしとしているチームです。

その中で今までチームリードや PM の経験がなかった私ですが、今回ご縁があり、現在のチームでリーディングの機会をいただきました。
このチームでリーディングのためにどういう動きをしたか、動いてみて気づいたことお話ししていきます。

2. コミック ROLLY のサービスと PM をしたプロジェクトについて

「コミック ROLLY」とは、株式会社ソニー・ミュージックエンタテインメント から配信している Android, iOS のスマートフォンアプリケーションです。

様々なコミックスを無料で読めたり、アプリを通して課金してコミックスを購入できるなどの機能があり、書店で売られているコミックスほか、先行配信するコミックスなどもそろっているサービスです。

今回のプロジェクトは、コミック ROLLY で一定期間課金せずに読むことができる「期間限定無料」というコミックス商品に対応するというものでした。

コミックROLLYの商品の流れ

コミック ROLLY が商品をアプリに出すまでが以下の流れになっています。

① 社内別チームが担当する商品管理システム(取次システムと呼んでいます)からコミック ROLLY の商品取り込みサーバーに商品情報を連携する。
② 商品取り込みサーバーが連携を受け取ったら、コミック ROLLY の DB に情報を登録する。
③ コミック ROLLY のコンテンツ(アプリに出す特集情報など)管理から、商品と特集などの紐付けを行う。
④ アプリと通信する webAPI を通して DB から情報を取得、整形する。
⑤API から情報を受け取ったアプリが商品情報を表示する。

このプロジェクトは新しい商品をアプリに出すことが目的になるので、①~⑤ に関わる開発が必要になります。
あわせて、商品管理システムのチーム側もコミック ROLLY に対応するための開発が必要になるので、足並みをそろえての開発・リリースをする必要もありました。

3. なぜ自分が PM 役をやることになったのか

コミック ROLLY チームでは スクラム開発という短期間で計画・作業・リリース・振り返りを繰り返す開発手法を採用しています。

プロジェクトを短期間で回す単位(スプリント)を 2 週間で回るように設定していて、タスクもその間に対応できる規模のものがほとんどになります。
ですが、まれに要望が複雑で複数システムの開発が必要なタスクもありました。

そのようなタスクが出てきた場合、要件整理・進捗の見通しなど「誰かが主導して動かす」必要がありました。

当時チーム内で一番所属経歴が長く、ドメイン知識や技術的な理解が深かったため、自然と自分がその役割を担うことになりました。

4. "PM 的な役割”でやったこと

プロジェクト推進役を実施するにあたり、主に以下の対応を重点的に行いました。

1.明確な方針の元の要件・仕様の整理とステークホルダー(PO や他チーム)とのコミュニケーション

中・大規模開発において重要なのは、要望を出すステークホルダー、ステークホルダーと開発メンバーの仲介であるプロダクトオーナーと一緒に要望の認識を合わせていくことです。

チームの設立時から「最低限の開発でユーザーへの提供スピードを優先する」という方針をもとに開発してきており、プロダクトオーナー、ステークホルダーとも方針を共有できていました。

この方針を元に要件・要望を整理し、最低限の機能開発で収めてリリースすることを確定できました。

2.仕様漏れ、変更に対しての柔軟に対応

今まで対応していたプロジェクトやチームでは仕様確定 → 開発 → テスト → リリースの流れが決まっていて、テスト時に仕様の考慮漏れがあった場合の修正やリリース時期の調整に時間がかかりました。

先に記載していますが、コミック ROLLY では 2 週間サイクルで開発を進めているので、中・長規模なプロジェクトも 2 週間で実施できる程度にタスクを細分化しています。そのなかで仕様の整理と開発を並行して行い、都度テストして確認していく方法を取りました。

この対応の結果テストで仕様の考慮漏れに気づけて次のスプリントで修正対応ができるので、大きな手戻りの発生がなく、リリースもリスケせずに対応できました。

3.定期的な振り返りで声を上げやすい環境を作る

スクラム開発ではレトロスペクティブというイベントがあり、プロジェクトに対しての振り返りを行います。私たちはプロジェクト以外にも開発プロセス内の振り返りも行っていました。

レトロスペクティブ中にプロセスの変えたいことをメンバー同士出し合い、アプローチ方法の検討し、次のスプリントにプロセス変更を取り入れる流れで改善案を迅速に取り入れる仕組みを導入しました。

この対応で改善の提案と実行のサイクルを早めたことで「自分の意見でチームが動いている」実感を得やすい形になりました。

その結果、プロジェクトの進行への取り組み、仕様や要件、開発プロセスに対してメンバーそれぞれが自主的に改善意見を言ってくれるようになるなどメンバーの自律性が格段に向上しました。

5. やってみての気づき・視野の変化

今回PM の役割を体験して、以下の気づいた点があります。

① チーム内で声を上げやすい環境にすることの大事さ。
チーム内のコミュニケーション方法を考慮したおかげで、チーム間で声を上げることのハードルが大幅に減りました。
それによってプロジェクト進行中に「リリース手順に考慮漏れがある」「仕様に矛盾点を発見した」などのさまざまな指摘をもらえる機会が多くありました。
おかげで慣れない進行でも進捗が早く、想定した時期に適切な機能をリリースできました。

この状況がないと、リリース直前で仕様の見落としや重大なバグが発見されリリースに大幅な遅れが出る可能性もありました。改めて意見を通しやすい環境があったことの大事さを実感しました。
この気づきとともに「頼れるところはもっと他メンバーにも頼ってよかった」という反省点もありました。

進行上エンジニアのメンバーには決定事項を共有することが多かったのですが、もっと早い過程の段階で意見を聞くことで、メンバーそれぞれの知識を活かせる場面がもっとあったのではと考えています。

② 物事を進めることの大変さ。
プロジェクトの推進役を経験することで、仕様に従うだけではなく、サービスとしてありたい方向、チームの状況に応じて柔軟に対応することがいかに大変かつ重要かも感じることができました。

③「リーダー」は役職じゃなく、チーム状況によって生まれる役割のひとつ。
PMの役割を体験したことで、要件定義や実装の流れを十分理解していれば誰でもチームリーダーとして対応できると考えを改めました。

今回はたまたま私がリーダーでしたが、プロジェクト周りの習熟度で他メンバーをアサインする可能性があったように誰でもリーダーになる機会はあります。

普段からこの感覚を意識すると、関わっているプロジェクトでも開発プロセスに対する積極的な提案や、仕様の曖昧な部分があったときにすぐに確認に入るなどを自主的に考えていけるようになりました。

6. 次にPMをやる時に気をつけること

先の気づきで「決定前の段階で他メンバーにも頼るところがあってよかった」という反省点があったのでそこに対してアプローチしていきたいです。
次にPMをやることがあったら、たとえば設計初期や技術選定の段階でメンバーに相談して考える段階でメンバーを巻き込んで色んな視点を取り入れる対応をすべきだと感じています。

7. 最後に:この経験を通じて

今までリード経験がなかった自分でも、プロジェクトの進行からトラブルの対応まで必要に応じて動くことができました。これは意見を言いやすい環境を作れた上で生じたチーム内での自律的な動きや、メンバー同士で協力できる信頼関係があったからこそだったなと感じています。

メンバー同士が積極的にプロジェクトに関心をもてる状況があれば、メンバー誰でも PM やリーダーの役割を担当できることも重要な気づきになりました。
これからもこの考えを元にエンジニアとして対応していければと思っています。

Reader StoreでAWSアカウント間でのデータ同期をしてみた

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」の新しい環境を構築した際に発生したAWSアカウント間でのデータ同期についてお伝えします。

新環境検討の背景

新環境検討のきっかけとなった背景としては以下があります。

  • 大きな開発案件では開発や事業部など全体の部署で成果物がどのようなものか認識を合わせるための環境が欲しい。という要望が上がった
  • 検証環境などでは開発者のテストデータや古い作品のデータが多くストアとしてのイメージがつきにくく認識を合わせることが難しくなっていた

今後システム改善をしていく上でも、事業部全体で認識合わせながら進めることが必須事項になってくることもあり、本番相当のデータで可能な環境を用意する運びとなった。

データ同期をどう行うか

弊社のAWSは商用と開発検証でアカウントが異なるため、アカウントを跨いでデータを同期する必要がありました。
大きく以下2つをどうにかする必要がありました。

1. RDS

候補として以下がありました。

export/importはデータ量がそれなりにありデータ同期するとなるとリードタイムがかかってしまうため不採用にしました。
バイナリログレプリケーションはお試し構築で実施していたため最有力候補でした。
ただ、テーブルを絞った上でデータ同期をするため、今後同期するテーブルが増えることを想定して容易に対応できるようにするためDMSを採用しました。

2. S3

候補として以下がありました。

S3レプリケーションは前提条件でバケットのバージョニングを有効にしなければならず、システム影響調査および環境差異が発生してしまうため不採用にしました。
awscliは処理時間がかかってしまうため不採用にしました。
1h程度でデータ同期が完了したのでDataSyncを採用しました。

構築の流れ

今回私が対応した流れとしては以下となります。

RDSおよびS3の同期設定以外

以前のブログで記載しましたが、Terraformコードがあるので、環境設定だけ追加をしてterraform applyを実施するだけなのですぐ完了しました。

RDS

RDSはDMSでレプリケートしますが、初期はソースDBのスナップショットから復元しました。
以下はDMSを利用したデータ同期を行うために実施した作業内容となります。

データ同期を行う環境構成としては以下のイメージのような形となります。
図のaccount-Aが商用のAWSアカウント、account-Bが開発検証用のAWSアカウントとなります。

AWS/DMS

ネットワーク周り

まずはソースDBが別アカウントに存在していたためVPC PeeringでVPC間通信できるようにしました。
その後Route TableやSecurityGroupの設定を実施しました。

DMS

次にDMS環境構築をしました。
実施した内容としては以下の通りとなります。

  • レプリケーションインスタンスの作成
    レプリケーションタスクが動作するインスタンスを作成しました。

  • ソース/ターゲットエンドポイントの作成
    ソースエンドポイント(移行元RDS接続情報)、ターゲットエンドポイント(移行先RDS接続情報)を作成しました。

  • 移行タスクの作成
    上記で作成したレプリケーションインスタンスとソース/ターゲットエンドポイントを紐付けてデータを移行するタスクを作成しました。

S3

S3は空のバケットを最初に作成しました。
DataSyncで実施した内容としては以下の通りです。

データ同期を行う環境構成としては以下のイメージのような形となります。
図のaccount-Aが商用のAWSアカウント、account-Bが開発検証用のAWSアカウントとなります。

AWS/DataSync

DataSync用のIAMロール作成

DataSyncからS3アクセスできるようにIAMロールを作成しました。
ロールに付与したポリシーは以下のような形になります。

ソース用ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name"
        },
        {
            "Action": [
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:GetObjectTagging",
              ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

ターゲット用ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name"
        },
        {
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:GetObjectTagging",
                "s3:PutObjectTagging",
                "s3:PutObject"
              ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

信頼ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {
            "Service": "datasync.amazonaws.com"
        },
        "Action": "sts:AssumeRole",
        "Condition": {
            "StringEquals": {
                "aws:SourceAccount": "account-id"
            },
            "StringLike": {
                "aws:SourceArn": "arn:aws:datasync:region:account-id:*"
            }
        }
    }]
}

S3バケットポリシー修正

先ほど作成したDataSync用のIAMロールでS3アクセスできるようにバケットポリシーを修正しました。

S3バケットポリシーサンプル

{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "DataSyncCreateS3LocationAndTaskAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account-id:role/datasync-role"
      },
      "Action": [
        "s3:GetBucketLocation",
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads",
        "s3:AbortMultipartUpload",
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:ListMultipartUploadParts",
        "s3:PutObject",
        "s3:GetObjectTagging",
        "s3:PutObjectTagging"
      ],
      "Resource": [
        "arn:aws:s3:::bucket",
        "arn:aws:s3:::bucket/*"
      ]
    },
    {
      "Sid": "DataSyncCreateS3Location",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account-id:role/datasync-role"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::bucket"
    }
  ]
}

DataSync

次にDataSync環境構築をしました。
実施した内容としては以下の通りとなります。

  • ソース/ターゲットロケーションの作成
    ソースロケーション(移行元S3)、ターゲットロケーション(移行先S3への接続情報)を作成しました。
    ソースロケーションは、アカウントが異なりコンソールからは設定できなかったので、aws cliコマンドを実行しました。
  #aws datasync create-location-s3 \
    --s3-bucket-arn arn:aws:s3:::bucket \
    --s3-config '{
      "BucketAccessRoleArn":"arn:aws:iam::account-id:role/datasync-role"
    }'
  • 移行タスクの作成
    上記で作成したソースロケーションからターゲットロケーションへデータを移行するタスクを作成しました。

データ同期

作成したDMSおよびDataSyncの移行タスクを実行しました。
どちらも初回フル同期して、フル同期後はソース側で変更があれば継続的にレプリケーションする設定となっています。

DMSはタスク実行の際に、ターゲットDB内のテーブルをtruncateして再度ソースDBからフルロードする設定にしているため初回同期は3hほどかかりました。

DataSyncの初回はソースS3の全てのオブジェクトをターゲットS3にコピーするため12hほどかかりました。

直面した課題・反省点

S3関連のコスト面の見積が甘かった

S3上のオブジェクト数からデータ同期する際のDataSync料金(想定データ転送量)を見積りして大したコストにならないとしていました。
だが、移行し始めた翌月のS3コスト(GET、SELECT、他のすべてのリクエスト)が前月差で3倍ほど跳ね上がりました。

原因は、S3同期を毎時やっていたことにありました。
毎時S3オブジェクト同期し、その都度オブジェクト比較するためオブジェクト数2431のリクエストが発生していました。
こちらのコストを下げるべく、同期の頻度を毎時から1日1回に変更することでオブジェクト数*31になり、当初想定していた試算コストくらいになりました。

GuardDutyのコストを見積もっていなかった

GuardDuty S3 Protection機能を有効にしていたのを失念していました。
こちらも移行し始めた翌月のコスト(S3データイベント分析)が前月差30倍ほど跳ね上がりました。

こちらもS3同期頻度を変更することでS3データイベント分析が減ったため前月差2倍程度になりました。

まとめ

今回Reader Storeで新しい環境を構築した件についてお話ししました。
DataSyncを継続的に移行する際にはS3コストにご注意下さい。

新しい環境はまだ未完な状態ではあるので、今後も環境の改善を進めて本番に近い確認ができるようにして、サービスの品質も高めていきたいです。

他にも実施した内容はあるのでどこかでまたお話しできたらと思っています。

以上、読んでいただきありがとうございました。

Reader StoreでSaaS型headlessCMSを使ってみて感じたこと

プロダクト開発部アプリケーションエンジニアの有末と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。

Reader Storeでは段階的なサイトリニューアルを進行中です。一部のコンテンツ管理にCMSを利用していますが、リニューアルに伴いCMSの切り替えも行うこととなりました。そこで従来一般的だったフロントエンド一体型のCMS(以降、"従来型CMS"と呼びます。)ではなくheadlessCMSを導入することに。製品はSaaSとして提供されているmicroCMSを利用しました。

本記事では、headlessCMSの導入をした中で感じたことを中心にお伝えしていきます。現在CMSを利用している方や利用を検討している方の参考になれば幸いです。

headlessCMSとは

CMS(Contents Management System)とはWebサイトの構成要素(=コンテンツ。具体的には画像、テキストなど)を管理・配信するシステムを指します。headlessCMSはその中の一種です。バックエンドの機能のみ提供しAPIでコンテンツを配信するCMSをheadlessCMSと呼びます。
パッケージとして提供されている"従来型CMS"と"headlessCMS"、スクラッチ開発するCMSを比較すると以下のようになります。

従来型CMS headlessCMS スクラッチ
概要 コンテンツデータ、管理画面、Webサイトと総合的な機能を提供する。 コンテンツデータ管理といったバックエンドに特化。Webサイト自体の機能は持たない 0から開発をする
開発コスト 低い バックエンド:低い
フロントエンド:高い
高い
自由度 低い バックエンド:低い
フロントエンド:高い
高い
サービス例 WordPress
Movable Type
contentful
microCMS
-

バックエンド・フロントエンドともに提供される従来型では、提供される範囲内では開発に時間をかけずサイトを提供できます。ただし、パッケージの提供範囲外のことを実現しようとするのは難しいです。

スクラッチ開発は全て自分たちの要件に合わせてバックエンド・フロントエンドともに提供できますが、その全てを開発しなければならないため、開発コストは高くなリます。

バックエンドのみを提供しているheadlessCMSでは、バックエンドの開発は提供されている機能で作成をするため、自由度は低いですが開発コストも低いです。フロントエンドはスクラッチ同様、要件に合わせて自由に開発が可能です。裏を返すと、フロントエンドやビューワーは自分たちで用意をしなければなりません。

もちろん一長一短あるので手放しにheadlessCMSが良い、とは言えません。しかし、スクラッチと従来型CMSの良いとこ取りをしたCMSと言えます。Reader Storeでは、バックエンドで独自機能を提供する必要性は薄く、お客さまに提供するフロントエンドへ集中したい、という意図もありheadlessCMSを選択しました。

headlessCMSを使って開発してみた

リニューアルしたお知らせページ

実際にサイトの1機能であるお知らせ機能をリニューアルした時のおおまかな開発の流れが以下です。

  1. PM・デザイナーからの要件受領
  2. 要件をもとにフロントエンドへ渡す項目を決定
  3. headlessCMSで項目を設定
  4. APIが出るので想定通りかテストを実施
  5. APIを利用してフロントエンドを開発

この工程の中でheadlessCMSを導入したことで特筆したいのは2点です。

バックエンドの開発工数の圧縮

まず特筆すべきは項番3"headlessCMSで項目を設定"の工程です。
今回お知らせ機能を作る際に必要なものは"お知らせを入稿する管理画面"と"画面を出すために利用するAPI"でした。これらを作る際に、項番3で行ったことといえば、"2で決まった項目を、画面から設定していく"ことのみです。実際にはCMS上で設定をした後に、"運用は回るのか"、 "フロントで要件を満たしているのか"などの調整をしていたためやりとりや修正の時間は数日かかっています。しかし、その叩き台となるドラフト版を作るだけなら1~2時間もあれば作成は完了していました。

もしスクラッチで開発をしようとしていた場合はどうだったでしょうか。 headlessCMSで必要だったものは"お知らせを入稿する管理画面"と"画面を出すために利用するAPI"でした。スクラッチであればさらにデータを保管するDBの設計なども必要になります。これらを自前で開発しようとした場合、1~2時間ではとても収まらなかったはずです。

短期間で管理画面がすぐ見える形になると、運用部門と実際の画面を見ながらブラッシュアップできます。フロントエンドとの疎通も前倒しにでき、開発時にも実際のAPIで検証しながら開発を進めることができます。この提供スピードが一番のメリットであると感じました。

フロントエンド開発の柔軟性

項番5"APIを利用してフロントエンドを開発"の工程においてもメリットを発揮しました。
段階的にリニューアルを進めているReader Storeでは、既存のCMSで提供している箇所が多くあります。2023年時点でリニューアルを完了していないページでも、今回移行をした"お知らせ"を出している箇所がありました。

部分的に差し替えたページ

もしも従来型のCMSでリニューアルをしていた場合、従来型のCMSではフロントのページは丸々1ページ分、CMSから直接出されているものになります。部分的な対応をしようとするとiframeで埋め込むといった手法をとる形になります。後々リニューアルするページなので作った埋め込みページは丸々破棄になるか、拡張できるよう対処することになるか、いずれにせよのちのリニューアルに向けては検討が必要になっていそうです。

一方で、headlessCMSではAPIを出すのみなので、既存のページで出しているお知らせの箇所を従来の出し方からAPIの呼び先をheadlessCMSにすることで部分的な対応を実現しました。既存ページの改修は必要ですが、APIはリニューアル後も利用できますし、コストや無駄は従来型と比べ低いものになったはずです。

こうした段階的なリニューアルにおいても、headlessCMSではスムーズな移行に寄与しています。

使って感じた点

先述の通り、2点のメリットをheadlessCMSを用いることで実感できました。

  • バックエンドの提供スピード
  • フロントエンド開発の柔軟性

それ以外にもSaaS型のheadlessCMSを利用する中で実感したことがありましたのでご紹介します。

コンテンツ登録や公開日、バージョンなどの管理機能開発が基本不要

CMSにおいて必要な機能ですが、自前で開発をしようとすると大変な機能です。こちらが元から提供されていることで開発の必要がなかったのは非常に助かりました。

一方で、提供されている標準機能では足りない機能が要件としてある場合は実装に考慮が必要です。
Reader Storeでは未来に公開する記事を、プレビューの日時を指定し、その特定日時に想定した記事で公開されていることを確認したい、という要望がありました。ECサイトであるReader Storeにとって、未公開情報や商品の販売日などを考慮した日時指定プレビュー機能は重要なニーズです。microCMSには標準で下書きプレビュー機能がありますが、この要望を実現するには不十分でした。そのため、CMS側ではなくアプリケーション側の実装で対応することになりました。
このように、標準で提供されている機能から外れると何かしらの考慮が必要になってきますが、microCMSの標準として提供されている機能の範囲であれば、容易に利用ができています。プレビュー機能も標準的な導入は、各フレームワークごとにドキュメントが整理されており容易でした。

内部で管理するインフラを縮小

SaaS型のheadlessCMSを利用することで、インフラ側のOS、利用モジュールのEOLやセキュリティ対応がCMSサービス提供側の責務になります。これにより、自社でメンテナンス対象とするインフラの範囲を現在よりも縮小できました。
今回利用したmicroCMSは開発用ステージング環境を作成する機能を提供していました。本番環境をベースに迅速に別の環境を用意できます。各環境に対して接続IPなどを個別に設定できるため、本番用と開発用を簡単に作成できました。
しかし、サービス提供型のCMSであるため、提供側のインフラに関する制約も考慮する必要があります。例えば、秒間リクエスト数に制限があり、キャッシュからのリクエストには制限がないといった点です。この制限により、クエリに日時を指定した場合にmicroCMSのCDN(CloudFront)のキャッシュがヒットしにくいという事象が発生しました。キャッシュのヒット率を高めるために、指定する日時の秒・ミリ秒単位を切り捨てるといった実装上の工夫をしました。

管理画面上の細かいカスタマイズはフルカスタムに比べ劣る

提供されている機能で実現をする必要があったので、制限されることや別の箇所で回避しなければいけないことがありました。運用部門に強いこだわりがあり、管理画面のカスタマイズが必須要件の場合、フィットはしなかったです。 以下は一例です。

複数項目でのバリデーションができなかった

日時の開始日・終了日を設定するなど、複数項目間でのチェックをしたい場合、スクラッチであれば開始日<=終了日でなければ入力を弾くなどのバリデーションをかけるところでした。しかし、そういった機能の提供はなかったため、管理画面上に注釈を残して運用部門に注意を促し、フロントエンドの表示ロジックにて制御しました。

直接htmlを打ち込めない

テキストエリアにWYSIWYGエディターを提供されていたのですが、WYSIWYGエディター内でhtmlを直接打ち込む機能は提供されていませんでした。シンプルなページを作成する場合は問題ないのですが、特定のページをよりリッチに作成したい、といった要望が簡単には実現できないです。

さいごに

リニューアルに伴いSaaS型headlessCMSを使い始めることになりましたが、一般的に言われるメリット・デメリットは実感できました。それらを踏まえて、開発チームがフロント開発に注力できるチームであれば、CMSを導入する際の選択肢としてheadlessCMSは良い選択肢と言えます。本稿でそれらの感じたことをお伝えできていれば幸いです。
ここまで読んでいただきありがとうございました。

コミックROLLYアプリ速度改善のためにやったことについて

自己紹介

株式会社ブックリスタのプロダクト開発部でエンジニアをしている城と申します。 コミックアプリ「コミック ROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」のアプリ開発を担当しています。


はじめに

コミックROLLYアプリはFlutterを用いて開発され、リリースから1周年を迎えました。 機能やUIの充実が進む一方で、「画面表示がもう少し早ければ...」という声が増えてきました。

幸いにも、エンジニア間でこの課題が議論されており、具体的な改善案があったため迅速に対応できました。 本記事では、コミックROLLYが実施したパフォーマンス改善の具体的な内容を共有します。


アプリの基本構成

コミックROLLYでは、以下の技術スタックを採用しています。

  • 状態管理: Riverpod
    UIとロジックの責務を分離し、再利用性と保守性を向上
  • 主な構成要素:
    1. Widget
      • 継承元: HookConsumerWidget
      • 画面を表すUIクラス
      • UIの状態管理はRiverpod経由で実現
    2. State
      • 画面の状態やデータを保持するクラス
    3. Notifier
      • 継承元: StateNotifier
      • ビジネスロジックを実装するクラス
    4. Provider
      • StateNotifierとUIを結びつける役割

パフォーマンス改善内容

State監視方法の見直し

WidgetRef.watch編

下記のStateからepisodesを監視して、値が変更されたらWidgetの再構成をトリガーしたい場合を例とします。

@freezed
class WorkPageState with _$WorkPageState {
  const factory WorkPageState({
    @Default([]) List<EpisodeEntity> episodes,
    @Default([]) List<RecommendEntity> recommends,
  }) = _WorkPageState;
}

改善前

final episodes = ref.watch(workPageProvider).episodes;

改善後

final episodes = ref.watch(workPageProvider.select((state) => state.episodes));

解説

改善前: WorkPageStateクラス自体を監視対象とし、そのメンバー変数episodesの参照を代入しているに過ぎません。そのためepisodes以外のrecommendsを変更した場合でもWidgetの再構成がトリガーされます。

改善後: 監視対象のepisodesを変更した場合だけWidgetの再構成がトリガーされます。


WidgetRef.listen編

StateにエラーメッセージerrorMessageが設定されたらダイアログでエラーメッセージを表示したい場合を例とします。

@freezed
class WorkPageState with _$WorkPageState {
  const factory WorkPageState({
    @Default("") String errorMessage,
  }) = _WorkPageState;
}

改善前

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final errorMessage = ref.watch(workPageProvider.select((state) => state.errorMessage));

    // エラーメッセージがあればダイアログを表示する
    if (errorMessage.isNotEmpty) {
      showOkAlertDialog(context: context, message: errorMessage);
    }

    return Container(...);
  }
}

改善後

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(workPageProvider.select((state) => state.errorMessage), (prev, next) {
      if (next.isNotEmpty()) {
        showOkAlertDialog(context: context, message: next);
      }
    });

    return Container(...);
  }
}

解説

改善前: ダイアログを表示する目的でwatchを使用しています。 これでも一見期待通りの動作は得られますが、watchWidgetの再構成をトリガーするためのリスナーです。 ダイアログを表示したい事とWidgetの再構成は無関係なのにもかかわらずトリガーされ、ダイアログ表示を含むパフォーマンスに影響を及ぼします。

改善後: Widgetの再構成が必要ないStateの監視にlistenを使用しています。 これによりlistenの第二引数の関数が呼び出されWidgetの再構成はトリガーされません。


再構成の独立編

下記のStateからepisodesとrecommendsを監視して、それぞれ値が変更されたらWidgetの再構成をトリガーしたい場合を例とします。

@freezed
class WorkPageState with _$WorkPageState {
  const factory WorkPageState({
    @Default([]) List<EpisodeEntity> episodes,
    @Default([]) List<RecommendEntity> recommends,
  }) = _WorkPageState;
}

改善前

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        _buildEpisodes(ref),
        _buildRecommends(ref),
      ],
    );
  }

  Widget _buildEpisodes(WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    return Container(...);
  }

  Widget _buildRecommends(WidgetRef ref) {
    final recommends = ref.watch(workPageProvider.select((state) => state.recommends));
    return Container(...);
  }
}

改善後

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        _buildEpisodes(),
        _buildRecommends(),
      ],
    );
  }

  Widget _buildEpisodes() {
    return Consumer(builder: (context, ref, _) {
      // ScopeA
      final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
      return Container(...);
    });
  }

  Widget _buildRecommends() {
    return Consumer(builder: (context, ref, _) {
      // ScopeB
      final recommends = ref.watch(workPageProvider.select((state) => state.recommends));
      return Container(...);
    });
  }
}

解説

改善前: episodesrecommendsがセットされた時、それぞれWidget全体が再構成されます。

改善後: episodesがセットされた時はScopeAのみ、recommendsがセットされた時はScopeBのみ再構成されます。 改善前はbuildWidgetRefwatchしていましたが、改善後はConsumerWidgetRefwatchしているのがミソです。 再構成が必要な箇所だけ独立できるので効率的になりました。


State設定の見直し

ここまでState監視時に気をつけたいポイントは記載しました。 次からはState設定時に気をつけたいポイントです。

下記のように画面の操作をビジネスロジック側でハンドリングさせたい場合を例にします。

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    final ignorePointer = ref.watch(workPageProvider.select((state) => state.ignorePointer));
    return IgnorePointer(
      ignoring: ignorePointer,
      child: Container(...),
    );
  }
}

改善前

class WorkPageStateNotifier extends StateNotifier<WorkTopState> {
  WorkPageStateNotifier(super.state) {
    fetch();
  }
  Future<void> fetch() async {
    final episodesResponse = await episodesRepository.get();
    state = state.copyWith(episodes: episodesResponse);
    final ignorePointer = await checkResponse(episodesResponse);
    state = state.copyWith(ignorePointer: ignorePointer);
  }
}

改善後

class WorkPageStateNotifier extends StateNotifier<WorkTopState> {
  WorkPageStateNotifier(super.state) {
    fetch();
  }
  Future<void> fetch() async {
    final episodesResponse = await episodesRepository.get();
    final ignorePointer = await checkResponse(episodesResponse);
    state = state.copyWith(
      episodes: episodesResponse
      ignorePointer: ignorePointer,
    );
  }
}

解説

改善前: 分かれていたStateの変更を1つにまとめています。 State変更処理の間に非同期処理があるため、例で記載したウィジェットでは再構成が2度トリガーされてパフォーマンスが低下します。

改善後: 非同期処理の完了後にまとめてState設定することで再構成を最小限に抑えられました。 補足:State変更処理の間が同期処理のみであればウィジェットの再構成は1度に集約されますが、listenの場合はそれでも2度に分かれます。


ListViewの見直し

ListView.builderの改善

ListView.builderを使用すると必要な要素だけを動的に作成することでListViewを高速に表示できます。 しかしListView.builderを使用していても全要素を一度に生成してしまうケースがありました。

改善前

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    return SingleChildScrollView(
      child: Column(
        children: [
          _buildSearchHeader(),
          ListView.builder(
            shrinkWrap: true,
            physics: const NeverScrollableScrollPhysics(),
            itemCount: episodes.length,
            itemBuilder: (context, index) {
              return _buildEpisode(episodes[index]);
            },
          ),
        ],
      ),
    );
  }
}

改善後

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    return Column(
      children: [
        _buildSearchHeader(),
        Expanded(
          child: ListView.builder(
            shrinkWrap: true,
            itemCount: filteredItems.length,
            itemBuilder: (context, index) {
              return _buildEpisode(episodes[index]);
            },
          ),
        ),
      ],
    );
  }
}

解説

改善前: SingleChildScrollViewがスクロール可能なビュー全体をメモリ上に展開しようとするためListView.builderのすべての要素分builderが走ります。 要素数が多くなるほど画面表示まで時間がかかることになりListView.builderの恩恵を受けられませんでした。

改善前: 必要な要素だけを動的に作成するようになりました。 今回の対応では_buildSearchHeaderをスクロールに含めないようにUI変更を伴いました。 ListViewのスクロール時にヘッダーもスクロールさせたい場合はCustomScrollViewを使用することで対応可能です。

itemExtentの勧め

結論から言うとListViewの要素の高さが一定である場合itemExitenを指定するとパフォーマンスが向上します。

ListView.builder(
  itemExtent: 100, // 各アイテムの高さを固定
  itemCount: 2000,
  itemBuilder: (context, index) {
    return ListTile(title: Text('Item $index'));
  },
);

解説

デバイスのディスプレイをスクロールした時、どれだけ画面をスクロールするかはスクロールの速さや距離が影響します。シークバーなら選んだ位置によってスクロール先が決まります。 このような方法でListViewを下方向に1000pxスクロールさせる必要があると仮定しましょう。 ListViewを1000px移動した先に表示するアイテムのindexはいくつでしょう。 それを求めるには順番にListViewのアイテムを生成して高さを求めないと答えが出なさそうです。 ではitemExtentであらかじめすべてのアイテムは100pxと決められていたらどうでしょう。 アイテムを生成せずとも即座に答えが出せます。パフォーマンスとメモリ使用量の削減ができました。


最後に

今回の記事ではレイアウトに関する内容をメインで紹介させていただきました。 コミックROLLYでは今回の記事で紹介した改善することで、チカチカして見えたのが解消する、画面起動からコンテンツが表示されるまでの時間を25%前後も短縮するなどの効果がありました。

紹介した内容の他に非同期処理の並列化など、現在もパフォーマンス改善に取り組んでいます。 またナレッジが溜まったらご紹介させていただきたいと思っていますし、より良いアプローチや別のナレッジがあればぜひ教えて頂きたいです。

この記事の内容が、アプリ開発をされている方の何かしらのヒントになれば幸いです。

GitHubで静的コンテンツを公開する仕組みを手軽く構築した話

はじめに

こんにちは。私は株式会社ブックリスタのプロダクト開発部の姚です。
現在、コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)のバックエンド開発を担当しています。
この記事では、運用チームが管理する静的なLPページ、バナー画像などコンテンツを公開する仕組みの構築方法を紹介します。

背景

運用チームの役割

運用チームは、LPページやバナー画像などの静的コンテンツの公開や更新を担当しています。
これらのコンテンツを管理し、適切なタイミングでエンドユーザーに提供することが求められています。

従来の業務フロー

LPページやバナー画像などのコンテンツは、Amazon S3に保存され、CloudFront経由で公開されていました。
運用チームはS3にアクセスする権限を持っておらず、コンテンツのアップロードや変更は開発チームに依頼する必要がありました。

この業務フローを図示すると以下のようになります。

旧業務フロー

  1. 依頼
    運用チームは、コンテンツのアップロードや変更を開発チームに依頼します。

  2. アップロード(検証環境)
    開発チームは依頼を受け、まず検証環境のAmazon S3にコンテンツをアップロードします。ここで、コンテンツの表示が正しいかどうかを確認します。

  3. 動作確認
    運用チームと開発チームが協力して、検証環境でアップロードしたコンテンツの動作確認を行います。

  4. アップロード(本番環境)
    検証環境で問題がないことを確認後、開発チームが本番環境のAmazon S3にコンテンツをアップロードします。コンテンツはAmazon CloudFrontを通じてエンドユーザーに提供されます。

課題

サービス開始当初はこの業務フローでも問題はありませんでした。しかし、事業が成長し、キャンペーンや企画の増加に伴い、コンテンツの更新頻度が大幅に増えました。その結果、次のような課題が浮上し、迅速な対応が難しくなりました。

  1. 対応時間にばらつきがある
    開発チームのスケジュールに依存するため、依頼から公開までの対応時間にばらつきが生じてしまいます。

  2. 手動による多重作業
    検証環境と本番環境の両方に手動でアップロードする必要があり、作業が煩雑になりミスのリスクも高まります。

  3. 更新頻度の増加による負荷
    コンテンツの更新頻度が増加することで、運用チームからの依頼が増え、コミュニケーションコストと開発チームにかかる負荷も高まります。

  4. ゴミファイルが残りやすい
    フォルダーの置き換えによるアップロード作業では、削除や差し替えの際に不要なファイルが残りやすく、管理が煩雑になります。

対策案の選定

複数の対策を検討し、課題解決と開発コストを考慮した結果、最終的にGitHubを使う案を採用しました。

コンテンツ管理画面を作る

メリット

  • 運用者が直感的に操作できる

デメリット

  • 環境間の同期で課題がある

    • 運用者に手動でアップロードしてもらうと、従来の環境間同期ミスが発生しやすい課題は解決しない
    • システムが検証環境から本番環境へ同期の仕組みを作ると、開発コストがかかるし、システム構成も複雑になる
  • 単純なアップロードは容易ですが、削除や差し替えも考慮すると複雑になる

FTPでS3に接続する

メリット

  • 運用側には慣れた手法でアップロードできる

デメリット

  • 環境間の同期でミスが発生しやすい
  • セキュリティを確保するためには、適切なプロトコルを選択し、設定を正しく行う必要がある
  • FTPサーバーのコストがかかる

GitHubでファイルを管理してS3に自動デプロイする

メリット

  • ファイル管理がしやすい

    • GitHub Desktopを使えば、リポジトリでファイルの追加・削除や差し替えが直感的に管理できる
    • GitHubのアカウントでログインするだけで利用でき、SSHなどの設定が要らない
    • GUIでファイルの操作ができるため、Gitに不慣れな運用者でも扱いやすい
  • 開発コストを削減できる

    • GitHubの機能を活用することで、ファイルアップロードや権限管理などの追加開発コストを削減できる
    • プルリクエストやレビュー機能を活用することで、承認フローを簡単に導入できる
  • 検証環境から本番環境への同期が容易

    • GitHubのブランチを使って、検証環境と本番環境の同期を自動化できる
    • 手動でのファイルアップロードミスや、本番環境と検証環境の差異を防ぐことができる

デメリット

  • 運用者がGitを操作する必要がある
    • Gitの基本操作やGitHubの使い方に慣れていない運用者には、操作方法を学ぶ必要がある
    • 運用者がスムーズに利用できるため、適切な手順書やレクチャーの準備とサポートが必要になる

実現

GitHub リポジトリの用意

プロジェクト用の新しいGitHubリポジトリを作成します。
GitHub Teamを使用して、運用チームメンバーのアカウントをチームに作成し、適切なアクセス権限をリポジトリに付与します。

検証環境と本番環境の管理を容易にするため、適切なブランチとディレクトリ構成を準備します。
以下は一例です。

ブランチ

検証環境: staging
本番環境: main

ディレクトリ

readme.md(説明や運用手順など書く)
resource-folder/(資材を置くフォルダー)

CodePipeline の設定

GitHubのリポジトリに変更があった場合、S3への自動デプロイの手法の選択肢がいくつかありますが、今回はCodePipelineを使用方法を紹介します。

CodeBuildポリシーの作成

S3へのアクセス権限を付与するため、適切なIAMポリシーを作成します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:Get*",
                "s3:List*",
                "s3:Put*",
                "s3:Delete*"
            ],
            "Resource": [
                "arn:aws:s3:::bucket-name",
                "arn:aws:s3:::bucket-name/*"
            ],
            "Effect": "Allow"
        },
    ]
}

CodePipelineのビルドステージを設定

  1. ソースのダウンロード

GitHubSourceActionを利用して、GitHubからソースコードをダウンロードします。
OauthTokenを用いて、GitHubのリポジトリにアクセスできるように設定します。

  1. S3にファイルをアップロードする

AWS CLIを使用して、ダウンロードしたファイルをS3に同期します。とくに、GitHubリポジトリから削除されたファイルもS3から削除するために、aws s3 syncコマンドを使用します。

aws s3 sync resource-folder s3://bucket-name/target-folder --delete

このようにして、GitHubのリポジトリに変更があるたびに、S3に自動で変更内容が反映されるようにします。

変更後の業務フロー

業務フローは以下のように変更され、運用者がGitHubを使ってコンテンツを管理できるようになります。

新業務フロー

使用後の評価と課題

実際に使った体験

使いやすさ

GitHub Desktop経由でGUI操作が多く、ファイルやフォルダーを直感的に管理できるため、運用者にとって使いやすかったです。

手間の削減

自動デプロイの導入により、開発チームへの依頼が不要になり、運用チームと開発チームの間でのやりとりや反映までのスケジューリングが不要となりました。これにより、作業の手間が大幅に削減されました。

反映の速さ

GitHubにコミットした変更がすぐに反映されるため、作業のタイムラグがほとんどなくなりました。また、検証環境と本番環境の同期も自動化されたため、ミスが発生しにくくなりました。

サポート体制

導入時に提供されたドキュメントが手厚く、レクチャーも充実していたため、スムーズに利用を開始できました。
GitHub運用初心者も、ドキュメントがわかりやすく、迷うことなく作業を進められました。また、導入ドキュメントがしっかりしていたため、運用者間の引き継ぎも想定より早く終わりました。

さらに、レクチャー後も困ったことがあればすぐに質問・相談できる環境が整っており、そのサポート体制は非常にありがたかったです。これにより、チーム全体が新しいシステムに迅速に適応し、効率的に運用できています。

残る課題

途中の作業の検証

作成中のコンテンツを検証環境にアップロードして検証している最中に、本番環境への緊急リリースが必要になる場合、検証中のコンテンツが本番環境に反映されるリスクがあります。

対策として、本番環境と同期しない別の検証環境を用意することが考えられます。

複数人での作業

通常のGit-flowは運用者にとって難しいため、stagingブランチへの直接アップロードを採用しました。しかし、複数人が同時に作業する場合には、ファイルの競合の可能性があります。

対策として、運用者には、同じファイルを同時に編集しないように注意してもらうこと、複数人で同時に作業する場合にはお互いに連携を取ることを促す。また、操作手順のレクチャーして、スムーズな運用をサポートします。

まとめ

GitHubを使用することで、ファイルの管理や権限設定がしやすくなり、検証環境から本番環境への反映も自動化できます。ただし、運用者がGitを操作する必要があるため、適切な手順書の作成とレクチャーが求められます。

このように、GitHubの機能を活用することで、効率的なコンテンツ管理が可能になります。

Qdrant を商用のレコメンドシステムで使ってみた

こんにちは。株式会社ブックリスタでレコメンドシステムの開発をしている佐伯です。 今回は、電子書籍ストアのレコメンドシステムに Qdrant を導入した事例を紹介します。 ここで得られたレコメンドシステムに向いたポイントや、苦労した点についても共有します。

この記事で伝えたいこと

  • レコメンドシステムを従来のバッチ処理型から、Qdrant を活用したリアルタイム型へ移行したこと
  • バッチ処理でシステム負荷や費用が課題になっており、それが改善できたこと
  • Qdrant はレコメンドに向いた機能があり、開発もしやすく、短期間で商用環境の切り替えが実現したこと

Qdrant とは

Qdrant(クワドラントと読みます) は Rust で開発されたオープンソースのベクトルデータベースです。 高次元ベクトルに対する効率的な検索や大規模データセットへの対応が可能なスケーラビリティを備えています。

Qdrant は開発も始めやすく、サーバーの Docker イメージを起動しクライアントの SDK をインストールすれば、ローカルでの試作を始めることが可能です。 公式ドキュメントも充実しており、ベクトルの更新や検索などの主要な操作がコード例とともに記載されているため、開発中の疑問が概ね解消できます。

さらに、Qdrant Cloud というマネジメントサービスを使うことでスムーズに次のステップに進めることができます。 テスト環境は無料で作成できますし、商用環境用のクラスタも簡単に立ち上げることができるため、短時間でリリースが可能になります。

Qdrant 導入の背景

今回話題にするレコメンドシステムは、電子書籍ストアへ来訪したユーザーにおすすめの本を提案するものです。

Qdrant を導入する前は、ユーザーごとの推薦リストをバッチ処理で事前に作成する方式でした。 私たちのレコメンドシステムでは、推薦リストを 1 ユーザー 1 リストではなく、商品の条件ごとに複数の推薦リストを作成します。 例えば、「無料のコミック」や「新刊のラノベ」など、条件に応じた推薦リストをユーザーごとに準備します。

導入前の構成図

この方式は、推薦時に RDB からレコードを取り出すだけで良いため、レスポンスが高速というメリットがあります。 一方で、以下のような点がデメリットとして挙げられます。

  • ストアを利用するユーザーや、リストを作成する商品の条件が多くなると、推薦リストの数が急激に増加する
  • 来訪しないユーザーにも推薦リストを作ることになるため、無駄な処理が発生する
  • 直近のユーザーの行動や新刊を推論結果に含めるため、バッチ処理を頻度高く行う必要がある

当初はこの方式でも問題のない規模で稼働できていましたが、時間が経つにつれ、以下の問題が顕在化してきました。

  • 推薦リストの件数が増加し、RDB への書き込み時間が数時間以上に及ぶ
  • 推薦リストの書き込み処理が集中して、RDB の負荷が高まり、書き込みエラーが発生する
  • 書き込みデータ量の増加に伴い、インフラコスト(主に AWS Aurora にかかるストレージ容量や IO 単価)が高騰化する

これらの課題の解決のためにバッチ方式のままいくつかの代替案を検討しましたが、いずれもコストや処理時間の問題を解決できませんでした。 そのため、推論リストの作成をリアルタイムに行う方針へ転換しました。 リアルタイム処理にすることで、大量の推薦リストの作成やそれに伴うエラーやコストの問題は解決します。 一方で、性能上のメリットを失うことになるため、それがクリアできる移行先を検討することになります。

Qdrant を選んだ理由

弊社のレコメンドエンジンでは、バッチでの推論の時点でも内部的に Faiss でのベクトル検索を使用して、近しいベクトルから推薦リストを作成する仕組みをとっていました。 リアルタイム処理に切り替える際も同じデータを利用できるベクトル検索データベースを利用するのが自然なアプローチになるため、これらの製品の中から切り替え先を選定しています。

対象の製品としては、Qdrant、Elasticsearch、Pinecone、または Faiss で自前のクラスタを組むなどの候補がありました。 これらに対しての検討は以下の観点で行っています。

  • 推薦商品の条件をリアルタイムに絞り込めるフィルタリング機構があること
  • チームメンバーが少ないため、なるべくサーバーの運用負担が少ないこと
  • レスポンス性能が SLO を満たすこと

後述しますが、Qdrant にはメタデータによる直感的に理解しやすいフィルタリングがあります。 サーバー運用についても、Qdrant Cloud を使用すれば、AWS などでインフラ構築をせずに運用できます。 また、検索時のレスポンスタイムが懸念でしたが、事前に簡単なベンチマークを行い十分に速いレスポンスが得られました。 想定スペックで試算をした場合のランニングコストも問題ないことが確認できたため、切り替えを進めることにしています。

なお、最初に検証した Qdrant で条件を満たせることがわかったため、私たちの検討では他の製品の検討は行なっていません。 さらに検討を進めれば、他の製品でも条件を満たすものはありえます。

導入後のシステム構成

Qdrant を導入した後の構成はこの図のようになります。 RDB に推薦リストを登録する処理を廃止し、学習した結果のベクトルデータを直接 Qdrant へアップロードしています。 レコメンド API への問い合わせも、ユーザー ID と商品条件を用いて Qdrant サーバーからリアルタイムに推薦リストを取得する方式にしています。

導入後の構成図

レコメンドシステムで使ってみて良かったところ

ここからは商用のレコメンドシステムで使用してみて、特にマッチしていた点を説明します。

商品属性での柔軟なフィルタリングができる

前述した通り、レコメンドエンジンから推薦する商品には所定の条件に応じた絞り込みを行います。 この絞り込みをベクトル検索の結果に対して行うと、レスポンスに必要な商品件数に対して不足するケースが発生します。 特に商品の条件が特殊な場合は、推薦上位の商品に対象がほとんどないケースもあり得ます。 バッチによる事前推論の時は、これらの条件で事前に絞り込んだ推薦リストを作ることで対処していましたが、リアルタイムで処理する場合は推薦商品リストの作成時に絞り込みを行う必要があります。

Qdrant では、検索したい条件をペイロードとしてベクトルに関連付けてアップロードしておき、ベクトル検索と同時にペイロードによるフィルタリングが可能です。 これにより、条件を満たす推薦リストを一度のリクエストで必要件数取得できます。

ペイロードによるフィルタリングは SDK に用意されているクラスで条件を合成する形で実装できます。 条件にはテキスト一致だけでなく値や日付の比較や範囲指定できるので、よく検索に使われる条件はカバーできます。

フィルタリングのコードが直感的で、視認性が高いこともポイントです。 例えば、「1 週間以内に発売された無料のコミック」という条件であれば、検索時に以下のようなフィルター記述を付与することで実装できます。 (key の名称はペイロードのアップロード時に指定するものです)

from qdrant_client import models

models.Filter(
  must=[
    models.FieldCondition(
      key="sales_from", range=models.DatetimeRange(lte=now)
    ),
    models.FieldCondition(
      key="price", match=models.MatchValue(value=0)
    ),
    models.FieldCondition(
      key="genre", match=models.MatchValue(value='comic')
    ),
  ]
)

ペイロードの構造は自由度が高く、事前にスキーマを定義しておく必要もありません。 例えば、属性の追加や削除は随時行うことができるほか、ベクトルごとに保持する属性を変えることも可能です。 そのため、運用しながら柔軟に変更していくことができます。

ただし、ペイロードによるフィルタリングを高速に行うためにはインデックスを作成しておく必要があります。 また、大量にペイロードをアップロードすると Qdrant サーバーのメモリやストレージの使用量が増加するので、注意は必要です。

推薦のユースケースに合わせた検索方法が複数用意されている

Qdrant にはベクトルの集まりを表現するコレクションというデータ構造があります。 例えば類似の商品を探す時は、商品ベクトルを集めたコレクションから特定の商品に近いベクトルを探すことになります。 その際もいくつかの方法が取れます。

  • コレクションの中にある商品ベクトルの ID を 1 つ指定して、近いものを検索する (ある商品に近いものの検索)
  • コレクションの中にある商品ベクトルの ID を複数指定して、近いものを検索する (複数の商品群に近いものの検索)
  • ベクトルデータの配列を直接検索条件に渡して、近いものを検索する (コレクションにない商品の検索)

さらに、検索元のベクトルとして、他のコレクションのベクトルを指定できます。 私たちのレコメンドエンジンでは、商品のベクトルとは別にユーザーごとのベクトルを作成しておき Qdrant にアップロードしてあります。 ユーザーに対するおすすめ商品を検索する際には、ユーザーベクトルのコレクションから ID を指定することで、商品のコレクションの近似ベクトルを検索できます。 (ユーザーベクトルと商品ベクトルは同じ次元数である必要があります)

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.query_points(
    collection_name="item_vector_collection",
    query=models.RecommendQuery(
        recommend=models.RecommendInput(
            positive=[100], # ここにユーザーベクトルのIDを指定する
        )
    ),
    lookup_from=models.LookupLocation(
        collection="user_vector_collection", vector="user_vector" # ここでユーザーベクトルのコレクションを指定
    ),
)

ベクトルのアップロード、インデックス作成が速く、データ切り替えが無停止で可能

Qdrant Cloud へのベクトルやペイロードのアップロードは当初時間がかかることを懸念していましたが、かなり短時間で完了します。 ベクトル数や次元数、ペイロードのデータ量にもよりますが、私たちの環境の場合、十数分ほどで完了しています。 当初のバッチ処理では、RDB のレコード作成で何時間もかかっていたことを考えると、これは大幅な改善です。

それとは別に、既存のベクトルデータを更新する場合も、Qdrant には便利な機能があります。 Qdrant でデータを更新する場合、ベクトルごとに更新データをアップロードしていく方式になります。 そのため更新中は一部のベクトルデータやペイロードのみ更新された状態になって、検索結果が予期しないものになります。 これが問題ないケースもありますが、私たちのシステムでは学習の度に全ベクトルデータを一括で差し替える方式にしているため、なるべく避けたいものでした。

Qdrant にはコレクションエイリアスという機能があり、この機能を使用すると上記の問題を解消できます。 コレクションエイリアスはアップロードしたコレクションに別名を割り当てるものです。 更新データを使用中のコレクションとは別にアップロードしておき、全て完了したらエイリアスを切り替えます。 こうすることで、中間状態の問題がなく、シームレスに更新後のデータを参照させることができます。 ベクトル検索をするクライアント側はエイリアス名を参照するため、クライアント側への影響もありません。

当初はデータ更新をハンドリングする仕組みを自前で実装する可能性を考慮していたのですが、この機能で必要がなくなりました。

苦労・妥協したポイント

一方で導入時に苦労したり、一部苦労した点もありました。

ペイロードのフィルター条件によっては速度が出ないケースがある

検索時のペイロードのフィルター条件によっては、検索レスポンスが遅くなるケースもありました。 基本的には、ペイロードのインデックスを作成する、カーディナリティが小さくならないようなペイロードを作成する、といった対応で検索速度は向上します。 ただし、一部のフィルター条件での検索速度がどうしても上がらないケースがあり、その箇所だけはバッチ推論方式を残す形で回避しています。

どのようなパターンでレスポンスタイムが悪化するかがはっきりとは分からなかったため、今回は使われる可能性のあるフィルター条件全てでベンチマークを実施して対応しています。 RDB のように、ノウハウが貯まれば注意すべきケースがわかっていくのですが、当初は主要なフィルターに対して個別に性能評価をした方が良さそうです。

スケールアップやスケールアウトはできるが、逆ができない

Qdrant Cloud のクラスターはサーバーのスペックやノード数などを Web コンソールで柔軟に変更可能です。 そのため、稼働後に負荷が高くなってきたらスペックをあげたり、ノード数を増やしたりして対応できます。 一方、スケールダウン・スケールインについては現状では対応しておらず、縮小するにはクラスターの再作成が必要になります。 クラスターの再作成は API で問い合わせる URL の変更と API 用のアクセスキーの変更が発生しますので、API を呼び出すシステムにも影響するものになります。

Qdrant ではレプリケーションやシャーディングをサポートしているため、無停止でのスケールインが難しい可能性はあります。 しかしながら、アクセス増加に対する一時的な増強は実運用上よく発生するもののため、対応して欲しいところです。

まとめと今後

以上が、商用のレコメンドシステムを Qdrant に切り替えた時の内容になります。 この切り替えは、稼働中のシステムのバックエンドをバッチ処理からリアルタイム処理へ大きく変えるものだったことに加え、エラーの発生やコストの増大など早期解決が必要な課題への対応を迫られたものでした。 しかしながら、Qdrant には商用のレコメンドシステムの実装に向いた機能が備わっており、性能上の問題もほぼ起こらなかったためスムーズに移行できました。 また、このブログ記事を書いているタイミングで切り替えから 4 ヶ月ほど経っていますが、サービスも安定しておりトラブルは全く起きていません。

Qdrant は現在も活発に更新されています。 私たちのシステムへの導入時点ではバージョン 1.9 だったのですが、今リリースノートを見ると 1.12.4 が最新になっていました。 その中には性能向上や新しい API の追加などあり、上手く活用すればさらにシステムの改善につなげることができますので、今後もウォッチしていきたいと考えています。

Laravel Queue を使ってデータ連携を準リアルタイム化した話

自己紹介

はじめまして。 株式会社ブックリスタ プロダクト開発部でエンジニアをしている八幡と申します。

現在は、コミックアプリ「コミック ROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」の開発・運用を担当しています。

はじめに

システム間のデータ連携方式は、大きく分けるとオンラインとバッチの 2 種類があります。

方式 連携の起点 即時性
オンライン 連携元 即時
バッチ 連携先 遅延あり

コミック ROLLY では、以下の観点からオンライン連携方式を採用することにしました。

  • 今回の案件では連携元側の開発が必要であったので、連携方式は自由に決めることが可能
  • 緊急でデータ状態を変更したいような業務フローが存在する
  • 連携頻度はそこまで高くない

今回はオンラインデータ連携方式を用いて、準リアルタイム化を実現したことについてお話いたします。

最終的な構成

  • 連携元システムの対応がシンプルになるように Http エンドポイントでの連携通知受領
  • データそのものは直接連携せずに連携先システム側へ照会する
    • 更新通知には照会のためのキーだけが存在する
  • 再実行は Http のエンドポイントに対して更新通知を直接再送する

メッセージキュー(Amazon SQS)を利用した非同期オンライン連携

オンラインデータ連携方式で最初に考慮すべき点が、同期で行うか非同期で行うかとなります。

同期連携を採用した場合、リアルタイム性は非常に高いものの、連携元システム側は連携先システム側の処理完了を待たなければなりません。
また、連携が失敗した場合に再送やトランザクションのロールバックといったことも考慮しなければなりません。

今回求められていた要件では、加工処理が複雑・業務上不整合を起こすデータは意図的にエラーとする、といった特徴があったので、同期方式ではなくメッセージキューを利用した非同期方式を採用しました。

メッセージキューとしては、Amazon Web Services のフルマネージドなメッセージキューサービス、Amazon SQSを採用しています。

Laravel Queue の活用

PHP の Web フレームワークであるLaravel には、Queue 連携機能があります。

今回のプロジェクトでは、一部サブシステムに Laravel を利用していることもあり、技術スタック的・ソースコード管理的にこの Laravel Queue を利用するのが最善と判断しました。

初期設定

Laravel ではconfig/queue.phpにてキューの定義をしますが、プリセットの状態で SQS 接続に対する定義が存在します。

        'sqs' => [
            'driver' => 'sqs',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
            'queue' => env('SQS_QUEUE', 'default'),
            'suffix' => env('SQS_SUFFIX'),
            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
            'after_commit' => false,
        ],

環境変数として、最小で以下の3つを利用している AWS 環境に併せた設定とすることで、SQS 接続の初期設定は完了となります。

環境変数 設定値 備考
SQS_PREFIX https://sqs.ap-northeast-1.amazonaws.com/9999999 東京リージョン&アカウント ID:9999999 の場合の設定
SQS_QUEUE キュー名称 SQS 上のキュー名をそのまま設定
AWS_DEFAULT_REGION ap-northeast-1 東京リージョンの例

key/secret は AWS 接続のためのアクセスキー情報ですが、今回はキューを扱うプロセスは ECS 上で稼働するため、アクセスキー接続ではなく IAM 認証で対応しています。

ジョブの作成

ジョブは、キューから取得したメッセージを処理するクラスです。

キューメッセージのコンシューム(ポーリング)は、Laravel 自体が適切に処理してくれるので、処理の実態となるジョブの実装に集中できます。

以下のコマンドでapp/jobs配下にジョブクラスが作成されます。

php artisan make:job TestJob

TestJob

class TestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        // コンストラクタ処理を記述
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        // 実ジョブ処理を記述
    }
}

キューメッセージの投入

前述のジョブを動かすためには、キューにメッセージを投入する必要があります。

今回の構成では、Http エンドポイントとして連携通知を受け取る仕組みとしていますので、シンプルに API として作成します。

route/api.php

 Route::post('enqueue', [EnqueueController::class, 'create']);

EnqueueController

class EnqueueController extends Controller
{
    /**
     * Store a new job.
     */
    public function create(EnqueueFormRequest $request): mixed
    {
        TestJob::dispatch(json_encode($request->toArray()));
        return response()->json('success');
    }
}

JSON 形式のメッセージを受け取るようにしていますので、コード例は割愛しますが FormRequest による Validation も実施しています。

ここで実行する Job クラスを指定していますが、この情報はメッセージ内に書き込まれます。

キューワーカーの起動

この状態で Laravel を起動した場合、API で連携通知の受信 → キューへのメッセージ投入は行われますが、ジョブは実行されません。
ジョブを動かすためには、Worker を別のプロセスとして起動する必要があります。

php artisan queue:work

Worker プロセスはキューをポーリングし、メッセージを受信したらジョブを起動します。

前述のキューメッセージの投入の項にある通り、実行する Job クラス情報がメッセージに含まれていますので、適切なジョブの実行が可能です。

また、同じキューに対して別の Job クラスを指定して投入したメッセージであれば、その別の Job クラスで処理することになります。

これにより、1つのキューで複数の Job クラスを稼働させることも可能です。

複数キュー指定

複数のキューを 1 つの Worker で処理する方法もあります。

php artisan queue:work --queue=queue,low-queue

--queueオプションを利用することで、queue.phpに定義したキュー名とは別のものを指定できます。

キュー名の指定は、カンマ区切りで複数指定が可能で、指定した順で優先度が設定されます。
複数キューを指定した場合、優先度が高いキューから処理し、すべてのメッセージを処理し終えたら次の優先度のキューを処理します。
よって前者のキューにメッセージが滞留する状況になると、後者のキューのメッセージは待たされ続けることになります。

複数キューを利用する場合は、この点を踏まえた設計が必要です。

処理済みメッセージの削除

Worker によって受信したメッセージは、Job 処理がタイムアウトとならない限り成否にかかわらずキューから削除されます。

タイムアウトによるメッセージの解放、処理完了によるメッセージの削除は、Worker が適切に実施してくれます。

なお、SQS を利用している場合、タイムアウト時間は SQS 側の可視性タイムアウト時間に準拠します。

可視性タイムアウトに関しては、以下を参照ください。

https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html

失敗時の自動リトライ

Laravel Queue では、失敗時に自動で再実行する仕組みが用意されています。
再実行回数の範囲内であれば失敗として扱われないことになるので、ネットワークの問題などの一時的な障害に対応できます。

Job クラスで、2つのプロパティを設定することでこの機能は利用できます。 以下の例では、失敗時には 10 秒待機してリトライするようになります。

class TestJob implements ShouldQueue
{
    /**
     * jobのリトライ回数
     */
    public int $tries = 3;

    /**
     * jobのリトライまでの待機時間(秒数)
     */
    public int $backoff = 10;

    ...
}

失敗時の通知処理

Laravel Queue では、失敗時の後処理を記述するためのフックポイントが用意されています。
今回の案件では、このフックポイントで Slack への通知連携を行うことによって、失敗を即時検知できるようにしました。

class TestJob implements ShouldQueue
{
    ...

    public function failed(Throwable $exception): void
    {
        // Slackへの通知処理
    }
}

失敗処理の保管

Laravel Queue では、失敗した情報をテーブルに書き込みます。
書き込まれた失敗情報をもとに、再実行をするための機能も用意されています。

今回は、Http による連携通知で再実行することとしたため利用しませんでしたが、興味がある方はぜひ調べて活用ください。

最後に

今回は Laravel Queue を利用した非同期データ連携による準リアルタイム化の実現についてお話させていただきました。

データ加工要件の複雑性から今回は採用を見送っていますが、よりシンプルな構成として SQS+Lambda 構成などもよく使われる手法ですので、興味がある方はぜひ調べてください。

データ連携をバッチで実現しているのをオンライン化したい、Laravel を活用したい、といった方にとって本記事が少しでも参考になることを祈っております。