prod: run container with restricted privileges
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 56s

This commit is contained in:
Jakub Famulski 2 2026-08-25 13:16:23 +02:00
parent 8e6dcb750d
commit 5ebfdc389a
6 changed files with 187 additions and 4 deletions

View File

@ -71,12 +71,15 @@ jobs:
- name: Build production image - name: Build production image
run: docker build --target runtime --tag engin-console:ci . run: docker build --target runtime --tag engin-console:ci .
- name: Assert runtime excludes training assets - name: Assert production image contract
run: | run: |
docker run --rm \ docker run --rm \
--entrypoint sh \ --entrypoint sh \
engin-console:ci \ engin-console:ci \
-c "test ! -e val.csv && -c "test \"\$(id -u)\" = 10001 &&
test \"\$(id -g)\" = 10001 &&
test ! -w /app &&
test ! -e val.csv &&
test ! -e train.csv && test ! -e train.csv &&
test ! -e final_pipeline.py && test ! -e final_pipeline.py &&
test ! -e severity_benchmark.py && test ! -e severity_benchmark.py &&
@ -87,8 +90,22 @@ jobs:
run: | run: |
docker run -d \ docker run -d \
--name engin-ci-app \ --name engin-ci-app \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--pids-limit 256 \
--memory 1g \
--cpus 2 \
engin-console:ci engin-console:ci
- name: Verify runtime isolation
run: |
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' engin-ci-app)" = true
test "$(docker inspect --format '{{.HostConfig.PidsLimit}}' engin-ci-app)" = 256
test "$(docker inspect --format '{{.HostConfig.Memory}}' engin-ci-app)" = 1073741824
test "$(docker inspect --format '{{.HostConfig.NanoCpus}}' engin-ci-app)" = 2000000000
- name: Verify Streamlit health - name: Verify Streamlit health
run: | run: |
set -eu set -eu

View File

@ -10,7 +10,11 @@ font = "sans-serif"
showErrorDetails = "none" showErrorDetails = "none"
toolbarMode = "minimal" toolbarMode = "minimal"
[browser]
gatherUsageStats = false
[server] [server]
headless = true headless = true
maxUploadSize = 10 maxUploadSize = 10
runOnSave = false runOnSave = false
fileWatcherType = "none"

View File

@ -1,4 +1,6 @@
ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17 ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17
ARG APP_UID=10001
ARG APP_GID=10001
FROM ${PYTHON_IMAGE} AS base FROM ${PYTHON_IMAGE} AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@ -15,6 +17,21 @@ COPY . .
FROM base AS runtime FROM base AS runtime
ARG APP_UID
ARG APP_GID
RUN groupadd --gid "${APP_GID}" engin \
&& useradd --uid "${APP_UID}" \
--gid "${APP_GID}" \
--create-home \
--home-dir /home/engin \
--shell /usr/sbin/nologin \
engin
ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
XDG_CACHE_HOME=/tmp/.cache \
MPLCONFIGDIR=/tmp/matplotlib
COPY app.py ./ COPY app.py ./
COPY engin/ ./engin/ COPY engin/ ./engin/
COPY assets/ ./assets/ COPY assets/ ./assets/
@ -22,9 +39,13 @@ COPY .streamlit/ ./.streamlit/
COPY artifacts/ ./artifacts/ COPY artifacts/ ./artifacts/
COPY test.csv predictions.csv prediction_diagnostics.csv ./ COPY test.csv predictions.csv prediction_diagnostics.csv ./
USER ${APP_UID}:${APP_GID}
EXPOSE 8501 EXPOSE 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
STOPSIGNAL SIGTERM
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"] CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]

View File

@ -28,10 +28,15 @@ Aplikacja otworzy się pod `http://localhost:8501`. Od razu uruchamia bezpieczne
Alternatywnie przez Docker: Alternatywnie przez Docker:
```bash ```bash
docker build -t engin-console . docker build --target runtime -t engin-console .
docker run --rm -p 8501:8501 engin-console docker run --rm -p 127.0.0.1:8501:8501 engin-console
``` ```
Zalecane uruchomienie produkcyjne korzysta z `compose.prod.yaml`: kontener działa
jako UID/GID `10001`, z systemem plików tylko do odczytu, bez Linux capabilities
oraz z limitami CPU, pamięci i procesów. Pełna procedura startu, aktualizacji i
rollbacku znajduje się w `docs/PRODUCTION_RUNBOOK.md`.
## Co zawiera produkt ## Co zawiera produkt
- mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra, - mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra,
@ -161,6 +166,8 @@ Oczekiwana odpowiedź: `ok`.
- Model jest trenowany poza aplikacją i ładowany raz z wersjonowanego artefaktu do cache procesu. - Model jest trenowany poza aplikacją i ładowany raz z wersjonowanego artefaktu do cache procesu.
- Finalny obraz runtime nie zawiera `val.csv`, `train.csv` ani skryptów treningowych. - Finalny obraz runtime nie zawiera `val.csv`, `train.csv` ani skryptów treningowych.
- Proces aplikacji działa jako użytkownik bez uprawnień roota; produkcyjny Compose
dodatkowo wymusza read-only root filesystem, usuwa capabilities i ustawia limity zasobów.
- Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo. - Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo.
- Nieprawidłowe dane nie docierają do modelu. - Nieprawidłowe dane nie docierają do modelu.
- Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie. - Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie.

31
compose.prod.yaml Normal file
View File

@ -0,0 +1,31 @@
services:
engin:
build:
context: .
target: runtime
image: "engin-console:${ENGIN_IMAGE_TAG:-local}"
restart: unless-stopped
init: true
user: "10001:10001"
ports:
- "${ENGIN_BIND_ADDRESS:-127.0.0.1}:${ENGIN_PORT:-8501}:8501"
environment:
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
XDG_CACHE_HOME: /tmp/.cache
MPLCONFIGDIR: /tmp/matplotlib
read_only: true
tmpfs:
- "/tmp:rw,noexec,nosuid,size=64m,mode=1777"
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
pids_limit: 256
mem_limit: 1g
cpus: 2.0
stop_grace_period: 30s
logging:
driver: local
options:
max-size: "10m"
max-file: "3"

103
docs/PRODUCTION_RUNBOOK.md Normal file
View File

@ -0,0 +1,103 @@
# ENGIN — production runbook
## Kontrakt wdrożenia
- Docker Engine i Docker Compose v2.
- Aplikacja działa jako UID/GID `10001:10001`.
- Port jest domyślnie dostępny tylko na `127.0.0.1:8501`.
- Root filesystem kontenera jest tylko do odczytu; zapisywalny jest wyłącznie
tymczasowy `/tmp` o rozmiarze 64 MiB.
- Kontener nie ma Linux capabilities i nie może uzyskać nowych uprawnień.
- Limity: 2 CPU, 1 GiB RAM i 256 procesów.
- Przesłane CSV nie są zapisywane na dysku ani w wolumenie.
## Pierwszy start
Wykonuj polecenia z katalogu repozytorium na czystym, zatwierdzonym commicie:
```bash
export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)"
docker compose -f compose.prod.yaml config --quiet
docker compose -f compose.prod.yaml build
docker compose -f compose.prod.yaml up -d --no-build
docker compose -f compose.prod.yaml ps
curl --fail http://127.0.0.1:8501/_stcore/health
```
Oczekiwana odpowiedź healthchecku: `ok`.
## Kontrola ograniczeń
```bash
CONTAINER_ID="$(docker compose -f compose.prod.yaml ps -q engin)"
docker inspect --format \
'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} pids={{.HostConfig.PidsLimit}} memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}' \
"$CONTAINER_ID"
```
Oczekiwane wartości:
```text
user=10001:10001 readonly=true pids=256 memory=1073741824 nanocpus=2000000000
```
## Logi i diagnostyka
```bash
docker compose -f compose.prod.yaml logs --tail 200 engin
docker compose -f compose.prod.yaml ps
```
Logi są rotowane przez sterownik `local`: maksymalnie trzy pliki po 10 MiB.
## Aktualizacja
Najpierw zachowaj SHA aktualnie działającego obrazu, następnie zbuduj nową,
jednoznacznie otagowaną wersję:
```bash
docker compose -f compose.prod.yaml ps
git pull --ff-only
export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)"
docker compose -f compose.prod.yaml build
docker compose -f compose.prod.yaml up -d --no-build
curl --fail http://127.0.0.1:8501/_stcore/health
```
Nie usuwaj poprzedniego obrazu przed zakończeniem smoke testu.
## Rollback
Jeżeli smoke test nowej wersji nie przejdzie, uruchom poprzedni lokalny tag bez
ponownego budowania:
```bash
ENGIN_IMAGE_TAG=<poprzedni_git_sha> \
docker compose -f compose.prod.yaml up -d --no-build
curl --fail http://127.0.0.1:8501/_stcore/health
```
Jeżeli obrazu o wskazanym tagu już nie ma, przełącz repo na zatwierdzony commit,
odbuduj obraz i ponownie wykonaj smoke test. Nie używaj niezaufanego artefaktu
modelu jako skrótu do rollbacku.
## Udostępnienie poza hostem
Domyślne wiązanie `127.0.0.1` jest celowe. Dostęp sieciowy włączaj dopiero za
reverse proxy z TLS i regułami firewalla. W takim środowisku można ustawić:
```bash
ENGIN_BIND_ADDRESS=0.0.0.0 \
docker compose -f compose.prod.yaml up -d --no-build
```
## Zatrzymanie
```bash
docker compose -f compose.prod.yaml down
```