プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

QNAP NAS 上の複数の docker コンテナに外部から https でアクセスできるようにする

いま、 Roudcube を https 接続で使っています。

最近いろんなこと(Pleasanter だとか RAG だとか)を docker で試しているんですが、こういう試しに作ったサイトも、デモなどのため外部からアクセスできるようにしたいと感じてます。いままでは、とりあえず http 接続で乗り切ったのですが、やっぱり https でつなぎたいなと。

で、 Roundcube コンテナを https 化するのに、 https-portal というコンテナを利用しているのですが、これは内部で Let's Encript を使っているので、 80 番ポートにアクセスできる必要があります。

そうすると、どうやって NAS の1つの 80 番ポートで複数の docker コンテナへの https リクエストをさばけばいいんだろうか?となります。最初は、無理なのかな?と思ったのですが、 https-portal の Github の説明を見ていると、

とあるので、同じポート番号であってもマルチドメインに対応して振り分けをやってくれるようです。

ということで、この機能をうまく自分の環境で使えないかと試したので、そのあたりのことをメモっておきます。

なお、 docker は QNAP の NAS (TS-262) 上の Container Station のものを利用しています。

docker network について

https-portal 自体はマルチドメインに対応していそうですが、ひとつの docker アプリケーション(ここでは、 docker-compose.yml で生成される、通常は複数の docker コンテナからなる一連のコンテナスタックをアプリケーションと呼んでいます)に全然関係ないコンテナを詰め込むのは避けたいところです。

どうしたものかと思いつつ、 docker compose は1つのアプリケーション内であれば、コンテナ間で通信ができることから、 docker の network について理解すればヒントがありそうかな?となりました。

いろいろと読んでみて自分なりに理解したつもりなのですが、まあ、中途半端に書くよりも詳しいことはググれば(ChatGPT にでも聞けば)いろいろと情報が得られるので、ここでは必要そうなことをまとめておくと

  • docker compose でアプリケーションを作成すると、そのアプリケーション用のネットワークが自動的に作られます
  • この network 内ではコンテナ間で通信することができます(コンテナは docker-compose.yml のサービス名で指定できます)
  • 一つのアプリケーションに複数の network を持たせることができます(また、サービス単位で利用する network を指定することもできます)
  • 同じ network につながっているコンテナ同士は通信が可能です

ということでした。

つまり、どういうことかというと

  • https-portal 用の network を用意
  • https 化したいアプリケーション(コンテナ)に https-portal 用の network を追加する

としておけば、 https-portal からの通信がアプリケーション側に届くということになり、さきほどのマルチドメインを実現できそうです。いろんなことできるんですね docker 。

なお、 network については、下記の記事などを参考にしました。

準備

では、早速やってみようと思います。

いま、 https-portal を動かしている Roundcube アプリケーションの docker-compose.yml を確認すると、

version: '3'

services:
  https-portal:
    image: steveltn/https-portal:1.23.1
    ports:
      - 'xxxx:80'
      - 'yyyy:443'
    restart: always
    volumes:
      - rcmail_ssl_certs:/var/lib/https-portal
    environment:
      DOMAINS: 'xxxx.yyyy.zzzz -> http://roundcubemail:80'
      STAGE: 'production'
      # FORCE_RENEW: 'true'
      CLIENT_MAX_BODY_SIZE: 0
  
  roundcubemail:
    image: myroundcube:0.3
    container_name: myrc
#    restart: unless-stopped
    volumes:
      - rcmail_www:/var/www/html
      - rcmail_sqlite:/var/roundcube/db
#    ports:
#      - 9001:80
    environment:
      - ROUNDCUBEMAIL_DB_TYPE=sqlite
      - ROUNDCUBEMAIL_SKIN=elastic
      - ROUNDCUBEMAIL_DEFAULT_HOST=ssl://サーバー名
      - ROUNDCUBEMAIL_DEFAULT_PORT=993
      - ROUNDCUBEMAIL_SMTP_SERVER=ssl://サーバー名
      - ROUNDCUBEMAIL_SMTP_PORT=465
      - ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=30M
      - ROUNDCUBEMAIL_PLUGINS=archive,zipdownload,attachment_reminder

volumes:
    rcmail_ssl_certs:
        external: true
    rcmail_www:
        external: true
    rcmail_sqlite:
        external: true

となっています。まずは、これを https-portal の部分と Roundcube の部分に分けたいと思います。

ボリュームをコピー

さて、上記の docker-compose.yml をみればわかりますが、 https-portal に関連する証明書等の保存先をボリュームにしています。rcmail_ssl_certs というやつですね。ですが、複数のアプリケーションで利用しようとすると、名前の先頭に rcmail_ とついているので、ちょっといけてません。

ということで、このボリュームをリネームしたいところなんですが、 docker はボリュームのリネームができないので、

【図解付き】Docker Data Volumeのバックアップ・リストア方法 #MySQL - Qiita

などを参考にして、新しいボリュームを作成し、既存のボリュームをこれにコピーすることにします。

一応、作業前に、このボリューム(https-portal が使っているボリューム)を含んでいる Roundcube のアプリケーションを停止しておきます。次に、コピー先となるボリュームを作成します。

[ユーザ名@NAS名 application]$ docker volume create ssl_certs
ssl_certs
[ユーザ名@NAS名 application]$ docker volume ls
DRIVER    VOLUME NAME
local     21ce479cdc9ce0e65fffc4a071bf46312c6c089cb54c0cf964344276b6630b25
(略)
local     ssl_certs
[ユーザ名@NAS名 application]$ 

既存のボリュームの内容をバックアップします。

[ユーザ名@NAS名 tmp]$ docker run --rm --volumes-from rcmail-https-portal-1 -v `pwd`:/backup busybox tar cvf /backup/ssl_certs_backup.tar /var/lib/https-portal
tar: removing leading '/' from member names
var/lib/https-portal/
(略)
var/lib/https-portal/xxxx.yyyy.zzzz/production/signed.crt
[ユーザ名@NAS名 tmp]$ 

ここで、 rcmail-https-portal-1 というのは https-portal コンテナの名前です。これを実行した結果、作業したディレクトリに tar ファイルができました。

[ユーザ名@NAS名 tmp]$ ls
shared-net/  ssl_certs_backup.tar
[ユーザ名@NAS名 tmp]$ 

この tar ファイルをさきほど作成した新しいボリュームにコピーします。

[ユーザ名@NAS名 tmp]$ docker run --rm -v ssl_certs:/var/lib/https-portal -v `pwd`:/backup busybox tar xvf /backup/ssl_certs_backup.tar
var/lib/https-portal/
(略)
var/lib/https-portal/xxxx.yyyy.zzzz/production/signed.crt
[ユーザ名@NAS名 tmp]$ 

ここの -v ssl_certs:/var/lib/https-portal で新しいボリューム ssl_certs を busybox の一時コンテナの /var/lib/https-portal に割り当てて、 tar xvf で解凍することで、このフォルダ(ひいては新しいボリューム)にファイルを書き込んでいるということです。うまくいったっぽいですね。

https-portal 用の network を作成

次の準備として、 https-portal で利用する network をコマンドラインから作っておきます(QNAP の Container Station には network 用のGUI がないため)。

[ユーザ名@NAS名 application]$ docker network create https-portal-nw
d5bdd0e55eed2468eaff67a216ba98197692c075a44bdbea34eadcfad13c3be5
[ユーザ名@NAS名 application]$ 

これで準備は完了です。

既存の docker compose を2つのアプリケーションに分離

さて、準備ができたら、いよいよ既存の docker-compose.yml を https-portal と Roundcube の2つに分離します。

https-portal 用の docker-compose.yml は

services:
  https-portal:
    image: steveltn/https-portal:1.25.2
    ports:
      - 'xxxx:80'
      - 'yyyy:443'
    restart: always
    volumes:
      - ssl_certs:/var/lib/https-portal
    environment:
      DOMAINS: 'xxxx.yyyy.zzzz -> http://roundcubemail:80'
      #STAGE: 'production'
      # FORCE_RENEW: 'true'
      CLIENT_MAX_BODY_SIZE: 0

volumes:
  ssl_certs:
    external: true

networks:
  default:
    name: https-portal-nw
    external: true

のように、トップレベルの networks を定義して、デフォルトのネットワークとして先ほど作成した https-portal-nw を指定しておきます。

一方、 Roundcube 用の docker-compose.yml は

services:
  roundcubemail:
    image: myroundcube:0.3
    container_name: myrc
#    restart: unless-stopped
    volumes:
      - rcmail_www:/var/www/html
      - rcmail_sqlite:/var/roundcube/db
#    ports:
#      - 9001:80
    environment:
      - ROUNDCUBEMAIL_DB_TYPE=sqlite
      - ROUNDCUBEMAIL_SKIN=elastic
      - ROUNDCUBEMAIL_DEFAULT_HOST=ssl://サーバー名
      - ROUNDCUBEMAIL_DEFAULT_PORT=993
      - ROUNDCUBEMAIL_SMTP_SERVER=ssl://サーバー名
      - ROUNDCUBEMAIL_SMTP_PORT=465
      - ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=30M
      - ROUNDCUBEMAIL_PLUGINS=archive,zipdownload,attachment_reminder
    networks:
      - default
      - https-portal-nw
      
volumes:
    rcmail_www:
        external: true
    rcmail_sqlite:
        external: true

networks:
  https-portal-nw:
    external: true

のように、 既存のものと比べると https-portal のサービス定義を削除して、トップレベルの networks を追加して、それを Roundcube のサービス定義でも参照するようにしています。

起動テスト

準備ができたら、一度動かしてみます。まず最初は Roundcube から起動します。

QNAP の Container Station で停止済みの Roundcube のアプリケーションを選択し、歯車アイコンから『再作成』を選択します。

docker-compose.yml が表示されるので、上記の修正したものに書き換えて『更新』を押します。問題がなければ無事に Roundcube が立ち上がります。

次は、 https-portal を起動します。QNAP の Container Station から『アプリケーション』を選択し『作成』を選びます。

アプリケーション名を入力後、 docker-compose.yml の内容を記入します。最後に『作成』ボタンを押します。ログを見て、特にエラー等が出力されていなければ OK です。

実際にブラウザで Roundcube に https で接続できることも確認しておきます。

(参考)ちょっと問題があった例

もし、最初に https-portal から起動すると、サービス名 roundcubemail がないということで、

という感じのエラーを吐き出して、起動に失敗します。今回の場合 restart: always とあるため、なんども再起動を繰り返してしまいます。本来は、同じネットワーク内にサービスがあれば名前が解決するはずなんですが、まだ、 Roundcube を起動していないための現象ですね。

このとき、急いで Roundcube も起動すると、無事に https-portal のエラーもなくなり、起動できるようになりました。一応動きましたが、ちょっと怖いですよね。

また、今回はコマンドラインで作成した https-portal-nw を https-portal の docker-compose.yml に含ませることもできます。

services:
  https-portal:
    image: steveltn/https-portal:1.25.2
(略)
networks:
  default:
    name: https-portal-nw

こんな感じに external の指定をなくした形になります。

この場合は、 Roundcube 側に https-portal-nw が external として与えられているため、必ず https-portal から起動する必要があります。そうなると、間違いなく上記で書いた現象になるので、個人的にはこの書き方はちょっと微妙な感じがしてしまいますね。

追加のコンテナを起動して https-portal へ追加

次に、追加のコンテナを起動します。追加のコンテナは、こちらで試した Pleasanter のものとします。

いま使っている docker-compose.yml に networks 設定を追加します。

services:
  db:
    container_name: postgres
    image: postgres:16
(略)
    volumes:
      - type: volume
        source: pg_data
        target: /var/lib/postgresql/data
  pleasanter:
    container_name: pleasanter
    image: implem/pleasanter:1.4.13.0
(略)
    networks:
      - default
      - https-portal-nw

volumes:
  pg_data:
    name: ${COMPOSE_PROJECT_NAME:-default}_pg_data_volume

networks:
  https-portal-nw:
    external: true

また、 postgres コンテナとの通信で使うため、 Pleasanter コンテナではアプリケーションのデフォルトのネットワークも利用するように明示的に指定しています。

Pleasanter の場合は QNAP の Container Station から再作成するだけではできなくて、こちらの記事にあるように

  1. Container Station から再作成
  2. 必要なファイルをコピー
  3. codedefiner を実行
  4. pleasanter を起動

として再作成します。なお、最後の2つの手順はこんな感じのコマンドラインになります。

[ユーザ名@NAS名 pleasanter-test]$ docker compose -f docker-compose.yml -f docker-compose.codedefiner.yml run --rm codedefiner
(略)
[ユーザ名@NAS名 pleasanter-test]$ docker compose up -d pleasanter

これで、 Pleasanter が無事に起動できているのを確認したら、次は https-portal コンテナを再作成します。このとき、https-portal のドキュメントに従って、マルチドメインに対応できるようにしておきます。

services:
  https-portal:
    image: steveltn/https-portal:1.25.2
    ports:
      - 'xxxx:80'
      - '443:443'
    restart: always
    volumes:
      - ssl_certs:/var/lib/https-portal
    environment:
      DOMAINS: >
        xxxx.yyyy.zzzz -> http://roundcubemail:80,
        aaaa.bbbb.cccc -> http://pleasanter:50001,
      STAGE: 'production'
(略)
networks:
  default:
    name: https-portal-nw
    external: true

マルチドメインの設定のほかにも、https のポート番号を 443 にしました(理由は後述します)。

テスト

さて、これで問題なく接続できるかテストします。ブラウザから https でそれぞれのドメイン名を指定して接続すると問題なく表示されました。

また、 https-portal のログを見てみると、

のように出ており、どちらのドメインも正しく認識できているようです。うまくできているようですね。

(参考) https-portal のホスト側のポート番号を 443 にした理由

さて、上記でマルチドメインに対応する際、 https-portal のホスト側のポート番号を 443 にしました。 実はこれ、今回追加した Pleasanter の場合、ホスト側のポート番号が 443 以外の場合(仮に 4444 としておきます)、うまく接続ないという問題が起きていました。

調べてみると、 Pleasanter の場合、 https://サーバー名 としてアクセスすると、ログイン画面が表示されるのですが、この際 302 リダイレクトでログイン画面の呼び出しを行っています。

開発者ツールで確認するとわかるのですが、このとき Location として

http://サーバー名/users/login?RerunUrl=%2F

のような http の絶対 URL が返ってきます。

すると、ブラウザはこれにアクセスしようとするのですが、 https-portal では日本語訳の説明

にあるように、 http 接続を https 接続にリダイレクトするため、https での接続になります。

この時、https-portal は https 接続は 443 として扱うので、ブラウザに返される Location は

https://サーバー名/users/login?RerunUrl=%2F

になり、ブラウザからは https のデフォルトのポート番号 443 でアクセスすることになります。すると、 https-portal で想定しているホスト側のポート番号 4444 ではアクセスできないため、接続ができなくなってしまいます。

具体例

このやり取りが、ブラウザの開発者ツールだと若干わかりにくかったので、コンソールから wget で実際の応答を確認してみました。

https で接続するポート番号をここでは 4444 としています。

mor@DESKTOP-DE7IL4F:~$ wget --server-response https://pleasanter.aaaa.bbbb.cccc:4444
--2025-06-14 22:50:05--  https://pleasanter.aaaa.bbbb.cccc:4444/
pleasanter.aaaa.bbbb.cccc (pleasanter.aaaa.bbbb.cccc) をDNSに問いあわせています... IPアドレス
pleasanter.aaaa.bbbb.cccc (pleasanter.aaaa.bbbb.cccc)|IPアドレス|:4444 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています...
  HTTP/1.1 302 Found
  Server: nginx
  Date: Sat, 14 Jun 2025 13:50:05 GMT
  Content-Length: 0
  Connection: keep-alive
  Location: http://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F
  X-Frame-Options: SAMEORIGIN
  X-XSS-Protection: 1; mode=block
  X-Content-Type-Options: nosniff
場所: http://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F [続く]

となり、 302 リダイレクトで Location に http:// で始まる絶対 URL が返ってきていることがわかります。続いて

--2025-06-14 22:50:06--  http://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F
pleasanter.aaaa.bbbb.cccc (pleasanter.aaaa.bbbb.cccc)|IPアドレス|:80 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています...
  HTTP/1.1 301 Moved Permanently
  Server: nginx
  Date: Sat, 14 Jun 2025 13:50:06 GMT
  Content-Type: text/html
  Content-Length: 162
  Connection: keep-alive
  Location: https://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F
場所: https://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F [続く]

となっていることがわかります。 http で接続を試みるのですが https にリダイレクトされています。また、その際の Location は

Location: https://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F

のように、 スキーマは https ですがポート番号の記載がないことがわかります。最初に書いた https 接続時のポート番号 4444 は docker のホスト側の値なので https-portal は知らないと考えていいのでしょうね。

すると、

--2025-06-14 22:50:06--  https://pleasanter.aaaa.bbbb.cccc/users/login?ReturnUrl=%2F
pleasanter.aaaa.bbbb.cccc (pleasanter.aaaa.bbbb.cccc)|IPアドレス|:443 に接続しています... 失敗しました: 接続を拒否されました.
pleasanter.aaaa.bbbb.cccc (pleasanter.aaaa.bbbb.cccc) をDNSに問いあわせています... IPアドレス
pleasanter.aaaa.bbbb.cccc (pleasanter.aaaa.bbbb.cccc)|IPアドレス|:443 に接続しています... 失敗しました: 接続を拒否されました.
mor@DESKTOP-DE7IL4F:~$

のように、ポート番号が違うため接続できなくなってしまっています。

最初に利用していた Roundcube でもリダイレクト処理はあったのですが、改めて調べてみると Location の書き方が相対 URL になっていたためか、特に問題が起きていませんでした。

https-portal の機能で、ホスト側のポート番号を返せるようなものがあるなら、公開するポート番号を自由に決められるのですが、とりあえずは 443 を使った形になりそうですね。

まとめ

若干苦労もしましたが、なんとか NAS 内のコンテナで複数の公開サーバーを立てた場合でも、 https 経由で接続できるようになりました。

この方法だと、公開サーバーが増えて、アプリケーションを追加する場合でも、 https-portal の docker-compose.yml を書き換えて再起動(再作成)すれば、既存のアプリケーションには手をつけなくてもよいのでなかなか便利そうです。

RAG をチャットボットに仕立てる (2/2):チャットボットの実装

次は、チャットボット部分の実装です。チャットボットもいろいろとありますが、今回は、 Wordpress のプラグインとして動くものにしようと思います。

なお、以下の関連記事がありますので、こちらもよろしく。

チャットボット作成

チャットボットの作り方も ChatGPT に聞いてみました。教えてくれた方法は、ショートコードで実装する方法です。

必要なファイルの作成

まずは、 chatbot-plugin.php というチャット用の機能および UI を Wordpress に追加するコードです。

<?php
/*
Plugin Name: RAG Chatbot Plugin
Description: チャットUIをWordPressに埋め込む
Version: 1.0
Author: あなたの名前
*/

function chatbot_enqueue_scripts() {
    wp_enqueue_style('chatbot-style', plugin_dir_url(__FILE__) . 'chat-style.css');
    wp_enqueue_script('chatbot-js', plugin_dir_url(__FILE__) . 'chatbot.js', array(), null, true);
}
add_action('wp_enqueue_scripts', 'chatbot_enqueue_scripts');

function chatbot_display() {
    ob_start();
    ?>
    <div id="chatbot-container">
        <div id="chat-window"></div>
        <input type="text" id="chat-input" placeholder="質問を入力..." />
        <button id="chat-send">送信</button>
    </div>
    <?php
    return ob_get_clean();
}
add_shortcode('chatbot', 'chatbot_display');

Wordpress が用意している API をいくつか呼び出して、必要な機能の登録を行っています。また、先頭部分のコメントは、 Wordpress でプラグインとして登録する際に必要になるとのことです。

次は、この UI に対する操作を行ったときに呼ばれる、 FastAPI コンテナと通信するための JS コードになります。

document.addEventListener("DOMContentLoaded", function () {
    const input = document.getElementById("chat-input");
    const sendButton = document.getElementById("chat-send");
    const chatWindow = document.getElementById("chat-window");

    function appendMessage(message, references, sender) {
        const msgDiv = document.createElement("div");
        msgDiv.className = `chat-message ${sender}`;
        msgDiv.textContent = message;
        // 参照がある場合はリンクを追加
        if (references && references.length > 0) {
            const refDiv = document.createElement("div");
            refDiv.className = "chat-references";
            refDiv.appendChild(document.createTextNode("参考情報: "));
            refDiv.appendChild(document.createElement("br"));
            for (let i = 0; i < references.length; i++) {
                const refLink = document.createElement("a");
                refLink.href = references[i].url;
                refLink.textContent = references[i].title;
                refLink.target = "_blank";
                refDiv.appendChild(refLink);
                if (i < references.length - 1) {
                    //refDiv.appendChild(document.createTextNode(", "));
                    refDiv.appendChild(document.createElement("br"));
                }
            }
            msgDiv.appendChild(refDiv);
        }
        chatWindow.appendChild(msgDiv);
        chatWindow.scrollTop = chatWindow.scrollHeight;
    }

    sendButton.addEventListener("click", async () => {
        const text = input.value.trim();
        if (!text) return;

        appendMessage(text, null, "user");
        input.value = "";

        try {
            // JS はブラウザ上で実行されるので、ブラウザから見た FastAPI サーバーの URL になる
            const response = await fetch("http://nas01:8000/query/", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ question: text })
            });

            const data = await response.json();
            appendMessage(data.answer, data.references, "bot");
        } catch (error) {
            appendMessage("エラーが発生しました。", "bot");
            console.error(error);
        }
    });
});

appendMessage 関数で、RAG からの結果に参考URLが含まれていた場合の処理を書いてるので、ちょっと長ったらしくなってますが、やってることは難しくありません。

あとは、この UI の CSS ファイルです。

#chatbot-container {
    width: 300px;
    border: 1px solid #ccc;
    padding: 10px;
    font-family: sans-serif;
}
#chat-window {
    height: 200px;
    overflow-y: auto;
    border: 1px solid #eee;
    margin-bottom: 10px;
    padding: 5px;
    background: #fafafa;
}
.chat-message {
    margin-bottom: 5px;
}
.user { text-align: right; color: blue; }
.bot { text-align: left; color: green; }

chat-message と共に設定するクラスの値(user, bot)に応じて、テキストの色と位置を変えています。

Wordpress コンテナにコピー

上記のファイルを作成したら、 chatbot-plugin というフォルダにまとめておきます。これをフォルダごと、Wordpress コンテナにコピーしてやります。

[user1@nas01 rag-test]$ docker cp ./chatbot-plugin/ rag-test-wordpress-1:/var/www/html/wp-content/plugins/
Successfully copied 6.14kB to rag-test-wordpress-1:/var/www/html/wp-content/plugins/
[user1@nas01 rag-test]$ 

FastAPI の CORS 設定を変更

あと、 FastAPI 側で Wordpress のサーバーからの通信を許可するために、 CORS 設定を追加してやります。

app = FastAPI(lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    # allow_origins=["*"],  # Allow all origins for CORS
    allow_origins=[
        "http://nas01:8001"
    ],  # Wordpress の URL, コンテナ外からのアクセスの形式
    allow_credentials=True,
    allow_methods=["*"],  # Allow all methods
    allow_headers=["*"],  # Allow all headers
)

ここでのホスト名(オリジン)は docker-compose.yml のサービス名ではなく、docker 外からみた FastAPI サーバーのURLになります。

FastAPI の内容が変わってしまっているので、一度ビルドしなおします。

[user1@nas01 rag-test]$ docker compose stop fastapi
[+] Stopping 1/1
 ✔ Container fastapi  Stopped
[user1@nas01 rag-test]$ docker compose build --no-cache fastapi
[+] Building 130.2s (11/11) FINISHED
(略)
[user1@nas01 rag-test]$ docker compose up -d --no-deps --force-recreate fastapi
[+] Running 1/1
 ✔ Container fastapi  Started
[user1@nas01 rag-test]$ 

チャットボットの呼び出し部分を追加

最後に、チャットボットを呼び出せるようにします。Wordpress のサイトへアクセスして、管理画面にログインして『プラグイン』を表示します。

このように追加したショートコード(プラグイン)が表示されるので、『有効化』をクリックします。

Wordpress の固定ページ一覧を開くと、

『サンプルページ』というのがあるので、これを編集して、一番下に追加したチャットボット用のショートコードを埋め込みます。

ショートコードの名前は、チャットボットの登録処理 add_shortcode('chatbot', の第1引数になります。

編集後保存して、サンプルページを表示すると、

こんな感じに、入力欄が表示されていれば OK です。

テスト

さて、テストします。チャットボットの画面で、質問を入力して『送信』ボタンを押すと、

こんな感じに回答と参考情報があればそれへのリンクが表示されます。問題なさそうですね。

失敗例

実は、最初から成功したわけではなく、最初は

こんな感じになってしまいました。このとき、ブラウザは Forefox を使っていたのですが、開発者ツールを見ると、

こんな感じのエラーになってました。開発者ツールのネットワークタブをみても、

という感じで、 OPTIONS で failed となっているので、てっきり CORS 関係の設定かな?と思い、 FastAPI 側の CORS 設定の書き方をいろいろと試してみたのですが、なかなか解消しません。

そのうち、(何がきっかけか覚えていませんが)ブラウザの CORS を無効にする方法が載ってる記事にあたりました。

ブラウザでCORSを無効化する方法 #Security - Qiita

言われてみれば、たしか CORS って同一オリジンポリシーを保証するためにブラウザで検証されているんだよな?と思い出し、改めて、 CORS を無効にしたら問題なく通信できるはずだ、と考えて試すことにしました。

ということで、このとき使っていた Firefoxの CORS を無効にして

試します。でも、同じエラーが返ってきます。

開発者ツールのネットワークタブをみると OPTIONS のリクエストはなくなっているので、 CORS が無効になっているのは間違いなさそうです。

ん?これはなんか変だな? CORS に関係なくエラーになっているということか?と思ったので、 Edge で試すと、

『ERR_NAME_NOT_RESOLVED』とエラーがでてきます。名前が解決できない?

・・・あ、よくよく考えると、JaaScriptなんでこの処理はブラウザで実行されています。ということは、ホスト名もブラウザ上で解決できないといけません。

実は、このエラーが起きていた当初は、この部分のリクエストを

        try {
            const response = await fetch("http://fastapi:8000/query", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ question: text })
            });

としていました。これですね。

この fastapi というのは、 docker compose でのサービス名です。 Wordpress コンテナから FastAPI コンテナへの通信と思ってしまったので、同じ docker network 上にあるということで、 docker-compose.yml のサービス名を指定する形にしてしまっていました(コンテナ間で通信できるのが気になったので、 Wordpress のコンテナに入って curl http://fastapi:8000 のようにして、応答返ってくるのも確認してました)。

でも、JS が実行されるブラウザ上では、docker compose のサービス名で指定されてもわかんないですよね、そりゃ。

ということで、この部分を FastAPI サーバーを指すように変更して試すと、はい、うまくいきました。

これに気づいてから、ネットを調べると、やっぱり同じようなところではまる方もいるんですね。

ruc-4130.hatenablog.com

最初にこの記事見つけられてれば、苦労しなかったのにな・・・

フローティングチャットウィンドウ

これでも一応はチャットボットなんですが、Web サイトによくある画面の右下に出てきて、クリックするとウィンドウが開いてチャットができるやつも作ってみたいと思います。

必要なファイルの作成

FastAPI 側の設定(CORS)とかはさきほどと同じでよいので、 Wordpress 側だけ追加のコードを用意します。

<?php
/*
Plugin Name: RAG Floating Chatbot
Description: 全画面で表示されるフローティングチャットボット
Version: 1.0
Author: あなたの名前
*/

function rag_chat_assets() {
    wp_enqueue_style('rag-chat-style', plugin_dir_url(__FILE__) . 'assets/chatbot.css');
    wp_enqueue_script('rag-chat-js', plugin_dir_url(__FILE__) . 'assets/chatbot.js', array(), null, true);
}
add_action('wp_enqueue_scripts', 'rag_chat_assets');

function rag_chat_html() {
    ?>
    <div id="rag-chat-icon">&#128172;</div>
    <div id="rag-chat-box" style="display:none;">
        <div id="rag-chat-header">チャットボット <span id="rag-chat-close">×</span></div>
        <div id="rag-chat-messages"></div>
        <div id="rag-chat-input-area">
            <input type="text" id="rag-chat-input" placeholder="質問を入力..." />
            <button id="rag-chat-send">送信</button>
        </div>
    </div>
    <?php
}
add_action('wp_footer', 'rag_chat_html');

UI の開閉や FastAPI サーバーと通信を行うための JS ファイルを用意します。

document.addEventListener("DOMContentLoaded", function () {
    const icon = document.getElementById("rag-chat-icon");
    const box = document.getElementById("rag-chat-box");
    const close = document.getElementById("rag-chat-close");
    const send = document.getElementById("rag-chat-send");
    const input = document.getElementById("rag-chat-input");
    const messages = document.getElementById("rag-chat-messages");

    icon.addEventListener("click", () => {
        box.style.display = "flex";
        icon.style.display = "none";
    });

    close.addEventListener("click", () => {
        box.style.display = "none";
        icon.style.display = "block";
    });

    function appendMessage(message, references, sender) {
        const msgDiv = document.createElement("div");
        msgDiv.className = `rag-msg ${sender}`;
        msgDiv.textContent = message;
        // 参照がある場合はリンクを追加
        if (references && references.length > 0) {
            const refDiv = document.createElement("div");
            refDiv.className = "chat-references";
            refDiv.appendChild(document.createTextNode("参考情報: "));
            refDiv.appendChild(document.createElement("br"));
            for (let i = 0; i < references.length; i++) {
                const refLink = document.createElement("a");
                refLink.href = references[i].url;
                refLink.textContent = references[i].title;
                refLink.target = "_blank";
                refDiv.appendChild(refLink);
                if (i < references.length - 1) {
                    //refDiv.appendChild(document.createTextNode(", "));
                    refDiv.appendChild(document.createElement("br"));
                }
            }
            msgDiv.appendChild(refDiv);
        }
        messages.appendChild(msgDiv);
        messages.scrollTop = messages.scrollHeight;
    }

    send.addEventListener("click", async () => {
        const text = input.value.trim();
        if (!text) return;

        appendMessage(text, null, "user");
        input.value = "";

        try {
            const res = await fetch("http://nas01:8000/query/", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ question: text })
            });
            const data = await res.json();
            appendMessage(data.answer, data.references, "bot");
        } catch (err) {
            appendMessage("エラーが発生しました。", "bot");
            console.error(err);
        }
    });

    input.addEventListener("keydown", (e) => {
        if (e.key === "Enter") send.click();
    });
});

FastAPI の呼び出し部分は最初のやつと同じです。それ以外はアイコンをクリックして、チャットウィンドウを開いたり反対に閉じたりという処理ですね。

最後に、 CSS ファイルです。

#rag-chat-icon {
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: #007bff;
    color: white;
    width: 60px;
    height: 60px;
    font-size: 30px;
    border-radius: 50%;
    text-align: center;
    line-height: 60px;
    cursor: pointer;
    z-index: 9999;
}

#rag-chat-box {
    position: fixed;
    bottom: 90px;
    right: 20px;
    width: 300px;
    height: 400px;
    background: white;
    border: 1px solid #ccc;
    box-shadow: 0 0 10px rgba(0,0,0,0.3);
    z-index: 9999;
    display: flex;
    flex-direction: column;
}

#rag-chat-header {
    background: #007bff;
    color: white;
    padding: 10px;
    font-weight: bold;
    display: flex;
    justify-content: space-between;
}

#rag-chat-close {
    cursor: pointer;
}

#rag-chat-messages {
    flex: 1;
    overflow-y: auto;
    padding: 10px;
    background: #f9f9f9;
}

#rag-chat-input-area {
    display: flex;
    padding: 10px;
    border-top: 1px solid #ccc;
}

#rag-chat-input {
    flex: 1;
    padding: 5px;
}

#rag-chat-send {
    padding: 5px 10px;
    margin-left: 5px;
}

.rag-msg.user {
    text-align: right;
    margin-bottom: 5px;
    color: #007bff;
}

.rag-msg.bot {
    text-align: left;
    margin-bottom: 5px;
    color: #28a745;
}

Wordpress コンテナにコピー

上記のファイルを作成したら、 rag-floating-chat フォルダにまとめておきます。これをフォルダごと、Wordpress コンテナにコピーしてやります。

[user1@nas01 rag-test]$ docker cp ./rag-floating-chat/ rag-test-wordpress-1:/var/www/html/wp-content/plugins/
Successfully copied 8.7kB to rag-test-wordpress-1:/var/www/html/wp-content/plugins/
[user1@nas01 rag-test]$ 

テスト

Wordpress の管理画面に入って、『プラグイン』を開き、

を有効にします。この状態で、画面を表示すると、

のように右下にチャットのアイコンが表示されます。

クリックすると、

こんな感じで広がり、質問を入力すると、

先ほどと同じように回答してくれます。

なかなかいいですね。

まとめ

RAG の実装もチャットボットの実装も ChatGPT (+ Gemini )に聞きながらやったら、結構手早くできました。RAG自体はいろいろと可能性があるな、と感じているんですが、いかんせん押さえておくべきたくさんありますね。

あと、今回試していて思ったのは、わざわざ RAG を使わなくても、公開されているサイトであれば

公開されているサイトの URL をプロンプトに含めて、 OpenAI API を呼び出す

とするだけで、結構いいものができるのかもしれないというところです。実際 Wordpress のプラグインを調べると、生成 AI を利用したものがたくさん見つかります。

クローズドな非公開情報であれば、当然 RAG は有効になってくるんでしょうが、なかなか判断も難しいものがあるように思えます。

その他にも LlamaIndex についてあれこれ調べていると、

llama-indexとRAGに関する話 - matobaの備忘録

というのがあり、 LlamaIndex がクローラをプラグインで持っているという話が載ってました。実際に見てみると、webページを簡単にクローリングする機能も追加できそうです。

Web Page Reader - LlamaIndex

これなども使うと、また RAG のカバー範囲とか精度とかも変わってきそうな感じがします。

その他にも RAG の先には fine-tuning もあるし、なかなか大変な分野っぽいです。注目されている分野ですから、折に触れて、いろいろと試してみようと思います。

RAG をチャットボットに仕立てる (1/2): FastAPI コンテナ作成

こちらの記事で Web サイトによくある『よくある質問』の内容を参照して回答する簡単な RAG を作ってみました。次は、これをチャットボット化してみようと思います。

今回目指すのは、 Wordpress で使うようなチャットボットをイメージしています。

なお、以下の関連記事がありますので、こちらもよろしく。

API にする

いままでは、コマンドラインでループ内で質問を受け付けていたので、まずはこれを質問を投げたら答えが返ってくるような API に仕立てようと思います。

ChatGPT に聞いたところ FastAPI がおすすめとのこと。ざっと、 FastAPI について調べてみると、めっちゃ簡単に REST API を作ることができるライブラリっぽいです。

FastAPIを用いたAPI開発テンプレート #Python - Qiita

ということで、早速やってみます。

準備は、

pip install fastapi uvicorn

でいいみたいです。

FastAPI を使って、APIを作るのは、メソッド名のデコレータを書いていけばよいようです。その際、デコレータの引数に API パスを書いておけば、そのパスでアクセスしたときに応答してくれるとのことです。

from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel

import vector_index

query_engine = None


# POST のペイロードを定義
class FaqQuery(BaseModel):
    question: str = Query(
        min_length=1,
        max_length=1000,
        description="The question to query the FAQ system.",
    )

app = FastAPI(lifespan=lifespan)

# 問い合わせ エンドポイント
@app.post("/query/")
def query(payload: FaqQuery):
    """
    Handle the FAQ query.
    """
    global query_engine
    if not query_engine:
        return {"error": "Query engine is not initialized."}

    try:
        response = query_engine.query(payload.question)

        # レスポンスの整形
        result = {
            "answer": str(response),
            "references": [
                {"title": d.get("title", ""), "url": d.get("url", "")}
                for d in response.source_nodes[0].metadata.get("references", [])
            ],
        }
        return result
    except Exception as e:
        print(f"error: {str(e)}")
        raise HTTPException(status_code=500, detail=str(e))

POST で受け取るデータは、 BaseModel というのを継承してクラスを定義すればいいそうです。このとき、API呼び出しを処理する関数(この例だと query )に型ヒントがあると、送られてきたデータのバリデーションもやってっくれるそうです。めっちゃ便利。

毎回の API 呼び出しのたびに、 RAG のデータを読み込む(こちらの記事で作った、 vector_index.load_query_engine を呼びだす)のはいかにも効率が悪いので、実行時に一度だけ読ませるようにします。

ChatGPT によると @app.on_event("startup") というデコレータで定義すればいい、と出てきたんですが、すでに非推奨になっているらしくて、いまは、

# 起動前と後に実行されるのはこうやって定義するらしい
@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    Application lifespan event to initialize the query engine.
    """
    global query_engine
    try:
        # Initialize the query engine
        query_engine = vector_index.load_query_engine()
        yield
    finally:
        # Cleanup if necessary
        query_engine = None

のように定義するっぽいです。

これらを1つのファイル(rag_fastapi.py)にまとめておきます。

テスト

これを呼び出して動かしてみます。コマンドラインから

.venvmor@DESKTOP-DE7IL4F:~/tmp/openai/samples/ragSample/app$ uvicorn rag_fastapi:app --reload --port 8000
INFO:     Will watch for changes in these directories: ['/home/mor/tmp/openai/samples/ragSample/app']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [52743] using StatReload
INFO:     Started server process [52745]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

こんな感じに起動します。引数の rag_fastapi:app の部分は、実行したいファイル(rag_fastapi.py)のappオブジェクト、を指しています。 --reload はファイルに変更があった際に自動的に再読み込みしてくれるオプションだそうです。--port はいわずもがなポート番号です。

実際に API をたたいてみます。 VSCode の REST Client

という拡張機能を入れると POST でも GET でも(別のメソッドでも)簡単にリクエストを投げられるとのことなので、これで試してみます。

拡張子を .http にしたファイルを作成し、

という感じにリクエストを書きます。先頭行に、メソッド名(GETは省略可)とURL、続く行にヘッダ、1行開けて、POSTのペイロードという感じですね。

このファイルの拡張子が .http だと、URLの上側に Send Request と表示されるので、これをクリックすると

こんな感じに応答が返ってきます。いい感じやね。

(参考)別の実行方法

さきほどは uvicorn というサーバーをコマンドラインから実行しましたが、他の方法もあります。先ほどのファイルの最後に、

if __name__ == "__main__":
    import uvicorn

    # Start the FastAPI application
    uvicorn.run(app, host="127.0.0.1", port=8000)

とつけておけば、

.venvmor@DESKTOP-DE7IL4F:~/tmp/openai/samples/ragSample/container/app$ python rag_fastapi.py 
INFO:     Started server process [62108]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

とするだけでも呼び出すことができます。ご参考までに。

docker コンテナとして起動

さて、次は、 Wordpress からこの API を呼べるようにしてやる必要があります。テストなんでお手軽に試すため QNAP の Container Station を利用してコンテナを立てます。

Wordpress のコンテナのサンプルはいっぱいあるので後回しにして、まず先にさきほど作った FastAPI をdocker コンテナに仕立ててやります。

ちょっと調べてみると、こちらの記事がほぼやりたいことを実現してくれてます。

DockerでFastAPIの環境を作ってGETするまで

ということで、これに従って作業します。

なお、今回作業したフォルダはこんな感じにしました。

.
├── .env
├── Dockerfile
├── app
│   ├── __index__.py
│   ├── faiss_index
│   │   ├── default__vector_store.json
│   │   ├── (略)
│   │   └── index_store.json
│   ├── faq_dict.py
│   ├── rag_fastapi.py
│   └── vector_index.py
├── docker-compose.yml
└── requirements.txt

docker-compose.yml

コンテナを起動するための docker-compose.yml は次のようにします。

services:

  fastapi:
    container_name: fastapi
    build: .
    restart: always
    ports:
      - 8000:8000
    environment:
      - OPENAI_API_KEY

ここで、 OpenAI の API キーは .env に書いておいてこれを渡すようにします。

# added by Junichi MORI, 2025/6/5, for openai api
OPENAI_API_KEY="API キー"

Dockerfile

docker-compose.yml では Dockerfile からイメージを作っています。この Dockerfile は次のようにしました。

FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY ./app .

CMD ["uvicorn", "rag_fastapi:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]

requirements.txt

LlamaIndex の処理と FastAPI を実行するために必要なものを書いておきます。

llama-index
faiss-cpu
llama-index-vector-stores-faiss
openai
fastapi
uvicorn

ビルドとコンテナの起動

最初は、 Contaienr Station の『アプリケーション』から docker-compose.yml を与えて、コンテナを起動させます。もちろんこの段階では、起動に失敗します。

次に、上記の一式をローカルで作成して、これらを QNAP NAS の Container Station 所定のアプリケーションフォルダに ssh でログインしてコピーします。

ssh でアプリケーションフォルダにいる状態で、

[user1@nas01 rag-test]$ docker compose build
[+] Building 7.5s (11/11) FINISHED                                                                                                                                                                                       docker:default
(略)

のようにして、イメージをビルドしておきます。

次に、 Contaienr Station の画面に戻って、 アプリケーション全体を再起動します。問題がなければ、 FastAPI がコンテナで起動されます。

Wordpress コンテナを追加

ここまでできたら、この FastAPI のコンテナを呼び出すため、 docker-compose.yml に Wordpress のコンテナを追加します。

services:

  fastapi:
    container_name: fastapi
    build: .
    restart: always
    ports:
      - 8000:8000
    environment:
      - OPENAI_API_KEY

  wordpress:
    image: wordpress
    restart: always
    ports:
      - 8080:80
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: exampleuser
      WORDPRESS_DB_PASSWORD: examplepass
      WORDPRESS_DB_NAME: exampledb
    volumes:
      - wordpress:/var/www/html

  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_DATABASE: exampledb
      MYSQL_USER: exampleuser
      MYSQL_PASSWORD: examplepass
      MYSQL_ROOT_PASSWORD: wordpress
    volumes:
      - db:/var/lib/mysql

volumes:
  wordpress:
  db:

Wordpress に関する部分は、 DockerHub の Wordpress のサンプルから持ってきました。

QNAP NAS の ContainerStation の画面から、さきほど動かした FastAPI のアプリケーションを選択して、歯車アイコンをクリックして、『再作成』を選びます。

すると、docker-compose.yml を記入する画面が表示されるので、上記の Wordpresss を追加した内容に書き換えます。

このまま『更新』をクリックすると、3つのコンテナからなるアプリケーションが起動します。

ちょっと面倒なのが、この方法で再作成すると、 fastapi コンテナを作ったときのファイル等はいったん削除されてしまいます(フォルダごと削除されて、再作成されます)。fastapi イメージが残っているので、 fastapi コンテナの起動はできているのですが、 .env ファイルも消されているので、 docker-compose.yml に書いている OpenAI API キー が引き渡されないため正しく動作しません。

実際、 fastapi コンテナにリクエストしても、こんな感じに

Connection Error となって返ってきます。

なので、 .env ファイルを再度コピーしておきます。faiss インデックスのファイルや Dockerfile はすぐには不要なのですが、イメージの再ビルドに備えて、一緒に他のファイルもコピーしておきます。

ということで、 .env ファイルコピー後に、もう一度 fastapi のコンテナを作り直しておきます。

[user1@nas01 rag-test]$ docker compose up -d --no-deps --force-recreate fastapi

Wordpress のセットアップ

コンテナが問題なく起動したら、指定したポート番号(この例だと 8001)へアクセスしてみます。すると、 こんな感じの

言語選択画面が表示されます。『日本語』を選択して、進むと、引き続いて、

こんな感じの初期設定画面(管理者ユーザー名とパスワードの指定画面)が表示されるので、必要な項目を入力して、一番下の『WordPress をインストール』ボタンをクリックしてしばらく待つと、

と画面が表示され、セットアップ完了です。

この状態で、問題なくサイトを表示したり、管理画面にログインできることを確認しておきます。

まとめ

さて、これで準備ができました。次はチャットボットを実装していきます。