独自の「S3」を構築して年間50万ドルを削減した方法
(engineering.nanit.com)- Nanitは、赤ちゃんの睡眠状態を分析する動画処理パイプラインでAWS S3を利用していたが、毎秒数千件のアップロードにより、PutObject リクエスト料金が総コストの大半を占めていた
- また、S3 Lifecycle ルールの最小1日保持制限のため、実際には2秒以内に処理される動画に対しても24時間分の保存料金を支払う必要があった
- これを解決するため、Rustベースのインメモリストレージシステム N3を構築し、S3はオーバーフローバッファとしてのみ利用
- N3はSQS FIFOを通じて既存の処理パイプラインと完全互換で、厳密な順序保証と信頼性を維持
- 結果として、年間約50万ドルのコスト削減と、シンプルかつ安定した構成を実現した
背景
動画処理パイプラインの概要
- Nanitのカメラが動画チャンクを録画し、Camera ServiceからS3 presigned URLを要求して、S3へ直接アップロードする構成
- AWS LambdaがオブジェクトキーをSQS FIFO キューに公開し(baby_uidでシャーディング)、動画処理PodがSQSから消費してS3からダウンロードし、睡眠状態の推論を実行
- この構成の利点
- S3への着地 + SQSキューイングにより、カメラのアップロードと動画処理を分離でき、メンテナンスや一時的なダウンタイム中でも動画損失を防げる
- S3によって可用性と耐久性を自前で管理する必要がない
- SQS FIFO + グループIDにより、赤ちゃんごとの順序を維持でき、処理ノードの大半をステートレスに保てる
- S3 Lifecycle ルールがガベージコレクションを担うため、処理済み動画を追跡する必要がない
変更が必要だった理由
- PutObject コストが支配的: 動画は数秒間だけ着地して処理される短寿命オブジェクトであり、毎秒数千件のアップロード規模では、オブジェクトごとのリクエスト料金が最大のコスト要因だった
- チャンク頻度を上げて(より多くの小さなチャンクを送る)レイテンシを下げると、追加チャンクごとに別の PutObject リクエストが発生するため、コストが線形に増加する
- ストレージは二重課金: 処理が約2秒で完了しても、Lifecycle 削除ルールにより約24時間分のストレージ料金が発生する
- 信頼性と厳密な順序保証を維持しながら、通常経路でのオブジェクト単位コストを回避し、「待機のために払う」ストレージを最小化する設計が必要だった
計画
-
設計原則
- アーキテクチャによるシンプルさ: 巧妙な実装ではなく、設計レベルで複雑さを除去する
- 正確性: パイプラインの他の部分に対する透過的な完全代替であること
- 通常経路の最適化: 一般的なケース向けに設計し、エッジケースではS3をセーフティネットとして利用する。処理アルゴリズムはまれな欠落に強いため、複雑な保証を構築するよりシンプルさを優先
-
設計上の制約要因
- 短寿命オブジェクト: セグメントは着地領域に数秒しか存在しない
- 順序: 赤ちゃんごとの厳密なシーケンス管理(新しいものを先に処理しない)
- スループット: 毎秒数千件のアップロード、セグメントあたり2〜6 MB
- クライアント制約: カメラの再試行回数に制限があり、再送を前提にできない
- 運用: メンテナンスやスケールアップ中に数百万件のバックログを許容する必要がある
- ファームウェア変更なし: 既存のカメラで動作しなければならない
- 損失許容性: ごく小さな欠落は許容可能で、アルゴリズムが吸収できる
- コスト: 通常経路でのオブジェクト単位のS3コストを避け、「待機のために払う」ストレージを最小化
設計概要(N3通常経路 + S3オーバーフロー)
-
アーキテクチャ
- N3は、処理で排出されるまでに必要な時間(約2秒)だけ動画をメモリ上に保持するカスタムの着地領域で、N3が負荷を処理できないときだけS3を使う
- 2つのコンポーネント
- N3-Proxy(ステートレス、二重インターフェース)
- 外部(インターネット接続): presigned URL経由でカメラのアップロードを受け付ける
- 内部(プライベート): Camera Serviceにpresigned URLを発行する
- N3-Storage(ステートフル、内部専用): アップロードされたセグメントをRAMに保存し、Pod宛てに解決可能なダウンロードURLをSQSにキューイングする
- N3-Proxy(ステートレス、二重インターフェース)
- 動画処理PodはSQS FIFOから消費し、URLが指すストレージ(N3またはS3)からダウンロードする
-
通常フロー(Happy Path)
- カメラがCamera ServiceにアップロードURLを要求
- Camera ServiceがN3-Proxyの内部APIにpresigned URLを要求
- カメラがN3-Proxyの外部エンドポイントに動画をアップロード
- N3-ProxyがN3-Storageへ転送
- N3-Storageが動画をメモリに保持し、自身を指すダウンロードURL付きでSQSにキューイング
- 処理PodがN3-Storageからダウンロードして処理
-
2段階のフォールバック
- Tier 1: プロキシレベルのフォールバック(リクエスト単位)
- メモリ逼迫、処理バックログ、Pod障害などでN3-Storageがアップロードを受けられない場合、N3-Proxyがカメラの代わりにS3へアップロードする
- カメラは障害検知前にN3 URLを受け取っている状態
- Tier 2: クラスタレベルの再ルーティング(全トラフィック)
- N3-ProxyまたはN3-Storageが不健全な場合、Camera ServiceはN3 URLの発行を停止し、S3 presigned URLを直接返す
- N3が復旧するまで、すべてのトラフィックがS3へ流れる
- Tier 1: プロキシレベルのフォールバック(リクエスト単位)
-
2つのコンポーネントに分離した理由
- 障害半径: ストレージがクラッシュしてもプロキシはS3へルーティング可能。プロキシがクラッシュしても影響はそのノードのトラフィックに限られ、ストレージクラスタ全体は無事
- リソースプロファイル: プロキシはCPU/ネットワーク集約型(TLS終端)、ストレージはメモリ集約型(動画保持)で、異なるインスタンスタイプとスケーリング要件を持つ
- セキュリティ: ストレージはインターネットに一切接続しない
- ロールアウト安全性: プロキシ(ステートレス)更新時に、ストレージ(アクティブデータ保持)へ触れない
設計検証
-
検証が必要だったこと
- 容量とサイジング: クライアントネットワーク全体での実際のアップロード継続時間、必要な計算資源、アップロードバッファサイズ
- ストレージモデル: すべてをRAMに保持できるか、それともディスクが必要か
- 復元力: 安価にロードバランシングし、障害ノードをどう扱うか
- 運用ポリシー: GC要件、再試行の想定、GET時削除で十分かどうか
- 未知の不確実性: アイデアが現実に触れたとき、どんなエッジケースが現れるか
-
アプローチ1: 合成ストレステスト
- さまざまな同時実行数、遅いクライアント、継続負荷、処理ダウンタイムでシステムを限界まで追い込むロードジェネレータを構築
- 目標: 限界点を見つけ、想定外のボトルネックを把握し、容量計画のための決定論的なベースラインを得る
-
アプローチ2: 本番PoC(ミラーモード)
- 合成テストでは実際のカメラ挙動を再現できない: 不安定なWi-Fi、多様なファームウェアバージョン、予測不能なネットワーク条件
- ミラーモード: n3-proxyがまずS3へ書き込み(本番保持)し、その後PoCのN3-Storage(カナリアSQS + 動画プロセッサ接続)にも書き込む
- 対象コホート: ファームウェアバージョン / Baby-UID リストごと
- データパリティ: PoCと本番の睡眠状態を比較し、差異を調査
- 可観測性: 経路別ダッシュボード(N3 vs S3)、キュー深度、レイテンシ/RPS、エラーバジェット、egress分析
- 機能フラグ(Unleashを使用)が重要: デプロイなしでリアルタイムにコホート切り替えが可能で、狭いスライス(古いファームウェア、弱いWi-Fiのカメラ)を試し、問題発生時は即座に復元できる
-
判明したこと
- ボトルネック: TLS終端がCPUの大半を消費し、AWSのバースト可能ネットワーキングはクレジット枯渇後にスロットリングされた
- メモリ専用ストレージは実用的: 実際のアップロード時間分布と同時実行性から、ワーキングセットをRAMに安全な余裕を持って格納できることが確認され、ディスクは不要だった
- TCPタイムスタンプのオーバーヘッド: 転送総バイト数の約85%がACKフレームで、TCPタイムスタンプを無効化(
sysctl -w net.ipv4.tcp_timestamps=0)することで、ACKごとに12バイト削減- リスク: 同一ソケットで大量のバイトを送るとシーケンス番号がラップし、遅延パケットの誤結合で破損する可能性がある
- 緩和策: (1) アップロードごとに新しいソケットを使用、(2) n3-proxy ↔ n3-storage 間のソケットは約1 GB転送後に再利用をやめる
- メモリリーク: 初期リリース後、n3-proxyのメモリが継続的に増加
jemallocプロファイリングにより、接続ごとのhyperBytesMut バッファで増加を確認- 一部クライアント接続が転送途中で停止し、クリーンアップされないため、バッファが残ってメモリが増え続けていた
- 修正: ソケットを短命化し、時間制限を適用
- Keep-alive無効化: 各アップロード完了後、接続を即時終了
- タイムアウト強化: ヘッダ/ソケットのタイムアウト設定により、停止したアップロードを打ち切ってバッファを解放
ストレージ
-
インメモリストレージ
- 最もシンプルな経路から開始: インメモリストレージでI/Oチューニングを避け、直感的なデータ構造を使う
Arc<DashMap<Ulid, Bytes>>で動画を保存し、各動画アップロードでbytes_usedを増加、各ダウンロードで動画を削除して減少- 容量の約80%以上でアップロード拒否を開始してOOMを回避し、n3-proxyへアップロードURL署名停止を通知
controlハンドルでアップロードとガベージコレクションを手動一時停止可能
-
グレースフルリスタート
- メモリ専用ストレージのため、再起動時に進行中データのドロップを防ぐ必要がある
- グレースフルリスタートの手順
- Podに
SIGTERM(StatefulSetが一度に1つずつローリング) - PodがNot ReadyになりServiceから外れる(新規アップロードなし)
- すでにアップロード済みの動画に対するダウンロード提供は継続
- ダウンロードが止まったら(最近の読み出しなし → 処理ドレイン完了)
- 開いているリクエストの完了を待つ
- 再起動後、次のPodへ進む
- Podに
- 正常時には、Podは数秒でドレインする
-
GC
- 2つのクリーンアップ機構を使用
- ダウンロード時削除: ダウンロード直後に動画を削除。PoCでは再ダウンロードがゼロだったことが確認され、動画プロセッサが内部的に再試行するため、データ保持や「処理済み」状態の追跡は不要
- 取り残し向けTTL GC: ダウンロード時削除では、プロセッサがスキップしたセグメント(ダウンロードされない → 削除されない)をカバーできない
- 軽量なTTL GCを追加し、定期的にインメモリのDashMapをスキャンして、設定可能なしきい値(例: 数時間)より古い項目を削除
- メンテナンスモード: 計画的な処理ダウンタイム中は内部制御によりGCを一時停止でき、消費が止まっている間の動画削除を防げる
- 2つのクリーンアップ機構を使用
結論
-
主な成果
- S3をフォールバックバッファ、N3を主要な着地領域として使うことで、年間約50万ドルのコスト削減を達成しつつ、システムをシンプルで信頼性高く維持した
- 重要な洞察: 多くの「作るか買うか」の判断は機能に焦点を当てるが、大規模になると経済性が前提を変える
- 短寿命オブジェクト(正常時で約2秒)にはレプリケーションや高度な耐久性は不要で、シンプルなインメモリストアで機能する
- 処理が遅延したりメンテナンスでオブジェクト寿命が延びたりする場合は、S3の信頼性保証が必要
- 両方の長所: N3が通常経路を効率的に処理し、S3がオブジェクトを長く保持すべき場合に耐久性を提供
- N3に問題が起きても(メモリ逼迫、Podクラッシュ、クラスタ障害)、アップロードはS3へシームレスにフェイルオーバーする
-
成功要因
- 問題を事前に明確に定義: 制約条件、仮定、境界を定めることでスコープ拡大を防いだ
- ミラーモードPoCで早期検証: ボトルネック(TLS、ネットワークスロットリング)を発見し、コミット前に前提を検証
- 過剰なエンジニアリングや後戻りを防止
-
こうしたものをいつ構築すべきか
- 十分な規模によって意味のあるコスト削減が見込め、シンプルな解決策を可能にする特定の制約条件が揃っているときに、カスタムインフラを検討すべき
- システムの構築・保守にかかるエンジニアリング労力が、削減されるインフラ費用より小さくなければならない
- Nanitの場合、特定要件(一時ストレージ、損失許容性、S3フォールバック)により、保守コストを低く保てるほどシンプルなものを構築できた
- この2条件が満たされないなら、マネージドサービスを使い続けるべき
- もう一度やるか? はい。本システムは本番で安定稼働しており、フォールバック設計により信頼性を犠牲にせず複雑さを回避できている
3件のコメント
単に EC2 や EKS の Pod が直接動画をアップロード受信して処理すればよかったのでは、と気になります。
proxy まで作るほどなら、Pod 負荷に応じた EKS オートスケーリングも十分可能に見えますしね。
動画処理は通常、ファイル全体をメモリ上に丸ごと載せる必要はないので、各インスタンスのローカル SSD に一時ファイルを作って処理していれば、S3 フォールバックも不要だったように感じます。
サーバーレスとS3の使い方を誤った例のようですね
ただ、解決策のほうもさらに奇妙に思えます
Hacker Newsの意見
とても有益な記事だった。こういう技術的なアプローチの過程を共有してくれるのが本当に良い
自分が同じ問題を直接経験していなくても、どういう考え方でアプローチしたのかを見るだけでも学びが多かった
正直に言うと、これは最初からserverlessを使わなければずっとすっきりしていた気がする
数秒単位のデータを無理やりAWS serverlessのパラダイムにはめ込もうとして、不要なコストと複雑さが生まれた印象
それでもメモリベースのソリューションに移したのは良い選択だった
TLS handshakeがCPUを多く使うとは言っていたが、それが主なボトルネックだとは思えない
それでも、こうしたワークフローに合わせたシステム設計を試みている点は興味深かった
実際のところ、タイトルのように「S3を自前実装した」というよりは、S3の前段にメモリキャッシュを置いた構成だった
かっこよくはあるが、完全な自前S3代替ではない
タイトルが何であれ、興味深いプロジェクトだった
HNっぽく言うなら、Nanitという会社そのものについて話したい
Nanitはクラウドベースのベビーモニターカメラを運用している。すべての映像と音声がE2EEなしでアップロードされる
ハードウェアは高価で、サブスクなしではほとんど使えない。しかも$200のスタンドを買わないと睡眠トラッキング機能が有効にならない
こうした構造が結局クラウド依存モデルを強化しているのが残念だ
それでも今回の記事のようにS3依存を減らして自前ストレージへ移したのは良いことだ
他の製品はアプリが不安定だった。ローカル優先 + E2EEのソリューションがあればいいが、現実には使い勝手のほうが重要だった
本当にE2EEを望むなら、ローカルで分析して結果だけをアップロードすべきだ
この記事は、まるで自分で問題を作ってから解決したと自賛しているように感じた
むしろ最初からローカル保存ハードウェアを売っていれば、シンプルで安く済んだだろう
クラウド中心設計はもう2015年式のアプローチに思える
記事は素晴らしかったが、
delete on readをS3で実装した場合のコスト削減効果も気になったS3が秒単位課金なら、削減幅はかなり大きかったかもしれない
また、このソリューションは実質的にS3の
reduced redundancyオプションに近い$50万削減したと言っているが、総コストがどれくらいだったのか分からない
それが$50万のうち$50万1ドルなのか、$5,500万のうち$50万なのかで意味が変わる
最初から間違ったアーキテクチャを選んで、その上からキャッシュを塗ったような印象だ
平均2秒の映像をS3に上げる理由は、重複保存以外にはない
単にサーバーで直接処理していれば、S3、SQS、Lambdaをすべてなくせたはずだ
こんな単純な問題をなぜここまで複雑にしたのか分からない
「アプリ開発に集中してインフラは単純化せよ」という古典的な教訓のように思える
むしろキャッシュを動画処理サーバーの中に直接入れるほうがよかったはずだ
タイトルはむしろ「S3を間違って使った」のほうが正確だったかもしれない
結局自前のメモリストアを作ったのだから、むしろRedisのようなものを使えば済んだ話だ
自作システムがダウンしたら映像は消えるのか?
最初からKinesisやSQSに送っていればずっとよかったはずだ