「LLM を自分専用の API として公開したい。でも GPU サーバーを24時間動かすと高い……」
そんな悩みを、Azure Container Apps のサーバーレス GPU(使った分だけ課金・アイドル時は0円)で解決します。本記事では、Ollama 上で gpt-oss:20b を動かすエンドポイントを、1本の PowerShell スクリプトでデプロイしながら、Azure Container Apps の基本をやさしく解説します。
この記事について
本記事は、Microsoft Learn の「Azure Container Apps サーバーレス GPU に Ollama を使用して OpenAI gpt-oss モデルをデプロイする」を参考に作成した GitHub リポジトリ potofo/containerapps_gpt-oss-20b の概要を、初心者向けに解説するものです。元記事の手順をベースに、.env設定と1本のdeploy.ps1でデプロイを自動化し、APIキー認証(nginx サイドカー)・ゼロスケール・モデル差し替えを扱いやすくまとめています。
- 元記事(Microsoft Learn): https://learn.microsoft.com/ja-jp/azure/container-apps/deploy-openai-gpt-oss-ollama
- 題材リポジトリ(GitHub): https://github.com/potofo/containerapps_gpt-oss-20b
想定読者: クラウド/コンテナ初心者〜中級の入口。Docker は少し触ったが Kubernetes は難しく感じる人。「サーバーレスでコンテナを動かしたい」「GPU で LLM を手軽に動かしたい」人。
TL;DR
- Azure Container Apps のサーバーレス GPU に、APIキー認証付きの
gpt-oss:20bエンドポイントを立てる。 -
.envを書いて.\deploy.ps1を実行するだけ。リソース作成〜モデル準備まで自動。 - アイドル時は0レプリカ=ほぼ0円。実測では約10回動かした日で ¥60.92 だった。使い始めの初回だけ数分の起動待ちがある、というトレードオフ。
完成する構成はこちらです。
この記事で使う言葉(用語集)
先に、最低限おさえたい言葉を「一言でいうと」で整理します。詳細は各章で説明するので、ここでは全体像の地図として眺めてください。
コンテナ関連の基本
| 用語 | 一言でいうと | この記事での例 |
|---|---|---|
| コンテナ (Container) | アプリと動かすのに必要なもの一式を詰めた「箱」。 |
ollama/ollama:latest や nginx:alpine から起動する箱。 |
| イメージ (Image) | コンテナの「設計図・ひな形」。起動するとコンテナになる。 | Docker Hub 上の docker.io/ollama/ollama:latest。 |
| サーバーレス (Serverless) | サーバー管理を意識せず、使った分だけ課金される考え方。 | 使わない時はレプリカ0=ほぼ0円。 |
Azure Container Apps の構成要素
| 用語 | 一言でいうと | この記事での例 |
|---|---|---|
| Azure リソースグループ | 関連リソースをまとめる「フォルダ」。まとめて作成・削除できる。 |
rg-ollama-gptoss20b。既存グループの再利用も可。 |
| Azure Container Apps | サーバーレスにコンテナを動かすサービス名(総称・複数形)。 | 今回使う Azure サービスそのもの。 |
| Container Apps 環境 (Environment) | アプリを載せる「土台」。ネットワーク境界・ログ集約・GPU設定を共有。 |
cae-ollama-gptoss20b。 |
| Container App(アプリ) | 実際に動かす1つのアプリ(単数形)。環境の中に作る。 |
ca-ollama-gptoss20b。 |
| リビジョン (Revision) | アプリ設定のスナップショット。変更のたびに新規作成(中身は不変)。 | 設定変更=新リビジョン。旧に戻せる。 |
| レプリカ (Replica) | アプリの「実行中の実体」。0〜N個に増減し、課金対象になる。 | 今回は最小0・最大1。 |
| ワークロードプロファイル | 「どんなマシン(CPU/メモリ/GPU)で動かすか」の定義。 |
Consumption-GPU-NC8as-T4(T4 GPU / 8 vCPU / 56 GiB)。 |
| イングレス (Ingress) | 外部からアプリへの「入り口」設定(公開の有無・ポート)。 | 外部公開・ターゲットポート8080(認証プロキシ)。 |
スケールとサイドカー
| 用語 | 一言でいうと | この記事での例 |
|---|---|---|
| スケール / オートスケール | 負荷に応じてレプリカ数を自動で増減。 | リクエストが来ると0→1、来なくなると1→0。 |
| ゼロスケール (Scale to zero) | レプリカを0まで減らせる=アイドル時に課金ゼロ。 |
minReplicas: 0。最後のアクセスから約5分で0に。 |
| コールドスタート | 0から起動する初回の待ち時間(今回はモデル再ロードを含む)。 | 久しぶりの1回目のリクエストは遅い。 |
| サイドカー (Sidecar) | メインに寄り添う「補助コンテナ」。同一レプリカ内で localhost 通信。 |
メイン=Ollama、サイドカー=nginx認証プロキシ。 |
LLM・認証まわり
| 用語 | 一言でいうと | この記事での例 |
|---|---|---|
| GPU / VRAM | 並列計算が速いチップと専用メモリ。VRAM容量が「載るモデルの上限」を決める。 | T4=VRAM 16GB。gpt-oss:20b(約13GB)がぎりぎり載る。 |
| Ollama | LLM を手軽に動かすランタイム。pullで取得しrunで起動。 |
ollama pull gpt-oss:20b → ollama run。 |
| gpt-oss:20b | 今回動かすオープンな LLM(約200億パラメータ)。 |
OLLAMA_MODEL で他モデルにも変更可。 |
| リバースプロキシ (nginx) | 受けたリクエストを裏のサーバーへ転送する「受付・仲介役」。 | APIキー検証後に Ollama(localhost:11434) へ転送。 |
| APIキー / X-API-Key | アクセスを許可する「合言葉」。ヘッダーで送る。 |
X-API-Key が不一致なら401で拒否。 |
Azure Container Apps とは?(比較で理解する)
一言でいうと「Kubernetes の複雑さを隠した、サーバーレスなコンテナ実行環境」です。
うれしいポイントは4つ:
- インフラ管理が不要(ノードやクラスターを意識しない)
- HTTP でそのまま公開できる(イングレスが組み込み)
- リクエストに応じて自動スケールする
- ゼロスケール=使わない時は0インスタンス=課金なし
Azure には似たコンテナ系サービスが複数あります。ざっくりの住み分けは次のとおりです。
| サービス | 向いている用途 |
|---|---|
| Azure Container Instances (ACI) | 単発・一時的なコンテナ実行 |
| Azure Container Apps (ACA) ← 今回 | マイクロサービス / API・自動スケール・イベント駆動 |
| Azure App Service | Web 中心の PaaS |
| Azure Kubernetes Service (AKS) | フルコントロール・高度だが運用が重い |
初心者向けの指針は「まず ACA で十分。足りなくなったら AKS」です。今回のような「LLM を API 公開して、使うときだけ動かす」用途は ACA がぴったりです。
押さえるべき4つの登場人物
Azure Container Apps は、次の4つが入れ子になっています。ここを理解すると全体像がスッと入ります。
- Container Apps 環境 (Environment) … アプリを載せる「土台」。ネットワーク境界・ログ集約(Log Analytics)・GPU の「ワークロードプロファイル」をここに持つ。
- Container App … 1つのアプリ。イングレス(公開設定)・シークレット・スケール設定を持つ。
- リビジョン (Revision) … アプリ設定のスナップショット(イミュータブル)。設定を変えると新しいリビジョンが作られ、問題があれば旧に戻せる。
- レプリカ (Replica) … 実際に動くインスタンス。ここが0〜N個に増減し、そのまま課金の実体になる。
今回のワークロードプロファイルは Consumption-GPU-NC8as-T4(T4 GPU / 8 vCPU / 56 GiB)です。
最大の魅力「ゼロスケール」と課金の仕組み
Azure Container Apps のサーバーレス GPU は、稼働している秒数だけ課金されます(vCPU秒・メモリ秒・GPU秒)。いわゆる「アイドル料金」の概念がありません。
minReplicas: 0 に設定しておくと、無通信が続けば自動で0レプリカに縮小=GPU 課金が停止します。次のタイムラインが挙動のすべてです。
- リクエストが来ると 0→1 にスケールアウト(起動=コールドスタート)。
- 最後のリクエストから**約5分(既定のクールダウン300秒)**で 1→0 にスケールイン。
- 課金されるのはレプリカが動いている区間だけ。0の区間は $0。
初心者が誤解しやすい点: Azure ポータルの「実行中」表示は「アプリがデプロイ済みで受付可能」という意味で、課金の有無とは別です。課金の実体は「レプリカ数」。レプリカ0でも「実行中(0 にスケーリング済み)」と表示されます。
実測: 初回・再開時の「待ち時間」
ゼロスケールは「使わない時は0円」がうれしい反面、使い始めるときに起動の待ち時間が発生します。実際に計測した「3つの場面」で目安をつかんでおきましょう(gpt-oss:20b / T4 / westus3 での実測)。
| 場面 | いつ起きる? | 利用可能までの待ち時間 | なぜ |
|---|---|---|---|
| ① 初回デプロイ |
deploy.ps1 を実行した直後 |
約8.4分 | リソース作成(約5分)+ gpt-oss:20b(約13GB)のDL&ロード(約3.4分) |
| ② 再開(0スケール→アクセス) | しばらく使わず0に落ちた後、久しぶりにアクセス | 約3.4分 | レプリカ起動+13GBを再ダウンロード&ロード |
| ③ 2回目以降(稼働中) | すでに起動している状態で続けてアクセス | 数秒 | 起動済みなので即応答 |
-
なぜ「再開」でも約3.4分かかる? このプロジェクトは永続ストレージを使わない構成のため、0スケールから復帰するたびに gpt-oss:20b(約13GB)を毎回ダウンロードし直すからです。「コストはほぼ0」の代償として、久しぶりの1回目は数分待つと覚えておきましょう。常に即応答させたいなら
minReplicas: 1にもできます(ただし常時課金)。 -
初回アクセスで見える挙動(①②共通): 送信直後は
404 Not Found(モデルがまだ無い)→ 途中で504 Gateway Timeout(起動処理中)→ 数分後に正常応答。404/504 が返っても故障ではありません。慌てず数分待ってリトライすればOKです。 -
勘違いしやすい点:
runningStateがRunning/RunningAtMaxScaleになっても、それは「コンテナのプロセスが起動しただけ」。裏でモデルの13GBダウンロードが続いており、実際に使えるのはさらに数分後です。
実測: クリーンアップ(削除)にかかる時間
起動と対になる「片付け(削除)」の時間も測っておくと安心です。teardown.ps1 で削除を開始してから、Azure 上でリソースグループが完全に消えるまでを計測しました。
| 項目 | 値 |
|---|---|
| 削除開始 → 完全削除までの合計 | 約25分(1,515秒) |
| 支配的な要素 | Container Apps 環境の削除(他リソースは早く消え、最後まで残るのは環境1つ) |
- 削除時間はばらつきます: 別の回では約11分でした。GPU ワークロードプロファイル付き環境の解体は 15〜30分程度を見ておくとよいです。待っている間も GPU 課金は発生しません(レプリカは0)。
- 削除中の追加課金はほぼ無い: Consumption は稼働課金ベースのため、削除処理そのもので大きな課金は増えません。「確実にコストを完全ゼロにする最終手段」=削除、という位置づけです。
今回のアーキテクチャ ― サイドカーでAPIキー認証を足す
Ollama 自体にはAPIキー認証機能がありません。そのままインターネットに公開すると、URL さえ分かれば誰でも叩けてしまいます。
そこで、同じレプリカ内に nginx のサイドカーコンテナを置き、X-API-Key ヘッダーを検証してから Ollama(localhost:11434) へ転送する構成にします。
- イングレスは nginx(8080) に向け、Ollama(11434) は直接公開しない。
- APIキーが一致すれば
proxy_passで Ollama へ転送、欠落・不一致なら 401 を返す。
これが「サイドカーパターン」です。同一レプリカ内の複数コンテナは localhost で通信でき、起動・停止・スケールをともにします。
実装 Tips: GPU は「Container App 内で最初に定義したコンテナ」に割り当てられます。そのため Ollama をコンテナ配列の先頭に定義します。
作ってみる(デプロイ手順)
前提
- Azure サブスクリプション(と
az login済みであること) - 必要に応じてサーバーレス GPU のクォータ(T4/A100)
- 対応リージョン:
westus3またはswedencentral
手順
-
.env.exampleを.envにコピーして値を編集します(APIキーは空欄にしておくと自動生成されます)。
AZURE_SUBSCRIPTION_ID="<your-subscription-id>"
AZURE_TENANT_ID="<your-tenant-id>"
AZURE_RESOURCE_GROUP="rg-ollama-gptoss20b"
AZURE_LOCATION="westus3"
AZURE_CONTAINER_APPS_ENVIRONMENT="cae-ollama-gptoss20b"
AZURE_CONTAINER_APP_NAME="ca-ollama-gptoss20b"
AZURE_GPU_TYPE="T4"
OLLAMA_MODEL="gpt-oss:20b"
API_KEY= # 空欄なら deploy.ps1 が自動生成して書き戻す
- デプロイスクリプトを実行します。
.\deploy.ps1
内部では次の順で処理が進みます。
成功時の出力例(APIキーはマスク):
現在のサブスクリプション (xxxxxxxx-...) は .env の指定と一致しています。切替は不要です。
Resource Group 'rg-ollama-gptoss20b' の準備が完了しました。
Container Apps Environment 'cae-ollama-gptoss20b' の準備が完了しました。
GPUワークロードプロファイル 'Consumption-GPU-NC8as-T4' の準備が完了しました。
Container App 'ca-ollama-gptoss20b' を作成しました。
Public Endpoint: https://ca-ollama-gptoss20b.<random>.westus3.azurecontainerapps.io
モデルの準備が完了しました(Container_App名: ca-ollama-gptoss20b)。
GUI 派の方向けに、Azure ポータルから同じ構成を手動で作る手順も用意しています → docs/portal-deployment.ja-JP.md
使ってみる(動作確認)
curl で叩く(Windows PowerShell の注意点)
Windows PowerShell では curl が Invoke-WebRequest のエイリアスなので、curl.exe を明示し、JSON 内の " を \" にエスケープします(ここは高確率でハマるポイント)。"stream":false を付けると応答が1つの JSON にまとまって読みやすくなります。
curl.exe -X POST "https://<your-app>.<region>.azurecontainerapps.io/api/generate" -H "X-API-Key: <YOUR_API_KEY>" -H "Content-Type: application/json" -d '{\"model\":\"gpt-oss:20b\",\"prompt\":\"Hello\",\"stream\":false}'
クォートで悩みたくなければ、PowerShell ネイティブの Invoke-RestMethod も使えます。
$body = @{ model = "gpt-oss:20b"; prompt = "Hello"; stream = $false } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "https://<your-app>.<region>.azurecontainerapps.io/api/generate" `
-Headers @{ "X-API-Key" = "<YOUR_API_KEY>" } -ContentType "application/json" -Body $body
APIキーが欠落・不一致のリクエストは、nginx サイドカーから 401 が返り、Ollama には転送されません。
同梱の chat.py で対話する
Python 標準ライブラリだけで動く簡易チャットクライアントも同梱しています(pip install 不要)。deploy.ps1 が書き出す接続情報ファイルを自動で読み込みます。
python chat.py
初回起動(コールドスタート)は約8〜10分かかります(前述「ゼロスケールと課金」の実測を参照)。0スケール状態からの初回リクエストは gpt-oss:20b(約13GB)のダウンロードとロードが走るためです。初回はタイムアウトを長めに(例:
python chat.py --timeout 600)するか、気長に待ちましょう。2回目以降(稼働中)は高速です。
コストを管理する
- アイドルでゼロスケール=ほぼ0円、稼働時のみ課金。
- 永続ストレージを使っていない(モデルは毎回 pull)ので、「コンテナ維持のためのストレージ課金」は発生しません。
- Log Analytics のログ課金だけは微小に残ります。
- 確実に止めたいときは、ポータルの「停止」ボタン、または
teardown.ps1で削除。
実測コスト
実際に gpt-oss:20b を約10回動かした日(2026年7月4日)のコストは ¥60.92でした(内訳: Azure Container Apps ¥60.92 / Log Analytics ¥0.00)。
グラフを見ると、動かしていない日は課金が発生していない(7月4日だけに棒が立っている)ことが分かります。「使うときだけ課金」を実データで確認できました。金額はリージョン・為替・実行回数・稼働時間で変動する一例です。
つまずきポイント集(実際にハマった落とし穴)
このプロジェクトを実機で動かす中で、実際に踏んだ罠を「症状 → 原因 → 対処」でまとめます。
-
PowerShell の
curlはInvoke-WebRequestのエイリアス
症状:curl ... -H ...がInvoke-WebRequest : パラメーター 'Headers' をバインドできませんで失敗。
対処:curl.exeを使い、JSON の"を\"にエスケープする。 -
Ollama 公式イメージに
curlが入っていない
症状: 起動スクリプトのヘルスチェックが常に失敗し、ollama pullに到達しない。
対処: ヘルスチェックはcurlではなくollama listで行う。 -
sh -cの二重ネスト
症状: コンテナが起動時にクラッシュループ。
対処: 起動スクリプトは本体のみを渡し、command: ["sh","-c"]は1回だけにする。 -
az containerapp create/update --yamlのバグ
症状: 正しい YAML でもBad Request: ... could not be converted to System.Booleanで必ず失敗(az 2.87.0)。
対処:--yamlを使わず、**az rest(ARM REST API 直叩き)**でリソースを作成/更新する。 -
CRLF 問題
症状: Windows で書いたシェルスクリプトの改行(CRLF)が Linux コンテナで悪さをし、command not found等でクラッシュ。
対処: コンテナに渡す前に LF に正規化する。 -
ポータルの「実行中」≠ 課金
症状: 0スケール済みでも「実行中」と表示され、課金されていると誤解する。
対処: 課金の実体はレプリカ数。判定はaz containerapp replica listの件数より、リビジョンのrunningState(ScaledToZero/RunningAtMaxScale)が正確。
発展: プライベート運用(さわりだけ)
社内限定などインターネットに出したくない場合は、プライベートエンドポイント+パブリックアクセス無効化+プライベートDNSで、ExpressRoute / VNet 経由の閉域アクセスにできます。カスタムドメインは必須ではなく、既定 FQDN + プライベート DNS で到達できます。
初心者が誤解しやすい点だけ、要点を押さえておきます(詳細は docs/research-privatelink.md と公式ドキュメントへ)。
-
プライベートエンドポイントは「環境(
Microsoft.App/managedEnvironments)単位」で作る。アプリ単位ではない(サブリソース group-id はmanagedEnvironments)。 - 前提: ワークロードプロファイル環境のみ対応/環境の Public Network Access を Disabled/PE 用サブネットは /27 以上。
-
プライベートDNSゾーンは
privatelink.{リージョン}.azurecontainerapps.io。環境識別子を名前とする A レコードを1つだけ作る(アプリごとには作らない)。 -
名前解決:
app.xxx.<region>.azurecontainerapps.io→(CNAME)→xxx.privatelink.<region>.azurecontainerapps.io→(A)→ プライベートIP。環境内の全アプリが同一IPに解決され、振り分けは ingress(Envoy) が Host ヘッダー/SNI で行う。 - 課金の注意: Private Link 料金に加え、**専用インフラ料金(Dedicated Plan Management)**が発生する。ゼロスケールの「使うときだけ課金」とは別の固定的コストが乗る。
公式: Private endpoints and DNS / Use a private endpoint
まとめ
この記事で学んだことは3つです。
- Azure Container Apps の基本概念 … 環境 → アプリ → リビジョン → レプリカ の入れ子。Kubernetes より手軽にコンテナを API 公開できる。
- ゼロスケールと課金 … 使わない時は0レプリカ=ほぼ0円。実測でも約10回動かした日で ¥60.92。代わりに久しぶりの1回目は数分の起動待ちがある。
- サイドカーで認証を足す … Ollama に無い APIキー認証を、nginx サイドカーで前段に足す設計。
「使うときだけ課金」の Azure Container Apps サーバーレス GPU は、個人・検証用途で自分専用の LLM API を立てるのに最適です。
次の一歩:
-
モデルを変える:
.envのOLLAMA_MODELを変更(例:qwen3:8b)。大きいモデルはAZURE_GPU_TYPE=A100に。 -
常時応答にする:
minReplicas: 1(コールドスタートを無くす代わりに常時課金)。 - 閉域化する: プライベートエンドポイントで社内限定に。
リポジトリはこちら → potofo/containerapps_gpt-oss-20b
本記事は Microsoft Learn「Azure Container Apps サーバーレス GPU に Ollama を使用して OpenAI gpt-oss モデルをデプロイする」を参考に作成しました。