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
|
- 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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@ -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"]
|
||||||
|
|||||||
11
README.md
11
README.md
@ -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
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