
Streamlitとは、Pythonのスクリプト1本を、ブラウザで操作できるWebアプリとして動かすオープンソースのフレームワークです(ライセンスはApache-2.0)。HTMLやJavaScriptを書かずに、絞り込みの部品・グラフ・表・ダウンロードボタンを持つ画面を作れます。URLごとに処理を書き分けるFlaskやFastAPIとは仕組みが違い、画面を操作するたびにスクリプト全体を上から実行し直します。
社内の機器台帳や操作ログのCSVを、Excelのファイルを回覧せずにブラウザで絞り込んで見せたい情シス・インフラ担当向けの使い方です。コンテナはUbuntu 26.04で、そこにStreamlit 1.66.0を入れ、架空の機器台帳を絞り込み・グラフ・CSVダウンロード付きで表示するアプリを作ります。入門記事で省かれがちな再実行の仕組み、st.session_state、st.cache_dataの効き方と外れ方、アップロードの上限は、Streamlit付属のテスト機能AppTest、curl、ブラウザで実際に確かめた結果のまま載せています。最後はDockerイメージにして127.0.0.1だけで公開し、nginxを前に置いてWebSocketを通すところまで進めます。
この記事でわかること
- 操作のたびにスクリプト全体が再実行される仕組みと、普通の変数に貯めた値が消える理由
st.cache_dataが効く条件・外れる条件と、CSVを更新しても古い表示が残る落とし穴- アップロード上限(
server.maxUploadSizeとnginxのclient_max_body_size)を超えたときに利用者に見える表示 - AppTestとpytestで、画面の動作をブラウザなしで確かめる書き方
- nginxの後ろに置くとき、WebSocketのUpgradeヘッダーがないと何が起きるか
作るものと検証環境
作るのは「機器台帳ビューア」です。部署・種別・ホスト名の一部・「90日以上確認されていない機器だけ」の4つで絞り込み、表示中の台数、種別ごとの棒グラフ、表を出し、絞り込んだ結果をCSVで保存できます。棚卸しで確認した機器をその場で記録する欄と、手元にある別のCSVを読み込んで同じ画面で見る欄も付けます。データは20台分の架空の台帳で、実在の機器や人は含みません。
コンテナは3つ使い、どれもDockerのプライベートネットワークstreamlit-python-internal-app-netにつなぎ、ポートはホストの127.0.0.1にだけ公開します。
streamlit-python-internal-app-dev:開発とテスト用。Pythonの仮想環境にStreamlitを入れて作業します(127.0.0.1:18501)streamlit-python-internal-app-viewer:配布用に作ったイメージでアプリを動かします(127.0.0.1:18502)streamlit-python-internal-app-nginx:aptで入れたnginxで、viewerの前に立つリバースプロキシです(127.0.0.1:18080)
ベースは3つともubuntu:26.04にしました。aptで入るpython3が3.14.4で、Streamlitの公式ドキュメントが挙げる対応範囲(執筆時点ではバージョン3.11から3.15)に入っていること、nginxも同じイメージにaptで入れられるので、取ってくるベースイメージが1種類で済むことが理由です。python:3.12-slimをベースにする選択肢もありますが、この記事ではそちらは試していません。確認したホストのDocker Engineは29.1.3です。
コマンドやログに出てくるコンテナ内の作業ユーザーryomaとホスト名nagoyadevlab01は、この記事の例で使っている名前です。自分の環境の名前に置き換えてください。streamlit-python-internal-app-で始まるコンテナ名・ネットワーク名と、18501・18502・18080のポート番号も、空いていれば別の値で構いません。コード欄のうち$ で始まる行が入力するコマンド、それ以外の行が出力です。$ のない欄(ヒアドキュメントでファイルを書き込む欄)は、欄全体を入力します。
何も設定しないStreamlitのアプリは、ポートに届いた人全員に画面とデータを見せます。この記事で開いた画面にもログインはありません。ポートを127.0.0.1に限って公開しているのはそのためで、社内LANに出す前に、後半の「認証」の節で前に置くものを決めてください。
Ubuntu 26.04のコンテナにStreamlitを入れる
ホスト側で作業用のディレクトリを作り、以降のホスト側のコマンドはすべてそこで実行します。ネットワークを作り、開発用のコンテナをsleep infinityで起動したままにします。-p 127.0.0.1:18501:8501はコンテナの8501番を、ホストのループバックの18501番にだけ出す指定です。メモリの上限は--memory 1gにしました。
mkdir streamlit-lab && cd streamlit-lab
docker network create streamlit-python-internal-app-net
docker run -d --name streamlit-python-internal-app-dev --hostname nagoyadevlab01 --network streamlit-python-internal-app-net -p 127.0.0.1:18501:8501 --memory 1g ubuntu:26.04 sleep infinityコンテナにはdocker exec -it streamlit-python-internal-app-dev bashでrootとして入ります。イメージにはPythonが入っていないので、仮想環境を作るためのpython3-venvを入れ(依存でpython3も入ります)、作業用の一般ユーザーを作ります。
$ apt-get update && apt-get install -y --no-install-recommends python3-venv
$ useradd -m -s /bin/bash ryoma
$ grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 26.04.1 LTS"
$ python3 --version
Python 3.14.4以降はdocker exec -it -u ryoma -w /home/ryoma streamlit-python-internal-app-dev bashで作業ユーザーとして入り直します。仮想環境(venv)は~/venvに作り、activateせずに~/venv/bin/streamlitのようにパスを付けて呼びます。activateしてあればstreamlitだけで同じ意味です。本番のイメージに入れるパッケージはrequirements.txtに版を固定して書き、テストにしか使わないpytestはコマンドラインで別に指定します。
mkdir -p ~/device-viewer && cd ~/device-viewer
python3 -m venv ~/venv
cat > requirements.txt <<'EOF'
streamlit==1.66.0
pandas==3.0.6
EOF
~/venv/bin/pip install -r requirements.txt pytest==9.1.1入った版を確かめます。websocketsはStreamlitの依存として入ったもので、後でnginx越しのWebSocket接続を試すときに使います。
$ ~/venv/bin/streamlit version
Streamlit, version 1.66.0
$ ~/venv/bin/pip freeze | grep -E '^(streamlit|pandas|pytest|websockets)=='
pandas==3.0.6
pytest==9.1.1
streamlit==1.66.0
websockets==17.2
$ ~/venv/bin/pip show streamlit
Version: 1.66.0
License-Expression: Apache-2.0
Requires: altair, anyio, click, itsdangerous, numpy, packaging, pandas, pillow, protobuf, pyarrow, pydeck, python-multipart, requests, starlette, typing-extensions, uvicorn, watchdog, websocketsRequiresにstarletteとuvicornが並んでいて、後で見る起動ログにもUvicorn server startedと出ます。このあと使う/_stcore/health(ヘルスチェック)と/_stcore/stream(WebSocket)は、どちらもこのサーバーが受けるURLです。
機器台帳ビューアを書く:絞り込み・グラフ・CSVダウンロード
台帳は「ホスト名,部署,種別,OS,最終確認日」の5列にしました。プリンターとネットワーク機器のOS欄は-です。見出しを含めて21行になります。
cat > devices.csv <<'EOF'
ホスト名,部署,種別,OS,最終確認日
nb-soumu-01,総務部,ノートPC,Windows 11,2026-09-30
nb-soumu-02,総務部,ノートPC,Windows 11,2026-05-12
dt-soumu-01,総務部,デスクトップ,Windows 11,2026-09-29
pr-soumu-01,総務部,プリンター,-,2026-09-30
nb-eigyo-01,営業部,ノートPC,Windows 11,2026-09-28
nb-eigyo-02,営業部,ノートPC,Windows 11,2026-09-30
nb-eigyo-03,営業部,ノートPC,macOS 15,2026-06-02
nb-eigyo-04,営業部,ノートPC,Windows 10,2026-03-18
pr-eigyo-01,営業部,プリンター,-,2026-09-27
nb-kaihatsu-01,開発部,ノートPC,macOS 15,2026-09-30
nb-kaihatsu-02,開発部,ノートPC,Ubuntu 24.04,2026-09-30
dt-kaihatsu-01,開発部,デスクトップ,Ubuntu 24.04,2026-09-25
dt-kaihatsu-02,開発部,デスクトップ,Windows 11,2026-04-20
srv-kaihatsu-01,開発部,サーバー,Ubuntu 24.04,2026-09-30
nb-joshis-01,情報システム部,ノートPC,Windows 11,2026-09-30
srv-joshis-01,情報システム部,サーバー,Ubuntu 24.04,2026-09-30
srv-joshis-02,情報システム部,サーバー,Ubuntu 22.04,2026-07-01
sw-joshis-01,情報システム部,ネットワーク機器,-,2026-09-30
sw-joshis-02,情報システム部,ネットワーク機器,-,2026-09-30
ap-joshis-01,情報システム部,ネットワーク機器,-,2026-02-10
EOF$ wc -l devices.csv
21 devices.csv絞り込みとCSVの書き出しは、Streamlitを読み込まないviewer_lib.pyに分けます。画面を通さずにpytestから直接呼べるようにするためです。「90日以上確認されていない」の基準日は、実行した日ではなくCSVの中で最も新しい確認日(この台帳では2026-09-30)にしました。今日の日付を基準にすると、同じCSVでも開いた日によって件数が変わり、テストも日によって通ったり落ちたりします。書き出しは、Excelでダブルクリックして開く人がいる前提で、先頭にBOMを付けたUTF-8(utf-8-sig)にしています。Excelでの見え方はこの環境では確認していません。
cat > viewer_lib.py <<'EOF'
"""機器台帳の絞り込みと書き出し(Streamlitに依存しない部分)"""
import pandas as pd
STALE_DAYS = 90
def filter_devices(df: pd.DataFrame, depts, kinds, keyword: str = "", stale_only: bool = False) -> pd.DataFrame:
out = df[df["部署"].isin(depts) & df["種別"].isin(kinds)]
if keyword:
out = out[out["ホスト名"].str.contains(keyword, case=False, regex=False)]
if stale_only:
# 基準日は「今日」ではなくCSV内で最も新しい確認日(いつ開いても同じ結果になる)
base = df["最終確認日"].max()
out = out[(base - out["最終確認日"]).dt.days >= STALE_DAYS]
return out
def to_excel_csv(df: pd.DataFrame) -> bytes:
# Excelで開く人がいる前提で、BOM付きUTF-8にする
return df.to_csv(index=False, date_format="%Y-%m-%d").encode("utf-8-sig")
EOFapp.pyが画面です。上から順に、ページ設定、実行回数のカウント、台帳の読み込み関数、サイドバーの部品、件数・グラフ・表・ダウンロードボタン、棚卸しチェックの欄を置いています。読み込み関数のtime.sleep(3)は、ファイルサーバーやDBからの読み込みが遅い状況をわざと作るためのもので、次の節以降でキャッシュの効果を測るのに使います。絞り込みの部品に付けたkey=は、後でAppTestから部品を名前で探すための目印です。
cat > app.py <<'EOF'
import os
import time
import pandas as pd
import streamlit as st
from viewer_lib import filter_devices, to_excel_csv
CSV_PATH = os.environ.get("DEVICES_CSV", "devices.csv")
st.set_page_config(page_title="機器台帳ビューア", layout="wide")
# 操作のたびにこのファイルが上から全部実行される。回数はセッションごとに数える
st.session_state.setdefault("runs", 0)
st.session_state.setdefault("checked", [])
st.session_state.runs += 1
print(f"[rerun] runs={st.session_state.runs}", flush=True)
@st.cache_data(show_spinner="台帳を読み込んでいます…")
def load_devices(path: str, mtime: float) -> pd.DataFrame:
# mtimeは中では使わない。キャッシュのキーに更新時刻を含めるための引数
print(f"[load] {path} を読み込みます", flush=True)
time.sleep(3) # ファイルサーバーやDBの遅さをわざと再現している
return pd.read_csv(path, parse_dates=["最終確認日"])
st.title("機器台帳ビューア")
uploaded = st.sidebar.file_uploader("別の台帳CSVで確認する", type="csv")
if uploaded is not None:
df = pd.read_csv(uploaded, parse_dates=["最終確認日"], encoding="utf-8-sig")
st.sidebar.caption(f"表示中: {uploaded.name}(台帳ファイルは書き換えません)")
else:
df = load_devices(CSV_PATH, os.path.getmtime(CSV_PATH))
dept_options = sorted(df["部署"].unique())
kind_options = sorted(df["種別"].unique())
depts = st.sidebar.multiselect("部署", dept_options, default=dept_options, key="dept")
kinds = st.sidebar.multiselect("種別", kind_options, default=kind_options, key="kind")
keyword = st.sidebar.text_input("ホスト名に含む文字", key="keyword")
stale_only = st.sidebar.checkbox("90日以上確認されていない機器だけ", key="stale")
view = filter_devices(df, depts, kinds, keyword, stale_only)
st.metric("表示中の台数", len(view))
st.bar_chart(view["種別"].value_counts())
st.dataframe(
view,
hide_index=True,
column_config={"最終確認日": st.column_config.DateColumn(format="YYYY-MM-DD")},
)
st.download_button(
"表示中の一覧をCSVで保存",
data=to_excel_csv(view),
file_name="devices_filtered.csv",
mime="text/csv",
)
st.subheader("棚卸しチェック")
target = st.selectbox("確認した機器", view["ホスト名"], key="target")
if st.button("確認済みに追加") and target and target not in st.session_state.checked:
st.session_state.checked.append(target)
st.write("確認済み: " + (", ".join(st.session_state.checked) or "なし"))
st.caption(f"このセッションでスクリプトが実行された回数: {st.session_state.runs}")
EOFアプリごとの設定は、作業ディレクトリの.streamlit/config.tomlに書きます。server.maxUploadSizeはアップロード1ファイルあたりの上限をMB単位で決める項目で、既定値は200(200MB)です。台帳のCSVにそこまでの大きさは要らないので5にしました。browser.gatherUsageStatsは、ドキュメントの説明では利用状況の統計をStreamlitに送るかどうかの項目で、既定値はtrueです。社内の台帳を扱うアプリなので、外部への送信を1つでも減らす目的でfalseにしています。streamlit config showで、ファイルの値が読まれていることを確かめます。
mkdir -p .streamlit
cat > .streamlit/config.toml <<'EOF'
[server]
# アップロード1ファイルあたりの上限(MB)。既定は200
maxUploadSize = 5
[browser]
# 利用状況の統計をStreamlitに送らない
gatherUsageStats = false
EOF$ ~/venv/bin/streamlit config show 2>/dev/null | grep -E '^(maxUploadSize|gatherUsageStats) ='
maxUploadSize = 5
gatherUsageStats = false起動します。このコマンドは止めるまで戻ってこないので、この端末はそのままにしておきます。
$ ~/venv/bin/streamlit run app.py
2026-10-18 05:55:49.739 Uvicorn server started on :::8501
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501
Network URL: http://172.19.0.2:8501実際のログには、このほかにExternal URLの行が出ます。そこにはホストのグローバルIPアドレスが表示されたので、ここでは省きました。ログの:::8501は、コンテナ内のすべてのアドレスの8501番で待ち受けている意味です。外からどこまで届くかは、docker runの-pで決まります。別の端末から、ホスト側でヘルスチェックのURLを叩きます。
$ curl -s http://127.0.0.1:18501/_stcore/health
okokが返れば、ホストのブラウザでhttp://127.0.0.1:18501/を開けます。左のサイドバーに絞り込みの部品が、右に件数・棒グラフ・表が出ます。streamlit runを動かしている端末はそのままにして、ここから先のコンテナ内の作業は、ホストのもう1つの端末からdocker exec -it -u ryoma -w /home/ryoma/device-viewer streamlit-python-internal-app-dev bashで入ったシェルで行います。
再実行モデル:操作のたびにスクリプト全体が上から動く
Streamlitのドキュメントには、画面を更新する必要があるたびにスクリプト全体を上から下まで実行し直す、と書かれています。きっかけは、利用者が部品を操作したときです。これを目で見るために、app.pyの冒頭で実行回数を数え、[rerun]の行を出しています。ブラウザの代わりにAppTestでアプリを動かし、3回操作してみます。AppTestは、アプリのコードをブラウザなしで直接実行し、部品の操作と表示内容の確認をAPIで行うStreamlit付属の仕組みです。
cat > rerun_demo.py <<'EOF'
from streamlit.testing.v1 import AppTest
# ブラウザの代わりにAppTestでapp.pyを動かし、3回操作する
at = AppTest.from_file("app.py", default_timeout=10)
print("--- 1. 初回表示")
at.run()
print("--- 2. 部署を情報システム部だけにする")
at.multiselect(key="dept").set_value(["情報システム部"]).run()
print("--- 3. 90日以上未確認だけにする")
at.checkbox(key="stale").check().run()
print("表示中の台数:", at.metric[0].value)
print(at.caption[-1].value)
EOF$ ~/venv/bin/python rerun_demo.py
--- 1. 初回表示
[rerun] runs=1
[load] devices.csv を読み込みます
--- 2. 部署を情報システム部だけにする
[rerun] runs=2
--- 3. 90日以上未確認だけにする
[rerun] runs=3
表示中の台数: 2
このセッションでスクリプトが実行された回数: 3先頭にはmissing ScriptRunContext! This warning can be ignored when running in bare mode.という警告が1行出ます。スクリプトからAppTestを直接動かしたときのもので、文面どおり無視して構いません(抜粋では省きました)。
部署を変えた操作でも、チェックボックスを入れた操作でも、[rerun]の行が1つずつ増えています。部品1つの変更で、ファイルの先頭のimportから最後のst.captionまで全部がもう一度動いたということです。一方で[load]は最初の1回しか出ていません。読み込み関数に付けた@st.cache_dataが2回目以降の呼び出しを飛ばしたためで、仕組みは後の節で測ります。読み込み関数に@st.cache_dataを付けていなければ、操作のたびに3秒の待ちが入る計算です。
ブラウザで操作した場合、この[rerun]の行はstreamlit runを実行した端末に出ます。手元の確認では、ブラウザのセッションを3つ開いたあいだに[load]の行はやはり1回しか出ていませんでした。
st.session_stateで再実行をまたいで値を持つ
毎回上から実行し直すということは、スクリプトの中の普通の変数は毎回作り直されるということです。棚卸しで確認した機器をリストに貯める処理を、st.session_stateを使わずに書くとどうなるかを、小さなスクリプトで確かめます。
cat > list_demo.py <<'EOF'
from streamlit.testing.v1 import AppTest
# session_stateを使わず、普通のリストに貯めようとした版
SCRIPT = '''
import streamlit as st
checked = []
target = st.selectbox("確認した機器", ["nb-soumu-02", "ap-joshis-01"])
if st.button("確認済みに追加"):
checked.append(target)
st.write("確認済み: " + (", ".join(checked) or "なし"))
'''
at = AppTest.from_string(SCRIPT).run()
at.selectbox[0].select("nb-soumu-02").run()
at.button[0].click().run()
print("1台目を追加:", at.markdown[0].value)
at.selectbox[0].select("ap-joshis-01").run()
at.button[0].click().run()
print("2台目を追加:", at.markdown[0].value)
at.selectbox[0].select("nb-soumu-02").run()
print("選択だけ変更:", at.markdown[0].value)
EOF$ ~/venv/bin/python list_demo.py
1台目を追加: 確認済み: nb-soumu-02
2台目を追加: 確認済み: ap-joshis-01
選択だけ変更: 確認済み: なし2台目を追加すると1台目が消え、ボタンを押さずに選択だけ変えると何も残りません。st.buttonは押された直後の1回の実行でだけTrueを返し、checked = []の行は再実行のたびに空のリストを作り直すからです。app.pyでは、最初の実行でだけst.session_state.setdefault("checked", [])がリストを作り、以降の実行では同じリストを使い続けます。実行回数のrunsが3まで増えたのも同じ仕組みです。確認済みのリストが残ることは、後のpytestでも確かめます。
公式ドキュメントでは、Session Stateはユーザーのセッションごとに再実行をまたいで変数を共有する仕組みで、ブラウザのタブごとに新しいセッションができると説明されています。つまり、確認済みのリストは操作した本人のタブの中にしかなく、同じ画面を開いている同僚には共有されません。
st.session_stateはサーバーのメモリ上にあり、ドキュメントにも、タブを閉じると中身が失われ、永続化されず、サーバーが落ちると消えると書かれています。棚卸しの結果のように後で集計する値は、ボタンを押したときの処理の中でファイルやDBに書き出してください。この記事のアプリは、表示中の画面だけで使う控えとして持っています。
st.cache_dataで遅い読み込みを1回にする
ドキュメントでは、st.cache_dataを付けた関数は、呼び出しのたびに「引数の値」と「関数の中のコード」の2つを確かめ、前と同じなら保存しておいた結果を返すと説明されています。ここで見落としやすいのは、読み込むファイルの中身はこの2つに入っていないことです。CSVのパスだけを引数にした、よく見かける書き方で試します。
cat > stale_demo.py <<'EOF'
import shutil
from streamlit.testing.v1 import AppTest
# パスだけを引数にしてキャッシュする、よくある書き方
SCRIPT = '''
import pandas as pd
import streamlit as st
@st.cache_data
def load(path):
return pd.read_csv(path)
st.write(f"{len(load('/tmp/stale.csv'))} 台")
'''
shutil.copy("devices.csv", "/tmp/stale.csv")
print("追記前:", AppTest.from_string(SCRIPT).run().markdown[0].value)
with open("/tmp/stale.csv", "a", encoding="utf-8") as f:
f.write("nb-soumu-03,総務部,ノートPC,Windows 11,2026-10-01\n")
print("追記後:", AppTest.from_string(SCRIPT).run().markdown[0].value)
EOF$ ~/venv/bin/python stale_demo.py
追記前: 20 台
追記後: 20 台ファイルには21行目の機器を書き足したのに、2回目も20台のままです。引数/tmp/stale.csvも関数のコードも変わっていないので、1回目の結果がそのまま返りました。台帳のCSVを夜間に上書きして配る運用だと、何かのきっかけでキャッシュが外れるまで、前日の台帳が表示され続けます。app.pyのload_devices(path, mtime)は、関数の中では使わない更新時刻os.path.getmtime(CSV_PATH)を引数に入れて、ファイルが書き換わるとキャッシュが外れるようにしています。
この作りで、キャッシュの効き方と外れ方を測ります。元のdevices.csvを書き換えないよう、コピーを環境変数DEVICES_CSVで読ませています。
cat > bench_cache.py <<'EOF'
import os
import shutil
import time
import streamlit as st
from streamlit.testing.v1 import AppTest
# 元のdevices.csvを書き換えないよう、コピーを読ませる
shutil.copy("devices.csv", "/tmp/bench.csv")
os.environ["DEVICES_CSV"] = "/tmp/bench.csv"
def open_new_session(label):
at = AppTest.from_file("app.py", default_timeout=10)
start = time.perf_counter()
at.run()
print(f"{label}: {time.perf_counter() - start:.2f} 秒 / 表示中の台数 {at.metric[0].value}")
open_new_session("1回目(キャッシュなし)")
open_new_session("2回目(別セッション)")
with open("/tmp/bench.csv", "a", encoding="utf-8") as f:
f.write("nb-soumu-03,総務部,ノートPC,Windows 11,2026-10-01\n")
open_new_session("CSVに1行追記した後")
st.cache_data.clear()
open_new_session("st.cache_data.clear()の後")
EOF$ ~/venv/bin/python bench_cache.py
[rerun] runs=1
[load] /tmp/bench.csv を読み込みます
1回目(キャッシュなし): 3.86 秒 / 表示中の台数 20
[rerun] runs=1
2回目(別セッション): 0.14 秒 / 表示中の台数 20
[rerun] runs=1
[load] /tmp/bench.csv を読み込みます
CSVに1行追記した後: 3.13 秒 / 表示中の台数 21
[rerun] runs=1
[load] /tmp/bench.csv を読み込みます
st.cache_data.clear()の後: 3.12 秒 / 表示中の台数 21この実行では、1回目が3.86秒、別のセッションとして開き直した2回目が0.14秒でした。2回目は[load]の行が出ておらず、1回目の結果がそのまま使われています。CSVに1行追記した後は更新時刻が変わったので読み直しになり(3.13秒、21台)、st.cache_data.clear()の後も読み直しました(3.12秒)。秒数は1回だけ測った値で、マシンや回数によって変わります。
| キャッシュが外れる条件 | この記事での確認 | 結果 |
|---|---|---|
| 引数の値が変わる(このアプリでは更新時刻) | 上の測定の3回目 | 読み直した。台数が21台に増えた |
st.cache_data.clear()を呼ぶ | 上の測定の4回目 | 読み直した |
| Pythonのプロセスが別になる | 後の「つまずきポイント」の実行で、新しいプロセスの最初に[load]が出た | メモリ上のキャッシュは引き継がれない |
| ファイルの中身だけが変わる(引数は同じ) | stale_demo.py | 外れない。古い20台のまま |
| 関数の中のコードを書き換える | ドキュメントの記載のみ(この環境では試していません) | — |
ttlで決めた時間が過ぎる | ドキュメントの記載のみ(この環境では試していません) | — |
DBへの問い合わせのように更新時刻を取れない読み込みでは、@st.cache_data(ttl="10m")のように保持時間を決める方法があります。この記事のアプリでは使っていないので、使う場合は上の測定スクリプトの追記の代わりに時間をおいて、読み直しが起きることを確かめてから入れてください。
2回目は「別のセッション」でもキャッシュが使われました。キャッシュした値はアプリの全ユーザーが使える、とドキュメントにも書かれています。ログインした人によって結果が変わる関数(自分の部署の機器だけ返す、など)をキャッシュするときは、ユーザーや部署を必ず引数に入れてください。引数に入れないと、最初に開いた人の結果が他の人にも返ります。
アップロードの上限と、超えたときに利用者に見えるもの
サイドバーの「別の台帳CSVで確認する」は、手元のCSVをその場で表示するための部品で、server.maxUploadSize = 5の上限がかかっています。上限の前後の大きさのCSVを、ブラウザを動かすホスト側で作ります。中身は同じ1行を番号だけ変えて並べたものです。
$ { echo 'ホスト名,部署,種別,OS,最終確認日'; seq -f 'nb-bulk-%06g,総務部,ノートPC,Windows 11,2026-09-30' 1 35000; } > upload-2mb.csv
$ { echo 'ホスト名,部署,種別,OS,最終確認日'; seq -f 'nb-bulk-%06g,総務部,ノートPC,Windows 11,2026-09-30' 1 110000; } > upload-6mb.csv
$ ls -l upload-*.csv
-rw-r--r-- 1 root root 2065046 Oct 18 05:56 upload-2mb.csv
-rw-r--r-- 1 root root 6490046 Oct 18 05:56 upload-6mb.csvブラウザでhttp://127.0.0.1:18501/を開き、サイドバーのアップローダーにupload-6mb.csvを渡すと、ファイル名の下に次のエラーが出ました。表示中の台数は20台のまま変わりません。この確認は、ヘッドレスのChromium 153(Playwright 1.63.0から操作)で行いました。
upload-6mb.csv
6.5MB
Error: File must be 5.0MB or smaller.サーバー側のログを見ると、このときのセッションは[rerun] runs=1のままで、スクリプトは再実行されていませんでした。アプリのコードに大きすぎるファイルの処理を書かなくても、上限を超えたファイルはapp.pyまで届かないということです。upload-2mb.csv(表示は2.1MB)を渡したセッションはruns=2に進み、表示中の台数が35000台に変わり、サイドバーに「表示中: upload-2mb.csv(台帳ファイルは書き換えません)」と出ました。
上限は「業務で扱う最大のファイル+少しの余白」で決め、根拠をconfig.tomlのコメントに残しておくのがおすすめです。後でnginxを前に置くと、nginxにも既定で別の上限があり、2.1MBのファイルでもそちらで止まります。その表示はnginxの節で見ます。
AppTestでアプリの動作をテストする
ここまで手で確かめた動きを、pytestで繰り返し確かめられるようにします。テストは6つで、初期表示が20台であること、営業部と「nb-」で4台に絞れること、90日以上未確認の絞り込みが91日前のsrv-joshis-02を含めて6台になること、確認済みのリストが再実行をまたいで残ること、アップロードしたCSVに表示が切り替わること、書き出したCSVの先頭にBOMがあり日付が2026-09-30の形で出ることです。最後の1つはviewer_lib.pyを直接呼ぶ普通のテストで、残りがAppTestです。
cat > test_app.py <<'EOF'
import pandas as pd
from streamlit.testing.v1 import AppTest
from viewer_lib import filter_devices, to_excel_csv
def run_app():
# 台帳の読み込みに3秒かけているので、既定の3秒より長く待つ
return AppTest.from_file("app.py", default_timeout=10).run()
def test_initial_view_shows_all_devices():
at = run_app()
assert not at.exception
assert at.metric[0].value == "20"
def test_filters_narrow_the_table():
at = run_app()
at.multiselect(key="dept").set_value(["営業部"]).run()
at.text_input(key="keyword").input("nb-").run()
assert at.metric[0].value == "4"
assert set(at.dataframe[0].value["部署"]) == {"営業部"}
def test_stale_only_uses_newest_date_in_csv():
at = run_app()
at.checkbox(key="stale").check().run()
assert at.metric[0].value == "6"
assert "srv-joshis-02" in set(at.dataframe[0].value["ホスト名"]) # 91日前
def test_checked_list_survives_reruns():
at = run_app()
at.selectbox(key="target").select("nb-soumu-02").run()
at.button[0].click().run()
at.selectbox(key="target").select("ap-joshis-01").run()
at.button[0].click().run()
at.checkbox(key="stale").check().run() # 関係のない操作でもう一度再実行させる
assert at.markdown[-1].value == "確認済み: nb-soumu-02, ap-joshis-01"
def test_uploaded_csv_replaces_the_ledger():
at = run_app()
csv = "ホスト名,部署,種別,OS,最終確認日\nnb-test-01,総務部,ノートPC,Windows 11,2026-10-01\n"
at.sidebar.file_uploader[0].upload("new.csv", csv.encode("utf-8"), "text/csv").run()
assert at.metric[0].value == "1"
def test_csv_export_has_bom_and_plain_dates():
df = pd.read_csv("devices.csv", parse_dates=["最終確認日"])
data = to_excel_csv(filter_devices(df, ["総務部"], ["プリンター"]))
assert data.startswith(b"\xef\xbb\xbf")
assert data.decode("utf-8-sig").splitlines()[1] == "pr-soumu-01,総務部,プリンター,-,2026-09-30"
EOF$ ~/venv/bin/python -m pytest -q --durations=3
...... [100%]
============================= slowest 3 durations ==============================
3.53s call test_app.py::test_initial_view_shows_all_devices
0.25s call test_app.py::test_checked_list_survives_reruns
0.20s call test_app.py::test_filters_narrow_the_table
6 passed in 4.96s時間がかかったのは最初のテストだけで(3.53s)、残りは0.25s以下でした。キャッシュはpytestのプロセスの中で共有されるので、最初のテストが台帳の読み込みを肩代わりした形です。テストの結果がこの順番に左右されないよう、各テストはrun_app()で毎回新しいセッションから始めています。
ブラウザを使わずにスクリプトを動かす仕組みなので、前の節で見たブラウザ上のサイズエラーや、次に出てくるWebSocketの接続はAppTestの対象外です。画面のロジックはAppTest、配信経路はcurlとブラウザ、と分けて確かめます。
Dockerイメージにして127.0.0.1だけで公開する
開発用コンテナで作ったファイルを、ホストの作業ディレクトリへdocker cpで取り出し、そこにDockerfileを書きます。
docker cp streamlit-python-internal-app-dev:/home/ryoma/device-viewer ./device-viewer
cat > device-viewer/Dockerfile <<'EOF'
FROM ubuntu:26.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3-venv \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /tmp/requirements.txt
RUN python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r /tmp/requirements.txt
WORKDIR /app
COPY .streamlit/ .streamlit/
COPY app.py viewer_lib.py devices.csv ./
# イメージに最初からある一般ユーザー(UID 1000)で動かす
USER ubuntu
EXPOSE 8501
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s \
CMD ["/opt/venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"]
CMD ["/opt/venv/bin/streamlit", "run", "app.py"]
EOFDockerfileで選んだことと理由です。
--no-install-recommends:推奨パッケージを入れず、python3-venvとその依存だけにする- venvを
/opt/venvに作り、requirements.txtだけを入れる。pytestや練習用のスクリプトはイメージに入れない(COPYでファイルを名指しする) USER ubuntu:ubuntu:26.04のイメージに最初からいるUID 1000の一般ユーザーで動かす。rootで動かさないために新しいユーザーを作る手間が要らないHEALTHCHECK:ヘルスチェックのためだけにcurlを足さず、入っているPythonの標準ライブラリで/_stcore/healthを叩く。Docker向けのStreamlitのドキュメントの手順でも、このURLをヘルスチェックに使っているCMDに--server.addressを付けない。既定(未設定)のままで、起動ログのとおりコンテナ内の全アドレスで待ち受ける。外への公開範囲はdocker run -pで絞る
$ docker build -t streamlit-python-internal-app-viewer:1.0 device-viewer
$ docker image ls streamlit-python-internal-app-viewer
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
streamlit-python-internal-app-viewer:1.0 36d56aaa064c 865MB 204MBビルドの出力は長いので省きました。このホストのDockerでは旧来のビルダーが動き、DEPRECATED: The legacy builder is deprecatedの警告とStep 1/11からの表示になりました。BuildKitが使える環境では見た目が変わります。イメージの大きさはDISK USAGEが865MB、CONTENT SIZEが204MBでした。
起動して、ヘルスチェックの状態、ポートの公開範囲、動いているユーザーを確かめます。ヘルスチェックは10秒おきに走るので、15秒待ってから見ています。
$ docker run -d --name streamlit-python-internal-app-viewer --network streamlit-python-internal-app-net -p 127.0.0.1:18502:8501 --memory 1g streamlit-python-internal-app-viewer:1.0
$ sleep 15 && docker inspect --format '{{.State.Health.Status}}' streamlit-python-internal-app-viewer
healthy
$ curl -s http://127.0.0.1:18502/_stcore/health
ok
$ docker exec streamlit-python-internal-app-viewer id
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev)
$ docker exec streamlit-python-internal-app-viewer sh -c 'command -v sudo || echo "sudo: not installed"'
sudo: not installed
$ ss -ltn | grep -E ':(18501|18502) '
LISTEN 0 4096 127.0.0.1:18502 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:18501 0.0.0.0:*ssの出力で、18501番と18502番がどちらも127.0.0.1でだけ待ち受けていることがわかります。ホストの外向きのアドレスでは待ち受けていないので、LANの別のPCからこのポートへ届く経路はない設定です(別のPCからの接続はこの環境では試していません)。ubuntuユーザーはsudoグループに入っていますが、このイメージにはsudoコマンド自体を入れていません。
/_stcore/healthがokを返すのは、サーバーのプロセスが応答しているという意味までです。次の節の失敗する設定でも、nginx越しのヘルスチェックはokを返しましたが、ブラウザでは画面が出ませんでした。監視にはヘルスチェックに加えて、利用者と同じ経路でWebSocketがつながるかを見る確認(次の節のws_check.pyなど)を足してください。
nginxの後ろに置く:WebSocketのUpgradeヘッダーが要る理由
Streamlitの画面は、最初のHTMLとJavaScriptを普通のHTTPで受け取った後、/_stcore/streamにWebSocketでつなぎます。ドキュメントでは、この接続をつなぎ続けてサーバーからクライアントへ更新を送り、利用者ごとのセッションを保つと説明されています。nginxのドキュメントには、UpgradeとConnectionはホップごとのヘッダーなので、明示しないかぎりプロキシ先へは渡されないと書かれています。リバースプロキシとしてのnginxの書き方はnginxでリバースプロキシを構築する手順にまとめてあるので、ここではStreamlit特有の部分だけを扱います。
nginxもubuntu:26.04にaptで入れたイメージを作ります。設定ファイルはホストのnginx/conf.dに置き、ディレクトリごと読み取り専用でマウントします。まずはプロキシ先とHostヘッダーだけを書いた、よくある最小の設定で起動します。
mkdir -p nginx/conf.d
cat > nginx/Dockerfile <<'EOF'
FROM ubuntu:26.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx \
&& rm -rf /var/lib/apt/lists/* /etc/nginx/sites-enabled/default
CMD ["nginx", "-g", "daemon off;"]
EOF
docker build -t streamlit-python-internal-app-nginx:1.0 nginx
cat > nginx/conf.d/viewer.conf <<'EOF'
server {
listen 80;
location / {
proxy_pass http://streamlit-python-internal-app-viewer:8501;
proxy_set_header Host $host;
}
}
EOF
docker run -d --name streamlit-python-internal-app-nginx --network streamlit-python-internal-app-net -p 127.0.0.1:18080:80 --memory 256m -v "$PWD/nginx/conf.d:/etc/nginx/conf.d:ro" streamlit-python-internal-app-nginx:1.0$ docker exec streamlit-python-internal-app-nginx nginx -v
nginx version: nginx/1.28.3 (Ubuntu)
$ curl -s http://127.0.0.1:18080/_stcore/health
okヘルスチェックはokです。次に、WebSocketでつながるかを確かめる小さなスクリプトを開発用コンテナに作り、Dockerのネットワーク越しにnginxへつなぎます。第2引数に秒数を渡すと、その秒数だけ何も送らずに待ってから、接続がまだ生きているかをpingで確かめます。
cat > ws_check.py <<'EOF'
"""WebSocketの接続確認。使い方: python ws_check.py URL [無通信で待つ秒数]"""
import sys
import time
from websockets.sync.client import connect
url = sys.argv[1]
idle = int(sys.argv[2]) if len(sys.argv) > 2 else 0
with connect(url, open_timeout=5) as ws:
print("接続:", ws.response.status_code)
if idle:
time.sleep(idle)
print(f"{idle}秒待った後のping応答:", ws.ping().wait(timeout=5))
EOF$ ~/venv/bin/python ws_check.py ws://streamlit-python-internal-app-nginx/_stcore/stream
Traceback (most recent call last):
(省略)
websockets.exceptions.InvalidStatus: server rejected WebSocket connection: HTTP 200スクリプトは例外で終わりました。同じリクエストがnginxのアクセスログにどう残ったかを、ホスト側で見ます。
$ docker exec streamlit-python-internal-app-nginx grep -m 1 '_stcore/stream' /var/log/nginx/access.log
172.19.0.2 - - [18/Oct/2026:05:59:03 +0000] "GET /_stcore/stream HTTP/1.1" 200 6467 "-" "Python/3.14 websockets/17.2"WebSocketへの切り替えを求めたのに、返ってきたのは101 Switching Protocolsではなく200でした。nginxがUpgradeヘッダーを落としたので、Streamlitには普通のGETとして届き、普通に200で応答したわけです。nginxのアクセスログにも、/_stcore/streamへのGETがステータス200として残っています(上の出力)。エラーのステータスではないので、アクセスログの集計だけでは失敗に気づきにくい形です。
ブラウザでhttp://127.0.0.1:18080/を開くと、20秒待ってもタイトルの「機器台帳ビューア」が出ず、ページの本文から読み取れる文字はSkip to main contentだけでした(前の節と同じヘッドレスのChromiumで確認)。開発者ツールのコンソールには、次のエラーが繰り返し出ていました。
WebSocket connection to 'ws://127.0.0.1:18080/_stcore/stream' failed: Error during WebSocket handshake: Unexpected response code: 200
Client Error: WebSocket onerrorUpgradeとConnectionを渡す設定に書き換えます。mapは、クライアントがUpgradeを送ってきたときだけConnection: upgradeを、送ってこない普通のリクエストにはConnection: closeを渡すための対応表です。proxy_http_version 1.1は、nginxのドキュメントのWebSocketの例で、バージョン1.29.7より前の版向けに書かれている行です。Ubuntu 26.04のnginxは1.28.3なので入れています。書き換えたら、nginx -tで文法を確かめてから再読み込みします。
cat > nginx/conf.d/viewer.conf <<'EOF'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
location / {
proxy_pass http://streamlit-python-internal-app-viewer:8501;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
EOF$ docker exec streamlit-python-internal-app-nginx nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ docker exec streamlit-python-internal-app-nginx nginx -s reload
2026/10/18 05:59:29 [notice] 23#23: signal process started$ ~/venv/bin/python ws_check.py ws://streamlit-python-internal-app-nginx/_stcore/stream
接続: 101今度は101が返りました。ところが、この設定のままサイドバーからupload-2mb.csvを渡すと失敗します。ブラウザを使わずに確かめるには、アップロード用のURLへcurlで同じ大きさのデータを送ります。
$ curl -s -X PUT --data-binary @upload-2mb.csv http://127.0.0.1:18080/_stcore/upload_file/test/test
<html>
<head><title>413 Request Entity Too Large</title></head>
<body>
<center><h1>413 Request Entity Too Large</h1></center>
<hr><center>nginx/1.28.3 (Ubuntu)</center>
</body>
</html>
$ docker exec streamlit-python-internal-app-nginx tail -n 1 /var/log/nginx/error.log
2026/10/18 05:59:30 [error] 30#30: *114 client intended to send too large body: 2065046 bytes, client: 172.19.0.1, server: , request: "PUT /_stcore/upload_file/test/test HTTP/1.1", host: "127.0.0.1:18080"client_max_body_sizeの既定値は、nginxでは1m(1MB)です。超えると413を返す、とドキュメントにも書かれています。ブラウザではアップローダーに次の表示が出て、台数は20台のままでした。
upload-2mb.csv
2.1MB
Error: AxiosError: Request failed with status code 413nginxには、Streamlit側の上限(5MB)にmultipartの区切りなどの余白を足した6mを設定します。
cat > nginx/conf.d/viewer.conf <<'EOF'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
# Streamlit側の上限(5MB)に、multipartの区切りなどの余白を足す
client_max_body_size 6m;
location / {
proxy_pass http://streamlit-python-internal-app-viewer:8501;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
EOF$ docker exec streamlit-python-internal-app-nginx nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ docker exec streamlit-python-internal-app-nginx nginx -s reload
2026/10/18 05:59:39 [notice] 45#45: signal process started
$ curl -s -X PUT --data-binary @upload-2mb.csv http://127.0.0.1:18080/_stcore/upload_file/test/test
XSRF token missing or invalid応答が、nginxが返す413のHTMLから、StreamlitのXSRF token missing or invalidに変わりました。curlからの送信はXSRFトークンを持っていないのでStreamlitに断られますが、リクエストがnginxを通り抜けてStreamlitまで届いたことはこれでわかります。ブラウザからupload-2mb.csvを渡すと、今度は表示中の台数が35000台になりました。一方、upload-6mb.csvはnginx経由でも前の節と同じError: File must be 5.0MB or smaller.になり、nginxのアクセスログにはこのファイルのアップロードのリクエストが残っていませんでした。5MBの確認は、ファイルを送る前にブラウザ側で行われています。
最後に、長く操作しないときに接続が切れないかを見ます。プロキシ先が60秒何も送らないと既定で接続を閉じる、とnginxのドキュメントにはあり、対策としてproxy_read_timeoutを延ばす方法と、プロキシ先からpingを送る方法が挙げられています。
$ ~/venv/bin/python ws_check.py ws://streamlit-python-internal-app-nginx/_stcore/stream 75
接続: 101
75秒待った後のping応答: Trueこちらからは何も送らずに75秒待っても接続は生きていて、pingに応答が返りました。既定の設定のStreamlit 1.66.0とnginx 1.28.3の組み合わせでは、proxy_read_timeoutを延ばさなくても切れなかったので、設定には入れていません。別のロードバランサーを挟む場合や、Streamlitのserver.websocketPingIntervalを変えた場合は、同じコマンドで確かめ直してください。
認証:st.loginか、既存のSSOを前に置くか
Streamlit自身が持つ認証はst.loginです。使えるのは、ドキュメントによるとOpenID Connect(OIDC)に対応したIdPで、汎用のOAuthのプロバイダーは使えません。.streamlit/secrets.tomlの[auth]にredirect_uriとcookie_secretを、IdPごとにclient_id・client_secret・server_metadata_urlを書き、パッケージとしてAuthlibのバージョン1.3.2以上を入れる必要があります。ログイン後はst.user.is_logged_inでログイン済みかを判定でき、IdPが返したIDトークンのクレーム(emailやnameなど)をst.userから読めます。この記事ではIdPを用意していないため、st.loginは実行していません。
もう1つの方法は、アプリには手を入れず、社内のIdP(Microsoft Entra ID、Google Workspace、Keycloakなど)とOIDCで連携する認証プロキシをnginxの前段に置くやり方です。IdPを自前で立てる場合の手順はKeycloakでSSO基盤を構築する手順で扱っています。こちらの構成もこの記事では動かしていません。
選び方の目安です。棚卸しの記録に確認者のメールアドレスを残すなど、画面の中で「誰が操作したか」を使うならst.loginにします。アプリは表示するだけで、社内の他のツールと同じログイン画面にそろえたい、アプリごとにIdPへの登録を増やしたくない、という場合は前段の認証プロキシにします。どちらにする場合も、Streamlitのポートを直接社内LANに出さず、認証を通った経路からだけ届く形にしておきます。
社内ツールの選び方:Streamlit・Flask/FastAPI・BIツール
社内ツールの作り方を決めるときに見る観点を、3つの選択肢で並べました。
| 観点 | Streamlit | Flask / FastAPI | BIツール |
|---|---|---|---|
| 作る人 | pandasでデータを扱えるPythonの書き手が1人いればよい | HTMLのテンプレートやAPIの設計も書ける人が要る | コードを書かずに作れる。製品の操作を覚える人が要る |
| 処理の単位 | 操作のたびにスクリプト全体を再実行 | リクエストごとに、URLに対応する関数を呼ぶ | 画面の部品ごとに集計の問い合わせを流す |
| 画面の自由度 | 部品を上から順に置く。配置の細かい調整は苦手 | HTMLとJavaScriptで自由に作れる | 製品が用意するグラフと表の範囲 |
| 向く用途 | CSVやDBの絞り込み、小さな入力フォーム、確認用の道具 | 他のシステムから呼ばれるAPI、画面遷移や権限が細かい業務アプリ | 決まった集計を多くの人が毎日見るダッシュボード |
| ログイン | st.login(OIDC)か前段の認証プロキシ | ライブラリを組み込んで自前で実装 | 製品の機能とライセンスで決まる |
判断のルールにすると次のとおりです。データを触るのが情シスの1〜2人で、画面を数日で用意したいならStreamlitにします。他のシステムから呼ばれるAPIが主役のときや、画面ごとに権限を細かく分けるときは、最初からFastAPIかFlaskで書きます。集計の形がほぼ固まっていて、見る人が多く、データの更新も定期的なら、BIツールのほうが運用が軽く済みます。また、1回の実行に何十秒もかかる集計をStreamlitの画面の中で回す設計は避けます。再実行のたびに走るからです。st.cache_dataで抑えきれないなら、集計はバッチで済ませてCSVやDBに置き、Streamlitはそれを読むだけにします。
つまずきポイント
python app.py で起動すると警告が並び、ブラウザで開けない
streamlit runではなくpython app.pyで動かすと、スクリプトは最後まで実行されますが、Webサーバーは立ち上がりません。missing ScriptRunContext!の警告が大量に並び、その中に次の行が混じります。
$ ~/venv/bin/python app.py
2026-10-18 06:01:07.377 WARNING streamlit.runtime.state.session_state_proxy: Session state does not function when running a script without `streamlit run`
(省略)
streamlit run app.py [ARGUMENTS]途中には黄色い文字でWarning: to view this Streamlit app on a browser, run it with the following command:とも出ます。終了コードは0なので、シェルスクリプトから起動していると失敗に気づきません。~/venv/bin/streamlit run app.pyで起動し直します。
AppTest script run timed out after 3(s)
AppTestは、1回の実行を既定で3秒まで待ちます。台帳の読み込みに3秒かけているこのアプリを既定のまま動かすと、キャッシュがない最初の実行で時間切れになります。
$ ~/venv/bin/python -c 'from streamlit.testing.v1 import AppTest; AppTest.from_file("app.py").run()'
[rerun] runs=1
[load] devices.csv を読み込みます
(省略)
RuntimeError: AppTest script run timed out after 3(s)対処は、AppTest.from_file("app.py", default_timeout=10)のように待ち時間を延ばすか、at.run(timeout=10)で1回分だけ延ばします。この実行でも、新しいプロセスなので[load]が出てCSVを読み直していることがわかります。
アップロードのエラー表示から、どこの上限かを見分ける
アップローダーに出る文言で、どこで止まったかがわかります。Error: File must be 5.0MB or smaller.はStreamlitのserver.maxUploadSizeによるもので、ファイルを送る前にブラウザの段階で止まっています(nginxにもリクエストは届いていません)。Error: AxiosError: Request failed with status code 413は、この構成ではnginxのclient_max_body_sizeで、nginxのerror.logにclient intended to send too large bodyが残ります。前者はconfig.tomlを、後者はnginxの設定を直します。上限の大小は、nginx側をStreamlit側より少し大きくしておくと、利用者に見えるのはStreamlitの分かりやすいほうの文言になります。
ヘルスチェックはokなのに画面の中身が出ない
この症状のときは、ブラウザのコンソールにError during WebSocket handshake: Unexpected response code: 200が出ていないかを見ます。出ていれば、途中のプロキシがUpgradeヘッダーを渡していません。nginxなら、前の節の2つ目の設定のようにmapを置き、UpgradeとConnectionをproxy_set_headerで渡します。
動作確認
3つのコンテナがすべて127.0.0.1でだけ公開され、viewerがhealthyであること、nginx越しにヘルスチェックとWebSocketが通ること、テストが通ることを順に確かめます。
$ docker ps --filter name=streamlit-python-internal-app --format '{{.Names}} {{.Status}} {{.Ports}}'
streamlit-python-internal-app-nginx Up 2 minutes 127.0.0.1:18080->80/tcp
streamlit-python-internal-app-viewer Up 2 minutes (healthy) 127.0.0.1:18502->8501/tcp
streamlit-python-internal-app-dev Up 6 minutes 127.0.0.1:18501->8501/tcp
$ curl -s http://127.0.0.1:18080/_stcore/health
ok$ ~/venv/bin/python ws_check.py ws://streamlit-python-internal-app-nginx/_stcore/stream
接続: 101
$ ~/venv/bin/python -m pytest -q
...... [100%]
6 passed in 5.47sブラウザでhttp://127.0.0.1:18080/を開き、サイドバーの「90日以上確認されていない機器だけ」にチェックを入れると、テストで確かめたとおり表示中の台数が6台に絞られ、棒グラフと表もその6台分に変わります。
試し終えたら、作ったコンテナ・ネットワーク・イメージを消します。
docker rm -f streamlit-python-internal-app-nginx streamlit-python-internal-app-viewer streamlit-python-internal-app-dev
docker network rm streamlit-python-internal-app-net
docker image rm streamlit-python-internal-app-nginx:1.0 streamlit-python-internal-app-viewer:1.0よくある質問
Streamlit Community Cloudの料金はいくらですか?
公式ドキュメントでは、Community Cloudでのアプリの作成・デプロイ・管理は無料とされています(執筆時点)。リソースは2024年2月時点の目安として、メモリ690MB〜2.7GB、ストレージ最大50GBと書かれ、12時間アクセスのないアプリはスリープします。GitHubのリポジトリから外部のサービスに載せる形なので、社内の台帳を扱うなら社内規程を先に確認してください。
StreamlitをPythonにインストールするにはどうすればいいですか?
仮想環境を作ってpip install streamlitで入れます。この記事のUbuntu 26.04では、先にpython3-venvをaptで入れ、python3 -m venvで環境を作りました。配布するアプリではrequirements.txtにstreamlit==1.66.0のように版を固定すると、開発時とイメージの中で同じ版がそろいます。
StreamlitとFlaskの違いは何ですか?
Flaskは、URLごとに書いた関数をリクエストのたびに呼ぶWebフレームワークで、画面はHTMLのテンプレートで作ります。一方のStreamlitは、1本のスクリプトを部品の操作のたびに上から実行し直して画面を組み立てます。データを絞り込んで見せる社内ツールならStreamlit、APIや画面遷移の多い業務アプリならFlaskが向きます。
社内公開するStreamlitアプリに足すべき設定は?
既定ではログインがないので、ポートは127.0.0.1か社内の限られた経路にだけ出し、st.loginか前段の認証プロキシで認証を付けます。nginxを前に置くなら、WebSocketのUpgrade・Connectionヘッダーとclient_max_body_sizeをStreamlitの設定に合わせます。
まとめ
Streamlitは、操作のたびにスクリプト全体を上から実行し直す前提で書くと素直に動きます。消えては困る値はst.session_stateに、遅い読み込みはst.cache_dataに入れ、ファイルを読むならキャッシュのキーに更新時刻を含めます。画面のロジックはAppTest、配信経路はcurlとブラウザで確かめ、nginxではWebSocketのヘッダーとアップロードの上限をStreamlitに合わせます。自分の環境では、まず手元のCSVを1つ選び、この記事のdevices.csvと差し替えてpytestのテストを書き直すところから始めてください。
次に読む
- Docker Compose入門:Web・DB・リバースプロキシをまとめて動かす(今回の3つのコンテナを1つのファイルで管理する)
- nginxでリバースプロキシを構築する(HTTPSの終端や複数アプリの振り分け)
- KeycloakでSSO基盤を構築する(
st.loginや認証プロキシにつなぐIdP)