Multi-stage build
The Dockerfile has two stages: abuilder stage with the full compiler
toolchain and dev headers, and a runtime stage that only carries the
final binary and shared libraries it needs. Final runtime image content
size is ~455 MB (~135 MB without the baked-in models — see below).
Baking in models
Models are baked into the image (COPY models /app/models in the runtime
stage) rather than mounted at deploy time — the goal is a single
self-contained artifact you can push to any cloud host and start with no
setup step beyond docker run. This trades image size (+~179 MB, the size
of models/) for zero-friction deployment: no volume to wire up, no
separate “copy models onto the host first” step in your deploy pipeline,
no risk of a host running the image against stale or mismatched model
files.
Verified this actually works standalone: built the image, ran it with
no volume mount at all, and confirmed GET /v1/ready → 200 and
POST /v1/embed returns a real 512-d embedding — the container is
genuinely self-contained.
Build args
default:"1.19.2"
ONNX Runtime release version to download (prebuilt, not compiled).
default:"v1.9.7"
Git tag to build Drogon from.
default:"4.12.0"
Git tag to build OpenCV from. See below
for why this can’t just be an
apt-get install. Must stay ≥ 4.12.0 —
4.10.0/4.11.0 have CVE-2025-53644
(CVSS 9.8), confirmed reachable through this service’s upload endpoints.
Do not downgrade this.Why OpenCV is built from source
This is the single most important thing to understand about this Dockerfile, and it was not obvious until the image was actually built and run — the local macOS build (Homebrew’s OpenCV 5.0.0) gave no warning of this. Ubuntu 24.04’sapt-packaged OpenCV is version 4.6.0. The YuNet
detector model this service uses
(face_detection_yunet_2023mar.onnx) requires OpenCV ≥ 4.8.0 — this
is a documented upstream incompatibility
(opencv_zoo#172), not
a bug in this codebase. Critically, the failure mode is a runtime
crash during model warmup, not a build error:
libopencv-dev looks completely fine — it
builds, it produces a binary, docker build exits 0 — and then crash-loops
the instant it tries to actually run detection. The fix: the builder stage
clones and compiles OpenCV 4.12.0 from source (-DBUILD_LIST=core,imgproc, imgcodecs,objdetect,dnn,calib3d, trimmed to just the modules this project
uses to keep build time down), and the runtime stage copies the resulting
.so files instead of apt-installing them.
Other bugs the local build couldn’t have caught
Building and actually running the Docker image (not just compiling it) surfaced one more real bug: Silently-brokenapt-get install. The runtime stage’s package list
originally included libjsoncpp26 and libssl3 — neither exists on
Ubuntu 24.04 (the real names are libjsoncpp25 and libssl3t64, part of
the same 64-bit time_t transition that gives the OpenCV packages their
406t64 suffix). Because apt-get install fails atomically when any
package in the list is missing, and the line originally had a || echo "NOTE: ..." fallback, the entire install silently failed —
including the OpenCV packages, whose names were actually fine. The
container built “successfully” with zero OpenCV libraries present,
and crashed on start with error while loading shared libraries: libopencv_imgcodecs.so.406: cannot open shared object file.
Fixed by correcting the package names and — more importantly — removing
the || fallback entirely. A silently-broken runtime image (missing
.so files that only surface as a crash on container start) is worse
than a build that fails loudly at docker build time.
Validation performed
The image was built forlinux/amd64 and run end-to-end (not just
compiled) to catch exactly the class of bug described above:
docker build --platform linux/amd64 -t face-engine .— full build, both stages.- Container started with models mounted;
GET /v1/ready→200. - Ran the same same-person/different-person
/v1/comparetest cases used to validate the model (see Models) against the containerized service over real HTTP. Results matched the native macOS build almost exactly (e.g.me/me2: 0.6842 in Docker vs. 0.6834 native — trivial floating-point noise from platform/compiler differences, not a correctness issue). - Measured
/v1/embedlatency inside the container: p50 ≈ 1026 ms. This is not representative of real deployment performance — this particular test ranlinux/amd64under QEMU emulation on an Apple Silicon host. The native macOS p50 (~300 ms, see Models → Performance) is a better proxy for how a genuine x86_64 host will perform.
.dockerignore
docker build’s context upload was pulling in the local
CMake build directory, the locally-built Drogon/OpenCV source trees (used
for local macOS development — see Quickstart → Local
build), the model weights themselves
(174 MB+, mounted at runtime instead), and personal test photos — none of
which the Dockerfile actually needs.
Not done yet
Intentionally, per the current plan:- Micro-batching for the embedder — biggest win on GPU, marginal on CPU. Skip until actually running on GPU.
- Prometheus metrics endpoint —
ThreadPool::pendingJobs()is already exposed in code as a natural queue-depth metric to wire up when this happens. - Rate limiting — belongs at the load balancer/gateway, not baked into this service.
- CUDA/TensorRT variant — same source, different base image plus
EXECUTION_PROVIDER=cuda(see Configuration), once there’s a GPU to benchmark against.