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 に照らし合わせ、
一つずつ “壊れても直帰できる・壊れたらわかる” 仕組みを仕込んでみてください。
