すべてのピースが繋がる時。あなただけのAIが産声を上げる
こんにちは!「LINUX工房」管理人の「リナックス先生」です。
全8回にわたる「生成AI初心者向けAIサーバーサイドプログラミングの基礎」、いよいよ今回が最終回です!
これまで、AlmaLinux 9のセキュアな環境構築から始まり、プロンプトエンジニアリング、MariaDBを使った記憶の保持(セッション管理)、自社データを読み込ませるRAG、画像生成、そして悪意ある攻撃を防ぐ多段防御やCronバッチ処理と、プロの現場で使われるインフラ・サーバーサイドの技術を一つずつ積み上げてきました。
先生!第1回の頃は「APIって何?」状態だった僕ですが、今ではサーバーの裏側でどんなプログラムが動いているのか、完全にイメージできるようになりました!
でも、これまでのコードは別々のPHPファイルに分かれていますよね。これを1つの「チャットアプリ」として完成させるには、どうやって合体させればいいんですか?
よく頑張ったわね、コウ君。
最終回となる今回は、これまで作ってきた「記憶(DB)」「知識(RAG)」「画像生成」「防壁(レート制限)」の全モジュールを、1つの洗練されたWebアプリケーションとして統合するわ。
そして、別タブを開かずに同一画面でサクサク動くモダンなフロントエンド(SPA風)の実装と、インターネットへ公開(デプロイ)する前の「プロの最終インフラチェックリスト」を伝授するわよ!
本記事は、これまでの連載の集大成です。コードの統合からサーバーの最終チューニングまで、実践的なデプロイの全行程を網羅します。
📚 本連載のアーカイブ(全8回)
目次
1. システムアーキテクチャの最終形態
バラバラに存在していたPHPファイル群を、1つの「統合エンドポイント(assistant_api.php)」にまとめます。フロントエンドのブラウザ(HTML/JavaScript)は、常にこの単一のURLに対して非同期通信(Fetch API)を行います。
統合ロジック(ルーティング)の考え方
ユーザーからの入力テキスト($user_message)の内容に応じて、バックエンド側で自動的に処理を分岐させます。
- 画像生成モード: メッセージが
/imageというコマンドから始まる場合、第5回の画像生成モジュールを呼び出す。 - RAGモード: 通常のテキスト入力の場合、まず第4回のMariaDBフルテキスト検索(社内文書検索)を実行する。
- 記憶の結合: 検索結果と、第3回で実装した「過去の会話履歴」を結合し、巨大なプロンプトを作成する。
- API実行と防御: 第6回の「レート制限」をクリアした場合のみAI APIを実行し、結果をDBに保存してフロントエンドへ返す。
このMVC(Model-View-Controller)に近い設計パターンを取り入れることで、今後の機能拡張が非常に容易になります。
2. フロントエンド実装:画面遷移なしのモダンUI
Webブラウザ側のUI(ユーザーインターフェース)を作成します。
ここでは、ユーザー体験(UX)を向上させるための重要な要件として、「画像や動画などのメディアが生成された際、新しいタブ(target=”_blank”)を立ち上げず、必ず同一ブラウザ画面内のネイティブ機能のみでインライン表示させる」という仕様を厳格に実装します。これにより、スマートフォン等での操作性が劇的に向上します。
index.html (チャット画面)
公開ディレクトリ(/var/www/html/)に配置します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>独自AIアシスタント | LINUX工房</title>
<style>
body { font-family: sans-serif; background-color: #f4f7f6; margin: 0; padding: 20px; }
#chat-container { max-width: 800px; margin: auto; background: #fff; border-radius: 10px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); display: flex; flex-direction: column; height: 80vh; }
#chat-box { flex: 1; padding: 20px; overflow-y: auto; border-bottom: 1px solid #ddd; }
.message { margin-bottom: 15px; line-height: 1.6; }
.user-msg { text-align: right; color: #0056b3; font-weight: bold; }
.ai-msg { text-align: left; color: #333; background: #f0f0f0; padding: 10px; border-radius: 8px; display: inline-block; max-width: 80%; }
/* 生成された画像を同一画面内に美しく収めるCSS */
.ai-generated-image { max-width: 100%; height: auto; border-radius: 8px; margin-top: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
#input-area { display: flex; padding: 15px; background: #fafafa; border-radius: 0 0 10px 10px; }
#user-input { flex: 1; padding: 10px; border: 1px solid #ccc; border-radius: 5px; font-size: 16px; }
#send-btn { padding: 10px 20px; margin-left: 10px; border: none; background: #0078d4; color: white; border-radius: 5px; cursor: pointer; font-size: 16px; }
#send-btn:disabled { background: #999; }
</style>
</head>
<body>
<div id="chat-container">
<div id="chat-box"></div>
<div id="input-area">
<input type="text" id="user-input" placeholder="メッセージを入力... (画像生成は /image 猫 のように入力)">
<button id="send-btn">送信</button>
</div>
</div>
<script>
const chatBox = document.getElementById('chat-box');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
function appendMessage(role, content, isImage = false) {
const div = document.createElement('div');
div.className = 'message ' + (role === 'user' ? 'user-msg' : 'ai-msg');
if (isImage) {
// 新しいタブを開かず、同一画面内のimgタグとして表示する(要件への厳密な対応)
div.innerHTML = `<img src="${content}" alt="AI Generated Image" class="ai-generated-image">`;
} else {
div.textContent = content;
}
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
}
sendBtn.addEventListener('click', async () => {
const message = userInput.value.trim();
if (!message) return;
appendMessage('user', message);
userInput.value = '';
sendBtn.disabled = true;
appendMessage('ai', '思考中...');
try {
const response = await fetch('assistant_api.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message })
});
const data = await response.json();
// 「思考中...」のメッセージを削除
chatBox.lastChild.remove();
if (data.error) {
appendMessage('ai', 'エラー: ' + data.error);
} else if (data.image_url) {
// 画像モードでの応答(インライン表示フラグを立てる)
appendMessage('ai', data.image_url, true);
} else {
// 通常テキスト応答
appendMessage('ai', data.reply);
}
} catch (err) {
chatBox.lastChild.remove();
appendMessage('ai', '通信エラーが発生しました。');
} finally {
sendBtn.disabled = false;
}
});
// Enterキーで送信
userInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') sendBtn.click();
});
</script>
</body>
</html>
3. バックエンド統合:ルーティングとモジュール呼び出し
次に、すべての処理を受け止める assistant_api.php を作成します。コードが肥大化しないよう、これまでに作成した関数ファイルを require_once で読み込んで組み立てます。
<?php
// /var/www/html/assistant_api.php
session_start();
header('Content-Type: application/json; charset=UTF-8');
// 第3回:DB接続と履歴管理
require_once 'db_connect.php';
require_once 'chat_manager.php';
// 第6回:レート制限チェック関数(別ファイルに切り出していると仮定)
require_once 'rate_limiter.php';
$api_key = "あなたの_API_KEY";
$api_url_chat = "https://api.example.com/v1/generate";
$api_url_image = "https://api.example.com/v1/images/generations";
$inputJSON = file_get_contents('php://input');
$input = json_decode($inputJSON, TRUE);
$user_message = $input['message'] ?? '';
$client_ip = $_SERVER['REMOTE_ADDR'];
$session_id = session_id();
if (empty($user_message)) { echo json_encode(["error" => "メッセージが空です"]); exit; }
// --- 1. インフラ層保護:レート制限チェック ---
if (!checkRateLimit($pdo, $client_ip)) {
header("HTTP/1.1 429 Too Many Requests");
echo json_encode(["error" => "アクセスが集中しています。しばらく待ってから再度お試しください。"]);
exit;
}
// --- 2. ルーティング処理 ---
// メッセージが "/image " で始まる場合は画像生成モード
if (strpos($user_message, '/image ') === 0) {
$prompt = str_replace('/image ', '', $user_message);
// 第5回の画像生成ロジック(簡略化して記載)
$post_data = json_encode(["model" => "image-generation-model", "prompt" => $prompt, "response_format" => "url"]);
$ch = curl_init($api_url_image);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $post_data, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $api_key]]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200) {
$result = json_decode($response, true);
$remote_url = $result['data'][0]['url'];
$image_data = file_get_contents($remote_url);
$filename = uniqid('ai_gen_') . '.png';
$save_path = __DIR__ . '/ai_images/' . $filename;
if (file_put_contents($save_path, $image_data)) {
echo json_encode(["image_url" => "/ai_images/" . $filename]);
exit;
} else {
echo json_encode(["error" => "画像の保存に失敗しました。SELinux等の権限を確認してください。"]);
exit;
}
} else {
echo json_encode(["error" => "画像生成APIエラー ($http_code)"]);
exit;
}
}
// --- 3. 通常テキストモード(RAG + 記憶) ---
saveMessage($pdo, $session_id, 'user', $user_message);
// 第4回:RAG(MariaDBフルテキスト検索)
$stmt = $pdo->prepare("SELECT title, content FROM company_documents WHERE MATCH(content) AGAINST(? IN BOOLEAN MODE) LIMIT 2");
$stmt->execute([$user_message]);
$docs = $stmt->fetchAll();
$rag_context = "";
foreach ($docs as $doc) { $rag_context .= "【{$doc['title']}】\n{$doc['content']}\n\n"; }
// プロンプトの構築
$system_prompt = "あなたは有能なアシスタントです。以下の参考資料に情報があればそれを元に答え、なければ自身の知識で自然に会話を続けてください。\n【参考資料】\n" . $rag_context;
$history = getHistory($pdo, $session_id, 10);
$messages = [["role" => "system", "content" => $system_prompt]];
foreach ($history as $msg) { $messages[] = ["role" => $msg['role'], "content" => $msg['content']]; }
// 第3回・第6回:テキスト生成API呼び出し
$post_data = json_encode(["model" => "high-performance-model", "messages" => $messages, "temperature" => 0.7]);
$ch = curl_init($api_url_chat);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $post_data, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $api_key]]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200) {
$result = json_decode($response, true);
$ai_reply = $result['choices'][0]['message']['content'];
saveMessage($pdo, $session_id, 'assistant', $ai_reply);
echo json_encode(["reply" => $ai_reply]);
} else {
echo json_encode(["error" => "API通信エラーが発生しました。"]);
}
?>
4. デプロイ前夜:インフラエンジニアの最終チェックリスト
コードが完成し、ブラウザで動作確認ができたら、いよいよ本番公開(デプロイ)です。
しかし、AlmaLinux 9環境で公開ボタンを押す前に、プロとして必ずチェックすべきインフラ項目のリストがあります。
✅ SELinuxのブール値確認
Apache(PHP)が外部のAI APIと通信し、かつMariaDBと連携するための許可が出ているか、最終確認します。
getsebool httpd_can_network_connect getsebool httpd_can_network_connect_db # どちらも "on" になっていることを確認
✅ 画像ディレクトリの書き込み権限とコンテキスト
画像保存用ディレクトリに対して、Apache権限での書き込みが永続的に許可されているか確認します。
絵で見てわかるLinuxカーネルの仕組み 【電子書籍】[ 市川 正美 ] 価格:3058円 |
ls -ldZ /var/www/html/ai_images/ # "httpd_sys_rw_content_t" が付与されていることを確認
✅ Firewalldのポート開放
Web公開に必要なHTTP(80)とHTTPS(443)が開いているか確認します。データベース(3306)はローカル内通信のみなので開けてはいけません。
sudo firewall-cmd --list-ports # 開いていなければ以下を実行 sudo firewall-cmd --add-service=http --permanent sudo firewall-cmd --add-service=https --permanent sudo firewall-cmd --reload
✅ Fail2banの起動確認
第6回で設定したAPI保護用のjailが正しく動いているか確認します。これが落ちているとAPI破産のリスクが跳ね上がります。
sudo fail2ban-client status ai-api
5. SSL証明書の罠:Let’s EncryptとDNS認証の落とし穴
最後に、本番公開に必須の「HTTPS化(SSL証明書の導入)」について触れておきます。
通常、Certbotを使用してLet’s Encryptの無料証明書を発行しますが、ワイルドカード証明書や特定の環境において「DNS認証(TXTレコードの追加)」を手動で求められるケースがあります。
⚠️ 現場のリアルなトラブル事例:DNS認証のタイムラグ
ドメイン(例:xxx.com)の管理画面から指定されたTXTレコードを登録した直後に、Certbotのコンソールで「Enterを押して続行」してしまうと、「Incorrect TXT record found(不正なTXTレコードが見つかりました)」というエラーで認証に失敗することが多々あります。
これは、DNSサーバーの設定変更が世界中に伝播(プロパゲーション)するまでに数分〜数十分のタイムラグがあるためです。
TXTレコードを追加した後は、すぐにEnterを押さず、別のターミナルから dig -t TXT _acme-challenge.あなたのドメイン コマンドを実行し、値が正しく反映されていることを確認してから続行するのがプロの鉄則です。
SSL証明書が無事に発行され、Apacheの ssl.conf に設定を記述すれば、あなたのAI Webアプリは世界中から安全にアクセスできる状態となります。
総まとめ:終わりのない最適化の旅へ
長きにわたる連載、本当にお疲れ様でした。
第1回から今日に至るまで、あなたは単なる「APIの叩き方」ではなく、Linux OSの挙動、セキュリティ、データベース設計、そして自動化という、地に足の着いたフルスタックなインフラ・サーバーサイド技術を習得しました。
生成AIのモデルは数ヶ月単位で進化し、APIの仕様も変わっていくでしょう。
しかし、今回AlmaLinux 9上で構築した「堅牢なアーキテクチャの基礎」は、AIモデルがどれほど進化しても決して色褪せることのない、エンジニアとしての普遍的な財産です。
バッチ処理のエラーログ(/var/log/ai_batch.log)を監視し、MariaDBのクエリをチューニングし、より洗練されたプロンプトを追求し続ける……。システムが稼働し始めた今日ここからが、本当の意味での「エンジニアリング」の始まりです。
あなたが構築したAIシステムが、多くのユーザーの役に立つことを心から願っています。
それでは、また新しい技術の最前線でお会いしましょう!
LINUX工房の「リナックス先生」でした。
▼ AI Webアプリを世界に公開しよう ▼
本番運用に耐えうる安定基盤!
「ビジネス向け高スペックVPS」
フルスタックAIエンジニアへ
「高待遇ITエンジニア転職」

コメント