はじめに
この記事は、Qiita Advent Calendar 2025 ラクスパートナーズ6日目の記事です。
ラクスパートナーズで開発エンジニアをしております、工藤です。
早いことでもう年末ですね。皆様いかがお過ごしでしょうか。
私はといえば、野球のシーズンが終わってしまい、日曜に野球ができない悶々とした気持ちを、日々の筋トレとラーメン巡りにぶつけております。
皆さまイチオシラーメンがあればぜひ、教えてほしいです。
話がそれたようでそれていないんですが、今回はAzure OpenAIでラーメンレコメンドアプリを作ってみます。
これまでの業務メインはアプリケーションレイヤーの開発だったのですが、現在の案件ではAzureを使用したインフラレイヤーも見ることが増えたので、設計から構築辺りをメインに、最終的にデプロイまでの流れをまとめていこうと思います。
(12月に AZ-104 を受験するので、ハンズオン学習的な要素もあります)
全て網羅してまとめると膨大になるため、割愛する箇所が多くなりますが、良かったら読んでいってください。
アプリケーション設計
「東京都23区内において、場所・値段・種類(系統)を指定すると、その条件にマッチしたオススメのラーメン店を提示してくれる Web アプリケーション」 をテーマとして実装を行います。
ユーザーが条件を選択すると、バックエンド側で Azure OpenAI Service を用いてレコメンド処理を行い、その結果をフロントエンドで見やすい形に整形して表示する、という構成を想定しています。
全体アーキテクチャ
アプリケーションは大きく以下の 2 コンポーネントから構成します。
フロントエンド:Next.js
・ユーザーが条件を入力する UI(場所・価格帯・ラーメンの種類など)を提供
・入力内容をバリデーションした上で、バックエンドの API エンドポイントにリクエスト送信
・バックエンドから返却されたレコメンド結果を画面にレンダリング(Markdown/テーブル形式)
バックエンド:Node.js(API サーバー)
・フロントエンドからの HTTP リクエストを受け付ける API レイヤーとして実装
・リクエストボディから「エリア」「価格帯」「ラーメンの系統」などの条件を取り出し、Azure OpenAI に渡すためのプロンプトを組み立て
・Azure OpenAI Service の Chat Completions API をコールし、LLM からの応答をアプリケーション都合のフォーマット(Markdown)に正規化
・整形したレスポンスをフロントエンドに返却
データ永続化とセッション方針
今回は構成をシンプルにするため、DBは用意しません。
そのため、以下のような割り切りを行います。
・ユーザー登録・ログインなどの認証・認可機能は実装しない
・投稿履歴やお気に入り店などの永続的なデータ保存は行わない
・1回のリクエスト〜レスポンスの中で処理が完結する、ステートレスな API として設計
よって、今回は DB・キャッシュなどのミドルウェアは利用せず、Azure OpenAI をコアとしたシンプルな 2 tier 構成とすることを前提とします。
アプリケーション実装
フロントエンド
Next.jsでの実装です。
シングルページで完結するため複雑な実装は特にありませんが、23区・ラーメン種別・価格帯を useState で管理するシンプルな検索フォームになっています。
submit 時に最低限のバリデーションを行ったうえで、選択済みの条件を JSON でバックエンドの /recommend API に POST し、返ってきた Markdown 形式のレコメンド結果(今回バックエンドからのレスポンスは Markdown 形式になります)を react-markdown ライブラリと remark-gfm プラグインでレンダリングしています。
react-markdown ライブラリでは Markdown のベーシック仕様には対応しているものの、標準仕様にはテーブル構文が含まれていないことから、テーブル構文の表示の補助として、remark-gfm プラグインを使用します。
表示部分は Tailwind CSS の prose クラスと framer-motion を組み合わせて、テーブルやリンクを含む AI レスポンスを読みやすくリッチに見せる構成にしています。
Markdown 形式のレコメンド結果のレンダリングは下記で行っています。
{/* 結果表示 */}
{result && (
<motion.div
className="relative mt-8 overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur shadow-xl ring-1 ring-black/5"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
>
{/* 上部アクセントライン */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-amber-400 via-rose-400 to-indigo-400" />
{/* アクションバー*/}
<div className="flex items-center justify-between px-6 pt-4">
<h3 className="text-sm font-medium text-neutral-500">
AIからの結果
</h3>
</div>
<div className="p-6">
<div
className="prose prose-amber prose-lg max-w-6xl leading-relaxed text-left
prose-headings:scroll-mt-20 prose-headings:font-semibold
prose-a:text-blue-600 hover:prose-a:text-blue-800 prose-a:underline hover:prose-a:no-underline
prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-transparent prose-pre:shadow-none
prose-ol:marker:text-amber-500 prose-ul:marker:text-amber-500
dark:prose-invert [&_td]:px-4 [&_th]:px-4 [&_td]:py-2 [&_th]:py-2 [&_table]:w-full [&_table]:table-fixed
[&_a]:text-blue-600 [&_a]:underline
[&_a:hover]:text-blue-800 [&_a:hover]:no-underline"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{result}
</ReactMarkdown>
</div>
</div>
</motion.div>
)}
バックエンド
Node.jsでの実装です。
Azure OpenAIの初期化や、型定義は割愛していますが、メインの実装は下記になります。
/* =========================================================
ホットペッパーAPIから取得した候補からのみ選ばせる
========================================================= */
export async function recommendFromCandidates(
shops: Shop[],
userCond: UserCond,
topN: number = 3
): Promise<string> {
if (shops.length === 0) {
return "候補データが空でした。外部APIから実在店舗候補を取得してから再実行してください。";
}
// LLMに渡す許可リスト(この中からのみ選択させる)
const context = shops
.slice(0, 60) // トークン節約のため上限設定
.map((s, i) =>
[
`${i + 1}.`,
s.name,
s.address,
s.genre ?? "",
s.budget ?? "",
s.url ?? "",
].join(" | ")
)
.join("\n");
const system = [
"あなたは厳密なレビュアーです。",
"与えられた候補以外の店名・情報を出すことは禁止です。",
"出典URLは必ずMarkdownリンク形式で記載すること(例: [ホットペッパー](https://example.com))。",
"URLが不明の場合は '不明' と記載し、リンクにはしないこと。",
"存在未確認の店舗名を創作することは禁止です。",
].join("");
const user = [
`条件: エリア=${userCond.districts.join("・")} / 種類=${userCond.ramenTypes.join("・")} / 価格=${userCond.minPrice}〜${userCond.maxPrice}円`,
"",
"候補一覧(この中からのみ選べ。候補にない店名を出すのは禁止):",
context,
"",
`出力指示: Markdownの表で最大${topN}件まで。列は「店名|住所|価格帯|出典URL|一言コメント」。`,
"出典URLの列は、必ず [リンクテキスト](URL) という Markdown リンク形式で記載してください。",
"候補内で条件に合致する店舗が無い場合は「該当なし」とだけ出力。",
].join("\n");
const res = await openai.chat.completions.create({
model: AZ_DEPLOYMENT,
temperature: 0.2, // 低温で事実寄りにする
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
});
console.log("OpenAI レスポンス:", res);
return res.choices[0]?.message?.content ?? "回答なし";
}
今回ですが、ホットペッパーから取ってきた実在する店舗候補リストを LLM に渡し、その中からだけ条件に合う店を選ばせる、という方式でレコメンデーションを実現しています。
実装を進める中で、全く条件に合致しない店舗や、存在しない店舗を返却しようとするケースが散見されたため、幻覚(架空店舗の生成)を防ぐための強い制約をメッセージに組み込んでいます。
そのため、若干制約が強く、取得店舗数が少なくなりがちだったので、ここのチューニングは課題といったところでしょうか。。。
インフラ構成図
続いてインフラ構成です。
今回はAzureを使用して構築していきます。
今回は、Azure App Service を利用した フロントエンド / バックエンド分離型 Web アプリケーションを、Azure OpenAI と連携させます。
バックエンド API は セキュアな構成(プライベートアクセス) とし、Azure OpenAI との通信は プライベートエンドポイント経由の閉域接続にすることでセキュリティを強化しています。
アーキテクチャ概要
| 役割 | リソース | ネットワーク特性 |
|---|---|---|
| Web画面提供 | App Service(Frontend) | ネットワークからのアクセス可 |
| API提供 | App Service(Backend) | フロントエンドからプライベートエンドポイント経由でアクセス可 |
| AI推論 | Azure OpenAI | プライベートエンドポイント経由 |
| DNS制御 | プライベート DNS ゾーン(privatelink.openai.azure.com) | 名前解決のプライベート化 |
| 閉域通信制御 | Virtual Network, Subnet, NSG | ゼロトラスト構成 |
サブネット設計
| サブネット名 | 目的 | 配置リソース |
|---|---|---|
| snet-int | App Service の Outbound 通信(VNet統合用のサブネット) | Frontend / Backend App Service |
| snet-pe | プライベートエンドポイント配置用のサブネット | Azure OpenAI PE / Backend PE |
通信経路
| 通信 | 経路 | プロトコル | セキュリティ |
|---|---|---|---|
| ユーザー → フロントエンド | Public → Frontend | HTTPS | Web認証 |
| フロント → バックエンド | VNet統合 → プライベートエンドポイント | HTTPS(プライベート) | インターネット遮断 |
| バックエンド → Azure OpenAI | VNet統合 → プライベートエンドポイント | HTTPS(プライベート) | インターネット遮断 |
App Service → Azure OpenAI は プライベートエンドポイントを経由してのみ到達可能とする
DNS 設計
- プライベートDNSゾーン:
privatelink.openai.azure.com - 上記を VNet にリンク
- プライベートエンドポイントのプライベートIPを返すように制御
Backend は Public 経路を利用せずに Azure OpenAI へアクセス可能
インフラ構築
さて、では実際にインフラの構築を始めます。
※今回、 Azure OpenAI を East US リージョンで作成したことから、リージョンは基本的に East US に統一していきます。
※画像を貼りながらの説明だと長くなりすぎるので、テキストベースになる旨、ご了承ください。
App Service プラン
作成手順
-
Azure ポータル → 「App Service プラン」→「作成」
-
基本設定
項目 設定例 サブスクリプション 任意 リソースグループ rg-ramen-ai-jpe名前 asp-ramen-aiリージョン East US(全体と統一) OS Linux(推奨) ゾーン冗長 無効 -
SKU
-
Basic B1(最小コスト)
※ Freeは無料だが、今回プライベートエンドポイント連携を行うため、B1以上を選択
-
Basic B1(最小コスト)
-
「確認および作成」→「作成」
ポイント
- App Service プランは後からスケールアップ可能
-
Backend と Frontend は同じプランに乗せてOK
→OSが同じであれば同じプラン使用可能、コスト最小化につながる
注意
今回プラン作成時にクォータ制限でのエラーが発生しました。
どうやら East US リージョンで作成するための上限が0になってしまっていたのが原因でした。
必要に応じてクォータ引き上げ申請を行ってください。
App Services
App Service(バックエンド)の作成手順
-
Azure Portal → 「App Service」→「作成」
-
基本設定
項目 設定例 サブスクリプション 任意 リソースグループ rg-ramen-ai-jpe名前 app-ramen-backend発行 コード ランタイム Node 20 LTS リージョン East US App Service プラン 作成済みの asp-ramen-aiを選択 -
「確認および作成」→「作成」
Frontend App Serviceの作成手順
-
Azure Portal → 「App Service」→「作成」
-
基本設定
項目 設定例 サブスクリプション 任意 リソースグループ rg-ramenapp-ai-jpe名前 app-ramen-frontend発行 コード ランタイム Node 20 LTS リージョン East US App Service プラン 作成済みの asp-ramen-aiを選択 -
「確認および作成」→「作成」
App Service と GitHub リポジトリの連携手順
-
Azure Portal で
app-ramen-frontendを開く -
左メニューから 「デプロイセンター」 を選択
-
ソース (Source) の選択
- 「ソース」欄で GitHub を選択
- 初回は GitHub へのサインイン・認可が求められるので許可する
-
リポジトリの選択
- アカウント
- リポジトリ名
- ブランチ
を選択する
-
ビルドプロバイダーの選択
- 「ビルドプロバイダー」で GitHub Actions を選択
- ランタイムは Backend と同じく Node 20 LTS を選択
-
設定内容を確認し、「保存」または「完了」 をクリック
仮想ネットワーク(VNet)・サブネット
作成手順
-
Azure ポータル → 「仮想ネットワーク」→「作成」
-
基本設定
- サブスクリプション:任意
- リソースグループ:
rg-ramen-ai-jpe(任意でOK) - 名前:
vnet-ramen-ai - リージョン:Azure OpenAI と 同じリージョンを指定
- IPv4 アドレス空間:
10.0.0.0/16
-
サブネット追加(default は削除)
名前 用途 アドレス範囲 snet-intApp Service VNet統合用 10.0.1.0/24snet-peプライベートエンドポイント用 10.0.10.0/24 -
確認 → 作成
- 仮想ネットワークを1つ、サブネットを「VNet統合用」と「プライベートエンドポイント用」の2つを作成していきます。
NSG(ネットワークセキュリティグループ)
VNet統合用サブネットの NSG 作成手順
-
Azure ポータル → 「Network security groups」→「作成」
-
基本設定
- サブスクリプション:任意
- リソースグループ:
rg-ramen-ai-jpe(任意でOK) - 名前:
nsg-integration - リージョン:仮想ネットワークと同じリージョン
-
サブネット関連付け
対象 設定内容 サブネット snet-intを選択説明 App Service の VNet 統合先に適用する -
セキュリティ規則
- 初期状態は既定ルールのみでOK
- 後から必要に応じて段階的に制限していく
-
確認 → 作成
-
snet-intに NSG が適用された状態になればOK
-
プライベートエンドポイント用サブネットの NSG 作成手順
作成手順
-
Azure ポータル → 「Network security groups」→「作成」
-
基本設定
- サブスクリプション:任意
- リソースグループ:
rg-ramen-ai-jpe - 名前:
nsg-pe - リージョン:仮想ネットワークと同じリージョン
-
サブネット関連付け
対象 設定内容 サブネット snet-peを選択説明 プライベートエンドポイント用サブネットに適用する -
セキュリティ規則追加
優先度 名前 アクション プロトコル 送信元 宛先 宛先ポート メモ 100 Allow-Int-443Allow TCP 10.0.1.0/24(snet-int)VirtualNetwork 443 AppService → プライベートエンドポイントの HTTPS通信を許可 200 Deny-VNet-OtherDeny Any VirtualNetwork VirtualNetwork * その他アクセスを拒否 -
確認 → 作成
- プライベートエンドポイントへのアクセスが最小限に制限された状態になる
Azure OpenAI 用のプライベートDNSゾーン作成手順
-
Azure ポータルの検索で 「プライベート DNS ゾーン」 を選択
-
「作成」をクリック
-
以下を入力
項目 値 名前 privatelink.openai.azure.comリソースグループ rg-ramen-ai-jpe(任意でOK)リージョン 選択不可(デフォルトでグローバルリソース選択済) -
「確認および作成」→「作成」
仮想ネットワークのリンク設定
-
作成したプライベート DNS ゾーンを開く
-
左メニュー 「仮想ネットワークリンク」 → 「追加」
-
以下を設定
項目 値 名前 link-vnet-ramen-ai(任意)仮想ネットワーク vnet-ramen-ai(既存のVNet)自動登録 有効 -
保存
ポイント
- プライベートエンドポイントが作られた時に DNSレコードが自動登録される
- App Service(VNet統合済み)から
*.openai.azure.comを解決した際
→ プライベートエンドポイントに紐づいた プライベートIP を返すようになる
これで Azure OpenAI のプライベート接続準備が完了した形になります。
App Service(バックエンド)用のプライベートDNSゾーン作成手順
こちらも、Azure OpenAI 用のプライベートDNSゾーンと同様の手順で作成していきます。
DNSゾーン名は privatelink.azurewebsites.net とし、
仮想ネットワークリンク名は vnetlink-websites とします。
注意点
Azure の仕様として、自動登録できるプライベート DNS ゾーンは1つの VNet につき1つのみです。
そのため、Azure OpenAI 側の仮想ネットワークリンクで自動登録を有効化していると、2つめの仮想ネットワークリンクでの自動登録が弾かれるはずです。
その場合は、 Azure OpenAI 側の仮想ネットワークリンクを無効化し、App Service(バックエンド)側も無効で登録し、下記の手順で登録を行ってください。
Azure OpenAI
作成手順
- Azure Portal で Azure OpenAI の作成を選択
- Azure AI Foundry ではない
- リソースグループ、名前、価格レベル(Standard S0)を選択
- 「インターネットを含むすべてのネットワークがこのリソースにアクセスできます。」を選択
- タグなしで作成
- リソースに移動し、「 Azure AI Foundry ポータル」に移動を押下
- 「デプロイ」→「+モデルのデプロイ」→「基本モデルをデプロイする」と進む
- 「gpt-4o-mini」を選択し、「確認」→「デプロイ」を押下
Azure OpenAI 用のプライベートエンドポイント作成
作成手順
- Azure ポータルで Azure OpenAI リソースを開く
- 左メニューの 「ネットワーク(Networking)」 を選択
- 「プライベートアクセスの構成」
→ 「+ 追加」 をクリック - 以下を設定
| 項目 | 設定例 |
|---|---|
| 名前 | pe-openai |
| 仮想ネットワーク | vnet-ramen-ai |
| リージョン | 仮想ネットワークと Azure OpenAI と同じリージョン |
| サブネット |
snet-pe(プライベートエンドポイント用) |
| プライベートDNSゾーンと統合する | 有効にする |
| 対象サブサービス |
openai またはデフォルト選択 |
- 「確認および作成」 → 「作成」
- 数分ほど待機(状態が「承認済み」になればOK)
確認ポイント
- プライベートエンドポイントに プライベートIP が割り当てられている
- プライベート DNS ゾーンに、 ramen-recommend-ai の Aレコードが自動作成されている
これにより、Azure OpenAI への通信は、
パブリックインターネットを経由せず完全閉域となります。
App Service(バックエンド)用のプライベートエンドポイント作成
作成手順(自動登録が有効になっている場合)
- Azure Portal →
app-ramen-backendを開く - 左メニューで 「ネットワーク」 を選択
- 「プライベート エンドポイント」 → 「+ 追加」→「簡易」
- 以下を設定
| 項目 | 設定 |
|---|---|
| 名前 | pe-backend |
| 仮想ネットワーク | vnet-ramen-ai |
| サブネット | snet-pe |
| プライベートDNSゾーンと統合する | はい |
- 「確認および作成」→「作成」
- 数分待機 → 状態が「承認済み」になればOK
作成手順(自動登録が無効になっている場合)
- Azure Portal →
pe-backend(Backend用 プライベートエンドポイント) を開く - 左メニュー → DNS構成 を選択
- 「+ 追加」をクリック
- 以下を設定
| 項目 | 設定例 |
|---|---|
| DNS ゾーン | privatelink.azurewebsites.net |
| ※その他はそのままでOK |
- 保存
- 反映まで 1~3 分待機
確認ポイント
-
pe-backendに プライベートIP が付与されている - プライベート DNS Zone に DNS レコードが自動追加されていれば OK
Backend App Service の VNet 統合
手順
-
Azure ポータルで
app-ramen-backendを開く -
左メニューから 「ネットワーク」 を選択
-
「仮想ネットワーク統合」セクションの「構成されていません」 をクリック→**「仮想ネットワーク統合の追加」**を押下
-
以下のように設定する
- 仮想ネットワーク:
vnet-ramen-ai - サブネット:
snet-int(VNet 統合用に作った方)
- 仮想ネットワーク:
-
「接続」で保存
-
数十秒〜数分待つと、状態が 接続済み(Connected) になる
App Service から Azure OpenAI への疎通確認
- 名前解決テスト
nslookup コマンドで名前解決が正常に行われるかのテストを行います。
nslookup <Azure OpenAI リソース名>.openai.azure.com
「Server:」「Address:」に続く形でIPアドレスが返ってくればOKです。
- HTTP疎通テスト
curlコマンドで疎通テストを行います。
ここでは Azure OpenAI の API キーが必要となるので準備しておきましょう。
curl -i \
-H "api-key: <Azure OpenAI Key>" \
"https://<リソース名>.openai.azure.com/openai/models?api-version=2024-02-01"
200 OK のレスポンスとともに長いデータ返却が確認できればOKです。
環境変数設定
設定手順(フロントエンド)※GitHub Actions使用時
- GitHubのリポジトリ設定から Settings → Secrets and variables → Actions → Variables に移動
- Repository variables に環境変数を入力
- workflow の yml ファイル内 build ジョブに env を追加する
設定手順(バックエンド)
- Azure ポータルで
app-ramen-backendを開く - 左メニューから「設定」→「環境変数」を開く
- アプリ設定内で「追加」を押下
- 名前と値を登録して、最後に「適用」を押下
実際に動かしてみる
さて、以上でインフラ構築が完了です。
長くなり過ぎました、申し訳ございません。
実際にサイトにアクセスしてレコメンドアプリが正常に動作するか確認してみましょう。
なお、詳細は割愛しますが、アプリケーションが正常に表示されるまで、GitHub Actions のビルド方式の変更等、一悶着あったので、同様のバグに悩まれた方はコチラの stack overflow が大変参考になりましたので、ご参考までに。
早速検索してみます。
条件は「新宿区」で「700~2000円以内」「醤油」とします。
レコメンドを聞いてみると、、、

厳選してくれた後に、、、
表示されました!
そのままホットペッパーのリンクへも飛べます。
石や嗟さんですね、本店は新潟にあります。新潟生姜醤油ラーメンが人気です。(当方学生時代は新潟に住んでおりまして、新潟ラーメンに目がないです)
新潟生姜醤油といえば、青島食堂さんも有名ですね、こちらも本店はもちろん新潟ですが、秋葉原でも食べられるので気になる方はぜひ!
ちなみに新潟には5大ラーメンなるものがありまして、
- 長岡生姜醤油ラーメン
- 燕背脂ラーメン
- 新潟濃厚味噌ラーメン
- 新潟あっさり醤油ラーメン
- 三条カレーれーめん
の5つです。
どれもめっちゃ美味いので、もしも新潟に行く機会があれば、ぜひ。
やばい、脱線しすぎた。
すみません戻ります。
課題
今回実装からデプロイまで一通り行う中で反省点というか、もっとこうしたいな、ということが多くあったので備忘録的にまとめます。
-
レコメンド精度の不足
今回、無償利用可能な HotPepper API を採用した結果、提供データ量そのものに限界があり、検索クエリに対する候補不足や店舗情報の網羅性に課題が残った。
APIリファレンスを確認しながら実装を進めたものの、認識の違いや考慮漏れもありえそう。
それを踏まえた上でも、より高精度なレコメンドを実現するためには、外部データソースの拡充や独自データ蓄積など、追加検討が必要。 -
データ永続化基盤(DB)の欠如による機能制約
インフラ設計を中心に扱う目的から今回は DB を持たない構成としたが、
お気に入り管理・認証/認可機能・履歴データ蓄積など、アプリケーションのユーザビリティ向上につながる機能が制限された。
機能拡張を見据えるのであれば、ストレージ設計が今後の検討課題。 -
個人開発レベルのインフラ設計に留まっている
負荷分散(LB / Application Gateway)、冗長化(可用性ゾーン)、ネットワークセキュリティなど、
商用環境で一般的に求められる非機能要件を十分に織り込めていない。
スケーラビリティと信頼性を担保できるアーキテクチャへ発展させる余地が大きい。
このあたりでしょうか。
今回は「個人利用を前提とした1日限りのミニマムアーキテクチャ」を意識して設計しましたが、
実務ではスケーリングやセキュリティ、ログ、監視を前提とした構成が不可欠であり、今後の学習を通じてその知見を深めていきたいと思います。
まとめ
いかがでしたでしょうか。
長くなってしまいましたが、今回はこんな感じで作成してみました。
こうやってイチから構築する機会は個人開発ならではなので、新たな学びが多くオススメです。ただ、実務レベルでの設計としては甘い部分が非常に多く散見されるので、精進します。
良かったらぜひ皆さんも何かしらのアプリケーションのデプロイまで一通りやってみてはいかがでしょうか。
(全部まとめようとすると骨が折れるのでやめておいた方がいいです)
ご覧いただきありがとうございました。


