Skip to content

Commit b36fb90

Browse files
authored
Merge pull request #387 from OpenMS/claude/fix-opendiakiosk-apptainer-Sdrl8
Support Apptainer/Singularity with read-only root filesystem
2 parents 3c59a2f + dd17f25 commit b36fb90

5 files changed

Lines changed: 333 additions & 106 deletions

File tree

.github/workflows/build-and-test.yml

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,103 @@ jobs:
111111
path: /tmp/image.tar
112112
retention-days: 1
113113

114+
test-apptainer:
115+
# Apptainer/Singularity is the dominant container runtime on HPC clusters.
116+
# It mounts the root filesystem read-only and runs as the host user's UID
117+
# (not root inside the image). The entrypoint must tolerate both: this job
118+
# exercises that contract by running the built image under apptainer and
119+
# waiting for the streamlit /_stcore/health endpoint to come up.
120+
needs: build
121+
runs-on: ubuntu-latest
122+
strategy:
123+
fail-fast: false
124+
matrix:
125+
variant: [full, simple]
126+
steps:
127+
- uses: actions/checkout@v4
128+
129+
- name: Download image artifact
130+
uses: actions/download-artifact@v4
131+
with:
132+
name: openms-streamlit-${{ matrix.variant }}-image
133+
path: /tmp
134+
135+
- name: Install apptainer
136+
uses: eWaterCycle/setup-apptainer@v2
137+
with:
138+
apptainer-version: 1.3.4
139+
140+
- name: Build SIF from docker-archive
141+
run: |
142+
sudo apptainer build /tmp/openms.sif docker-archive:///tmp/image.tar
143+
sudo chmod a+r /tmp/openms.sif
144+
145+
- name: Start apptainer instance (read-only root, host UID)
146+
run: |
147+
# Default apptainer semantics: read-only root, no --writable-tmpfs.
148+
# This matches how users on HPC clusters run the SIF.
149+
# Use `instance run` (apptainer 1.1+), not `instance start`: the SIF
150+
# was built from docker-archive, which populates %runscript with the
151+
# Docker ENTRYPOINT but leaves %startscript as the default no-op
152+
# `exec "$@"`. `instance start` would launch an empty instance and
153+
# streamlit would never bind 8501.
154+
apptainer instance run /tmp/openms.sif openms-test
155+
apptainer instance list
156+
# Record where this run's logs will land so subsequent steps can tail
157+
# them deterministically (path depends on hostname/user).
158+
LOG_DIR=$(find "$HOME/.apptainer/instances/logs" -type d -name "$(whoami)" 2>/dev/null | head -n 1)
159+
echo "APPTAINER_LOG_DIR=${LOG_DIR}" >> "$GITHUB_ENV"
160+
ls -la "$LOG_DIR" || true
161+
162+
- name: Wait for streamlit /_stcore/health
163+
run: |
164+
# Tail the entrypoint's stdout/stderr alongside the health probe so
165+
# any startup failure surfaces directly in the CI log (the dedicated
166+
# "Dump entrypoint logs on failure" step is post-mortem only and
167+
# easy to miss in the GH Actions UI).
168+
OUT="${APPTAINER_LOG_DIR}/openms-test.out"
169+
ERR="${APPTAINER_LOG_DIR}/openms-test.err"
170+
for i in $(seq 1 90); do
171+
if curl -fsSo /dev/null --max-time 2 http://127.0.0.1:8501/_stcore/health; then
172+
echo "Streamlit is ready after $i attempts"
173+
exit 0
174+
fi
175+
if [ $((i % 5)) -eq 0 ]; then
176+
echo "--- attempt $i: instance log tail ---"
177+
tail -n 20 "$OUT" 2>/dev/null || echo "(no $OUT yet)"
178+
tail -n 10 "$ERR" 2>/dev/null || echo "(no $ERR yet)"
179+
apptainer instance list || true
180+
fi
181+
sleep 2
182+
done
183+
echo "TIMED OUT waiting for streamlit health endpoint"
184+
echo "--- full entrypoint stdout ---"
185+
cat "$OUT" 2>/dev/null || echo "(missing)"
186+
echo "--- full entrypoint stderr ---"
187+
cat "$ERR" 2>/dev/null || echo "(missing)"
188+
exit 1
189+
190+
- name: Verify health endpoint returns 200
191+
run: curl -fsS http://127.0.0.1:8501/_stcore/health
192+
193+
- name: Verify Redis is reachable inside container (full variant)
194+
if: matrix.variant == 'full'
195+
run: |
196+
apptainer exec instance://openms-test redis-cli ping | grep -i pong
197+
198+
- name: Dump entrypoint logs on failure
199+
if: failure()
200+
run: |
201+
echo "--- apptainer instance list ---"
202+
apptainer instance list || true
203+
echo "--- apptainer instance logs ---"
204+
find "$HOME/.apptainer" \( -name '*.out' -o -name '*.err' \) 2>/dev/null \
205+
| while read -r f; do echo "=== $f ==="; cat "$f"; done || true
206+
207+
- name: Stop apptainer instance
208+
if: always()
209+
run: apptainer instance stop openms-test || true
210+
114211
test-nginx:
115212
needs: build
116213
runs-on: ubuntu-latest

Dockerfile

Lines changed: 16 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ RUN wget -q \
4747
&& rm -f Miniforge3-Linux-x86_64.sh
4848
RUN mamba --version
4949

50+
# Make /root traversable so the entrypoint can `source
51+
# /root/miniforge3/bin/activate ...` when the container runs as a non-root
52+
# user (apptainer/singularity maps the host UID into the container; the
53+
# default ubuntu /root is 0700 which would block path traversal). +x only,
54+
# not +r, so the directory listing remains private.
55+
RUN chmod o+x /root
56+
5057
# Setup mamba environment.
5158
RUN mamba create -n streamlit-env python=3.10
5259
RUN echo "mamba activate streamlit-env" >> ~/.bashrc
@@ -120,8 +127,11 @@ FROM compile-openms AS run-app
120127
RUN apt-get update && apt-get install -y --no-install-recommends redis-server nginx \
121128
&& rm -rf /var/lib/apt/lists/*
122129

123-
# Create Redis data directory
124-
RUN mkdir -p /var/lib/redis && chown redis:redis /var/lib/redis
130+
# Create Redis data directory. Default 0755 root-owned is enough: the docker
131+
# entrypoint runs as root (can write regardless of mode), and the apptainer
132+
# entrypoint relocates Redis state to /tmp/openms-runtime-* so this dir is
133+
# never written under apptainer.
134+
RUN mkdir -p /var/lib/redis
125135

126136
# Create workdir and copy over all streamlit related files/folders.
127137

@@ -155,67 +165,10 @@ ENV REDIS_URL=redis://localhost:6379/0
155165
# Set to >1 to enable nginx load balancer with multiple Streamlit instances
156166
ENV STREAMLIT_SERVER_COUNT=1
157167

158-
# create entrypoint script to start cron, Redis, RQ workers, and Streamlit
159-
RUN echo -e '#!/bin/bash\n\
160-
set -e\n\
161-
source /root/miniforge3/bin/activate streamlit-env\n\
162-
\n\
163-
# Start cron for workspace cleanup\n\
164-
service cron start\n\
165-
\n\
166-
# Start Redis server in background\n\
167-
echo "Starting Redis server..."\n\
168-
redis-server --daemonize yes --dir /var/lib/redis --appendonly no\n\
169-
\n\
170-
# Wait for Redis to be ready\n\
171-
until redis-cli ping > /dev/null 2>&1; do\n\
172-
echo "Waiting for Redis..."\n\
173-
sleep 1\n\
174-
done\n\
175-
echo "Redis is ready"\n\
176-
\n\
177-
# Start RQ worker(s) in background\n\
178-
WORKER_COUNT=${RQ_WORKER_COUNT:-1}\n\
179-
echo "Starting $WORKER_COUNT RQ worker(s)..."\n\
180-
for i in $(seq 1 $WORKER_COUNT); do\n\
181-
rq worker openms-workflows --url $REDIS_URL --name worker-$i &\n\
182-
done\n\
183-
\n\
184-
# Load balancer setup\n\
185-
SERVER_COUNT=${STREAMLIT_SERVER_COUNT:-1}\n\
186-
\n\
187-
if [ "$SERVER_COUNT" -gt 1 ]; then\n\
188-
echo "Starting $SERVER_COUNT Streamlit instances with nginx load balancer..."\n\
189-
\n\
190-
# Generate nginx upstream block\n\
191-
UPSTREAM_SERVERS=""\n\
192-
BASE_PORT=8510\n\
193-
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
194-
PORT=$((BASE_PORT + i))\n\
195-
UPSTREAM_SERVERS="${UPSTREAM_SERVERS} server 127.0.0.1:${PORT};\\n"\n\
196-
done\n\
197-
\n\
198-
# Write nginx config\n\
199-
mkdir -p /etc/nginx\n\
200-
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 0.0.0.0: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\
201-
\n\
202-
# Start Streamlit instances on internal ports\n\
203-
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
204-
PORT=$((BASE_PORT + i))\n\
205-
echo "Starting Streamlit instance on port $PORT..."\n\
206-
streamlit run app.py --server.port $PORT --server.address 0.0.0.0 &\n\
207-
done\n\
208-
\n\
209-
sleep 2\n\
210-
echo "Starting nginx load balancer on port 8501..."\n\
211-
exec /usr/sbin/nginx -g "daemon off;"\n\
212-
else\n\
213-
# Single instance mode (default) - run Streamlit directly on port 8501\n\
214-
echo "Starting Streamlit app..."\n\
215-
exec streamlit run app.py --server.address 0.0.0.0\n\
216-
fi\n\
217-
' > /app/entrypoint.sh
218-
# make the script executable
168+
# Install the apptainer-compatible entrypoint that starts cron (when the root
169+
# FS is writable), Redis, RQ workers, optional nginx load balancer, and the
170+
# Streamlit server. The script falls back to /tmp paths under apptainer.
171+
COPY docker/entrypoint.sh /app/entrypoint.sh
219172
RUN chmod +x /app/entrypoint.sh
220173

221174
# Patch Analytics

Dockerfile_simple

Lines changed: 11 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ RUN wget -q \
4242
&& rm -f Miniforge3-Linux-x86_64.sh
4343
RUN mamba --version
4444

45+
# Make /root traversable so the entrypoint can `source
46+
# /root/miniforge3/bin/activate ...` when the container runs as a non-root
47+
# user (apptainer/singularity maps the host UID into the container; the
48+
# default ubuntu /root is 0700 which would block path traversal). +x only,
49+
# not +r, so the directory listing remains private.
50+
RUN chmod o+x /root
51+
4552
# Setup mamba environment.
4653
RUN mamba create -n streamlit-env python=3.10
4754
RUN echo "mamba activate streamlit-env" >> ~/.bashrc
@@ -85,49 +92,10 @@ RUN echo "0 3 * * * /root/miniforge3/envs/streamlit-env/bin/python /app/clean-up
8592
# Set to >1 to enable nginx load balancer with multiple Streamlit instances
8693
ENV STREAMLIT_SERVER_COUNT=1
8794

88-
# create entrypoint script to start cron service and launch streamlit app
89-
RUN echo -e '#!/bin/bash\n\
90-
set -e\n\
91-
source /root/miniforge3/bin/activate streamlit-env\n\
92-
\n\
93-
# Start cron for workspace cleanup\n\
94-
service cron start\n\
95-
\n\
96-
# Load balancer setup\n\
97-
SERVER_COUNT=${STREAMLIT_SERVER_COUNT:-1}\n\
98-
\n\
99-
if [ "$SERVER_COUNT" -gt 1 ]; then\n\
100-
echo "Starting $SERVER_COUNT Streamlit instances with nginx load balancer..."\n\
101-
\n\
102-
# Generate nginx upstream block\n\
103-
UPSTREAM_SERVERS=""\n\
104-
BASE_PORT=8510\n\
105-
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
106-
PORT=$((BASE_PORT + i))\n\
107-
UPSTREAM_SERVERS="${UPSTREAM_SERVERS} server 127.0.0.1:${PORT};\\n"\n\
108-
done\n\
109-
\n\
110-
# Write nginx config\n\
111-
mkdir -p /etc/nginx\n\
112-
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 0.0.0.0: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\
113-
\n\
114-
# Start Streamlit instances on internal ports\n\
115-
for i in $(seq 0 $((SERVER_COUNT - 1))); do\n\
116-
PORT=$((BASE_PORT + i))\n\
117-
echo "Starting Streamlit instance on port $PORT..."\n\
118-
streamlit run app.py --server.port $PORT --server.address 0.0.0.0 &\n\
119-
done\n\
120-
\n\
121-
sleep 2\n\
122-
echo "Starting nginx load balancer on port 8501..."\n\
123-
exec /usr/sbin/nginx -g "daemon off;"\n\
124-
else\n\
125-
# Single instance mode (default) - run Streamlit directly on port 8501\n\
126-
echo "Starting Streamlit app..."\n\
127-
exec streamlit run app.py --server.address 0.0.0.0\n\
128-
fi\n\
129-
' > /app/entrypoint.sh
130-
# make the script executable
95+
# Install the apptainer-compatible entrypoint (shared with the full image).
96+
# The script auto-skips the Redis/RQ section when redis-server is not
97+
# installed, so it works equally well in the simple variant.
98+
COPY docker/entrypoint.sh /app/entrypoint.sh
13199
RUN chmod +x /app/entrypoint.sh
132100

133101
# Patch Analytics

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ This repository contains two Dockerfiles.
136136
and falls back to the standard upload UI. To use a different container
137137
path, change `local_data_dir` in `settings.json` before building.
138138
139+
## 🛰️ Run with Apptainer / Singularity (HPC)
140+
141+
Apptainer (formerly Singularity) is the dominant container runtime on HPC
142+
clusters. Pull the OCI image from GHCR, convert it to a SIF, and run it as
143+
your user — no root, no `--writable-tmpfs` required:
144+
145+
```bash
146+
apptainer pull docker://ghcr.io/openms/streamlit-template:latest
147+
apptainer run \
148+
--bind /path/to/data:/mounted-data:ro \
149+
--bind /path/to/workspaces:/workspaces-streamlit-template \
150+
streamlit-template_latest.sif
151+
```
152+
153+
The entrypoint auto-detects the read-only root filesystem (set by apptainer's
154+
default isolation) and switches its runtime state — Redis data directory,
155+
nginx config, PID files — to `/tmp/openms-runtime-$$`, which is always
156+
writable inside an apptainer container. The workspace cleanup cron job is
157+
skipped in this mode; rerun `clean-up-workspaces.py` manually if needed.
158+
139159
## Documentation
140160

141161
Documentation for **users** and **developers** is included as pages in [this template app](https://abi-services.cs.uni-tuebingen.de/streamlit-template/), indicated by the 📖 icon.

0 commit comments

Comments
 (0)