0. まえがき:なぜ 24 時間ランが難しいのか
長時間 ― とりわけ 24 時間連続 ― で暗号資産取引所(今回は Bybit v5)の WebSocket フィードを取り込み、ファイルへロスなく保存し続けるのは意外とハードルが高い作業です。私自身、最初の試行錯誤では 「PC がフリーズする」 や 「突然 Recorder プロセスがいなくなる」 といったトラブルに何度も遭遇しました。本節では、その壁を整理しながら「そもそも何が難しいのか」を俯瞰します。
もう一つ保険積んだ。ある程度知見が貯まってきたのでそろそろブログにまとめる。 https://t.co/0j2Tt8LAyC pic.twitter.com/HhqEWuzmho
— よだか(夜鷹/yodaka) (@yodakablog) June 18, 2025
0-1. データ量とリアルタイム性の両立
- スループットの波
- ボラティリティが高くなると trade フィードが秒間数千件 に跳ね上がります。
- 反面、板情報(orderbook)は 50〜100 ms ごとに来るため、平均では CPU が余る瞬間もある。
- リアルタイム性の罠
- 「落とさないための sleep」はバッファ遅延を招く。
- 書き込み I/O が詰まると、WebSocket が back-pressure で切断 → 再接続スパイラルに陥る。
教訓:ピークを滑らかにする token-bucket と、I/O を非同期キューで分離する構成が必須。
0-2. ラップトップ環境ならではの落とし穴
症状 | 実際に起きたこと | 対策の方向 |
---|---|---|
深夜に突然プロセス終了 | macOS が AC 電源でも disksleep → 書き込みブロック | pmset -c disksleep 0 で無効化 |
画面ロック後のフリーズ | 省電力モード移行で CPU ファン停止、熱暴走 | 冷却台+内蔵温度センサ監視 |
ログが 0 byte のまま | gzip 書き込みバッファが flush されずプロセス kill | os.fsync() を 1 万行ごとに実施 |
0-3. 取れないログは「なかったこと」になる
長時間ランで最も痛いのは「異常が起きた瞬間のログが残らない」ことでした。
とくに以下の2ケースは実際に筆者がハマったポイントです。
- WebSocket が静かに切れる
- 受信ループは async for のまま終了 →
await
はエラーを投げない。 - 対応: 60 s 無通信なら強制再接続する idle-watchdog を実装。
- 受信ループは async for のまま終了 →
- キュー溢れがサイレントドロップ
asyncio.QueueFull
を握り潰していたため、後で欠落に気付く。- 対応: 1,000 件ごとに累積 drop 数を WARN ログ。
try: queue.put_nowait(msg) except asyncio.QueueFull: dropped += 1 if dropped % 1000 == 0: logger.warning("⚠️ writer queue full – dropped=%d", dropped)
0-4. 成功体験:24 時間を完走したときのリソースグラフ
以下は改善後に 24 h ランさせているときのメトリクス例です。RSS が 15 MB 前後で安定し、
dropped=0 を維持できているのが分かります。
2025-06-18 14:28:53 📊 rss=11.7 MB cpu=1.5 % q=0 file=19.0 MB dropped=0
ポイント:ピークでも queue=0 で推移=バックプレッシャが解消されている証拠。
0-5. このシリーズで扱う6つの設計原則
- 非同期パイプラインで詰まりを消す
- セルフヒーリング(自動再接続 & ウォッチドッグ)
- 可観測性の仕込み方
- バックプレッシャ制御(token-bucket & drop 戦略)
- 設定とコードの分離(ENV / CLI 化)
- Graceful Shutdown(安全停止でデータを守る)
次章からは、これらを 実装コード とともに順に掘り下げていきます。
1. 非同期パイプラインで「詰まり」を根こそぎ除去する
現在も 24 時間ランを回している最中ですが、最初に手を入れたのは “受信 → 書き込み” の通り道を完全に非同期化すること でした。ここがボトルネックを残したままだと、どこかのタイミングで WebSocket が途切れ、ログには何も残らず――という悪夢が再発します。
1-1. 受信/処理/書き込みを段差で分ける
- recv_loop
- WebSocket から最速でメッセージを受け取るだけ。重い処理は一切しません。
- asyncio.Queue
- 受信ループとライターの間に“緩衝材”を置くことで、
短期的 な I/O ブロックを吸収します。
- 受信ループとライターの間に“緩衝材”を置くことで、
- writer_loop
- キューから 1 行ずつ取り出して gzip ファイルに直書き。
- 10 000 行ごとに
flush()
+os.fsync()
で確実にディスクへ。
flowchart LR subgraph Recorder_Flow RL["recv_loop"] -- put_nowait() --> Q["asyncio.Queue\n(qsize / dropped)"] Q --> WL["writer_loop\n(gzip write)"] WDG["idle_watchdog\n(if idle 60s)"] Q --- WDG end
ポイントは「“とりあえず受信”と“とりあえず書く”を分離し、両方が互いをブロックしない」ことです。
1-2. 実装スニペット
async def _recv_loop(self): async for msg in self.ws: try: self._queue.put_nowait(msg) except asyncio.QueueFull: self._dropped += 1 if self._dropped % 1000 == 0: logger.warning("⚠️ queue full – dropped=%d", self._dropped) async def _writer_loop(self): flushed = 0 while True: line = await self._queue.get() if line is None: # 終了シグナル break self._raw_fh.write(line + "\n") flushed += 1 if flushed >= 10_000: self._raw_fh.flush() os.fsync(self._raw_fh.fileno()) flushed = 0
- Queue サイズ (
QUEUE_MAX
) は 10 000。- 受信レートがピークで秒間 2 500 行でも、約 4 秒のバッファになります。
- flush 間隔 を 1 万行に設定すると、CPU 使用率 2 % 前後で安定しました(現在観測中)。
1-3. 今回つまずいた点と対策
詰まりポイント | 実際の症状 | 手当て |
---|---|---|
gzip の内部バッファ | ファイルが 0 byte のまま/急に 10 MB 出力 | os.fsync() を明示的に呼ぶ |
QueueFull が黙殺 | 欠損に気付かない | 1000 件ごとに累積 drop を WARN 出力 |
キュー drain に時間 | await queue.join() がタイムアウト | JOIN_TIMEOUT を 15 s に調整し、まだ残れば警告だけ出して続行 |
執筆時点(16:00 頃)でも RSS≒12 MB、queue=0、dropped=0 を継続中。
このまま 24 時間完走できるかどうか、ログを監視しながら検証を続けています。
1-4. まとめ
- 非同期パイプライン を導入するだけで、
- CPU スパイク
- I/O ブロック
- サイレントメッセージ欠損
をほぼ一掃できます。
- それでも残る「想定外の詰まり」はログに必ず痕跡を残す設計にしておく――これが長時間ランでは生命線になります。
次章では、切れても自ら回復する セルフヒーリング(自動再接続 & idle-watchdog) の実装に踏み込みます。
2. 自動再接続と idle-watchdog で WebSocket の「突然死」を封じ込める
24 時間ランの途中で最もヒヤッとするのが、WebSocket が無言のまま切れるパターンです。
前回の運用では、深夜帯に Bybit 側の瞬断が発生 → async for
が ConnectionClosed
例外を吐いて終了 → そのまま録画も停止…… という事故が一度ありました。
2-1. 仕組み ― “二段構え” で転ばぬ先の杖
- 再接続ループ(外側)
_recv_loop
の外側にwhile True:
を置き、エラー発生時はawait self._connect()
を呼び直して ループを続行 します。
- idle-watchdog(内側)
- 「接続は生きているがメッセージが来ない」
=サーバ側がハーフオープンになった状況を 60 秒で検出し、ws.close(code=4000, reason="idle-watchdog")
で 能動的に切断 → 即再接続。
- 「接続は生きているがメッセージが来ない」
flowchart LR %% -------- WebSocket ループ -------- subgraph WebSocket_Loop START([while True]) RECV["async for msg\nin self.ws"] EXCEPT[[ConnectionClosed]] RECONNECT["self._connect()"] START --> RECV --> START RECV -- "WS closed" --> EXCEPT --> RECONNECT --> START end %% -------- ウォッチドッグ分岐 -------- WDG["idle_watchdog\n60 s idle"] CLOSE["ws.close()"] WDG -- triggers --> CLOSE --> EXCEPT
2-2. コード断片(要点だけ抜粋)
async def _recv_loop(self): while True: try: async for msg in self.ws: await self._handle_msg(msg) except (websockets.WebSocketException, OSError) as e: logger.warning("WS closed (%s) – reconnecting", e) await self._connect() # ← 再接続 except asyncio.CancelledError: raise async def _idle_watchdog(self): while not self._stop_event.is_set(): await asyncio.sleep(30) if time.monotonic() - self._last_msg_ts > 60: # ← 無通信 60 s logger.warning("⏳ no message 60 s – reconnect") with suppress(Exception): await self.ws.close(code=4000, reason="idle-watchdog")
- 指数バックオフ:
_connect()
はdelay = min(delay*2, 300)
で最大 5 分までリトライ間隔を伸長。
現在もテスト中ですが、数十回以上の瞬断でも正常に復帰しています。
2-3. つまずきポイント & 対策
症状 | 原因 | 処置 |
---|---|---|
再接続直後に async for で再び例外 | サーバのバックプレッシャ | delay を指数的に延ばし DoS 化を防止 |
ハーフオープンでキューだけ溜まる | TCP は生存、payload 無し | watchdog で「無通信」を検知し 自爆→復活 |
再接続直後に古いタスクが残留 | 旧 recv_task が cancel されていない | コンテキストマネージャで 明示的に cancel() → await |
2-4. 実行中ログで確認する
現在回しているランでも、断続的に下記ログが出ていますが――
2025-06-18 14:12:07 [WARNING] WS closed (going away) – reconnecting 2025-06-18 14:12:08 [INFO] 🌐 connecting to wss://stream.bybit.com/... 2025-06-18 14:12:08 [INFO] 📡 subscribed → publicTrade.BTCUSDT
数秒後には再接続が完了し、dropped=0 を維持したまま処理が続行できています。
この動作をリアルタイムで確認できるのは、心理的にも大きな安心材料 ですね。
2-5. まとめ
- 再接続ループ+idle-watchdog の二段構えで、 「切れたら自分で戻る」
仕組みをコードに織り込みました。 - これにより “たまたま深夜に 1 回だけ瞬断→朝まで無記録” という悲劇を回避。
- 現在進行中の 24 h ランでも、再接続発生後の安定動作を継続確認中です。
次章では、token-bucket 制御とキュー設計で CPU スパイクを抑え、ドロップゼロを目指すテクニックを紹介します。
3. 「落とさない」ための token-bucket & 非同期キュー設計―― CPU スパイク/メッセージ取りこぼしを同時に抑える
3-1. なぜ bucket が必要か?
Bybit v5 の publicTrade チャネルは、相場急変時に 秒間 1 万件 超のパケットを吐くことがあります。
ローカル PC(M2 Air)で試したところ、素直に await ws.recv()
→ gzip 書込み では
- Python スレッドが 100 % 常時張り付き
- gzip 圧縮が追いつかず
queue full
→ メッセージ欠落
という二重苦に直面しました。
目標
CPU を殆ど専有せず、かつ 欠落ゼロ で 24 h 走らせること
これを両立する鍵が token-bucket レート制御 と バッファリング戦略 でした。
3-2. token-bucket の実装ポイント
# init self._tokens = float(MSG_CAP_PER_SEC) self._last_refill = time.monotonic() async def _handle_msg(self, msg: str): if MSG_CAP_PER_SEC: # 0 なら無制限 now = time.monotonic() # 経過時間分のトークン補充 self._tokens += (now - self._last_refill) * MSG_CAP_PER_SEC self._tokens = min(self._tokens, MSG_CAP_PER_SEC * 2) # 上限 2 秒分 self._last_refill = now if self._tokens < 1: return # ← 処理スキップ self._tokens -= 1
- 補充間隔は”計算式” ―
await asyncio.sleep()
を挟まないので
処理レイテンシはゼロ、しかも CPU 完全非占有。 - 上限を「2 秒分」に制限
急激な貯まり過ぎ を防ぎ、キュー長の暴走を抑えます。
実測:MSG_CAP_PER_SEC = 2 500 の設定で
CPU 1.5 % 前後、ドロップ 0(2025-06-18 ラン時)
3-3. asyncio.Queue
のチューニング
パラメータ | 今回の値 | 選定理由 |
---|---|---|
maxsize | 10 000 | 約 4 秒分 の余裕を確保(2 500 msg/s × 4 s) |
flush 間隔 | 10 000 行 | gzip I/O と fsync を 0.3 秒/回 まで削減 |
JOIN_TIMEOUT | 15 s | Ctrl-C 時に「待ち過ぎてハング」を避ける適度な妥協値 |
「埋まったら捨てる」ではなく「埋まる前に流量を絞る」
try: self._queue.put_nowait(msg) except asyncio.QueueFull: self._dropped += 1 if self._dropped % 1000 == 0: logger.warning("⚠️ queue full – dropped=%d", self._dropped)
実運用では QueueFull が一度も発生していない
→ token-bucket の流量調整が奏功している証左です。
3-4. つまずきポイント & 実験ログ
- バケットを 0.5 秒分しか貯めない設定 にした初期版では、
瞬間バーストで即queue full
→ dropped 50 以上 を観測。
→ トークン上限を「2 秒分」へ緩和して解決。 - gzip ファイルを 1 000 行ごとに flush していた頃は
I/O 待ち で CPU が一時 10 % まで跳ねる。
→ 10 000 行毎 +os.fsync()
を併用し、ピーク 2 % まで抑制。
3-5. 今走らせている 24 h ランの中間結果
時刻 | CPU (%) | RSS (MB) | dropped |
---|---|---|---|
16:27 | 1.6 | 12.5 | 0 |
16:29 | 1.6 | 12.5 | 0 |
スロットリングのおかげで CPU はほぼ一定。
キュー長も 0〜3 で推移し、安定記録を継続中です。
3-6. まとめ
- token-bucket で「受け付け量」をハードリミット
- キューサイズは“数秒分” − 過剰でも不足でもダメ
- flush 戦略 と
fsync()
で I/O ピークを“塊”化 - これらにより CPU スパイク 2 % 以下・欠落ゼロ を実現
次章では、ファイルローテーションと信頼性担保 について
「圧縮レース条件をどう潰したか」「fsync
を入れるタイミングは?」等、
実装ディテールを掘り下げます。
4. 観測できないものは直せない ― 可観測性の仕込み方
「動いている“っぽい”」ではなく
「いま何がどう動いているか」 を 数字 と グラフ で語れるようにする。
それが 24h ラン bot にとっての保険になります。
4-1 最小3指標 ― RSS / CPU / dropped
指標 | なぜ見る? | 目安 | 異常値が出たら |
---|---|---|---|
RSS(常駐メモリ) | メモリリーク・GC の効き具合 | 15 MB 前後で横ばい | 💡 gzip 書き込みを疑う/不要なオブジェクト参照を切る |
CPU | デコード/IO 詰まり・無限ループ | 平常 0 %〜3 % | 💡 MSG_CAP_PER_SEC を半分にして負荷を確認 |
dropped | キューあふれ検知 | 常に 0 | 💡 処理量≦受信量か確認、上限 2×バッファを拡大 |
コード抜粋
async def _stats_loop(self): proc = psutil.Process() while True: rss = proc.memory_info().rss / 2**20 cpu = proc.cpu_percent(interval=None) qlen = self._queue.qsize() f_mb = (self._raw_path.stat().st_size / 2**20 if self._raw_path and self._raw_path.exists() else 0) logger.info("📊 rss=%.1f MB cpu=%.1f %% q=%d file=%.1f MB dropped=%d", rss, cpu, qlen, f_mb, self._dropped) await asyncio.sleep(STATS_SEC)
30 行ちょっとの“ミニ Prometheus エクスポーター”みたいなもの
— これだけでトレンドと異常を リアルタイムに自己申告 してくれます。
4-2 JSON Lines → Loki / Fluent Bit で“グラフ化コスト0円”作戦
- Recorder 側
すべてのメトリクスを1行 JSON にシリアライズ
(例:{"ts":"2025-06-18T16:27:24Z","rss":11.1,"cpu":1.7,"drop":0}
)
→ テキスト log と同居できるので改修は1行で済みます。 - 収集エージェント
- Fluent Bit を
tail
モードで/logs/recorder/*.log
を監視 Parser json
で自動フィールド化- 出力は Loki (Promtail 互換) にポスト
- Fluent Bit を
- 可視化
Grafana でrss{job="recorder"}
等をパネル化。
実際、数分で “赤い壁” が立ち上がるので 閾値アラート も即設定できます。
各ツールは Docker コンテナ1発で立ちます。
“24h ラン中でも” Recorder 停止なしで計測レイヤを差し込めるのがメリットです。
4-3 詰まりポイントと私の対処
悩んだところ | 初期症状 | 解決策 |
---|---|---|
Loki にメトリクスが流れない | Fluent Bit の出力バッファが溜まり 100 % I/O | Mem_Buf_Limit を 10 MB → 50 MB へ増量 |
Grafana で時系列が途切れる | 再接続時に ts が飛ぶ | Recorder 側で datetime.utcnow() を必ず使い、システム時計依存を排除 |
dropped=0 でも Q が膨張 | キュー長が毎秒ジリジリ上昇 | flush() を1万行ごと → 2千行ごとに変更し、IO レイテンシを吸収 |
4-4 Take-away
- “観測できないものは直せない” を合言葉に
― まずは3指標をログに出す - ログは JSON Lines に寄せ、可視化は Loki + Grafana を採用
- 異常時は「RSS 上昇」「CPU スパイク」「dropped インクリメント」の どれか が先に吠える
→ 最小3指標 で大抵の劣化を先取りできます。
これで「原因不明のフリーズ」はほぼ撲滅。
残る課題は ネットワークと電源 くらい、というところまで漕ぎつけました。
おまけ:再接続ログが示す“安全に転けて立ち上がる”設計
❶ 典型的な再接続シーケンス
2025-06-18 16:26:54 [INFO] 📊 rss=… 2025-06-18 16:27:05 [WARNING] WS closed (no close frame received or sent) – reconnecting 2025-06-18 16:27:05 [INFO] 🌐 connecting to wss://stream.bybit.com/v5/public/linear 2025-06-18 16:27:06 [INFO] 📡 subscribed → publicTrade.BTCUSDT
ログ | 何を意味するか |
---|---|
WS closed … | WebSocket が瞬断。例外を捕捉できた証拠 |
🌐 connecting … | 自動リトライ・ループが即時発火 |
📡 subscribed … | 約 1 秒で 完全復旧 |
❷ “安全性”を裏づける 3 つの指標
観点 | ログから読める事実 | 解釈 |
---|---|---|
障害検知 | Warning が出ている | 例外ハンドリングが機能 |
サービス継続 | dropped = 0 q = 0 | キューが溢れずデータ欠損なし |
リソース健全 | RSS ≒ 11 MB/CPU ≤ 2 % | リトライでスパイクせず |
❸ 監視のミニ Tips
# 再接続と drop の回数を 30 分単位でざっくり確認 grep -E 'WS closed|dropped=' ~/logs/recorder/recorder.log \ | awk -F'[][]' '{print $2" "$3}' \ | ts '%Y-%m-%d %H:%M:%S'
- 再接続が 5 回/分以上 → ネットワークやサーバ側の恒常障害を疑う
- dropped が増分 →
MSG_CAP_PER_SEC
やQUEUE_MAX
を再チューニング
❹ ポイントまとめ
- 意図的に落としても速攻で復旧できる――これが「安全に転ける設計」。
- 運用では「再接続頻度」と「dropped 増減」をテキトーでも良いので“折れ線”で見ると安心です。
5. I/O を詰まらせない — 非同期キューと gzip ローテーション
CPU は WebSocket、ディスクはワーカーに。
“詰まり” はそこを分離しないと始まりません。
5-1 設計の骨格 ―「受信」と「書込」を完全分離
- 受信側 (
_recv_loop
)async for msg in self.ws:
で ノンブロッキング 取得- 受け取ったメッセージは即
queue.put_nowait()
- もし満杯なら 捨てる(§3の dropped カウンタ)
- 書込側 (
_writer_loop
)await queue.get()
でバッチリ同期- 1万行ごとに
flush()
&os.fsync()
— 安全と速度の折衷 - 終了シグナルは キューに
None
を流す ワンウェイ方式
flowchart LR TB(TokenBucket) --> WS[WSRecv] WS -- put_nowait() --> Q[QueueMax10k] Q -- get() --> WR[WriterLoopFlushFsync] SD((NoneSignal)) --> Q
ポイント
- 「キュー = バッファ」 と割り切り、QueueFull は 捨てる方が安全
- ワーカーは 書くだけ なので、障害時は “ファイル rotate → キュー drain” の最短経路
5-2 gzip Writer ― compresslevel=1 そのワケ
compresslevel | 書込スループット | 圧縮率 | 実地ベンチ (M2/SSD) |
---|---|---|---|
1 | 約 45 MB/s | 60 % | ⚪ — ボトルネックにならず |
6 (デフォ) | 17 MB/s | 40 % | △ CPU が跳ねる |
9 | 8 MB/s | 35 % | ✗ Dropped 急増 |
取捨選択
“ロスレス&高速” が命題なので level=1 に固定しました。
圧縮率は後段のオフライン処理(pigz -9
など)で挽回できます。
5-3 ローテーション2段構え — 時間 & サイズ
def _need_rotate(self) -> bool: if self.rotate_sec and time.monotonic() - self._opened_at >= self.rotate_sec: return True return self._raw_path.stat().st_size > 100 * 2**20 # 100 MB
- 10 min ごと:落ちても “失うのは 10 分のデータ” で済む
- 100 MB 上限:SSD に優しい & Loki 取り込みをタイムリーに
実践メモ
初期版ではexists() → stat()
の間でファイルが消えるレースが発生。
Try–except でOSError
を無視することで解決しました。
5-4 queue.join()
と “優雅な” シャットダウン
await queue.join() # 残りを書き切る ... await asyncio.wait_for(queue.join(), timeout=JOIN_TIMEOUT)
- JOIN_TIMEOUT = 15 s:ネットワーク断でも 少なくとも 15 秒分 は確実に吐き出す
- シグナル (
Ctrl-C
,SIGTERM
) は loop.add_signal_handler で捕捉 → 同じ_close()
で処理統一
実際に
Ctrl-C
を叩くと“締め” の3行が必ず出るので安心感が段違いでした。queue.join() timed-out 📝 closed ...jsonl.gz 👋 recorder shutdown complete
5-5 ここでハマった・直した
症状 | 原因 | 今の対策 |
---|---|---|
queue.join() が永久に返らない | 途中で await writer_task をキャンセルしていた | 先にキューへ None を送る → writer が自走で抜ける |
ファイルが 0 byte のまま閉じられる | flush 済みでも OS キャッシュ 未書込 | os.fsync() を追加 |
連続 rotate で 1 秒に 2 ファイル | time.strftime("%f") を付与して衝突回避 | prefix_YYYYmmdd_HHMMSS_%f_pid.jsonl.gz |
5-6 まとめ
- 受信と書込は徹底分離 — Python なら
asyncio.Queue
+ ワーカー1本で十分 - gzip level=1 で“CPU≦IO” を実現し、圧縮は後日
- ローテーションは 時間×サイズ のダブルトリガで “壊れても痛くない” を担保
- シャットダウン時は キューを空にしてから exit — 15 秒ルールがあれば十分実用的
これで「I/O が詰まって全体が止まる」という恐怖からは解放されました。
次章では 電源 or OS 側で落とされる ケース――スタンバイ・ハイバネートとの戦いを振り返ります。
6. 電源と OS の壁を超える — macOS スリープ/ハイバネート対策
「録りっぱなし」は OS の協力があってこそ──
まず 眠らせない、そして 眠ったら気付く。
6-1 原因の切り分け ― “フリーズ” の正体はスリープだった
- 現象
- 深夜帯に MacBook Air が画面真っ黒、SSH も反応せず。
- 翌朝ログを見ると
WS closed (no close frame received or sent)
が大量発生。
- 調査
pmset -g log | grep -E 'Sleep|Wake'
で確認すると、ちょうど落ちた時刻にSleep
イベント。- CPU 使用率・温度には怪しいピークなし → ハードフリーズ ではなく 自動スリープ がトリガ。
6-2 GUI でまず “絶対寝かせない” 設定に(クラムシェル運用対応)
手順 | 設定項目 | 推奨値 | 補足 |
---|---|---|---|
1 | システム設定 › ディスプレイ & エネルギー → 電源アダプタ | 「Mac をディスプレイのオフ時にスリープさせない」= オン | 外部モニタが接続・通電している限り、蓋を閉じてもスリープに移行しない |
2 | 同画面 → バッテリー | 「スリープまでの時間」= 最長 or なし | 万一 AC が抜けた場合はバッテリー保護のため適度にオフでも可 |
3 | システム設定 › ロック画面 | 「ディスプレイをオフにするまでの時間」= 長め | 画面オフ ≠ スリープ だが、短すぎると誤判定の原因になる場合がある |
ポイント
- 外部モニタが給電&映像を認識 → macOS は “クラムシェル・モード” と判定し、自動でスリープ抑止アサーション(
PreventSystemSleep
)を立ててくれます。- 逆に HDMI 切替器で一瞬信号が途切れる と判定が落ち、スリープに入るケースがあるため モニタを常時 ON にしておくと安全です。
pmset -g assertions | grep PreventSystemSleep
を実行しIOHIDSystemIdleTimer
に加えてcoreaudiod
やWindowServer
が立っていればクラムシェル抑止が有効 な目安になります。
補足:外部モニタなしでも蓋閉スリープを防ぎたいとき
外出先などで 電源⇔蓋クローズ の両立が欲しい場合は、
- Amphetamine / KeepingYouAwake などのメニューバー常駐アプリで lid sleep を無効化
- あるいは
sudo nvram boot-args="iog=0x0"
の T2 以前ハードウェアハック(※ Ventura 以降非推奨)
いずれも「公式サポート外」なので、自動アップデート前後は要検証です。
6-3 pmset
で細かくチューニング
# 現在値の確認 pmset -g custom # AC 電源時:ディスプレイ 30 分、システムスリープ 0(=無効) sudo pmset -c displaysleep 30 sleep 0 powernap 0 disksleep 0
項目 | 意味 | 推奨値 |
---|---|---|
sleep | 本体スリープまでの分 | 0 |
displaysleep | 画面オフまでの分 | 長め |
disksleep | HDD 回転停止までの分(SSD でも残る) | 0 または 10 |
powernap | スリープ中のバックグラウンド処理 | 0 |
ttyskeepawake | SSH・ターミナル接続中は起こしておく | 1 |
検証コマンド
pmset -g assertions | grep PreventUserIdleSystemSleep
Recorder 稼働中にPID xxxx(recorder.py) PreventUserIdleSystemSleep
が列挙されれば 自前のアサーションが効いている 証拠です。
6-4 caffeinate
で手軽に“起こし続ける”方法
録画スクリプトを手動起動するときは ワンライナーで包む と忘れ防止になります。
caffeinate -di python core.py ... # d=ディスプレイ保持, i=システムスリープ抑止
- macOS 公式の 一時アサーション 。停止は
Ctrl-C
で OK - ログイン項目に登録しておくと「再起動→自動録画」も楽に
6-5 24H ラン中の“見張り番” — idle-watchdog
コード側でも 60 秒無通信 → WebSocket 再接続 を実装。
async def _idle_watchdog(self): while not self._stop_event.is_set(): await asyncio.sleep(30) if time.monotonic() - self._last_msg_ts > 60: logger.warning("⏳ no message 60 s – reconnect") await self.ws.close(code=4000, reason="idle-watchdog")
- OS がスリープしかけても、復帰後すぐ再購読を掛け直す
- 以下のログが 「自動リカバリが機能した」 実例です。
16:27:05 [WARNING] WS closed (...) – reconnecting 16:27:06 [INFO] 📡 subscribed → publicTrade.BTCUSDT
6-6 私がハマった&学んだチェックリスト
チェック項目 | Yes/No | メモ |
---|---|---|
sleep 0 / powernap 0 / disksleep 0 を AC 側に設定したか | GUI 変更後でも pmset で上書き されないことあり | |
クラムシェル運用を避け、蓋を閉じっぱなしにしていないか | T2 Mac は蓋閉で “暗黒スリープ” へ移行しやすい | |
caffeinate や 独自 Assertion が 確実に立っている か | pmset -g assertions で都度検証 | |
WebSocket 停止 → 再接続ログが出ても メッセージが流れてくる か | 取れていなければ Bybit 側のレート制限を疑う |
6-7 まとめ
- OS が寝ると Recorder も落ちる — まずは GUI &
pmset
で “眠らせない” caffeinate
と独自 Assertion の二段構えでヒューマンエラーを消す- 万一寝ても idle-watchdog が再接続、データは途切れない
- ログで「
WS closed … – reconnecting
→subscribed
」が出れば 安全性の証明
ひとこと
「録るだけなら誰でもできる、録り続けて初めて資産になる。」
電源管理は地味ですが、耐久ランを“日常運用”に昇華させる最後の壁でした。
次章では、取得した JSONL をどう可視化・検索可能にするか ― Loki + Fluent-bit 連携アイデアを掘り下げます。
7. おわりに 〜“24 時間録りっぱなし”を日常運用にするために〜
24 時間連続でマーケットデータを取りこぼさず録り続ける——
やってみると ソフトウェアよりハードウェア/OS 設定のほうが足枷になる 場面が意外に多いと痛感しました。
本稿で取り上げた 6 つの観点(設計思想・バックプレッシャ・可観測性・再接続ロジック・ファイル I/O 安全弁・実機 Tips)は、私自身が 「動かしたらフリーズ」「ログがズタズタ」「原因も追えない」 を繰り返して得た “血と汗のチェックリスト” です。
7-1 成果と残った課題
項目 | 今回の仕込み | 今後の改善アイデア |
---|---|---|
データ欠損 | token-bucket + dropped モニタで抑制 | メッセージ重複排除 & 欠損自動リカバリ |
WS 切断 | 自動 Re-connect & idle-watchdog | バックオフ戦略を指数 + Jitter に |
I/O 詰まり | 10 k flush + fsync, gzip level 1 | Zstd + 非同期圧縮, S3 直送 |
スリープ | pmset + クラムシェル設定の固定 | 外部 UPS シグナル連携・自動メンテ枠 |
可観測性 | RSS / CPU / queue / dropped を全て JSON L で保存 | Loki へ送り DashBoard で SLA 可視化 |
7-2 ここから先のロードマップ
- 障害注入テスト
tc qdisc add netem
などで 意図的に回線を 50 % ドロップ させ、
dropped と再接続ロジックがどこまで耐えるかを検証する。 - クラスタリング対応
Recorder を複数ノードに展開、Kafka や NATS JetStream に書き込み先を切替えることで
“冗長化 × スケールアウト” を両立させる。 - 自動ヘルスチェック
Prometheus Exporter を仕込み、queue_len > 8,000
やrss > 256 MB
で
Slack/LINE にアラートを飛ばす——壊れる前に知らせる を徹底。 - IaC & CI/CD 化
Ansible/terraform で Mac → Linux サーバへ移行できるよう
依存の薄い Shell + Python レイヤに収斂させる。
7-3 まとめ
“止まらない録画” は、
“止まっても復帰できる録画”
+ “止まった事実をすぐ見える化する仕組み”
——この 2 つの掛け算で成り立つ。
ローカル PC 1 台でも、設計とオペレーションを揃えれば
「バックテスト用の全ティックが毎日数 GB(gzip 後)ペースで貯まっていく」環境は実現できます。
ぜひ皆さんも、この記事のチェックリストを 自分の Recorder / Scraper に照らし合わせ、
一つずつ “壊れても直帰できる・壊れたらわかる” 仕組みを仕込んでみてください。