私が作っているマーケットプレイス兼コミュニティアプリと、現場で見積もりを出す職人向けの見積もりアプリ。どちらも小さなプロダクトで、どちらもFly.ioのいちばん安いマシンで動いています。私のDevOpsモニタリングは、突き詰めると5つです。ERRORログを流すチャットチャンネル、オブジェクトストレージに全部ためてDuckDBで問い合わせるログ、Flyに標準で付いてくるPrometheusメトリクス、SentryのオープンソースSDKで拾って自前のAPIに送るアプリのクラッシュレポート、そして毎朝1本のサマリー投稿です。ホスティング型のAPMも、本番のトレーシングも、わざわざ思い出して開くダッシュボードもありません。追加でお金がかかっているのはログアーカイブだけで、月2ドルほどです。以下が全リストと、やめたもの・信じなくなったものです。そのうちのひとつのせいで、私はユーザーが誰ひとり体験していない障害を何日も追いかけていました。
ここで言うモニタリングと、どれだけ小さな規模の話か#
私がDevOpsモニタリングと言うときは、2つのことを指しています。誰かに言われる前に何かが壊れていると気づけること、そして当て推量なしに原因を突き止められることです。以下のツールはすべて、この2点で評価しています。
昨年、開発者が本当に使ってくれるモニタリングの作り方という記事を書きましたが、そのときはおもにチームを想定していました。小さなチームならクラウド事業者に組み込みのモニタリング、サービスが何百もあるならホスティング型のAPM、という具合です。今回はその同じ考え方の、いちばん小さい側の端です。2つのプロダクトはどちらもいまの技術スタックで書いたとおりFlyのshared-cpu-1xマシン上のDjangoで、開発者はどちらも私ひとりです。モニタリングがプロダクトで開発者がそのユーザーだとすれば、私のモニタリングのユーザーは1人だけということになります。9月にFlyのメトリクスからベースラインを取ったところ、マーケットプレイスのAPIのCPU使用率は平均1%でした。エンタープライズのスタックではありませんし、そう見せるつもりもありません。
メトリクス、ログ、トレースの3本柱のうち、私が持っているのは2本です。どちらのプロダクトにも分散トレーシングはありません。それぞれDjangoのコードベースひとつとデータベースひとつで、トレースがまたぐべきサービス境界がないからです。私が見ているトレースは、自分のワークステーションで動かしているリバースプロキシのものだけです。
一覧#
| ツール | 何を見ているか | 残したか、やめたか、その理由 | 費用 |
|---|---|---|---|
| Djangoのロギングハンドラーから流すチームのチャットチャンネル | API・WebSocketサーバー・ワーカーのERROR、デプロイ、間違ったサインインコードの集中 | 残した。言われなくても見に行く唯一の場所 | 無料 |
| FlyのログシッパーからS3互換のバケットへ、DuckDBで問い合わせ | マーケットプレイスのアプリのログ全行 | 残した。Fly自身のバッファは数分、こちらは全部、しかもSQLで | 月約2ドル+ストレージ |
| Fly標準のメトリクス(Prometheus API) | CPU、メモリ、バーストクレジット、レスポンス、マネージドPostgresのエクスポーター | 残した。DBの停止が自分のコードのせいではないと示してくれた | Flyに含まれる |
/api/health/ のFlyヘルスチェック(両アプリ) | マシンにトラフィックを送ってよいか | ルーティング用には残した。ユーザーが無事な証拠としてはやめた | Flyに含まれる |
| DSNを自前APIに向けたSentryのSDK | スマホで起きるアプリのクラッシュ | SDKは残した。sentry.ioには登録していない | API以外の追加なし |
| チームのチャットへの朝のサマリー | 前日のエラー、利用状況、検索、チャット、ボード | 残した。CIのcronジョブから自分のMacへ移した | 無料 |
| アプリ用のアナリティクスSDK(両アプリ)と、検索エンジンのウェブマスター向けコンソール | Web・iOS・Androidの利用状況、検索からのサイトの見え方 | 残した。APIで読んでいる | 無料 |
| CloudflareのGraphQLアナリティクス | ステータスコードやパスの裏に誰がいるか | 残した。ただし信じる前にユーザーエージェントでまとめる | 無料プラン、1クエリ1日分 |
| ホスティング型OTAアップデートサービスのダッシュボード | 端末がどのOTAリリースを動かしているか、クラッシュロールバック | 何も教えてくれなかった管理画面の一覧から移った | アップデートサービスの一部 |
| 小さなプローブ用スクリプト | デプロイや再起動の最中に失敗したリクエスト | 残した。オリジンを直接叩くので、キャッシュが空白を隠せない | 無料 |
flyctl logs | アプリ出力の直近数分 | ライブ表示用には残した。記録としてはやめた | Flyに含まれる |
| Cloudflare Early Hints | 何も見ていない。高速化の機能 | オフにした。そのプローブがゾーンの504のすべてだった | 無料 |
「両アプリ」と書いていないものは、すべてマーケットプレイスの話です。見積もりアプリはもっと少なく、Flyのヘルスチェック・ログ・メトリクス、iOSのアプリ解析、そしてデータベースを毎日Parquetに書き出してDuckDBで問い合わせる仕組みくらいです。クラッシュレポートもアラートのチャンネルもなく、この記事でいちばん大きな穴はここです。(アップデートサービスの行について開示しておくと、このサービスを提供している会社は、同社のブログに書く記事に対して私に報酬を払っています。)
エラーは、もともと自分がいる場所に届く#
9月までは、処理されなかった500も、失敗したプッシュ通知も、壊れたWebSocketフレームも、出てくる場所は flyctl logs だけでした。そしてあのストリームを自分から読みに行く人はいません。いまはロギングハンドラーが、API・WebSocketサーバー・バックグラウンドワーカーのどこで起きたものでも、ERROR以上のレコードをすべてチャットチャンネルに送ります。WARNINGはあえて通知しません。
ハンドラーはバックグラウンドのスレッドで送信するので、リクエストを遅くしません。ハンドラーの中では何もログに書かないので、再帰しません。そして同じエラーは10分に1回まで、1時間に最大30件までしか送らないので、障害が起きても千件のメッセージではなく1件として読めます。このチャンネルには、エラーと突き合わせて読みたいものも流れてきます。デプロイは開始時、立ち上がったとき、失敗した箇所でコミットと実行者付きで投稿し、OTAアップデートやWebリリースも同じです。ひとつのアカウントに間違ったサインインコードが集中したときもERRORで記録するので、誰かが当てずっぽうに試していれば障害と同じ場所に現れます。
10月、どのログにも対応する行がないアラートがひとつ届きました。それ以来、アラートには短いランダムなid、ホスト名、プロセスの起動コマンドを付け、同じ行をそのプロセスのログにも書くようにしています。idがどこにも見つからなければ、そのアラートはそこにログを書かないプロセス、たとえばSSHのシェルから来たということです。チャンネルに届くものはすべて、私が一緒に作業しているコーディングエージェントが読めるフィードにも入ります。コードを触っているエージェントも、私と同じタイミングでエラーを目にするわけです。
ログ:Flyは数分、アーカイブは全部#
Fly自身のバッファが持っているのは数分ぶんの出力、だいたい直近100行ほどです。デプロイを見守るには十分ですが、昨日のことには役に立ちません。そこで3つめのFlyアプリでFlyのログシッパーを動かしています。組織のログストリームを購読するVectorで、変えたのはS3シンクひとつだけです。
[sinks.aws_s3]
type = "aws_s3"
bucket = "myapp-logs"
compression = "gzip"
key_prefix = "logs/{{fly.app.name}}/%F/" # one folder per app per day
batch.timeout_secs = 300 # a quiet app still lands its linesマーケットプレイスのアプリのログは1行残らず、gzipしたJSONとしてS3互換のバケットにたまります。シッパーは256MBのshared-cpu-1xマシン1台で、月2ドルほど、ストレージ代は小銭程度です。DuckDBはマクロひとつでファイルをその場で読み、1日分ならおよそ1秒で返ってきます。
CREATE MACRO logs(app, day := '*') AS TABLE
SELECT * FROM read_json_auto('s3://myapp-logs/logs/' || app || '/' || day || '/*.log.gz',
union_by_name := true);
SELECT timestamp, message FROM logs('myapp-api', '2026-09-06') WHERE message LIKE '% 500 %';ゼロスケールの記事でコールドスタートを測ったのも、このマクロでした。起動スクリプトがフェーズごとに名乗り、どの行にもマシンのidが付いているので、あとは GROUP BY だけです。
つまり、ひとつのストリームを読む人ごとに3段階に分けているわけです。アーカイブはINFO以上をすべて保持し(本番のアプリがINFOでログを出しているのはこのためです)、ライブ表示のスクリプトはWARNINGとERRORだけを見せ、チャットチャンネルはERRORを受け取ります。ただ、どれも呼び出し元はわかりません。アクセスログにはクライアントのIPもユーザーエージェントもないので、それはCloudflareで調べます。
自分で作らずに済んだメトリクス#
Flyは組織ごとにマネージドのPrometheusを動かしてくれるので、自前のメトリクス基盤は持っていません。知りたいことがあるときにAPIを叩きます。マシンごとのメモリ、CPU、バーストクレジット(共有CPUでは fly_instance_cpu_balance が効いてきます)、アプリごとのレスポンス数や同時実行数などです。最初に知っておきたかったことが2つあります。認証は Authorization: FlyV1 <token> でなければならず、Bearerヘッダーや読み取り専用トークンではどちらも “something went wrong resolving organization” が返ってきます。そして保持期間はおよそ12日です。
これが本領を発揮したのは9月でした。ログに “Could not open a database connection” が1日に何度も出ていて、どう見てもアプリのバグに見えました。マネージドPostgresのクラスタも同じPrometheusに入っていて、そのエラーが出た分にはいつも、プライマリのスクレイプ時間が80ミリ秒ほどから1〜10秒に跳ね上がっていました。レプリカは平常で、Postgresの起動時刻は動いていません。Postgresが再起動したのではなく、その下のホストが止まっていたのです。タイムスタンプを添えてFlyのサポートに送り、10月初めのメンテナンス再起動でプライマリが別のホストに移ってからは、毎日の停止はなくなりました。
スマホで起きるクラッシュ#
以前は、スマホでアプリが落ちても、私に見える場所のどこにも痕跡が残りませんでした。当然の答えはSentryで、私はその半分だけを取り入れました。アプリにはSentryのオープンソースSDKが入っていますが、DSNは私たちのDjango APIのエンドポイントを指しています。そのエンドポイントがSentryのenvelope形式を受け取り、クラッシュをまとめ、同じチャット用のハンドラーで通知します。トレーシング、セッション、リプレイはオフにしていて、受け取るのはエラーだけです。
sentry.ioに登録しなかったのは、1日に数件のレポートのために、ユーザーデータを預ける業者をひとつ、プライバシーポリシーの記述を1行、請求書を1枚増やしたくなかったからです。代わりに払っている代償はシンボリケーションで、これはまだ置き換えられていません。スタックはminifyされたJavaScriptか生のネイティブアドレスなので、イシューを見れば何がどのリリースと端末で落ちたのか、直前にユーザーが何をしていたのかはわかっても、ソースのどこなのかはわかりません。
ダッシュボードはメッセージ1本#
どちらのアプリにもダッシュボードは作っていません。昨年の記事にダッシュボードの判定基準を書きました。誰にも自発的に開かれないダッシュボードは存在価値を証明できていない、というものです。勝手に届くメッセージなら開く必要がないので、私にとっていちばんダッシュボードに近いのは、毎朝チームのチャットに届く1本の投稿です。
最初は直近24時間の本番エラーです。チャットチャンネルではなくログアーカイブから読みます。アラートは件数を絞っているので、何かが何回起きたのかを教えてくれないからです。数字、id、アドレスを伏せてからまとめるので、同じ失敗が100件あっても件数付きの1行になり、メールアドレスがチャンネルに流れることもありません。続いて失敗したヘルスチェック、前日の利用状況を1週間前の同じ曜日と比べたもの(日曜日が月曜日と同じになることはありません)、直近1週間の検索流入、返信待ちのチャット、タスクボード、そして支出です。読み込めなかったセクションはそう書かれ、投稿自体は届きます。
アプリ解析の数字をAPIで読んでいると、少し疑ってかかるようになります。8月にはアゼルバイジャンからAndroidの初回起動が121件あり、その後アンインストールが119件続きました。その月のAndroidの合計は、これを除いて読んでいます。
リリースには専用の画面があります。OTAアップデートを自前のサーバーから配っていたころ、リリースを見る手段は “is active” のチェックボックスが付いた管理画面の一覧だけで、何台がアップデートを受け取ったのかまったくわかりませんでした。いまのサービスのダッシュボードは、端末が実際にどのリリースを動かしているか、インストールがどうだったか、そして壊れたバンドルが落ちてSDKが前のものに戻したときにはそのクラッシュロールバックまで見せてくれます。ホスティング型のアップデートサービスへの移行については別の記事にまとめています。
やめたもの、信じなくなったもの#
この中に有料のツールはひとつもありません。ほとんどはシグナルだと思っていたのに実はそうではなかったもので、あとひとつは置き場所を間違えていたジョブです。
ユーザーエージェントでまとめるまでの、Cloudflareの504率#
9月、Cloudflareのダッシュボードには、1日のリクエストの22%が504で失敗しているように見える数字が出ていました。アプリが30秒ごとにポーリングするエンドポイントに集中していて、ヘルスチェックはすべて緑、アプリ自身のログではそのリクエストは速く返っていました。何日もかけました。マシンのコールドスタートに25〜33秒かかり、それが1日92回起きていることを突き止めて直し、ゼロスケールの記事にそれが原因だと書きました。
翌朝、同じ504をユーザーエージェントでまとめてみました。9月5日から9日までの7,344件は、すべてCloudflare自身のEarly Hintsのプローブ(bastion early hints と nginx-ssl early hints)でした。オリジンから応答を受け取れず、504として記録されていたのです。実際のリクエストのパスをなぞるので、いちばん忙しいエンドポイントに集まり、本当にユーザーが失敗しているように見えました。アプリ、ブラウザ、curl、検索エンジンのクローラーには1件もありません。オリジンはpreloadヘッダーを送っていないので、Early Hintsはそもそも何の役にも立っておらず、オフにしました。
コールドスタートの対応自体は無駄になりませんでした。デプロイもスリープからの復帰も速くなっています。ただ、直しているつもりだった障害は、一度もユーザーに届いていませんでした。だからこそ、いちばん伝えたいルールはこれです。CDNのエラー率を信じる前に、ユーザーエージェントでまとめて、CDN自身の仕組みから出たものではないか確かめてください。Cloudflareの無料プランなら httpRequestsAdaptiveGroups へのGraphQLクエリで、1日ずつ見られます。次の騒ぎも同じクエリで片付きました。ポーリング用のエンドポイントのひとつが1日70〜260件から2,880件に跳ね上がったのですが、スクレイパーではありませんでした。テストのあとiOSシミュレーターでアプリを起動したままにしていた、私自身のMacでした。
緑のヘルスチェック#
Flyは両アプリの /api/health/ を30秒ごとに確認し、その結果でどのマシンにトラフィックを送るかを決めます。その仕事は得意です。ただ、ヘルスチェックはすでに起動しているマシンに対して動くので、マシンが起動する前に起きることは見えません。コールドスタート、プロキシの前の待ち行列、ついに届かないリクエストなどです。いまはルーティング以外のことは期待していません。デプロイにはかわりにプローブのスクリプトを使います。デプロイのあいだずっとオリジンを叩き、ひとつでも失敗すれば終了コード1で終わります。このスクリプトが素の flyctl deploy で7.3秒の空白を捉え、先に予備のマシンを起こすデプロイスクリプトに切り替えてからは、2,514件中の失敗はゼロでした。
ヘルスチェックは、それ自体が原因の障害も2回起こしています。7月には、Djangoの SECURE_SSL_REDIRECT がFlyの平文HTTPのプローブに301で応え、すべてのマシンがチェックに失敗して、全員に503が返りました。8月には、ヘルス用ビューの except Exception を絞り込んだせいで “connection already closed” のエラーが500として漏れ、ビューのリトライがまさにそのケースのためにあったのに、Flyがマシンを外してしまいました。いまのチェックは、このプロセスがリクエストを処理できるかだけを問い、データベースが止まっても両アプリがルーティングから外れることはありません。
朝のサマリーも、数え方で同じ教訓を学びました。マーケットプレイスはアイドルのマシンをサスペンドするので、リクエストで起こされたマシンは起動後数百ミリ秒で最初のチェックに失敗し、次のチェックで通ります。それを数えていたせいで毎朝が障害のように読めたので、1分以内に再び通った失敗は、いまはマシンの復帰とデプロイとして別に表示しています。
CIの定期実行ジョブ#
朝のサマリーは、cronで動くCIのワークフローとして始まり、1日で引っ越しました。アプリ解析と検索のデータを読むようになると、必要な鍵がアナリティクスのプロジェクトのサービスアカウントキーになったからです。そのプロジェクトで何でもできる鍵なので、CIのシークレットには置きたくありませんでした。いまは私のMacのスケジューラーが20分ごとに動かし、オークランド時間の朝8時を過ぎていて、その日の投稿がまだのときだけ投稿します。8時に眠っていたMacは、目を覚ましたときに投稿するわけです。ワークフローは、アプリ解析と検索のセクションを抜いた手動用の予備として残しています。
もう一度始めるなら#
最初の障害のあとではなく、最初のユーザーが来る前に3つを用意します。チームがすでに読んでいるチャットチャンネルへのエラー通知(件数制限付き)、SQLで問い合わせられるオブジェクトストレージのログアーカイブ、そしてプラットフォームがすでに集めているメトリクス。月に数ドルで済みます。クラッシュレポートは、アプリがスマホに載る週に入れます。そうしないと、スマホで起きたクラッシュは誰にも何も伝えないからです。朝のサマリーは、確認する場所が増えすぎて、結局どこも確認しなくなりそうになったころに足せば十分です。
トレーシングは、リクエストがまたぐ2つめのサービスができるまで、ホスティング型のAPMは、共有ダッシュボードとオンコールの当番表、そして他社の請求書を払う予算が必要なチームができるまで待ちます。昨年の記事で紹介したツールが元を取り始めるのはそこからです。それまでのいちばん役に立つ習慣はタダです。数字が怖く見えたら、何が壊れたかを決める前に、その数字を何が生み出したのかを確かめること。この記事に出てきた騒ぎのいくつかは、プロダクトではなくモニタリングそのものから生まれていました。
