Container images v0.2¶
The platform has seven images (and a worker target). The build makes them from
images/<name>/Dockerfile, with the repository root as the build context. The images have
three purposes:
- To run the exact production artifacts locally (Docker Compose).
- To support the docker executor.
- To become the Cloud Run Jobs in v0.3.
make docker-build # every image, tagged mapi/<name>:<version>
make docker-build IMAGES="server web" # a subset
make docker-pin-check # every base image pinned by digest?
The images¶
| Image | Contents | Runs as | Entry point |
|---|---|---|---|
mapi/base-bayes |
mapi-runner, SDK, steps, plot recipes, workflow nodes, mapi-pymc with the pathfinder extra, the mmm.*, tabular.* and sci.* plugins (mapi-plugin-science, which uses SciPy), mapi-jax for the CPU (v0.15), a C++ compiler for PyTensor |
10001 |
mapi-runner |
mapi/base-ml |
mapi-runner, SDK, steps, plot recipes, workflow nodes, mapi-sklearn with the gbm extra (sklearn.* plugins, LightGBM and CatBoost from v0.14) and libgomp1 (for LightGBM), mapi-jax for the CPU (v0.15). No PyMC. |
10001 |
mapi-runner |
mapi/base-dl (v0.15) |
mapi-runner, SDK, steps, plot recipes, workflow nodes, mapi-jax with the cuda12 extra (the dl.* models and the step dl.embed on JAX, Flax and Optax, with the NVIDIA CUDA 12 wheels). No PyMC, no scikit-learn, no C++ compiler. |
10001 |
mapi-runner |
mapi/base-forecast |
mapi-runner, SDK, steps, plot recipes, workflow nodes, mapi-pymc with the pathfinder extra, the ts.* plugins (mapi-plugin-ts and mapi-plugin-statsmodels), the causal.* plugins (mapi-plugin-causal) and pymc-extras |
10001 |
mapi-runner |
mapi/worker |
base-bayes with the ts.* and causal.* plugins and mapi-server (target worker of the base-bayes Dockerfile). Only for compose |
10001 |
mapi-server worker |
mapi/server |
The control plane: mapi-server, mapi-spec, the JSON-only mapi-plots core, the JSON-only mapi-workflows engine, docker-py. No science dependencies. |
10001 |
mapi-server api (or worker, migrate) |
mapi/gcs-shim |
The local Cloud Storage emulator | 10001 |
mapi-gcs-shim |
mapi/web |
nginx that serves apps/web (port 8080) and apps/plot-sandbox (port 8081) |
101 |
nginx |
causal.impact uses the ts.bsts model of mapi-plugin-ts. Thus the causal.* plugins are
in mapi/base-forecast, and mapi/base-bayes does not contain them. pymc-extras (state space
models and the Pathfinder engine) adds preliz, Numba and Matplotlib. Thus mapi/base-forecast
is larger than in v0.12. mapi/base-bayes also contains pymc-extras (the Pathfinder engine).
From v0.14, mapi-runner depends on Pillow (for the image_folder ingest). Thus all runner
images contain Pillow. Pillow decodes uploaded images only in the sandboxed ingest run, never
in the control plane. LightGBM and CatBoost (about 150 MB) are only in mapi/base-ml.
tabular.image_logistic runs in mapi/base-bayes and does not need Pillow.
The deep learning image v0.15¶
mapi/base-dl runs the dl.* models of mapi-jax. JAX comes with its CUDA 12 wheels
(jax-cuda12-plugin and the NVIDIA runtime wheels from pip) on the same python:3.12-slim
base. There is no NVIDIA base image. The CUDA wheels make the image about 3 GB. Thus it is
the largest image.
- Without a GPU, JAX uses the CPU. Each
dl.*model continues to train, but slowly. The runner sends the warningGPU_UNAVAILABLEwhen a run of the classGPUsees no NVIDIA device (Troubleshooting). - The image sets
XLA_PYTHON_CLIENT_PREALLOCATE=false(JAX gets GPU memory when it needs it) andXLA_FLAGS=--xla_gpu_deterministic_ops=true(a fit with a seed gives the same result again). The build argumentsMAPI_XLA_PREALLOCATEandMAPI_XLA_FLAGSset them. The other images leave them empty. mapi/base-bayesandmapi/base-mlcontainmapi-jaxfor the CPU. Thus the stepdl.embedcan run in front of their models, for exampletabular.image_logisticorsklearn.multiclass_classifier.
Not verified
Nobody has built or run mapi/base-dl on a GPU. Do a test with a small run of the class
GPU before you use it. Make sure that the param dl.backend of the run is gpu. Refer
to Docker Compose and GPU jobs.
The guide Deep learning tells how to use the models.
make docker-build IMAGES="server web" builds a subset. make docker-build builds all
images. It also builds mapi/worker when base-bayes is in the set. Each image has the OCI
labels title, version, revision (git commit) and created.
How the Python images are built¶
Each Python image uses the same multi-stage pattern:
manifestscopies the build context. It deletes all files except thepyproject.tomlanduv.lockfiles. Thus its output changes only when dependencies change.builderinstalls the third-party dependencies of the package set of the image. It uses only those manifests (uv sync --frozen --no-install-workspace, one time for each package, into/opt/mapi/venv). BuildKit keeps this layer in a cache. Thus, when you edit source code, the build does not install PyMC again. The builder then copies the sources and installs the workspace packages, non-editable. Each version comes fromuv.lock.-
runtimestarts again from the samepython:3.12-slimbase. It addstini(andg++for the PyMC images) and makes user10001. Then it copies in only the virtual environment.The images that run
mapi-server(mapi/serverandmapi/worker) also copyuv.lockto/opt/mapi/uv.lockand setMAPI_UV_LOCK_PATHto it. Its SHA-256 is the image digest. The API and the orchestrator use this digest as the cache key of the step and model catalog.make devcalculates the same value. The build makes the two images from one lock, soapiandworkeragree.
The package set is the MAPI_PACKAGES build argument. images/common/install_packages.py
changes it into uv sync calls. The build never installs the xgboost dependency
nvidia-nccl-cu13 (MAPI_NO_INSTALL). This is a 300 MB wheel that only multi-GPU training
uses.
Build-time guards¶
If an image is not what it claims to be, the build fails:
- Package assertions.
images/common/assert_dists.pydoes a check ofMAPI_ASSERT_PRESENTandMAPI_ASSERT_ABSENTagainst the real environment. For example,base-mlmust not containpymc,pytensorornumba, and must containpillow,lightgbmandcatboost(v0.14).base-bayesandbase-forecastmust containpillowand must not containlightgbmorcatboost.servermust not containnumpy,xarray,pandas,scipy,pyarrow, PyMC,pillow,lightgbm,catboost,mapi_sdk,mapi_runneror a plugin. From v0.15,base-bayesandbase-mlmust containmapi-jax,flaxandoptax, andbase-forecastmust not contain them.base-dlmust containjax,jaxlib,flax,optaxandjax-cuda12-plugin, and must not containmapi-pymc,mapi-sklearn,mapi-plugin-mmm,mapi-plugin-tabular,mapi-plugin-ts,mapi-plugin-causal,mapi-plugin-science,torchortensorflow. - Import contracts in the control-plane image. The
contractsstage ofimages/server/Dockerfileinstalls import-linter (hash-pinned fromuv.lock) into a temporary copy of the environment. It runs the contracts of the repository against the packages exactly as they are installed. The runtime stage depends on that stage, so BuildKit cannot skip it. - Catalog export. The runner images load each installed step, model and plot plugin. They
write
/opt/mapi/catalog.jsonwith its digest (/opt/mapi/catalog.digest). If no steps or models are registered, the build fails.
We also prove the same claims without Docker. tests/devops/test_container_invariants.py
calculates the dependency closure of each image from uv.lock (scripts/lock_closure.py).
Then it does a check of the closure against the assertions.
Compile caches and the read-only root filesystem¶
PyTensor compiles C at run time, and Numba keeps machine code in a cache. During the build, a
small hierarchical model compiles and fills the two caches. The build then puts the caches
read-only into /opt/mapi/cache. PyTensor needs a writable compile directory, because it
takes a lock and adds new modules.
Thus, at start-up, the entry point (images/common/runner-entrypoint.sh) builds a symlink
farm of the cache under /tmp/mapi-cache. The farm has real directories, with files that are
symlinks to the objects in the cache. This uses some KiB of RAM. This is important on the
in-memory filesystem of Cloud Run. PyTensor accepts the moved entries and does not compile
them again.
HOME, PYTENSOR_FLAGS, NUMBA_CACHE_DIR and MPLCONFIGDIR all point under /tmp. The
first three are on the worker-environment allow-list of the runner
(mapi_spec.run.WORKER_ENV_ALLOWLIST). The allow-list also has XLA_FLAGS. From v0.15, it also
has JAX_PLATFORMS, XLA_PYTHON_CLIENT_PREALLOCATE, XLA_PYTHON_CLIENT_MEM_FRACTION and
CUDA_VISIBLE_DEVICES, for the device and the GPU memory of JAX. Thus plugin code sees them after the supervisor
removes the other variables from the environment. As a result, each image runs with
--read-only and a tmpfs on /tmp.
docker run mapi/base-bayes runs the runner. The image gives arguments that start with -
to the runner. All other commands replace the runner
(docker run mapi/base-bayes python -c ...).
The web image and the plot sandbox¶
mapi/web builds the two Vite apps with pnpm. It uses --frozen-lockfile and
--ignore-scripts, so no package lifecycle scripts run. One unprivileged nginx with two
server blocks serves the apps. NGINX_ENVSUBST_TEMPLATE_DIR selects which blocks nginx
uses: /etc/nginx/templates/all (the default), web or plots. Origins are runtime
settings. At start-up, nginx puts them into the headers.
App (:8080, MAPI_WEB_ORIGIN) |
Plot sandbox (:8081, MAPI_PLOTS_ORIGIN) |
|
|---|---|---|
| CSP | default-src 'self'; script-src 'self' (no unsafe-eval, no inline scripts); frame-src = the plots origin; connect-src = self, MAPI_API_URL, MAPI_GCS_ENDPOINT; frame-ancestors 'none' |
default-src 'none'; scripts, styles and images only from its own origin; connect-src = only its own /topojson/; frame-ancestors = the app origin |
| Other headers | X-Frame-Options: DENY, nosniff, Referrer-Policy: no-referrer, COOP and CORP same-origin, Trusted Types in report-only mode |
nosniff, no-referrer, Access-Control-Allow-Origin: * |
| API access | nginx sends /v1/ to MAPI_API_UPSTREAM, unbuffered, with a read timeout of one hour for SSE |
None: no proxy, and the CSP blocks it |
The app frames the sandbox documents with sandbox="allow-scripts" and no same-origin
access. Thus they have an opaque origin, and the browser gets their module scripts,
styles and topojson in CORS mode. For this reason, the sandbox answers with
Access-Control-Allow-Origin: *. The assets are static and public, and there are no cookies.
For the same reason, its CSP lists the plots origin explicitly next to 'self'.
MAPI_WEB_SCRIPT_SRC_EXTRA (empty by default) adds sources to the script-src of the app.
Use it only after a review, for example if a library needs 'wasm-unsafe-eval'. Trusted
Types stay in report-only mode until the SPA has its policy.
Base images and pinning¶
Each base image is a build argument with a readable tag and a digest:
The Dockerfile frontend (# syntax=) and the compose postgres image are pinned in the same
way. make docker-pin resolves each tag again through the registry API. It needs no Docker
daemon. Examine the diff that it makes as you examine each dependency update.
make docker-pin-check fails if an image is not pinned. CI and make release-check also run
it.
Licences in the images¶
Each image has the licence files of the project and THIRD-PARTY-NOTICES.md in
/usr/share/doc/mapi/. The notices list each third-party Python and JavaScript package in
the image set, with its version and licence. They also contain the NOTICE files, and the
licence texts of the JavaScript packages that the build compiles into the web app. When
uv.lock or pnpm-lock.yaml changes, run make notices to make the file again. While the
file is not current, the test suite and make release-check fail. Refer to
Licensing.
CI¶
.github/workflows/ci.yml builds each image, and the worker target, on each push and pull
request. It uses the GitHub Actions layer cache. CI never pushes the images. A separate job
does a check of the digest pins. It also validates the two compose files with
docker compose config.
You can do a smoke test when you have built the images and a Docker daemon runs. Then
uv run pytest tests/devops/test_compose_smoke.py -m docker starts postgres, gcs-shim,
migrate, api and worker under a temporary project name. It publishes no host ports.
Then it makes sure that api and worker started. It also makes sure that they agree on the
values that bundle verification needs: the image digest and the bundle-signing key.