Bot プログラミングスキル 環境構築・インフラ 開発ログ

🛠️開発記録#254(2025/6/18)「24 時間耐久データレコーダを支える“6つの設計原則”――落ちない・詰まらない・止まらない Python WS パイプラインの作り方」

0. まえがき:なぜ 24 時間ランが難しいのか

長時間 ― とりわけ 24 時間連続 ― で暗号資産取引所(今回は Bybit v5)の WebSocket フィードを取り込み、ファイルへロスなく保存し続けるのは意外とハードルが高い作業です。私自身、最初の試行錯誤では 「PC がフリーズする」「突然 Recorder プロセスがいなくなる」 といったトラブルに何度も遭遇しました。本節では、その壁を整理しながら「そもそも何が難しいのか」を俯瞰します。


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 されずプロセス killos.fsync() を 1 万行ごとに実施

0-3. 取れないログは「なかったこと」になる

長時間ランで最も痛いのは「異常が起きた瞬間のログが残らない」ことでした。
とくに以下の2ケースは実際に筆者がハマったポイントです。

  1. WebSocket が静かに切れる
    • 受信ループは async for のまま終了 → await はエラーを投げない。
    • 対応: 60 s 無通信なら強制再接続する idle-watchdog を実装。
  2. キュー溢れがサイレントドロップ
    • 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つの設計原則

  1. 非同期パイプラインで詰まりを消す
  2. セルフヒーリング(自動再接続 & ウォッチドッグ)
  3. 可観測性の仕込み方
  4. バックプレッシャ制御(token-bucket & drop 戦略)
  5. 設定とコードの分離(ENV / CLI 化)
  6. Graceful Shutdown(安全停止でデータを守る)

次章からは、これらを 実装コード とともに順に掘り下げていきます。

1. 非同期パイプラインで「詰まり」を根こそぎ除去する

現在も 24 時間ランを回している最中ですが、最初に手を入れたのは “受信 → 書き込み” の通り道を完全に非同期化すること でした。ここがボトルネックを残したままだと、どこかのタイミングで WebSocket が途切れ、ログには何も残らず――という悪夢が再発します。


1-1. 受信/処理/書き込みを段差で分ける

  1. recv_loop
    • WebSocket から最速でメッセージを受け取るだけ。重い処理は一切しません。
  2. asyncio.Queue
    • 受信ループとライターの間に“緩衝材”を置くことで、
      短期的 な I/O ブロックを吸収します。
  3. 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 forConnectionClosed 例外を吐いて終了 → そのまま録画も停止…… という事故が一度ありました。


2-1. 仕組み ― “二段構え” で転ばぬ先の杖

  1. 再接続ループ(外側)
    • _recv_loop の外側に while True: を置き、エラー発生時は
      await self._connect() を呼び直して ループを続行 します。
  2. 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 のチューニング

パラメータ今回の値選定理由
maxsize10 000約 4 秒分 の余裕を確保(2 500 msg/s × 4 s)
flush 間隔10 000 行gzip I/O と fsync を 0.3 秒/回 まで削減
JOIN_TIMEOUT15 sCtrl-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:271.612.50
16:291.612.50

スロットリングのおかげで CPU はほぼ一定
キュー長も 0〜3 で推移し、安定記録を継続中です。


3-6. まとめ

  1. token-bucket で「受け付け量」をハードリミット
  2. キューサイズは“数秒分” − 過剰でも不足でもダメ
  3. flush 戦略fsync() で I/O ピークを“塊”化
  4. これらにより 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円”作戦

  1. Recorder 側
    すべてのメトリクスを1行 JSON にシリアライズ
    (例:{"ts":"2025-06-18T16:27:24Z","rss":11.1,"cpu":1.7,"drop":0}
    → テキスト log と同居できるので改修は1行で済みます。
  2. 収集エージェント
    • Fluent Bittail モードで /logs/recorder/*.log を監視
    • Parser json で自動フィールド化
    • 出力は Loki (Promtail 互換) にポスト
  3. 可視化
    Grafana で rss{job="recorder"} 等をパネル化。
    実際、数分で “赤い壁” が立ち上がるので 閾値アラート も即設定できます。

各ツールは Docker コンテナ1発で立ちます。
“24h ラン中でも” Recorder 停止なしで計測レイヤを差し込めるのがメリットです。


4-3 詰まりポイントと私の対処

悩んだところ初期症状解決策
Loki にメトリクスが流れないFluent Bit の出力バッファが溜まり 100 % I/OMem_Buf_Limit を 10 MB → 50 MB へ増量
Grafana で時系列が途切れる再接続時に ts が飛ぶRecorder 側で datetime.utcnow() を必ず使い、システム時計依存を排除
dropped=0 でも Q が膨張キュー長が毎秒ジリジリ上昇flush() を1万行ごと → 2千行ごとに変更し、IO レイテンシを吸収

4-4 Take-away

  1. “観測できないものは直せない” を合言葉に
    ― まずは3指標をログに出す
  2. ログは JSON Lines に寄せ、可視化は Loki + Grafana を採用
  3. 異常時は「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_SECQUEUE_MAX を再チューニング

❹ ポイントまとめ

  • 意図的に落としても速攻で復旧できる――これが「安全に転ける設計」。
  • 運用では「再接続頻度」と「dropped 増減」をテキトーでも良いので“折れ線”で見ると安心です。

5. I/O を詰まらせない — 非同期キューと gzip ローテーション

CPU は WebSocket、ディスクはワーカーに。
“詰まり” はそこを分離しないと始まりません。


5-1 設計の骨格 ―「受信」と「書込」を完全分離

  1. 受信側 (_recv_loop)
    • async for msg in self.ws:ノンブロッキング 取得
    • 受け取ったメッセージは即 queue.put_nowait()
    • もし満杯なら 捨てる(§3の dropped カウンタ)
  2. 書込側 (_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/s60 %⚪ — ボトルネックにならず
6 (デフォ)17 MB/s40 %△ CPU が跳ねる
98 MB/s35 %✗ 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 まとめ

  1. 受信と書込は徹底分離 — Python なら asyncio.Queue + ワーカー1本で十分
  2. gzip level=1 で“CPU≦IO” を実現し、圧縮は後日
  3. ローテーションは 時間×サイズ のダブルトリガで “壊れても痛くない” を担保
  4. シャットダウン時は キューを空にしてから 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 に加えて coreaudiodWindowServer が立っていればクラムシェル抑止が有効 な目安になります。

補足:外部モニタなしでも蓋閉スリープを防ぎたいとき

外出先などで 電源⇔蓋クローズ の両立が欲しい場合は、

  • 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画面オフまでの分長め
disksleepHDD 回転停止までの分(SSD でも残る)0 または 10
powernapスリープ中のバックグラウンド処理0
ttyskeepawakeSSH・ターミナル接続中は起こしておく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 まとめ

  1. OS が寝ると Recorder も落ちる — まずは GUI & pmset で “眠らせない”
  2. caffeinate と独自 Assertion の二段構えでヒューマンエラーを消す
  3. 万一寝ても idle-watchdog が再接続、データは途切れない
  4. ログで「WS closed … – reconnectingsubscribed」が出れば 安全性の証明

ひとこと
「録るだけなら誰でもできる、録り続けて初めて資産になる。」
電源管理は地味ですが、耐久ランを“日常運用”に昇華させる最後の壁でした。
次章では、取得した 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 1Zstd + 非同期圧縮, S3 直送
スリープpmset + クラムシェル設定の固定外部 UPS シグナル連携・自動メンテ枠
可観測性RSS / CPU / queue / dropped を全て JSON L で保存Loki へ送り DashBoard で SLA 可視化

7-2 ここから先のロードマップ

  1. 障害注入テスト
    tc qdisc add netem などで 意図的に回線を 50 % ドロップ させ、
    dropped と再接続ロジックがどこまで耐えるかを検証する。
  2. クラスタリング対応
    Recorder を複数ノードに展開、Kafka や NATS JetStream に書き込み先を切替えることで
    “冗長化 × スケールアウト” を両立させる。
  3. 自動ヘルスチェック
    Prometheus Exporter を仕込み、queue_len > 8,000rss > 256 MB
    Slack/LINE にアラートを飛ばす——壊れる前に知らせる を徹底。
  4. IaC & CI/CD 化
    Ansible/terraform で Mac → Linux サーバへ移行できるよう
    依存の薄い Shell + Python レイヤに収斂させる。

7-3 まとめ

“止まらない録画” は、
“止まっても復帰できる録画”
+ “止まった事実をすぐ見える化する仕組み”
——この 2 つの掛け算で成り立つ。

ローカル PC 1 台でも、設計とオペレーションを揃えれば
「バックテスト用の全ティックが毎日数 GB(gzip 後)ペースで貯まっていく」環境は実現できます。
ぜひ皆さんも、この記事のチェックリストを 自分の Recorder / Scraper に照らし合わせ、
一つずつ “壊れても直帰できる・壊れたらわかる” 仕組みを仕込んでみてください。

-Bot, プログラミングスキル, 環境構築・インフラ, 開発ログ