diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a393ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.build +.claude +.codex +.env +.env.* +.git +.opencode +.agents +.DS_Store +coverage +data +dist +node_modules +*.log +*.bun-build diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index acc639e..f095ebf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1053,7 +1053,54 @@ bun run clean # 清理 dist/ 构建产物、dist/release/ 发布产物和 .build/ 临时文件 ``` -### 3.4 跨平台发布 +### 3.4 Docker 镜像 + +Docker 镜像使用 Alpine 多阶段构建,保持与生产单可执行文件交付模型一致: + +``` +oven/bun:1-alpine → bun install --frozen-lockfile + → BUN_TARGET=bun-linux-*-musl bun run build + → dist/dial-server + +alpine → 仅复制 /usr/local/bin/dial-server + → 安装 ca-certificates、iputils-ping、libgcc、libstdc++、tzdata + → 使用非 root dial 用户运行 +``` + +#### 架构目标映射 + +Dockerfile 通过 Docker 提供的 `TARGETARCH` 选择 Bun compile target: + +| `TARGETARCH` | `BUN_TARGET` | 说明 | +| ------------ | ---------------------- | ----------------- | +| `amd64` | `bun-linux-x64-musl` | Alpine x64 musl | +| `arm64` | `bun-linux-arm64-musl` | Alpine ARM64 musl | + +不支持的架构必须在构建阶段失败,避免生成无法运行的镜像。 + +#### 运行时边界 + +- 最终镜像不复制源码、`node_modules`、`.build` 或 `dist/web` +- 默认入口为 `/usr/local/bin/dial-server`,默认配置路径为 `/etc/dial/probes.yaml` +- 容器示例配置为 `docker/probes.yaml`,默认监听 `0.0.0.0:3000`,数据目录为 `/data/dial` +- 健康检查使用 Alpine 自带 `wget` 请求 `/health`,不为健康检查安装 `curl` +- `libgcc` 和 `libstdc++` 是 Bun musl executable 在 Alpine 中运行所需的基础运行库 +- ICMP checker 依赖镜像内置的 `iputils-ping`,运行容器时仍需要按需授予 `--cap-add=NET_RAW` +- CMD checker 的额外命令环境不进入官方镜像,用户需要 `curl`、`dig`、`psql` 等命令时通过派生镜像安装 + +#### 验证命令 + +```bash +bun run check +bun run build +docker build -t dial:alpine . +docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine +docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine . +``` + +如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。 + +### 3.5 跨平台发布 #### 发布命令 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fae7de3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1.7 + +ARG BUN_IMAGE=oven/bun:1-alpine +ARG ALPINE_IMAGE=alpine:3.22 + +FROM ${BUN_IMAGE} AS deps +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +FROM deps AS build +WORKDIR /app + +COPY . . + +ARG TARGETARCH +RUN set -eux; \ + case "${TARGETARCH:-$(uname -m)}" in \ + amd64|x86_64) export BUN_TARGET=bun-linux-x64-musl ;; \ + arm64|aarch64) export BUN_TARGET=bun-linux-arm64-musl ;; \ + *) echo "不支持的架构: ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \ + esac; \ + bun run build + +FROM ${ALPINE_IMAGE} AS runtime + +RUN apk add --no-cache ca-certificates iputils-ping libgcc libstdc++ tzdata \ + && addgroup -S dial \ + && adduser -S -G dial -h /nonexistent -s /sbin/nologin dial \ + && mkdir -p /etc/dial /data/dial \ + && chown -R dial:dial /data/dial + +COPY --from=build --chmod=0755 /app/dist/dial-server /usr/local/bin/dial-server +COPY --chmod=0644 docker/probes.yaml /etc/dial/probes.yaml + +USER dial +WORKDIR /data/dial + +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O - "http://127.0.0.1:3000/health" >/dev/null || exit 1 + +ENTRYPOINT ["/usr/local/bin/dial-server"] +CMD ["/etc/dial/probes.yaml"] diff --git a/README.md b/README.md index fbf67c7..f3b14c2 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,48 @@ bun run build 构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行。 +### Docker 部署 + +DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、musl executable 必需运行库、时区数据和容器运行所需目录。 + +```bash +# 构建当前架构镜像 +docker build -t dial:alpine . + +# 运行容器,使用内置容器配置示例 +docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine + +# 使用自定义配置文件 +docker run --rm -p 3000:3000 \ + -v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \ + -v dial-data:/data/dial \ + dial:alpine +``` + +容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`。容器专用示例配置位于 [`docker/probes.yaml`](docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`。 + +多架构镜像可通过 Docker Buildx 构建: + +```bash +docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine . +``` + +如需在容器中运行 ICMP checker,除镜像内置的 `iputils-ping` 外,还需要授予 `NET_RAW` capability: + +```bash +docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine +``` + +官方镜像不内置 `bun`、`node`、`curl`、`dig`、`psql`、`mysql`、`redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装: + +```dockerfile +FROM dial:alpine + +USER root +RUN apk add --no-cache curl bind-tools postgresql-client +USER dial +``` + ### 跨平台发布打包 ```bash diff --git a/docker/probes.yaml b/docker/probes.yaml new file mode 100644 index 0000000..6c7493d --- /dev/null +++ b/docker/probes.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=../probe-config.schema.json + +server: + listen: + host: "${DIAL_HOST|0.0.0.0}" + port: "${DIAL_PORT|3000}" + storage: + dataDir: "${DIAL_DATA_DIR|/data/dial}" + +targets: + - id: "self-health" + name: "DiAL 自检" + type: http + http: + url: "http://127.0.0.1:${DIAL_PORT|3000}/health" + expect: + status: [200] diff --git a/openspec/specs/container-image-packaging/spec.md b/openspec/specs/container-image-packaging/spec.md new file mode 100644 index 0000000..d4e37f0 --- /dev/null +++ b/openspec/specs/container-image-packaging/spec.md @@ -0,0 +1,139 @@ +# Container Image Packaging + +## Purpose + +Provide Alpine-based multi-stage Docker container image packaging for DiAL, enabling containerized deployment with musl executables, minimal runtime dependencies, and documented build/run workflows. + +## Requirements + +### Requirement: Alpine 多阶段容器镜像构建 + +系统 SHALL 提供基于 Alpine 的多阶段 Dockerfile,用于从源码构建 DiAL 容器镜像。构建阶段 SHALL 使用 Bun 官方 Alpine 镜像安装依赖并执行现有生产构建命令;运行阶段 SHALL 使用 Alpine 基础镜像并只复制生产运行所需产物。 + +#### Scenario: 构建容器镜像 + +- **WHEN** 开发者在项目根目录执行文档化的 Docker 构建命令 +- **THEN** 镜像构建 SHALL 安装锁定依赖、执行 `bun run build`,并将生成的 `dist/dial-server` 复制到最终运行镜像 + +#### Scenario: 运行镜像不包含开发依赖 + +- **WHEN** Dockerfile 生成最终运行阶段 +- **THEN** 最终运行阶段 SHALL NOT 复制源码目录、`node_modules`、`.build` 或 `dist/web` 到镜像中 + +### Requirement: Alpine musl executable 目标 + +系统 MUST 为 Alpine 运行阶段生成 musl Linux executable。Docker 构建 SHALL 根据 Docker 构建架构选择 `bun-linux-x64-musl` 或 `bun-linux-arm64-musl`,并将该目标传递给现有 Bun compile 流程。 + +#### Scenario: amd64 镜像构建 + +- **WHEN** Docker 构建目标架构为 `amd64` +- **THEN** 构建阶段 MUST 使用 `bun-linux-x64-musl` 作为 Bun compile target + +#### Scenario: arm64 镜像构建 + +- **WHEN** Docker 构建目标架构为 `arm64` +- **THEN** 构建阶段 MUST 使用 `bun-linux-arm64-musl` 作为 Bun compile target + +#### Scenario: 不支持的架构 + +- **WHEN** Docker 构建目标架构不是 `amd64` 或 `arm64` +- **THEN** 构建 MUST 失败并输出不支持该架构的错误 + +### Requirement: 容器运行环境边界 + +运行镜像 SHALL 提供 DiAL 运行必需的系统能力,包括 HTTPS CA 证书、ICMP checker 所需 `ping` 命令、Bun musl executable 所需运行库、时区数据、非 root 运行用户和可写数据目录。运行镜像 SHALL NOT 默认提供 CMD checker 可能依赖的业务命令环境。 + +#### Scenario: 基础系统包 + +- **WHEN** 最终运行镜像构建完成 +- **THEN** 镜像 SHALL 包含 `ca-certificates`、`iputils-ping`、`libgcc`、`libstdc++` 和 `tzdata` + +#### Scenario: 非 root 运行 + +- **WHEN** 容器启动 DiAL 进程 +- **THEN** DiAL 进程 SHALL 以非 root 用户运行,并且该用户 SHALL 能写入 `/data/dial` + +#### Scenario: CMD checker 额外命令不内置 + +- **WHEN** 用户需要通过 CMD checker 执行 `curl`、`dig`、`psql`、`mysql`、`redis-cli` 或自定义脚本依赖 +- **THEN** 官方镜像 SHALL 要求用户通过派生镜像或运行环境自行提供这些命令 + +### Requirement: 容器默认启动约定 + +系统 SHALL 为容器镜像提供稳定的默认启动约定。镜像 SHALL 使用 `dial-server` executable 作为入口,默认配置文件路径 SHALL 为 `/etc/dial/probes.yaml`,默认服务端口 SHALL 暴露 `3000`。 + +#### Scenario: 默认配置路径 + +- **WHEN** 用户不覆盖容器启动命令 +- **THEN** 容器 SHALL 尝试使用 `/etc/dial/probes.yaml` 作为 DiAL YAML 配置文件路径启动 + +#### Scenario: 默认端口暴露 + +- **WHEN** 用户查看镜像元数据或使用文档化运行命令 +- **THEN** 镜像 SHALL 暴露并映射 DiAL 默认 HTTP 端口 `3000` + +### Requirement: 容器专用最小配置示例 + +系统 SHALL 提供容器专用最小配置示例,用于直接挂载到默认配置路径运行。该配置 SHALL 默认监听容器网络地址 `0.0.0.0`,默认端口为 `3000`,并将存储目录设置为 `/data/dial`。 + +#### Scenario: 容器配置监听所有接口 + +- **WHEN** 用户使用容器专用配置示例启动 DiAL +- **THEN** DiAL SHALL 监听 `0.0.0.0:3000`,使宿主机端口映射可访问服务 + +#### Scenario: 容器配置持久化目录 + +- **WHEN** 用户使用容器专用配置示例启动 DiAL +- **THEN** SQLite 数据和默认日志 SHALL 写入 `/data/dial` 下的路径 + +#### Scenario: 容器配置避免额外命令依赖 + +- **WHEN** 用户使用容器专用配置示例启动 DiAL +- **THEN** 示例配置 SHALL NOT 包含依赖 Bun、Node.js 或额外系统命令的 CMD checker target + +### Requirement: 容器健康检查 + +镜像 SHALL 提供 Docker HEALTHCHECK,通过 Alpine 自带 `wget` 访问 DiAL `/health` 端点判断进程健康。健康检查 SHALL NOT 为此额外安装 `curl`。 + +#### Scenario: 健康检查成功 + +- **WHEN** DiAL 在容器内启动并响应 `/health` +- **THEN** Docker HEALTHCHECK SHALL 通过 HTTP 请求 `/health` 成功返回健康状态 + +#### Scenario: 不额外安装 curl + +- **WHEN** 最终运行镜像构建完成 +- **THEN** 镜像 SHALL NOT 为健康检查目的安装 `curl` + +### Requirement: Docker 构建上下文控制 + +系统 SHALL 提供 Docker 构建上下文忽略规则,避免将开发依赖、构建产物、本地数据、环境文件和工具状态复制进构建上下文。 + +#### Scenario: 忽略开发和构建产物 + +- **WHEN** Docker 构建上下文被发送给 Docker daemon +- **THEN** `node_modules`、`dist`、`.build`、`data`、本地环境文件和工具状态目录 SHALL 被忽略 + +### Requirement: Docker 使用文档 + +系统 SHALL 在项目文档中说明 Docker 镜像的构建、运行和扩展方式。文档 MUST 覆盖单架构构建、多架构 buildx 构建、配置挂载、数据卷、ICMP capability 和 CMD checker 派生镜像策略。 + +#### Scenario: 单架构 Docker 构建文档 + +- **WHEN** 用户阅读 Docker 部署说明 +- **THEN** 文档 SHALL 提供在本机架构构建 Alpine 镜像的命令示例 + +#### Scenario: 多架构 buildx 文档 + +- **WHEN** 用户阅读 Docker 部署说明 +- **THEN** 文档 SHALL 提供 `linux/amd64` 和 `linux/arm64` 的 buildx 构建命令示例 + +#### Scenario: ICMP capability 文档 + +- **WHEN** 用户需要在容器中运行 ICMP checker +- **THEN** 文档 MUST 明确说明运行容器时需要授予 `NET_RAW` capability + +#### Scenario: CMD checker 派生镜像文档 + +- **WHEN** 用户需要 CMD checker 执行官方镜像未内置的命令 +- **THEN** 文档 SHALL 提供通过派生镜像安装额外命令的示例