個人でAI模擬面接サービスを開発・運用しています。Streamlit + Supabase + Stripe + OpenAI という構成です。
「動くものを作る」までは順調だったのですが、独自ドメインを当ててGoogle検索に載せようとした途端に壁が連続しました。結局 Streamlit Community Cloud から Render に移すことになり、その過程で7つの落とし穴を踏みました。
どれも検索してもすぐには出てこず、原因特定に時間を使ったものばかりです。同じ構成で本番運用しようとしている人の役に立てば幸いです。
- 検証時期: 2026年8月
- Streamlit: 1.40 以降
- ホスティング: Streamlit Community Cloud → Render (Starter)
1. Streamlit Community Cloud は独自ドメインに対応していない
最初にぶつかった、そして最も大きい壁です。
myapp.streamlit.app のようなサブドメインは設定できますが、自分で取得したドメインを接続する機能はありません。設定画面をいくら探しても Custom Domain の項目は存在しません。「見つからない」のではなく「最初から無い」のでした。
何が困るか
Google Search Console にサイトを登録できません。
GSCの所有権確認は、主に以下の方法があります。
| 方法 | Streamlit Cloud での可否 |
|---|---|
| HTMLファイルをルートに配置 | ❌ 任意のファイルをドメイン直下に置けない |
<head> にメタタグを追加 |
❌ <head> を操作する手段がない |
| DNS TXT レコード | ❌ streamlit.app のDNSはStreamlit社の管理 |
| Google Analytics | ❌ そもそもGAタグが動かない(後述) |
DNS TXTレコード方式が使えないのが決定的です。streamlit.app は他社のドメインなので、そのDNSに自分がレコードを追加することは原理的にできません。
私はここで、Googleの案内文にある
以下の TXT レコードを myapp.streamlit.app の DNS 設定に コピーします
という一文を読み飛ばして、自分が持っているドメインのDNSにTXTを追加し、何日も「反映待ち」をしていました。別ドメインなので何日待っても通りません。
有料版で解決するか
しません。Streamlit の上位版は Snowflake 経由で提供されますが、これはSnowflakeにログインした社内ユーザーが使う業務アプリ向けです。不特定多数の一般ユーザーが使う消費者向けサービスには構造的に合いません。
結論
独自ドメインが必要なら、Streamlit Community Cloud から出るしかありません。私は Render の Starter プラン($7/月)に移しました。12時間スリープも同時に解消できます。
Render での起動コマンドは以下です。
streamlit run app.py --server.port $PORT --server.address 0.0.0.0 --server.headless true
-
$PORTは実行時に割り当てられるので数字を直書きしない -
--server.headless trueを省くとブラウザ起動を試みて失敗する
2. st.secrets は secrets.toml が無い環境で例外を投げる
Render に移した直後、アプリが起動せずこのエラーが出ました。
streamlit.errors.StreamlitSecretNotFoundError: No secrets found.
Valid paths for a secrets.toml file or secret directories are:
/opt/render/.streamlit/secrets.toml, /opt/render/project/src/.streamlit/secrets.toml
トレースバックの先頭はこの行でした。
MAINTENANCE_MODE = st.secrets.get("MAINTENANCE_MODE", os.environ.get("MAINTENANCE_MODE", ""))
環境変数へのフォールバックを書いてあるのに落ちます。
原因
st.secrets.get(key, default) は、secrets.toml が1つも存在しない環境だと、default を返す前に例外を送出します。
dict.get() と同じ感覚で書くと踏みます。第2引数は「キーが無いとき」の保険であって、「secretsファイル自体が無いとき」の保険ではありません。
Streamlit Cloud には secrets.toml があるので開発中は気づけません。Render や Docker のように環境変数だけで動かす環境に移した瞬間に発現します。
対処
st.secrets へのアクセスを try で包み、失敗したら環境変数に委ねるヘルパーを作りました。
def get_secret(key, default=""):
"""secrets.toml が無い環境でも動くシークレット取得。
Streamlit Cloud(secrets.toml あり)では従来どおり secrets が優先され、
Render 等では環境変数にフォールバックする。
"""
try:
value = st.secrets.get(key)
if value is not None:
return value
except Exception:
pass
return os.environ.get(key, default)
あとは st.secrets.get(...) を全部これに置換します。正規表現で機械的に置き換えるのが安全です。
import re
pattern = re.compile(
r'st\.secrets\.get\(\s*"([A-Z_]+)"\s*,\s*os\.environ\.get\(\s*"\1"\s*,\s*(.*?)\s*\)\s*\)'
)
new, n = pattern.subn(lambda m: f'get_secret("{m.group(1)}", {m.group(2)})', src)
print("置換件数:", n)
この方式の利点は、Streamlit Cloud 側の挙動が一切変わらないことです。secrets があればそちらが優先されるので、移行中に両環境を併走させられます。
3. Markdown のインデントで HTML がコードブロックとして表示される
3枚のカードを並べるUIを作ったところ、1枚だけHTMLタグが生のまま表示されました。
<h4 style="color: #64748b; text-align...
<p style="text-align:center; margin...
残り2枚は正常に描画されています。同じループで生成しているのに、です。
原因
Markdown のインデントコードブロック記法です。
空行の直後に4スペース以上インデントされた行が続くと、それはコードブロックとして扱われる
問題のカードだけ、条件分岐で挿入していたバッジのHTMLが空文字になっていました。
if plan["badge"]:
badge_html = "<div ...>" + plan["badge"] + "</div>"
else:
badge_html = "" # ← ここ
st.markdown(f"""
<div style="...">
{badge_html} ← 空文字だと「空白だけの行」になる
<h4 style="..."> ← 直後の12スペースインデントがコードブロック扱いに
バッジがある場合は空行にならないので正常に描画され、無い場合だけ崩れていたわけです。
対処
HTMLを改行もインデントも含まない1行の文字列として組み立てます。
card_html = (
'<div class="card" style="background:' + p["bg"] + ';">'
+ badge_html
+ '<h4 style="color:' + p["accent"] + ';">' + p["name"] + '</h4>'
'<p>' + p["price"] + '</p>'
'</div>'
)
st.markdown(card_html, unsafe_allow_html=True)
見た目は読みにくくなりますが、変数の中身に関わらず崩れません。
同種の危険が他にないかは、行頭が変数展開だけの箇所を探せば見つかります。
grep -n "^\s*{_" app.py
4. st.html() 内の <script> は実行されない(GAが動かない)
Google Analytics のタグを st.html() で埋め込んだのですが、GA4の管理画面にこう出ました。
データ収集がウェブサイトで有効になっていません。
原因
st.html() は内容を innerHTML で挿入します。HTMLの仕様上、innerHTML 経由で挿入された <script> は実行されません。
st.markdown(unsafe_allow_html=True) でも同じです。st.components.v1.html() を使えばiframe内でスクリプトは動きますが、計測対象がiframeになるため親ページのアクセスは取れません。
Streamlit には <head> にタグを追加する公式APIがなく、2019年から機能要望が出ていますが実装されていません。
対処
確実に動かす方法は、Streamlitパッケージ内の static/index.html を起動時に書き換えるものだけです。ただしこれには相応のリスクがあります。site-packages を書き換えるコードを本番に入れることになりますし、実際に「戻せなくなった」という報告も見かけました。
私はGAを諦めました。代わりに以下で計測しています。
- Render の Metrics(リクエスト数・レスポンスタイム)
- Supabase のテーブル(実際の利用回数・プラン別ユーザー数)
- Stripe(課金の実数)
- Google Search Console(検索流入)
アプリのDBに書いてある数字のほうが、GAより正確です。個人開発の規模なら、GAが無くても困りません。
なお、GSCの所有権確認をメタタグ方式でやろうとしていた場合も同じ理由で動きません。DNS TXTレコード方式を使ってください。
5. マイク入力に外部コンポーネントは不要(st.audio_input)
音声入力を実装するとき、streamlit-mic-recorder のような外部コンポーネントを探しがちです。
Streamlit 1.40.0 で st.audio_input が標準搭載されました。
audio_value = st.audio_input("回答を録音する")
if audio_value is not None:
audio_bytes = audio_value.getvalue()
デフォルトのサンプルレートは 16000Hz で、これは音声認識に最適とされている値です。Whisperにそのまま渡せます。
def transcribe_audio(audio_bytes: bytes):
buf = io.BytesIO(audio_bytes)
buf.name = "answer.wav"
result = client.audio.transcriptions.create(
model="whisper-1",
file=buf,
language="ja",
# 文脈をヒントとして与えると固有名詞の精度が上がる
prompt="これは就職活動の面接における応募者の回答です。志望動機、自己PR などが含まれます。",
)
return result.text.strip()
二重変換に注意
Streamlit は操作のたびにスクリプト全体を再実行します。素朴に書くと、同じ録音を何度もWhisperに送って課金が無駄に増えます。
音声バイト列のハッシュで判定して、新しい録音のときだけAPIを呼びます。
import hashlib
if audio_value is not None:
audio_bytes = audio_value.getvalue()
digest = hashlib.md5(audio_bytes).hexdigest()
if st.session_state.get("last_audio_digest") != digest:
with st.spinner("音声を文字に起こしています..."):
text = transcribe_audio(audio_bytes)
st.session_state.last_audio_digest = digest
st.session_state.pending_transcript = text
認識結果は確認させたほうがいい
Whisperは固有名詞や専門用語を誤変換することがあります。そのまま送信すると後続の処理が誤った前提で動きます。編集可能なテキストエリアを挟んで、送信前に直せるようにするのがおすすめです。
外部ライブラリを追加しない判断には副次的な利点もあります。デプロイ時のビルド失敗リスクが増えません。個人開発では、依存を1つ減らすことが安定運用に直結します。
6. st.markdown で開始タグだけ書くと「空の箱」ができる
カードUIをこう書いていました。
st.markdown('<div class="card">', unsafe_allow_html=True)
st.write("中身")
st.markdown('</div>', unsafe_allow_html=True)
これが何もない白い四角形として画面に出ました。
原因
Streamlit は st.markdown の呼び出しごとに独立したコンテナに包みます。開始タグだけを書くと、その場で閉じられて <div class="card"></div> になります。中身は実際にはカードの外側に配置されています。
背景が半透明のうちは目立ちませんでしたが、白い実線のカードにデザイン変更した途端に露見しました。
対処
正しくは st.container(border=True) を使います。
with st.container(border=True):
st.write("中身")
既存コードを直す時間がない場合の応急処置として、CSSで空要素を隠す手もあります。
.card:empty {
display: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
7. デプロイ直後にクロールされると、検索結果にJSエラーが出る
GSCへの登録が済んで検索結果に載ったのですが、説明文がこうなっていました。
Mokipra - AI模擬面接パートナー
TypeError: Failed to fetch dynamically imported module:
https://mokipra.jp/static/js/Html.BLX6BjQN.js
原因
Streamlit のフロントエンドアセットは Html.BLX6BjQN.js のようにビルドごとに変わるハッシュ付きのファイル名です。
デプロイ直後にGoogleがクロールすると、古いハッシュのファイルを取りに行って失敗することがあります。Googleはページを取得してJavaScriptを実行するため、画面に表示されたエラーメッセージをページの内容と判断します。
Streamlit には meta description を設定する手段がないので、説明文はページ本文から自動生成されます。そこにエラー文が入ってしまったわけです。
対処
GSCの「公開URLをテスト」で再現しないことを確認してから、「インデックス登録をリクエスト」で取り直させれば直ります。
予防としては、アプリを大きく変更したら数十分置いてからインデックス登録をリクエストすることです。
おまけ: 環境変数を移すときのハマり
Streamlit Cloud から Render に環境変数を移すとき、Stripeの失効済みキーをコピーしてしまいました。
生成失敗: Expired API Key provided: sk_live_****(末尾マスク)
過去にキーをローテーションしていたのに、古い値がどこかに残っていたようです。
キーは「現に動いている環境」からコピーするのが確実です。ダッシュボードから取り直すと、どれが現行かの判断を1回挟むぶん間違えます。
移行後は、複数環境で同じキーを使っている場合に末尾数文字を照合するだけで食い違いが見つかります。全体を貼らずに済むので安全です。
まとめ
| # | 落とし穴 | 一言 |
|---|---|---|
| 1 | Community Cloud は独自ドメイン非対応 | GSC登録も不可。移行するしかない |
| 2 |
st.secrets が例外を投げる |
try で包んで環境変数にフォールバック |
| 3 | インデントでHTMLがコード化 | 1行文字列で組み立てる |
| 4 |
<script> が実行されない |
GAは諦めてDBで計測する |
| 5 | マイクは標準機能で足りる |
st.audio_input(1.40+) |
| 6 | 開始タグだけで空の箱 | st.container(border=True) |
| 7 | クロール時のJSエラー | 変更後は時間を置いて再リクエスト |
Streamlit は「とりあえず動くものを作る」までが圧倒的に速い一方で、本番運用を意識した瞬間に、フレームワークの想定外の領域に入ります。ここに挙げたものはいずれも、Streamlit が悪いというより「業務用ダッシュボードのためのツールを、消費者向けWebサービスに使った」ことによる摩擦だと感じています。
それでも、Python だけでここまで作れるのは大きな利点です。同じ構成で本番運用を考えている方の参考になれば幸いです。
実際に運用しているサービスはこちらです。AI が面接官になって、終了後に採点とフィードバックを返します。