JA EN
体系クラウドと運用
·無料·13分で読めます

サーバーレスとコスト設計 — 破産しないクラウド

「使った分だけ払う」は「使われた分だけ請求される」でもあります。スケールゼロの仕組み、GB-秒という課金単位、請求が指数で伸びる事故のパターンを前提知識ゼロから積み上げ、最後は自分のプロジェクトにキルスイッチを設計できるところまで持っていきます。

対象textタスクsystems

「使った分だけ」の意味

サーバーを借りるというのは、長いあいだ部屋を借りることでした。月いくらで一室を押さえ、鍵をもらい、そこに自分の荷物(プログラム)を置く。深夜に誰も来なくても、正月に閉めていても、家賃は同じだけかかります。

サーバーレスは、この契約をタクシーに変えました。乗っていない時間の料金はゼロで、乗った瞬間からメーターが回ります。誰もアクセスしてこない夜のあいだ、あなたのアプリの料金は文字どおり0円です。これがスケールゼロ、つまり「使われていないときは0台まで縮む」という性質です。

ここまでは良い話ばかりに聞こえます。しかし同じ性質を裏返すと、支払額を決めているのはあなたではなく、アクセスしてくる側だということになります。部屋を借りていれば、どれだけ人が押しかけても家賃は変わりません(代わりに部屋が溢れます)。タクシーは、行き先を告げる人がいる限りメーターを回し続けます。この記事は、その性質を利用しつつ、回り続けるメーターを自分の意思で止めるための設計の話です。

「サーバーがない」わけではない

誤解を1つ解いておきます。サーバーレスでもサーバーは動いています。消えたのは機械ではなく、あなたが機械を確保しておく時間です。

事業者は大量のマシンを抱え、その上で多数の顧客の関数を混ぜて動かしています。リクエストが来ると、あなたのコードを入れた小さな実行環境(コンテナ、あるいはより軽い隔離サンドボックス)が起動され、処理が終わると停止し、しばらく呼ばれなければ破棄される。この「起動して、使って、捨てる」の単位は、OSの用語でいえばプロセスとアドレス空間の作成そのものです。何が作られ何が守られているかはプロセスとメモリを1から — OSは何を守っているのかで扱っています。

ここから2つの帰結が出ます。1つはコールドスタート。破棄された状態から呼ばれると、環境の起動・ランタイムの初期化・コードの読み込みが必要になり、その分だけ最初の応答が遅くなります。直後にもう一度呼べば、さっきの環境が生きているので速い。同じ関数の応答時間が、呼ばれ方によって何倍も変わるわけです。

もう1つはステートレス性。ローカルディスクに書いたファイルも、グローバル変数に入れた値も、次の呼び出しで残っている保証はありません。厄介なのは「残っていないことがある」より「しばしば残ってしまう」ほうです。ウォームな環境が再利用されるので、テスト中はキャッシュが効いているように見え、本番でスケールした瞬間に別の環境へ散らばって破綻します。残っていても壊れず、消えても壊れない書き方を強制するのが正解です。

何を数えて請求されるのか

請求書を読めるようになるのが最短の近道です。関数の実行に対する課金は、だいたい次の3つの掛け算の和です。

C=prN  +  pci=1Nmiti  +  peEC = p_r N \;+\; p_c \sum_{i=1}^{N} m_i t_i \;+\; p_e E
(1)

言い換えると、「呼ばれた回数の代金」+「メモリ×時間の代金」+「外に出したデータの代金」です。NN は呼び出し回数、prp_r は1回あたりの単価。mim_iii 番目の実行に割り当てたメモリ量(GB)、tit_i はその実行時間(秒)で、この積の合計が GB-秒と呼ばれる単位になります。pcp_c はGB-秒あたりの単価、EE はクラウドの外へ出ていった通信量、pep_e はその単価です。単価は事業者・地域・時期で変わるので暗記する意味はありませんが、何を数えられているかは変わりません。

対して、常時起動のサーバーを1台借りる場合はこうです。

Cfixed=ph×730C_{\text{fixed}} = p_h \times 730
(2)

つまり時間単価 × 1か月の時間数(730は24時間×365日÷12か月の概算)。ここに NN は出てきません。1回も呼ばれなくても、100万回呼ばれても額は同じです(ただし後者では捌ききれずに落ちます)。

2つを見比べると、判断の軸が1本引けます。

N=Cfixedpr+pcmtN^{*} = \frac{C_{\text{fixed}}}{p_r + p_c \, m t}
(3)

固定費を「1回あたりの従量単価」で割った回数が損益分岐点です。月間の呼び出し回数がこれより少なければサーバーレスが安く、多ければ固定のほうが安い。ここに実務の直感が乗ります。サーバーレスは単価が高いかわりに下限が0、常時起動は単価が安いかわりに下限が高い。だからトラフィックが小さいうち、あるいは波の山と谷の比が大きいうちはサーバーレスが有利で、24時間ずっと高負荷なワークロードは固定のほうが安くなります。「サーバーレスは安い」ではなく「谷が深いほど安い」が正確な言い方です。

メモリを増やすと安くなることがある

GB-秒という単位には、直感に反する挙動が隠れています。多くの実行環境では、割り当てたメモリに比例してCPUの取り分も増えます。メモリ設定を2倍にすると、CPUを使う処理は実行時間がおおむね半分に近づく。mm が2倍で tt が半分なら積 mtmt は変わらないので、同じ料金で2倍速くなるわけです。

ただし条件があります。時間の大半が外部APIやデータベースの待ちである関数は、メモリを増やしても tt が縮みません。この場合 mm だけが2倍になり、料金も素直に2倍になる。つまりメモリ設定は「安全のために大きめ」でも「節約のために小さめ」でもなく、その関数がCPU律速かI/O律速かで決めるパラメータです。決め方は簡単で、代表的な入力で何段階か実測し、m×tm \times t が最小になる点を選びます。

破産は平均ではなく尾で起きる

従量課金の恐ろしさは平均額ではなく、上限が既定では存在しないことにあります。そして請求が跳ねるときは、たいてい足し算ではなく掛け算で跳ねます。

典型的な事故は、関数が自分自身を呼ぶ形になっているときに起きます。「画像がバケットに置かれたらサムネイルを作る関数」を書き、その出力を同じバケットに書いてしまう。出力がまたトリガを引き、そこからまた出力が生まれる。1回の実行が平均 bb 回の実行を生むとき、nn 段目の実行数は

Nn=bnN_n = b^{\,n}

です。要するに倍々ゲームで、bb が1を超えていれば増え続け、b<1b<1 ならいずれ止まります。この差は設計上たった1行の分岐で決まるのに、結果は「数百円」と「一晩で数十万円」ほど離れます。

似た構造の事故はいくつもあります。失敗した実行が自動で再試行され、その負荷でさらに失敗が増えるリトライの雪だるま。ループの中で従量課金の外部API(LLMの推論など)を呼び、入力データが想定の100倍だったケース。認証をかけていない公開エンドポイントがクローラや攻撃に叩かれ続けるケース。そして意外に多いのが外向き通信(egress)で、大きなファイルをCDNを挟まずに配ると、計算より転送量が主費目になります。

FIG 1呼び出し回数に対してコストがどう伸びるかを同じ絵で見比べられます。常時起動は横ばい(定数)、正常なサーバーレスは比例(線形)、自己トリガや無限リトライは指数。対数目盛りに切り替えると、指数のカーブが「しばらく何も起きていないように見える」区間を持つことが分かります

指数の怖さは、途中まで平坦に見えることです。線形に増えるコストは監視グラフの上でも比例して見えるので気づけます。指数は、目に見える高さになるまでが短く、見えた時点ですでに手遅れになっている。オーダーの違いが実測でどれほどの差になるかという感覚は計算量を実務で使うと地続きです。

止まる仕組みを先に作る

設計の原則は1つです。「気づいたら止める」ではなく「勝手に止まる」を先に作る。理由は単純で、請求データはリアルタイムではないからです。使用量が集計され、予算アラートが飛ぶまでには遅れがあります。予算アラートは請求書ではなく火災報知器だと思ってください。鳴った時点で何も自動では止まりません。止める仕掛けは、次の3層で別々に用意します。

入口を絞る。 一番効くのは同時実行数の上限です。関数に上限を設定しておけば、どれだけ叩かれても同時に走る数は頭打ちになり、単位時間あたりの請求額に天井ができます。緊急時にこの値を0にすれば、デプロイをやり直さずに関数だけを完全停止できます(AWSなら予約済み同時実行数、Cloud Run / Cloud Functions なら最大インスタンス数)。公開する必要のないエンドポイントには必ず認証をかけ、公開するものにはレートリミットとWAFを置きます。

1回の実行を絞る。 タイムアウトを既定の最大値のまま放置しないこと。外部APIの応答を待っている時間にも課金は続きます。再試行回数と最大バックオフに上限を置き、それでも失敗するメッセージはデッドレターキューへ逃がして、再試行のループを断ち切ります。

全体で絞る。 予算アラートを通知で終わらせず、通知をトリガに課金そのものを止める経路を作ります。GCPなら「予算アラート → Pub/Sub → 課金アカウントのリンクを解除する関数」、AWSなら Budget Actions で権限を剥がす構成が定番です。これは実質的にサービス停止なので、必ず実験用の隔離されたプロジェクト/アカウントに対して設定します。本番と同じ入れ物に仕掛けると、キルスイッチ自体が事故になります。

コードで書く「暴走しない関数」

自己トリガの遮断は、たいてい数行で終わります。書き込み先を分けたうえで、自分の出力に反応しないことを明示するのが定石です。

def on_upload(event):
    name = event["name"]
    if name.startswith("thumbs/"):      # 自分の出力には反応しない
        return
    if event.get("metadata", {}).get("generated_by") == "thumbnailer":
        return                          # 印を見てもう一度止める(二重の保険)
    make_thumbnail(name, dest=f"thumbs/{name}",
                   metadata={"generated_by": "thumbnailer"})

条件を2つ書いているのがポイントです。プレフィックスの判定だけでも動きますが、後から誰かが保存先を変えた瞬間に無限ループが復活します。メタデータの印はその変更に対しても生き残る。桁違いの請求に化ける領域では、冗長なガードのほうが安いという判断になります。

外部の従量課金APIを呼ぶ関数には、実行1回あたりの上限も持たせます。

MAX_CALLS = 50                      # 1実行あたりの外部API呼び出し上限

def handler(items):
    for item in items[:MAX_CALLS]:
        call_paid_api(item)
    if len(items) > MAX_CALLS:      # 溢れた分は捨てずに記録して人間に回す
        log.warning("truncated %d items", len(items) - MAX_CALLS)

想定外の巨大な入力が来たときに黙って全件処理しないこと。切り詰めて記録するほうが、事後に取り返しがつきます。

現場ではこう使う

この話が要るのは、個人開発者が自分のアプリを無料枠の範囲で公開したいとき、バックエンド担当が設計レビューで「これ、月いくら?」と聞かれたとき、そしてSRE・プラットフォームエンジニアが跳ねた請求の原因を切り分けるときです。

触る設定名は、クラウドが違っても対応が付きます。関数側では timeout(既定のまま長くしない)、memory(CPU律速かI/O律速かで決める)、maxInstances / 予約済み同時実行数(請求の天井)、minInstances、再試行設定とデッドレターキュー。プロジェクト側では、予算とアラートのしきい値、課金停止の自動化経路、コスト内訳を見るレポート(Cost Explorer / 課金レポート)。リソースにラベル(タグ)を付けておくと、跳ねた費目がどの機能のものか後から追えます。

知らないと事故になる落とし穴を、よくある順に挙げます。

設計レビューでよく問われるのは「同じワークロードで、サーバーレスと常時起動のどちらが安いか、どう判断するか」です。答えの筋道は本文のとおりで、月間呼び出し回数と損益分岐点 NN^{*} を比べ、そのうえで波形(ピークと平均の比)を見る、と答えれば十分です。ピークが平均の何十倍もあるなら、固定台数はピークに合わせて確保することになるので、平均値だけの比較は必ず外れます。

まとめ

次は同じ「クラウドと運用」の分野で、動いているシステムの内側を見る技術(メトリクス・ログ・トレース)を扱います。止め方の次は、気づき方の話です。

コメント

コメントにはログインが必要です