Skip to content

Commit e9313f6

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 9d584de commit e9313f6

2 files changed

Lines changed: 43 additions & 6 deletions

File tree

Dockerfile

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ RUN rm -rf openms-build
113113
# Prepare and run streamlit app.
114114
FROM compile-openms AS run-app
115115

116-
# Install Redis server for job queue
117-
RUN apt-get update && apt-get install -y --no-install-recommends redis-server \
116+
# Install Redis server for job queue and nginx for load balancing
117+
RUN apt-get update && apt-get install -y --no-install-recommends redis-server nginx \
118118
&& rm -rf /var/lib/apt/lists/*
119119

120120
# Create Redis data directory
@@ -147,6 +147,10 @@ RUN echo "0 3 * * * /root/miniforge3/envs/streamlit-env/bin/python /app/clean-up
147147
ENV RQ_WORKER_COUNT=1
148148
ENV REDIS_URL=redis://localhost:6379/0
149149

150+
# Number of Streamlit server instances for load balancing (default: 1 = no load balancer)
151+
# Set to >1 to enable nginx load balancer with multiple Streamlit instances
152+
ENV STREAMLIT_SERVER_COUNT=1
153+
150154
# create entrypoint script to start cron, Redis, RQ workers, and Streamlit
151155
RUN echo -e '#!/bin/bash\n\
152156
set -e\n\
@@ -173,9 +177,39 @@ for i in $(seq 1 $WORKER_COUNT); do\n\
173177
rq worker openms-workflows --url $REDIS_URL --name worker-$i &\n\
174178
done\n\
175179
\n\
176-
# Start Streamlit (foreground - main process)\n\
177-
echo "Starting Streamlit app..."\n\
178-
exec streamlit run app.py\n\
180+
# Load balancer setup\n\
181+
SERVER_COUNT=${STREAMLIT_SERVER_COUNT:-1}\n\
182+
\n\
183+
if [ "$SERVER_COUNT" -gt 1 ]; then\n\
184+
echo "Starting $SERVER_COUNT Streamlit instances with nginx load balancer..."\n\
185+
\n\
186+
# Generate nginx upstream block\n\
187+
UPSTREAM_SERVERS=""\n\
188+
BASE_PORT=8510\n\
189+
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
190+
PORT=$((BASE_PORT + i))\n\
191+
UPSTREAM_SERVERS="${UPSTREAM_SERVERS} server 127.0.0.1:${PORT};\\n"\n\
192+
done\n\
193+
\n\
194+
# Write nginx config\n\
195+
mkdir -p /etc/nginx\n\
196+
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\
197+
\n\
198+
# Start Streamlit instances on internal ports (localhost only)\n\
199+
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
200+
PORT=$((BASE_PORT + i))\n\
201+
echo "Starting Streamlit instance on port $PORT..."\n\
202+
streamlit run app.py --server.port $PORT --server.address 127.0.0.1 &\n\
203+
done\n\
204+
\n\
205+
sleep 2\n\
206+
echo "Starting nginx load balancer on port 8501..."\n\
207+
exec /usr/sbin/nginx -g "daemon off;"\n\
208+
else\n\
209+
# Single instance mode (default) - run Streamlit directly on port 8501\n\
210+
echo "Starting Streamlit app..."\n\
211+
exec streamlit run app.py\n\
212+
fi\n\
179213
' > /app/entrypoint.sh
180214
# make the script executable
181215
RUN chmod +x /app/entrypoint.sh

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)