Packaging an inlabru model API in a container

Once a model is pinned to a board (see vignette("vetiver")), the same /predict API can run in a container. This vignette shows a Dockerfile that works for inlabru fits, and why it differs from what vetiver_prepare_docker() would generate. The commands work with Docker and Podman alike.

Why not vetiver_prepare_docker()?

For most models vetiver_prepare_docker() writes a Dockerfile, a plumber.R and an renv lockfile in one go. It does not work for inlabru fits:

So write plumber.R with vetiver and the Dockerfile by hand.

1. Write plumber.R

Pin the model to a board the container can reach, then let vetiver write the API file:

library(vetiver)
library(pins)

board <- board_folder("model-store")   # or board_s3(), board_connect(), ...
vetiver_pin_write(board, v)

vetiver_write_plumber(board, "gaussian_glm")

The generated plumber.R reads the model from the board when the API starts, so deploying a new version means pinning it and restarting the container — no rebuild.

2. Write the Dockerfile

# inlabru imports sf, so every image needs GDAL/GEOS/PROJ:
# rocker/geospatial ships them prebuilt.
FROM docker.io/rocker/geospatial:4.6.1

# INLA is not on CRAN.
RUN Rscript -e "install.packages('INLA', repos = c(getOption('repos'), \
  INLA = 'https://inla.r-inla-download.org/R/stable'))"

# sn: INLA's posterior sampler needs it but only lists it in Suggests.
RUN Rscript -e "install.packages(c('inlabru', 'sn', 'vetiver', 'pins', \
  'plumber', 'rapidoc', 'remotes'))"
RUN Rscript -e "remotes::install_github('novica/inlaops', upgrade = 'never')"

WORKDIR /opt/ml
# Only for a local board_folder(); remote boards are read at startup.
COPY model-store model-store
COPY plumber.R plumber.R

EXPOSE 8000
# Load inlaops first: it provides the handlers that serve bru fits.
ENTRYPOINT ["R", "-e", "library(inlaops); pr <- plumber::plumb('/opt/ml/plumber.R'); pr$run(host = '0.0.0.0', port = 8000)"]

Notes:

3. Build and test locally

docker build -t gaussian_glm .
docker run --rm -p 8000:8000 gaussian_glm

The API answers once INLA has loaded, after a few seconds:

curl http://localhost:8000/ping

curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '[{"x1": 0.5, "x2": 1.0}, {"x1": -1.2, "x2": 0.3}]'
#> [{".pred":1.9943,".pred_low":1.893,".pred_high":2.1142,".pred_sd":0.057},
#>  {".pred":0.9361,".pred_low":0.8352,".pred_high":1.0505,".pred_sd":0.0543}]

Values differ slightly between calls because they come from posterior sampling.

4. Spatial models

Point-referenced fits (sf / sp points, SPDE fields, LGCP intensity surfaces) use the same image. The request carries coordinates, so the pin metadata needs coord_names and crs:

v <- vetiver_model(
  fit_sf, "sf_points",
  metadata = list(
    pred_formula = "~ Intercept + x1",
    coord_names  = c("X", "Y"),   # sf fits: sf::st_coordinates() names
    crs          = 4326L          # EPSG integer or WKT string
  )
)
curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '[{"X": 10.3, "Y": 59.3, "x1": 0.8}]'

See vignette("spatial") for the full request contract.

Polygon / areal fits (sf polygons, SpatialPolygonsDataFrame) are rejected at pin time. Until areal serving lands, use the custom-plumber pattern in vignette("lgcp") (section 3); its plumber.R goes into the same image.

5. Image size

The image is about 5 GB: rocker/geospatial plus INLA, which bundles binaries for several platforms. The first build takes several minutes; later builds reuse the cached layers until the package lines change.