Skip to content

Commit 20cf2de

Browse files
t0mdavid-mclaude
andcommitted
Add nginx load balancing support for multi-instance Streamlit deployments (#336)
* Add nginx load balancer for scaling Streamlit in a single container When STREAMLIT_SERVER_COUNT > 1, the entrypoint dynamically generates an nginx config and launches multiple Streamlit instances on internal ports (8510+), with nginx on port 8501 using ip_hash sticky sessions for WebSocket compatibility. Default (STREAMLIT_SERVER_COUNT=1) preserves existing behavior with no nginx overhead. https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 * Fix nginx config: create /etc/nginx directory before writing config https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 * Fix nginx: use absolute path /usr/sbin/nginx The mamba environment activation shadows system binaries on the PATH. https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 * Switch nginx from ip_hash to least_conn for load balancing ip_hash pins all users behind the same NAT/VPN/reverse-proxy to a single backend, defeating the load balancer. least_conn distributes new connections to the instance with fewest active connections, and once a WebSocket is established it stays on that backend for the session lifetime, so sticky sessions are not needed. https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 * Fix file uploads: disable nginx client_max_body_size limit nginx defaults to 1MB max body size, which blocks Streamlit file uploads with a 400 error. Set to 0 (unlimited) to let Streamlit enforce its own 200MB limit from config.toml. https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 * Fix file uploads: switch to hash-based sticky sessions least_conn routes each HTTP request independently, so the file upload POST (/_stcore/upload_file) can land on a different backend than the WebSocket session, causing a 400 error. Use hash $remote_addr$http_x_forwarded_for consistent instead: - Provides session affinity so uploads hit the correct backend - Behind a reverse proxy: XFF header differentiates real client IPs - Direct connections: falls back to remote_addr (like ip_hash) - "consistent" minimizes redistribution when backends are added/removed https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 * Implement cookie-based sticky sessions for nginx load balancer Replace ip_hash/hash-on-IP with cookie-based session affinity using nginx's built-in map and $request_id: - map $cookie_stroute $route_key: if browser has a "stroute" cookie, reuse its value; otherwise fall back to $request_id (a unique random hex string nginx generates per-request) - hash $route_key consistent: route based on the cookie/random value - add_header Set-Cookie on every response to persist the routing key This ensures each browser gets its own sticky backend regardless of source IP, fixing both: - File uploads (POST must hit the same backend as the WebSocket session) - Load distribution when all users share the same IP (NAT/VPN/proxy) No new packages required - uses only built-in nginx directives. https://claude.ai/code/session_018VEL5xKZfe4LCcUa8iUHJ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e8af5fe commit 20cf2de

File tree

2 files changed

+55
-6
lines changed

2 files changed

+55
-6
lines changed

Dockerfile

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ RUN mamba install pip
5858
RUN python -m pip install --upgrade pip
5959
RUN python -m pip install -r requirements.txt
6060

61+
RUN apt-get update && apt-get install -y --no-install-recommends nginx
6162

6263
# create workdir and copy over all streamlit related files/folders
6364
WORKDIR /app
@@ -81,11 +82,56 @@ COPY clean-up-workspaces.py /app/clean-up-workspaces.py
8182
# add cron job to the crontab
8283
RUN echo "0 3 * * * /root/miniforge3/envs/streamlit-env/bin/python /app/clean-up-workspaces.py >> /app/clean-up-workspaces.log 2>&1" | crontab -
8384

84-
# create entrypoint script to start cron service and launch streamlit app
85-
RUN echo "#!/bin/bash" > /app/entrypoint.sh
86-
RUN echo "source /root/miniforge3/bin/activate streamlit-env" >> /app/entrypoint.sh && \
87-
echo "service cron start" >> /app/entrypoint.sh && \
88-
echo "streamlit run app.py" >> /app/entrypoint.sh
85+
# Set default worker count (can be overridden via environment variable)
86+
ENV RQ_WORKER_COUNT=1
87+
ENV REDIS_URL=redis://localhost:6379/0
88+
89+
# Number of Streamlit server instances for load balancing (default: 1 = no load balancer)
90+
# Set to >1 to enable nginx load balancer with multiple Streamlit instances
91+
ENV STREAMLIT_SERVER_COUNT=1
92+
93+
# create entrypoint script to start cron, Redis, RQ workers, and Streamlit
94+
RUN echo -e '#!/bin/bash\n\
95+
set -e\n\
96+
source /root/miniforge3/bin/activate streamlit-env\n\
97+
\n\
98+
# Start cron for workspace cleanup\n\
99+
service cron start\n\
100+
\n\
101+
# Load balancer setup\n\
102+
SERVER_COUNT=${STREAMLIT_SERVER_COUNT:-1}\n\
103+
\n\
104+
if [ "$SERVER_COUNT" -gt 1 ]; then\n\
105+
echo "Starting $SERVER_COUNT Streamlit instances with nginx load balancer..."\n\
106+
\n\
107+
# Generate nginx upstream block\n\
108+
UPSTREAM_SERVERS=""\n\
109+
BASE_PORT=8510\n\
110+
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
111+
PORT=$((BASE_PORT + i))\n\
112+
UPSTREAM_SERVERS="${UPSTREAM_SERVERS} server 127.0.0.1:${PORT};\\n"\n\
113+
done\n\
114+
\n\
115+
# Write nginx config\n\
116+
mkdir -p /etc/nginx\n\
117+
echo -e "worker_processes auto;\\npid /run/nginx.pid;\\n\\nevents {\\n worker_connections 1024;\\n}\\n\\nhttp {\\n client_max_body_size 0;\\n\\n map \\$cookie_stroute \\$route_key {\\n \\x22\\x22 \\$request_id;\\n default \\$cookie_stroute;\\n }\\n\\n upstream streamlit_backend {\\n hash \\$route_key consistent;\\n${UPSTREAM_SERVERS} }\\n\\n map \\$http_upgrade \\$connection_upgrade {\\n default upgrade;\\n \\x27\\x27 close;\\n }\\n\\n server {\\n listen 8501;\\n\\n location / {\\n proxy_pass http://streamlit_backend;\\n proxy_http_version 1.1;\\n proxy_set_header Upgrade \\$http_upgrade;\\n proxy_set_header Connection \\$connection_upgrade;\\n proxy_set_header Host \\$host;\\n proxy_set_header X-Real-IP \\$remote_addr;\\n proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for;\\n proxy_set_header X-Forwarded-Proto \\$scheme;\\n proxy_read_timeout 86400;\\n proxy_send_timeout 86400;\\n proxy_buffering off;\\n add_header Set-Cookie \\x22stroute=\\$route_key; Path=/; HttpOnly; SameSite=Lax\\x22 always;\\n }\\n }\\n}" > /etc/nginx/nginx.conf\n\
118+
\n\
119+
# Start Streamlit instances on internal ports (localhost only)\n\
120+
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
121+
PORT=$((BASE_PORT + i))\n\
122+
echo "Starting Streamlit instance on port $PORT..."\n\
123+
streamlit run app.py --server.port $PORT --server.address 127.0.0.1 &\n\
124+
done\n\
125+
\n\
126+
sleep 2\n\
127+
echo "Starting nginx load balancer on port 8501..."\n\
128+
exec /usr/sbin/nginx -g "daemon off;"\n\
129+
else\n\
130+
# Single instance mode (default) - run Streamlit directly on port 8501\n\
131+
echo "Starting Streamlit app..."\n\
132+
exec streamlit run app.py\n\
133+
fi\n\
134+
' > /app/entrypoint.sh
89135
# make the script executable
90136
RUN chmod +x /app/entrypoint.sh
91137

docker-compose.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ services:
1212
- 8501:8501
1313
volumes:
1414
- workspaces-streamlit-template:/workspaces-streamlit-template
15-
command: streamlit run openms-streamlit-template/app.py
15+
environment:
16+
# Number of Streamlit server instances (default: 1 = no load balancer).
17+
# Set to >1 to enable nginx load balancing across multiple Streamlit instances.
18+
- STREAMLIT_SERVER_COUNT=1
1619
volumes:
1720
workspaces-streamlit-template:

0 commit comments

Comments
 (0)