Files
reader/server/Dockerfile
T
cnb.dQC5J6ohAGA 0f53088ffe fix(docker): 允许非 root 用户写入挂载的 /data
服务端以非特权用户 `reader` 运行,镜像里构建时做的 `chown /data` 在
`/data` 被 **挂载** 时完全失效:bind mount 的属主来自宿主机目录,宿主机上
`./data` 不存在时 Docker 会以 root:root 新建它,于是服务端写签名密钥时
直接以 `EACCES: permission denied, open '/data/token.secret'` 崩溃退出。
命名卷继承镜像的 chown,所以只有 bind mount 受影响,而 compose 与 README
推荐的正是 bind mount,等于默认路径就是坏的。

改动:

- 新增 `server/docker-entrypoint.sh`:以 root 启动,只在启动时把 /data
  这一层的属主改成 reader(不递归整棵树,避免大缓存目录每次重启都被
  chown),随后用 su-exec 降权到 reader 再 exec 服务端。服务端仍是 pid 1,
  main.ts 的 SIGTERM/SIGINT 优雅退出照常工作。若容器本来就不是 root
  启动(`--user`),脚本直接 exec 命令,不尝试改属主。
- `server/Dockerfile`:装 su-exec(约 10KB;alpine 的 busybox 没有等价的
  setpriv,util-linux 又不在基础镜像里),加 ENTRYPOINT。去掉 `USER reader`,
  因为降权改由入口脚本完成。
- `server/src/config/index.ts`:写密钥失败时,EACCES/EPERM/EROFS 不再只抛
  errno,而是报出「DATA_DIR 必须可写」并给出两种修法(改宿主机属主,
  或设 READER_TOKEN_SECRET 跳过写文件)。这是自部署最高频的坑,
  直接给出下一步能省掉一整轮排查。
- 新增 `server/test/config.test.ts`:覆盖密钥持久化与复用的稳定性、
  READER_TOKEN_SECRET 优先时不落盘、目录不可写时错误信息含修法、
  DATA_DIR 落在 BOOKS_DIR 内被拒。权限用例在非 root 下运行,
  以 root 跑测试时跳过。
- README 增加「故障排查」小节,写明成因与 4 步自查。

验证:`npm run typecheck` 通过;`npm test` 149 项 148 通过 1 跳过
(跳过项即需非 root 的权限用例,已用非 root 单独跑通);以非 root 手动
复现 EACCES 路径,确认错误信息与修法正确;入口脚本的 chown + 降权流程
手动模拟通过。
2026-09-17 10:16:11 +08:00

85 lines
3.5 KiB
Docker

# --- web client build stage ---------------------------------------------------
# The H5 client is built here and baked into the server image, because serving it
# from the same container is what keeps the deployment to a single command: the
# reader opens http://<nas-ip>:8080 and gets the app, with no second container and
# no CORS configuration to think about.
#
# `web` is optional. When the build context has no `web` directory the copy below
# fails, so the client stage is gated behind a build arg and the runtime stage
# tolerates an absent bundle. A server-only deployment stays possible.
#
# The base image is overridable because `docker.cnb.cool` is an authenticated
# mirror: a plain `docker build` on a machine whose token has no pull rights on
# it fails at the very first stage. Docker Hub is the default; pass the mirror
# where it is reachable (CNB pipelines, or anywhere Hub is slow):
#
# docker build . -f server/Dockerfile \
# --build-arg NODE_IMAGE=docker.cnb.cool/dockerhub/node:24-alpine
ARG NODE_IMAGE=node:24-alpine
FROM ${NODE_IMAGE} AS web
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci
COPY web/ ./
RUN npm run build
# --- server build stage ------------------------------------------------------
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY server/package.json server/package-lock.json* ./
# Dev dependencies included: the TypeScript toolchain is needed to compile.
RUN npm ci --include=dev
COPY server/tsconfig.json server/tsconfig.build.json ./
COPY server/src ./src
RUN npm run build
# Strip dev dependencies for the runtime image.
RUN npm prune --omit=dev
# --- runtime stage -----------------------------------------------------------
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production \
BOOKS_DIR=/books \
DATA_DIR=/data \
PORT=8080 \
WEB_DIR=/app/web
# Run as an unprivileged user. The library mount is read-only, so this user only
# ever needs write access to /data.
#
# `su-exec` is a ~10KB static helper that drops privileges from an entrypoint;
# Alpine's busybox has no equivalent (`setpriv` lives in util-linux, which the
# base image does not carry), and `su` there needs a password hash to be set up.
RUN apk add --no-cache su-exec \
&& addgroup -S reader && adduser -S reader -G reader \
&& mkdir -p /data /books /app/public && chown -R reader:reader /data /books /app/public
COPY --from=build --chown=reader:reader /app/node_modules ./node_modules
COPY --from=build --chown=reader:reader /app/dist ./dist
COPY --from=build --chown=reader:reader /app/package.json ./
# The built H5 client, served by the same process at `/`.
COPY --from=web --chown=reader:reader /web/dist ./web
# The entrypoint starts as root purely to take ownership of the *mounted* /data
# (a bind mount's uid/gid comes from the host and overrides the chown above),
# then re-execs the server as `reader`. See the script for the full rationale.
COPY --chown=root:root server/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080
VOLUME ["/data"]
# The healthcheck hits the unauthenticated endpoint so an orchestrator can tell
# "server up but library unreadable" from "server down".
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]