Skip to content

Commit 126a74b

Browse files
t0mdavid-mclaude
andauthored
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 <[email protected]>
1 parent eb9c205 commit 126a74b

File tree

3 files changed

+89
-11
lines changed

3 files changed

+89
-11
lines changed

Dockerfile

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

122-
# Install Redis server for job queue
123-
RUN apt-get update && apt-get install -y --no-install-recommends redis-server \
122+
# Install Redis server for job queue and nginx for load balancing
123+
RUN apt-get update && apt-get install -y --no-install-recommends redis-server nginx \
124124
&& rm -rf /var/lib/apt/lists/*
125125

126126
# Create Redis data directory
@@ -154,6 +154,10 @@ RUN echo "0 3 * * * /root/miniforge3/envs/streamlit-env/bin/python /app/clean-up
154154
ENV RQ_WORKER_COUNT=1
155155
ENV REDIS_URL=redis://localhost:6379/0
156156

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

Dockerfile_simple

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ USER root
2525

2626
RUN apt-get -y update
2727
# note: streamlit in docker needs libgtk2.0-dev (see https://yugdamor.medium.com/importerror-libgthread-2-0-so-0-cannot-open-shared-object-file-no-such-file-or-directory-895b94a7827b)
28-
RUN apt-get install -y --no-install-recommends --no-install-suggests wget ca-certificates libgtk2.0-dev curl jq cron
28+
RUN apt-get install -y --no-install-recommends --no-install-suggests wget ca-certificates libgtk2.0-dev curl jq cron nginx
2929
RUN update-ca-certificates
3030

3131
# Install Github CLI
@@ -84,11 +84,52 @@ COPY clean-up-workspaces.py /app/clean-up-workspaces.py
8484
# add cron job to the crontab
8585
RUN echo "0 3 * * * /root/miniforge3/envs/streamlit-env/bin/python /app/clean-up-workspaces.py >> /app/clean-up-workspaces.log 2>&1" | crontab -
8686

87+
# Number of Streamlit server instances for load balancing (default: 1 = no load balancer)
88+
# Set to >1 to enable nginx load balancer with multiple Streamlit instances
89+
ENV STREAMLIT_SERVER_COUNT=1
90+
8791
# create entrypoint script to start cron service and launch streamlit app
88-
RUN echo "#!/bin/bash" > /app/entrypoint.sh
89-
RUN echo "source /root/miniforge3/bin/activate streamlit-env" >> /app/entrypoint.sh && \
90-
echo "service cron start" >> /app/entrypoint.sh && \
91-
echo "streamlit run app.py" >> /app/entrypoint.sh
92+
RUN echo -e '#!/bin/bash\n\
93+
set -e\n\
94+
source /root/miniforge3/bin/activate streamlit-env\n\
95+
\n\
96+
# Start cron for workspace cleanup\n\
97+
service cron start\n\
98+
\n\
99+
# Load balancer setup\n\
100+
SERVER_COUNT=${STREAMLIT_SERVER_COUNT:-1}\n\
101+
\n\
102+
if [ "$SERVER_COUNT" -gt 1 ]; then\n\
103+
echo "Starting $SERVER_COUNT Streamlit instances with nginx load balancer..."\n\
104+
\n\
105+
# Generate nginx upstream block\n\
106+
UPSTREAM_SERVERS=""\n\
107+
BASE_PORT=8510\n\
108+
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
109+
PORT=$((BASE_PORT + i))\n\
110+
UPSTREAM_SERVERS="${UPSTREAM_SERVERS} server 127.0.0.1:${PORT};\\n"\n\
111+
done\n\
112+
\n\
113+
# Write nginx config\n\
114+
mkdir -p /etc/nginx\n\
115+
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\
116+
\n\
117+
# Start Streamlit instances on internal ports (localhost only)\n\
118+
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
119+
PORT=$((BASE_PORT + i))\n\
120+
echo "Starting Streamlit instance on port $PORT..."\n\
121+
streamlit run app.py --server.port $PORT --server.address 127.0.0.1 &\n\
122+
done\n\
123+
\n\
124+
sleep 2\n\
125+
echo "Starting nginx load balancer on port 8501..."\n\
126+
exec /usr/sbin/nginx -g "daemon off;"\n\
127+
else\n\
128+
# Single instance mode (default) - run Streamlit directly on port 8501\n\
129+
echo "Starting Streamlit app..."\n\
130+
exec streamlit run app.py\n\
131+
fi\n\
132+
' > /app/entrypoint.sh
92133
# make the script executable
93134
RUN chmod +x /app/entrypoint.sh
94135

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)