Google Cloud Compute Engine (e2-micro) には無料枠が存在します。基本スペックは以下の通りメジャークラウドの中では最弱クラスのインスタンスですが、せっかく無料なのでこの環境でBlueskyのトレンドワード算出システムを運用できるか試してみることにしました。
| 項目 | 基本スペック | 本記事における注意点 |
|---|---|---|
| CPU | 2コア / 0.25 vCPU | 最大30秒のバースト機能つき バースト中は 2.0 vCPU |
| メモリ | 1.0 GB | Swap なし |
| ストレージ | 30 GB | 無料枠で使えるのは pd-standard のみ ランダムアクセスが非常に遅い |
| 対応リージョン | us-west1 (オレゴン) us-central1 (アイオワ) us-east1 (サウスカロライナ) |
これら以外は課金対象 |
このような弱々なスペックでは、トレンドワードを算出すること自体はできても処理に時間がかかりすぎて速報性を確保することが難しくなります。なので今回は、できるだけ処理時間を短縮することを主なゴールとして改善に取り組みました(できればCPUバースト時間の30秒以内)。それを達成するための試行錯誤の中で得られた GCE および e2-micro 特有の知識や経験を、汎用性のありそうなものを中心に共有したいと思います。
制作テーマ:Bluesky のトレンドワード抽出システム
作るものはBlueskyのトレンドワード算出システムで、主なタスクは
- ユーザーの投稿データの取得、保存
- ワード抽出
- トレンド検知
- SNSに投稿
です。これら一連のタスクをcronで定期的にバッチ処理します。
これらのタスクは基本的にどれも重いです。投稿データは日本語だけでなく全言語のデータが流れてくるため処理量が膨大になりますし、ワード抽出は形態素解析器を使うのでやはり重いです。トレンド検知も大量のワードをさばいてトレンドスコアを計算する必要があります。
データ量の多さが処理時間をもっとも支配する要素となるため、今回実装したシステムではそれを減らすために個別対応的なロジックを大量に導入しましたが、そういうのは汎用性が低いのでこの記事では紹介しません。もっと他でも使えそうな、GCE や e2-micro の仕様に根ざした軽量化手法をメインに紹介していきます。
一番のネックはストレージ性能の低さ
無料枠マシン(e2-micro)のスペックは先程挙げたように、
- CPU: 2コア・0.25 vCPU (バースト時間中は 2.0 vCPU)
- メモリ: 1 GB
- ストレージ: 30 GB (pd-standard)
という本当にマイクロな性能なのですが、中でも特に足を引っ張るのがそのストレージ性能の低さです。
無料枠では pd-standard という種類のストレージのみが利用可能で、その中身はHDDかつネットワーク越しのストレージであり、ローカルのSSDなどと比べても著しく性能が低く、特にIOPSが低いです。
具体的にどれくらい遅いかというと:
-
pandas の import にかかる時間がローカルマシンと比べて7倍以上
-
Blueskyの投稿をMeCabで形態素解析処理するとローカルマシンの30倍くらいの時間がかかる(辞書ファイルへのランダムアクセスが発生するため)
計測条件
- どちらもCPUバースト中に計測。以下、e2-micro での計測時間について言及があるときはすべてCPUバースト中に計測した時間になります
- ページキャッシュは削除してから計測しています。今回の処理ではメモリを多く使うせいかキャッシュが頻繁にメモリから追い出されてしまい、キャッシュなしの状態で import することになる場合が多いため
- ローカルの環境は Ryzen 7 5800H 、メモリ 32GB、NVMe SSD
そして、MySQLなどのデータベースを使おうとする場合、メモリを圧迫するのはもちろん、インデックスを使って検索して該当するデータを読み込むという一連の処理でランダムアクセスが多く発生するため、IOPS の低い pd-standard ストレージにはかなり荷が重くなります。
このように、低いストレージ性能が原因で現代では当たり前の処理が何もかも異様に遅くなってしまいます。なので、 e2-micro を使う上ではランダムアクセスが多い処理はなるべく避けることが鉄則となります。
CPUも遅いが、2コア使えてバースト機能つき
CPUも基本性能が 0.25 vCPU とものすごく弱いですが、コア自体は2個使用可能で、かつバースト時間中は 2.0 vCPU 分の処理能力が利用可能になります。バースト時間は最大30秒間で、使用すると「トークン」を消費し、低負荷の時間帯に徐々に回復していきます。この特性をうまく利用することもe2-microを効率的に使うカギとなります。
それでは実際に処理を高速化するために使った手法について以下で説明していきます。
巨大なライブラリのimportを避ける
先程述べたように e2-micro では pandas のような大きなライブラリをimportするだけで普通の何倍も時間が掛かってしまうわけですが、他のライブラリでも以下のように時間が掛かってしまいます。
| e2-micro での所要時間(秒) | ローカルでの実行時間(秒) | |
|---|---|---|
| pandas | 6.130 ± 0.750 | 0.830 ± 0.028 |
| matplotlib | 1.936 ± 0.628 | 0.417 ± 0.012 |
| atproto (bluesky用) | 10.374 ± 0.951 | 4.727 ± 0.378 |
計測条件
- 計測コマンドは sync && echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null && python -X importtime -c "import pandas” 等で、これを10回実行して平均を算出
少しでも大きなライブラリを import しようとするとしばしばこのような感じになるので、その対策として
- そのライブラリを実際に使う直前にimportする(遅延インポート)
- 別の手段で代替する
などを積極的に検討します。
今回の実装テーマでは、グラフ描画が必要なときだけ matplotlib を import するようにしつつ、とても重い atproto は使用せず Bluesky Post 用の API を直接叩くことで代替しました。
データベースを使わずファイルでデータを管理する
ユーザーの投稿を貯めておく場合、通常はデータベースに保管して必要な時に検索・取得するのがセオリーです。しかしそのやり方だと取得時にストレージへのランダムアクセスが発生しやすいため e2-micro の場合はデータベースの使用は極力控えざるを得ません。
なのでその代わりに、ユーザーの投稿データは以下のような形式でファイルで保存・管理することを検討します。
./posts/20260510/posts_20260510_00100000.parquet
./posts/20260510/posts_20260510_00100500.parquet
./posts/20260510/posts_20260510_00101000.parquet
./posts/20260510/posts_20260510_00101500.parquet
./posts/20260510/posts_20260510_00102000.parquet
原始的なやり方ではありますが、トレンドワードを計算するにあたって必要なのは特定の日時範囲内の投稿データ一式なので、それらをまとめてファイルに保存しておき、ファイルパスにデータの日時を含めておけば十分機能します。
またそれ以外にも得られる利点としては以下が挙げられます:
- 古いデータを消すときはディレクトリごと削除すれば済む(削除時に検索が不要)
- もし投稿データをバックアップしたいときはGCSにファイル・ディレクトリごと保存すればよい
もちろんファイルのまま置いておくことによるデメリットもあり、特に検索・集計を素早く実行できないのが大きなマイナスですが、Parquet や CSV, NDJSON で保存してある場合は
- DuckDBを使う
- GCS経由でBig Queryを使う
などの方法である程度対処できます。
JSON よりも JSONL (NDJSON), Parquet
SNSのPostのようなデータをファイルで扱う場合、JSON よりも JSONL, Parquet に保存したほうが読み込み速度の点で有利になりました(pandas前提)。
複数のファイルをpandasで読み込むベンチマークを実施した結果、以下のようになりました。Parquet と JSONL の読み込みでは pyarrow engine を使えるため高速化したのだと思われます。
表1: ファイル形式ごとの読み込み時間
| File Type | Method | Time (sec) | Std Dev (sec) |
|---|---|---|---|
| Parquet | pd.read_parquet(path, engine=”pyarrow”) | 0.624 | ±0.042 |
| JSONL (NDJSON) | pd.read_json(path, lines=True, engine="pyarrow") | 0.713 | ±0.073 |
| JSON | pd.read_json(path) | 1.383 | ±0.104 |
計測条件
- ファイル数は各ファイル形式ごとに60個
- 1ファイルに1000行のデータが入っていて、ファイル形式が違っても中身は同じ
- 各ファイルのサイズは 100 KB~200 KB
- 圧縮方式はすべてzstd
- 60個のファイルを1ファイルずつ読み込む
並列化でファイル読み込み時間短縮 : pyarrow系が第一選択肢
pd-standard ストレージは 低 IOPS ですが、シーケンシャルな読み込みであればスループットはそれなりに出ます(100 MiB/s 以上)。また、CPUもいちおう2コア使えるので、並列化でファイル読み込みにかかる時間をいくらか短縮できます。
たとえば pandas で複数の parquet ファイルを読み込む場合、以下のように並列化できます。
# ①ThreadPoolExecutor で 並列読み込み
def load_one(path):
return pd.read_parquet(path, engine="pyarrow")
with ThreadPoolExecutor(max_workers=2) as pool:
dfs = list(pool.map(load_one, parquet_files))
df = pd.concat(dfs, ignore_index=True)
# ②もっと簡単に並列読み込み
df = pd.read_parquet(parquet_files, engine="pyarrow")
# ③逐次読み込み
df = pd.concat([pd.read_parquet(f, engine="pyarrow") for f in parquet_files], ignore_index=True)
このやり方で実際にファイルを読み込んでみると以下のような結果になりました。逐次読み込みよりも並列化したほうが約20%読み込みにかかる時間が短縮されています。
| Time (sec) | Std Dev (sec) | |
|---|---|---|
| ①ThreadPoolExecutor で並列読み込み | 0.485 | ±0.033 |
| ②もっと簡単に並列読み込み | 0.452 | ±0.106 |
| ③逐次読み込み | 0.610 | ±0.050 |
計測条件
- 1個あたりサイズ:100 KB ~ 200 KB
- ファイル個数:60個
- それぞれのテスト回数: 10回
ThreadPoolExecutor を使えばだいたいどのファイル形式でも並列化できますが、pyarrow ならライブラリ自体が並列読み込みの方法を提供しているのでもっと楽に実装できます。並列読み込みに対応しているファイル形式は CSV, NDJSON, Parquet などで、使い方は以下のような感じです。
import pyarrow.dataset as ds
import pyarrow.parquet as pq
import pandas as pd
############# pyarrow ###############
# Parquet
df = pq.read_table(parquet_files).to_pandas()
# Parquet その2
df = ds.dataset(parquet_files, format="parquet").to_table().to_pandas()
# NDJSON
df = ds.dataset(jsonl_files, format="json").to_table().to_pandas()
# CSV
df = ds.dataset(csv_files, format="csv").to_table().to_pandas()
細かいところは省いてありますが、だいたいこんな感じで並列読み込みできます。
CPUバースト機能をギリギリまで使い倒す
E2系のインスタンスではCPUバースト機能が使えるので、これをできるだけ多く使えばコスパを上げられます。バースト機能の仕様を実際に調べてみたところ、以下のような感じでした:
- 最大30秒間 CPU 100% の処理を継続できる
- 1分間 CPUを休ませれば15秒分のバーストが復活
- 2分ちょっと休ませれば30秒分のバーストが復活
つまり、cron などの定期処理でe2-microを利用する際は、
30秒間CPUをバーストで使用→2分30秒くらい処理を空ける→また30秒間使用→以下繰り返し
のような使い方をすれば e2-micro のCPUをギリギリまで使い倒せることになります。
ただし、GCP公式サイトには具体的に何分待てば回復するという記載はないため、時間帯やリージョンなどによって回復まで必要な時間は微妙に変動する可能性があります。実際に運用する際にはもう少し余裕を持たせたほうが良いでしょう。
最終手段: pd-balanced, pd-ssd ストレージの使用(※有料)
どうしても巨大なライブラリをimportする必要があったりデータベースを使用しなければならない場合、もっと性能の高い pd-balanced ストレージなどにそれらを格納することも検討します。
pd-balanced ストレージを実際に使うと以下のような効果が出たため 10GB 分だけ pd-balanced ストレージ を使うことにしました:
-
60秒ほど掛かっていた形態素解析処理が8秒で完了(MeCab の辞書ファイルを pd-balanced に配置)
-
各種ライブラリの import 時間を短縮
pd-standard (秒) pd-balanced (秒) pandas 6.130 ± 0.750 2.117 ± 0.236 matplotlib 1.936 ± 0.628 0.995 ± 0.132 atproto (bluesky用) 10.374 ± 0.951 7.643 ± 0.753
念のため pd-standard と pd-balanced の IOPS・Bandwidth ベンチマークを取ってみたところ、以下のような結果になりました。Bandwidthは思ったよりも差がなく、一方で IOPS は pd-balanced のほうが10倍以上高くなりました。
| pd-standard 30 GB | pd-balanced 10 GB | ||
|---|---|---|---|
| シーケンシャル Read | Bandwidth | 120MiB/s | 134MiB/s |
| IOPS | 120 | 134 | |
| シーケンシャル Write | Bandwidth | 102MiB/s | 102MiB/s |
| IOPS | 101 | 101 | |
| ランダム Read | Bandwidth | 0.619MiB/s | 12.3MiB/s |
| IOPS | 154 | 3147 | |
| ランダム Write | Bandwidth | 1.224MiB/s | 12.3MiB/s |
| IOPS | 305 | 3145 |
計測用コマンド例
# シーケンシャル Read
sudo fio --name=seqread --directory=/tmp --size=1G --ioengine=libaio --direct=1 --bs=1M --iodepth=32 --rw=read --runtime=60s --time_based --group_reporting
# シーケンシャル Write
sudo fio --name=seqwrite --directory=/tmp --size=1G --ioengine=libaio --direct=1 --bs=1M --iodepth=32 --rw=write --runtime=60s --time_based --group_reporting
# ランダム Read
sudo fio --name=randread --directory=/tmp --size=1G --ioengine=libaio --direct=1 --bs=4k --iodepth=32 --rw=randread --runtime=60s --time_based --group_reporting
# ランダム Write
sudo fio --name=randwrite --directory=/tmp --size=1G --ioengine=libaio --direct=1 --bs=4k --iodepth=32 --rw=randwrite --runtime=60s --time_based --group_reporting
pd-balanced ストレージは無料枠外のサービスにはなりますが 0.1 ドル / GB と安価な割にかなり高速化できるのでおすすめです。さらに高速な pd-ssd も 0.17 ドル/ GB で利用できるので、一般的なデータベースを使いたい場合はそちらも検討の余地があります。また、e2-micro はデフォルトでSwap領域がゼロなので、安全のため pd-balanced の領域にいくらか確保する、という用途も考えられます。
pd-standardとpd-balancedのベンチマーク結果についての余談
このベンチマーク結果の pd-standard の数値はGCP公式サイトの仕様とかなり乖離があります。(pd-balanced についてはほぼ仕様通り)
GCPのドキュメントによるとpd-standard 30GBと pd-balanced 10GB の性能上限は本来以下のようになるはずですが、pd-standardについてはスループットが30倍以上、IOPS は7倍くらい出ています。なので実際には pd-balanced のように 最低保証性能(baseline 性能)が設定されているのかもしれません。
表:ストレージ仕様
| ストレージ種別 | 容量 | 月額料金 | スループット (Read) | スループット (Write) | IOPS (Read) | IOPS (Write) |
|---|---|---|---|---|---|---|
| pd-standard | 30GB | $0.00(無料枠内) | 3.6 MiB/s | 3.6 MiB/s | 22.5 | 45 |
| pd-balanced | 10GB | $1.00 | 142.8 MiB/s | 142.8 MiB/s | 3,060 | 3,060 |
| (参考)pd-ssd | 10GB | $1.70 | 244.8 MiB/s | 244.8 MiB/s | 6,300 | 6,300 |
https://docs.cloud.google.com/compute/docs/disks/performance?hl=ja#baseline_performance
その他に有効だった対策
Bluesky のトレンドワード計算を軽量化するにあたってほかに有用だったのは、
- Regex はちゃんと事前にcompileして使い回す
- Dataframeのmap, transform, apply で lambda するよりも ベクトル化
- neologdn.normalize を使わず Regexで代用
- 計算データ量の削減
がありますが、このへんは当たり前すぎたり汎用性の低い話なので割愛します。
結果: 90秒→30秒以内に短縮
これらの取り組みを実施した結果、改善前に90秒以上掛かっていた処理が30秒以内にまで短縮できました (p50: 20.2 sec、p95: 29.1 sec)。1回あたりの処理時間がこれだけ短ければまずCPUバースト時間内に収まりますし、万が一処理が詰まってもリカバーが容易で継続的・安定的にトレンドワードを算出し続けることができます。
GCP内の他サービスの検討: Cloud Run Jobs / Functions
cron的な処理であればGCE以外でも Cloud Run Jobs/Functions + Cloud Scheduler で実現でき、無料枠もあるので場合によってはこちらでも代替できます。無料枠に収めようと思ったら、5分に1回処理する場合は 1 vCPU + 2GB メモリを毎回20秒くらい使っても枠内に収まる計算になります。
ただ、Cloud Run Jobs と Functions ではブロックストレージが使えないため、ファイルを大量に保存・読み込みする場合は主に GCS を使うことになり、使い方次第では pd-standard ストレージよりも性能が低くなるおそれがあります。実際にベンチマークを計測してみたところ以下のような結果になり、FUSE 経由なので正確ではない部分もあるかもしれませんが(特にランダムWriteのIOPS)、ブロックストレージの代わりとして使うのは厳しそうです。
| IOPS | MiB/s | |
|---|---|---|
| シーケンシャルRead | 19 | 19.18 |
| シーケンシャルWrite | 11 | 11.01 |
| ランダムRead | 3 | 0.01 |
| ランダムWrite | 2847 | 11.122 |
計測条件
- us-east1 の function + GCS で検証
- ファイルセットと実行回数はさきほどのストレージベンチマークと同じ
ちなみに、今回のシステムを GCE で実装する前は Cloud Run Jobs で実装を進めていたのですが、20秒以内に処理を終わらせないと無料枠に収まらないのであれば e2-micro のほうが余裕をもって処理できるし、ファイルをブロックストレージで管理できる GCE のほうが実装が楽ということに気づいて e2-micro での実装に切り替えました。
おわりに
わりと思いつきと好奇心で始めたプロジェクトでしたが、意外に処理効率化の余地があることが判明し個人的には有効な取り組みでした。
なお今回紹介したテクニックは e2-micro という特殊な状況下で効く手法であって、常にこのやり方が正しいわけではありません。予算が潤沢にあってストレージやCPUの性能をモリモリにできるのであればモジュールはバンバン import してデータベースを普通に使うほうがプロジェクト観点で見てコスパがいいと思います。
ただ、処理速度を少しでも上げたい場合や料金を抑えて実装したい場合にはもしかしたら使える手法もあるかもしれませんので、この記事が少しでもそういうお役に立てれば幸いです。
あと、Blueskyのトレンドは以下のアカウントで見ることができます。良かったら覗いてみてください。