diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3605378 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.dockerignore b/Dockerfile.dockerignore new file mode 100644 index 0000000..8a3b271 --- /dev/null +++ b/Dockerfile.dockerignore @@ -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 diff --git a/README.md b/README.md index a886348..6f39462 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,36 @@ English to every reader, with a note saying so. ./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 frontend: @@ -198,6 +228,8 @@ apsis-book/ ├── Cargo.toml one runtime dependency: apsis-rs ├── content/ authored documents, graphs, and generated catalog │ └── concepts// 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 │ ├── src/lib/ API client, router, components, formatting │ ├── src/pages/ one component per route diff --git a/README.zh.md b/README.zh.md index be10ba1..91fa81e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -91,6 +91,32 @@ apsis-book --address 0.0.0.0: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 接口不依赖前端即可访问: - `GET /health` @@ -174,6 +200,8 @@ apsis-book/ ├── Cargo.toml 一个运行时依赖:apsis-rs ├── content/ 撰写的文档、图谱与生成的目录 │ └── concepts// 文档,每层一个目录 +├── Dockerfile 发布镜像,分三个阶段 +├── Dockerfile.dockerignore 镜像构建不接收的内容 ├── frontend/ Svelte 与 TypeScript,由 Vite 构建 │ ├── src/lib/ API 客户端、路由器、组件、格式化 │ ├── src/pages/ 每个路由一个组件