Add the release image

The service embeds everything it serves, so the image carries one binary and
nothing else. The build is in three stages: the frontend is built with Node,
the binary is built with Rust, and the runtime holds the binary and the C
library it links against.

The build context is the directory above this one, because the crate depends
on `apsis-rs` by path; the ignore file is named beside the Dockerfile so that
it applies to that context without living in it. What it keeps out is the
build output of both projects, the installed dependencies, and the working
notes — the first two because the image produces its own, and a copy from the
host would replace the newly built one.

Two things the image has to get right, and both are stated where they happen.
The command names the host explicitly, because the program defaults to the
loopback address where it answers only itself, so a container started without
it would serve nothing to anyone. And the toolchain file is removed inside
the builder, because it asks for the `stable` channel by name and rustup
would fetch it over the network; the image already provides the toolchain the
project builds with.

There is no health check. Probing `GET /health` is what an orchestrator
should do, and a probe inside the container would mean adding an HTTP client
to an image that needs no other tool.

The image could not be built here: this machine cannot reach Docker Hub, so
no base image can be pulled. What was checked is the definition itself, which
the builder parses, and every command in it, which was run directly from the
workspace root — the frontend build, `cargo build --release --locked` with
the frontend build skipped, and the binary started with the host and port the
image uses.
This commit is contained in:
dev 2026-10-02 14:24:27 +08:00
parent f8954e6381
commit 081c7d9275
4 changed files with 231 additions and 0 deletions

133
Dockerfile Normal file
View File

@ -0,0 +1,133 @@
#
# The release image of apsis-book.
#
# The service embeds every asset it serves, so the image carries one binary
# and nothing else: no content directory, no frontend build, no runtime
# dependency beyond the C library. The build is in three stages, each of
# which needs a toolchain the next one does not.
#
# apsis-book 的发布镜像。
#
# 服务把它所提供的一切资源都内嵌其中,因此镜像只携带一个二进制,别无其他:没有
# 内容目录,没有前端构建产物,除 C 库外没有运行期依赖。构建分三个阶段,每个阶段
# 所需的工具链下一个阶段并不需要。
#
# The build context is the directory above this one, because the crate depends
# on `apsis-rs` by path. From the workspace root:
#
# docker build -f apsis-book/Dockerfile -t apsis-book:0.1.0 .
#
# 构建上下文是本目录的上一级,因为该 crate 以路径依赖引用 `apsis-rs`。在工作区
# 根目录执行上面那条命令。
#
# Run it with the listener bound to every interface, because the default is
# the loopback address and a container that listens only there cannot be
# reached from outside itself:
#
# docker run --rm -p 8080:8080 apsis-book:0.1.0
#
# 运行时把监听绑定到所有网络接口,因为默认是回环地址,而只监听回环的容器无法从
# 自身之外被访问。
#
# docker run --rm -p 8080:8080 apsis-book:0.1.0
ARG NODE_VERSION=22
ARG RUST_VERSION=1.85
ARG DEBIAN_RELEASE=bookworm
# The frontend, built once and handed to the binary.
#
# 前端,构建一次后交给二进制。
FROM node:${NODE_VERSION}-${DEBIAN_RELEASE}-slim AS frontend
WORKDIR /build/frontend
# The manifests are copied on their own so that a change to a source file
# does not reinstall the dependencies. `npm ci` installs from the lock file,
# so the image gets the versions that were tested.
#
# 单独复制清单文件,使源码改动不会触发重新安装依赖。`npm ci` 依据 lock 文件安装,
# 因此镜像得到的是经过测试的那些版本。
COPY apsis-book/frontend/package.json apsis-book/frontend/package-lock.json ./
RUN npm ci
COPY apsis-book/frontend/ ./
RUN npm run build
# The binary.
#
# 二进制。
FROM rust:${RUST_VERSION}-${DEBIAN_RELEASE} AS builder
# The frontend was built in its own stage, so the build script is told not to
# build it again; that would need Node in this stage. It embeds what it finds
# in `frontend/dist`, which the next line places.
#
# 前端已在其自身的阶段构建完毕,因此告知构建脚本不要再次构建它——那会需要本阶段
# 安装 Node。它嵌入在 `frontend/dist` 中找到的内容,下一行正是把它放到那里。
ENV APSIS_BOOK_SKIP_FRONTEND=1
WORKDIR /build
# The crate depends on `apsis-rs` by the relative path `../apsis-rs`, so the
# workspace keeps its shape inside the image.
#
# 该 crate 以相对路径 `../apsis-rs` 依赖 `apsis-rs`,因此工作区在镜像内保持原样。
COPY apsis-rs/ ./apsis-rs/
COPY apsis-book/ ./apsis-book/
COPY --from=frontend /build/frontend/dist ./apsis-book/frontend/dist
# The image provides the toolchain this project builds with, and the
# toolchain file asks for the `stable` channel by name, which rustup would
# then fetch over the network. The file is removed here so that the image's
# own toolchain is the one used, and the build needs no network beyond the
# frontend's dependencies.
#
# 镜像提供了项目构建所用的工具链,而工具链文件按名字要求 `stable` 通道,rustup
# 会因此去网络上拉取。这里移除该文件,使镜像自带的工具链成为所用者,构建除前端的
# 依赖之外不需要网络。
RUN rm -f apsis-book/rust-toolchain.toml
# `--locked` fails rather than quietly updating a dependency, so the image is
# built from the versions the lock file records.
#
# `--locked` 会直接失败而不是悄悄更新依赖,因此镜像由 lock 文件所记录的版本构建。
RUN cargo build --release --locked --manifest-path apsis-book/Cargo.toml
# The runtime. It holds the binary and the C library the binary links
# against, and nothing that a build needed.
#
# 运行阶段。它持有二进制以及该二进制链接的 C 库,不含任何构建所需之物。
FROM debian:${DEBIAN_RELEASE}-slim AS runtime
LABEL org.opencontainers.image.title="apsis-book" \
org.opencontainers.image.description="The documentation and tool service of ApsisRS" \
org.opencontainers.image.licenses="GPL-3.0-or-later"
COPY --from=builder /build/apsis-book/target/release/apsis-book /usr/local/bin/apsis-book
# The process runs as an unprivileged user. The numeric identifier of `nobody`
# is used rather than a new account, because creating one would mean carrying
# `useradd` and its package in an image that needs no other tool.
#
# 进程以非特权用户运行。这里使用 `nobody` 的数字标识而不是新建账户,因为新建账户
# 意味着在一个不需要其他工具的体积里带上 `useradd` 及其软件包。
USER 65534:65534
# Documentation only; the port is set by the command below.
#
# 仅作说明;端口由下面的命令设定。
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/apsis-book"]
# The host is given explicitly. The program defaults to the loopback address,
# where it answers only itself, so a container started without this would
# serve nothing to anyone.
#
# 显式给出主机。程序的默认值是回环地址,那里它只回应自己,因此不带这一项启动的
# 容器不会向任何人提供服务。
CMD ["--host", "0.0.0.0", "--port", "8080"]

38
Dockerfile.dockerignore Normal file
View File

@ -0,0 +1,38 @@
# What the Docker build does not send to the daemon.
#
# The build context is the workspace directory above this one, so the patterns
# here are relative to that directory rather than to this file. This file is
# named `Dockerfile.dockerignore`, which is the name the builder looks for when
# the ignore file is not in the context root; it is kept beside the Dockerfile
# so that the two stay together.
#
# Docker 构建不发给守护进程的内容。
#
# 构建上下文是本目录的上一级工作区目录,因此这里的模式相对于那个目录,而不是
# 相对于本文件。本文件名为 `Dockerfile.dockerignore`,当忽略文件不在上下文根时
# 构建器就找这个名字;它与 Dockerfile 放在一起,使两者不致分离。
# Build output. Both are produced inside the image, and a copy from the host
# would be used in place of the newly built one, which is the sort of mistake
# that produces an image of an older revision.
#
# 构建产物。两者都在镜像内产生,而从宿主机复制来的副本会被用来代替新构建的那个
# ——这正是会产出旧版本镜像的那类失误。
**/target
apsis-book/frontend/dist
# Dependencies, which the frontend stage installs from the lock file.
#
# 依赖,由前端阶段依据 lock 文件安装。
**/node_modules
# Version control, which the build does not read.
#
# 版本控制,构建不会读取。
**/.git
# Working notes dropped beside the project, such as screenshots of a page
# under inspection.
#
# 丢在项目旁边的临时笔记,例如正在检查的页面截图。
apsis-book/tmp

View File

@ -105,6 +105,36 @@ English to every reader, with a note saying so.
./target/release/apsis-book --address 127.0.0.1:8080 ./target/release/apsis-book --address 127.0.0.1:8080
``` ```
### Docker
The image carries the binary and nothing else, because the binary carries
everything it serves. The build context is the directory above this one,
because the crate depends on `apsis-rs` by path:
```sh
# From the workspace root.
docker build -f apsis-book/Dockerfile -t apsis-book:0.1.0 .
docker run --rm -p 8080:8080 apsis-book:0.1.0
```
The command names the host explicitly. The program defaults to the loopback
address, where it answers only itself, so a container started without it would
serve nothing to anyone.
There is no `HEALTHCHECK`. Probing `GET /health` is what an orchestrator
should do, and a probe inside the container would mean adding an HTTP client
to an image that needs no other tool. A Kubernetes probe says the same thing
without one:
```yaml
livenessProbe:
httpGet: { path: /health, port: 8080 }
```
The image is built for the architecture it is built on. A multi-architecture
image would need a builder per architecture; the `ARG`s at the top of the
Dockerfile are the versions, and `--build-arg RUST_VERSION=` changes one.
Open `http://127.0.0.1:8080`. The JSON interfaces are available without the Open `http://127.0.0.1:8080`. The JSON interfaces are available without the
frontend: frontend:
@ -198,6 +228,8 @@ apsis-book/
├── Cargo.toml one runtime dependency: apsis-rs ├── Cargo.toml one runtime dependency: apsis-rs
├── content/ authored documents, graphs, and generated catalog ├── content/ authored documents, graphs, and generated catalog
│ └── concepts/<layer>/ documents, one directory per layer │ └── concepts/<layer>/ documents, one directory per layer
├── Dockerfile the release image, in three stages
├── Dockerfile.dockerignore what the image build is not sent
├── frontend/ Svelte and TypeScript, built by Vite ├── frontend/ Svelte and TypeScript, built by Vite
│ ├── src/lib/ API client, router, components, formatting │ ├── src/lib/ API client, router, components, formatting
│ ├── src/pages/ one component per route │ ├── src/pages/ one component per route

View File

@ -91,6 +91,32 @@ apsis-book --address 0.0.0.0:8080
./target/release/apsis-book --address 127.0.0.1:8080 ./target/release/apsis-book --address 127.0.0.1:8080
``` ```
### Docker
镜像只携带二进制,别无其他,因为二进制携带了它所提供的一切。构建上下文是本目录
的上一级,因为该 crate 以路径依赖引用 `apsis-rs`:
```sh
# 在工作区根目录执行。
docker build -f apsis-book/Dockerfile -t apsis-book:0.1.0 .
docker run --rm -p 8080:8080 apsis-book:0.1.0
```
命令中显式给出主机。程序的默认值是回环地址,那里它只回应自己,因此不带这一项启动
的容器不会向任何人提供服务。
镜像没有 `HEALTHCHECK`。探测 `GET /health` 是编排系统应当做的事,而在容器内部
探测意味着给一个不需要其他工具的体积里加上 HTTP 客户端。Kubernetes 的探针无须
它即可表达同一件事:
```yaml
livenessProbe:
httpGet: { path: /health, port: 8080 }
```
镜像为其构建所在的架构而构建。多架构镜像需要每个架构各一个构建器;Dockerfile
顶部的 `ARG` 就是各版本号,用 `--build-arg RUST_VERSION=` 可更改其中之一。
打开 `http://127.0.0.1:8080`。JSON 接口不依赖前端即可访问: 打开 `http://127.0.0.1:8080`。JSON 接口不依赖前端即可访问:
- `GET /health` - `GET /health`
@ -174,6 +200,8 @@ apsis-book/
├── Cargo.toml 一个运行时依赖:apsis-rs ├── Cargo.toml 一个运行时依赖:apsis-rs
├── content/ 撰写的文档、图谱与生成的目录 ├── content/ 撰写的文档、图谱与生成的目录
│ └── concepts/<layer>/ 文档,每层一个目录 │ └── concepts/<layer>/ 文档,每层一个目录
├── Dockerfile 发布镜像,分三个阶段
├── Dockerfile.dockerignore 镜像构建不接收的内容
├── frontend/ Svelte 与 TypeScript,由 Vite 构建 ├── frontend/ Svelte 与 TypeScript,由 Vite 构建
│ ├── src/lib/ API 客户端、路由器、组件、格式化 │ ├── src/lib/ API 客户端、路由器、组件、格式化
│ ├── src/pages/ 每个路由一个组件 │ ├── src/pages/ 每个路由一个组件