prod: run container with restricted privileges
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 56s
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 56s
This commit is contained in:
parent
8e6dcb750d
commit
5ebfdc389a
@ -71,12 +71,15 @@ jobs:
|
||||
- name: Build production image
|
||||
run: docker build --target runtime --tag engin-console:ci .
|
||||
|
||||
- name: Assert runtime excludes training assets
|
||||
- name: Assert production image contract
|
||||
run: |
|
||||
docker run --rm \
|
||||
--entrypoint sh \
|
||||
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 final_pipeline.py &&
|
||||
test ! -e severity_benchmark.py &&
|
||||
@ -87,8 +90,22 @@ jobs:
|
||||
run: |
|
||||
docker run -d \
|
||||
--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
|
||||
|
||||
- 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
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
@ -10,7 +10,11 @@ font = "sans-serif"
|
||||
showErrorDetails = "none"
|
||||
toolbarMode = "minimal"
|
||||
|
||||
[browser]
|
||||
gatherUsageStats = false
|
||||
|
||||
[server]
|
||||
headless = true
|
||||
maxUploadSize = 10
|
||||
runOnSave = false
|
||||
fileWatcherType = "none"
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@ -1,4 +1,6 @@
|
||||
ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17
|
||||
ARG APP_UID=10001
|
||||
ARG APP_GID=10001
|
||||
FROM ${PYTHON_IMAGE} AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@ -15,6 +17,21 @@ COPY . .
|
||||
|
||||
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 engin/ ./engin/
|
||||
COPY assets/ ./assets/
|
||||
@ -22,9 +39,13 @@ COPY .streamlit/ ./.streamlit/
|
||||
COPY artifacts/ ./artifacts/
|
||||
COPY test.csv predictions.csv prediction_diagnostics.csv ./
|
||||
|
||||
USER ${APP_UID}:${APP_GID}
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
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)"
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]
|
||||
|
||||
11
README.md
11
README.md
@ -28,10 +28,15 @@ Aplikacja otworzy się pod `http://localhost:8501`. Od razu uruchamia bezpieczne
|
||||
Alternatywnie przez Docker:
|
||||
|
||||
```bash
|
||||
docker build -t engin-console .
|
||||
docker run --rm -p 8501:8501 engin-console
|
||||
docker build --target runtime -t 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Nieprawidłowe dane nie docierają do modelu.
|
||||
- Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie.
|
||||
|
||||
31
compose.prod.yaml
Normal file
31
compose.prod.yaml
Normal 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
103
docs/PRODUCTION_RUNBOOK.md
Normal 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
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user