Docker 自托管与服务配置
MyIP 的 Docker 部署将前端构建产物与 Node.js 服务装入同一镜像,通过前端服务提供静态页面并转发 /api 请求。本页说明镜像构建、Compose 部署、端口配置、后端保护与静态资源缓存,帮助自托管维护者建立可验证的运行模型。
目的与范围
本页面向部署及运维人员,覆盖仓库默认容器配置和服务入口中能够直接确认的行为。IP 数据源、离线数据库更新、DNS 泄漏检测、报告分享等功能内部逻辑不在此展开,应在对应功能专题中说明。
**证据边界:**本页核对了 Dockerfile、Compose 配置、前端服务以及后端入口的配置和限流部分。未读取 npm start 对应脚本定义、后端监听与后续路由实现,因此不推断进程管理方式、健康检查端点、完整环境变量清单或功能级 API 契约。文中源码链接均指向 main 分支,后续分支变更可能影响行号。
概述
默认部署只有一个 Compose 服务 myip,使用 jason5ng32/myip:latest,将宿主机 18966 映射至容器 18966。前端入口默认使用 FRONTEND_PORT=18966,后端入口和前端代理共同读取 BACKEND_PORT,默认值为 11966。
这一结构将页面与 API 的对外入口集中到前端服务:浏览器访问前端端口,/api 由前端转发至本机后端地址,而其他请求经过静态文件和单页应用回退处理。默认 Compose 不发布后端端口,也未声明数据卷、环境变量、健康检查或其他依赖服务。
依据:docker-compose.yml、frontend-server.js、backend-server.js。
架构与入口关系
Sources: Dockerfile、docker-compose.yml、frontend-server.js、backend-server.js。
图中将镜像打包与运行时请求路径分开表达。Dockerfile 的默认启动命令是 npm start;不能仅凭镜像复制了两个服务文件,就断言二者由 PM2、集群模式或特定的并行脚本启动。
镜像如何构建
构建阶段:可复现依赖与缓存复用
构建阶段基于 node:24-alpine,开启 Corepack,并安装 python3 make g++。Dockerfile 注释说明这些工具供缺少 musl 预编译包的原生依赖构建使用,例如由开发插件间接引入的 node-pty。
以下为实际构建步骤:
1COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
2RUN pnpm install --frozen-lockfile
3COPY . .
4RUN pnpm run buildSource: Dockerfile。
先复制依赖清单和锁文件,再安装依赖,最后复制其他源码,可以在仅业务源码变化时复用依赖安装层。--frozen-lockfile 避免构建时重新解析并改写锁文件;构建使用的 pnpm 版本由 Dockerfile 注释所述的 packageManager 字段约束,本页未核对其具体版本值。
运行阶段:只复制运行所需路径,但未裁剪依赖目录
运行阶段同样使用 node:24-alpine,工作目录为 /app。镜像复制 node_modules、包清单、dist、两个服务入口、Sentry 初始化入口,以及 api、common 目录,然后声明 EXPOSE 18966 并执行 npm start。
需要区分两个事实:
- 编译工具只在构建阶段安装,运行阶段未再次安装它们。
node_modules从构建阶段整体复制,并非经过源码可见的生产依赖裁剪。因此不能把该镜像描述成“只含 production dependencies”。完整复制也保留了 pnpm 在目录内的符号链接结构。
运行阶段没有显式复制 .env。若自托管需要环境配置,应在运行时注入,而不能假设仓库工作目录中的 .env 会自动存在于最终镜像内。这里仅说明可见的复制行为,不推断构建工具是否使用其他环境变量。
依据:Dockerfile。
使用示例与部署顺序
1. 使用仓库提供的 Compose 定义
1version: '3'
2services:
3 myip:
4 container_name: myip
5 image: jason5ng32/myip:latest # my-app: x.x.x
6 stdin_open: true # docker run -i interactive mode
7 tty: true # docker run -t terminal mode
8 restart: always # restart policy / on-failure:5 restart 5 times / always restart forever / unless-stopped unless stopped / no never restart
9 ports:
10 - "18966:18966"Source: docker-compose.yml。
仓库在该配置顶部给出的启动方式是 docker-compose up -d。默认配置直接使用镜像,没有 build 字段,因此它不是从当前源码现场构建镜像的 Compose 定义。
container_name: myip固定容器名;在同一 Docker 环境部署多个实例时,需要处理名称冲突。18966:18966左侧是宿主机端口,右侧是容器端口。restart: always是容器重启策略,不代表服务已就绪,也不是应用健康检查。latest是默认镜像标签;若需要可追溯发布,建议另行记录实际镜像摘要,不把该标签视为不可变版本。
2. 保持端口与代理目标一致
前端对端口和代理的实际配置如下:
const frontendApp = express();
const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10);
const frontEndPort = parseInt(process.env.FRONTEND_PORT || 18966, 10);Source: frontend-server.js。
1frontendApp.use('/api', createProxyMiddleware({
2 target: `http://localhost:${backEndPort}/api`,
3 changeOrigin: true
4}));Source: frontend-server.js。
如果只希望变更对外端口,调整宿主机映射即可,不必改变应用端口。如果变更 FRONTEND_PORT,则容器映射的目标端口也必须匹配;Dockerfile 中的 EXPOSE 不会替应用修改监听端口。
若变更 BACKEND_PORT,前端代理和后端配置必须使用相同值。代理主机固定为 localhost,可见实现只允许通过环境变量改变端口,没有后端主机地址配置项。将前后端拆成不同容器时,仅设置 BACKEND_PORT 不足以完成迁移。
服务配置参考
前后端入口均调用 dotenv.config({ quiet: true })。下表仅包含已读取实现中直接使用的选项,不代表项目完整配置清单。
| 配置项 | 读取类型 | 默认值 | 生效位置与含义 |
|---|---|---|---|
FRONTEND_PORT | 字符串经十进制 parseInt | 18966 | 前端 listen 使用的端口 |
BACKEND_PORT | 字符串经十进制 parseInt | 11966 | 前端代理目标端口;后端入口也读取同一变量 |
SECURITY_RATE_LIMIT | 字符串经十进制 parseInt | 0 | 0 不挂载通用限流;非零值作为 20 分钟窗口的 max |
SECURITY_DELAY_AFTER | 字符串经十进制 parseInt | 0 | 0 不挂载减速;非零值作为 1 小时窗口的 delayAfter |
SECURITY_BLACKLIST_LOG_FILE_PATH | 字符串 | 空字符串 | 可选限流记录文件;为空不调用文件记录逻辑 |
LOG_HTTP | 字符串精确比较 | 未设置时关闭 | 仅值为 'true' 时启用 /api HTTP 请求日志 |
依据:frontend-server.js、frontend-server.js、backend-server.js、backend-server.js。
**配置校验边界:**上述整数配置直接使用 parseInt,可见代码没有先做范围与格式校验。不要依赖非法字符串、负数或超出端口范围的值获得有意义的回退行为。默认值通过 || 选择,因此空字符串使用默认值,而非空非法字符串仍会进入解析。
请求处理流程
Sources: frontend-server.js、backend-server.js、backend-server.js。
前端:代理优先,静态资源其次,SPA 回退最后
/api 代理在静态文件之前注册,避免 API 请求被当作页面导航处理。静态服务随后使用 distDir,通过 setStaticHeaders 决定缓存策略。
当静态层没有返回文件时,最后一个中间件才判断是否提供单页应用入口:
1frontendApp.use((req, res, next) => {
2 if (req.method !== 'GET' || !req.accepts('html')) return next();
3 if (req.path.split('/').pop().includes('.')) return next();
4 res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
5 res.sendFile(path.join(distDir, 'index.html'));
6});Source: frontend-server.js。
这使诸如 /tools/whois 的历史路由可以直接打开,同时避免丢失的 JavaScript 文件被错误替换为 HTML。该判断实际上检查路径末段是否含有 .,并不解析真正的文件扩展名;因此自定义客户端路由若在末段包含点,也不会进入这一回退逻辑。
后端:日志先于限流,限流先于减速
LOG_HTTP=true 时,pinoHttp 在限流器前挂载,因此可以记录被限流器拒绝的请求。日志等级规则为:存在错误或响应码至少为 500 时使用 error,响应码至少为 400 时使用 warn,其余使用 info。请求序列化字段为方法和 URL,响应字段为状态码。
通用限流器以 20 分钟为窗口,将 SECURITY_RATE_LIMIT 作为 max,跳过中间件内路径为 /monitoring 的请求。超限处理返回 HTTP 429,JSON 消息为 Too Many Requests。只在 current === limit + 1 时记录一次警告并按配置更新文件,以避免同一轮大量拒绝请求造成日志洪泛。
减速器以 1 小时为窗口,超过阈值后的计算表达式为 (used - req.slowDown.limit) * 400,最大延迟 5000 毫秒。它跳过 /monitoring 和 /maxmind。注意 /maxmind 仅在这段减速配置中豁免,未出现在通用限流器的跳过条件中。
源码注释称 Sentry tunnel 有路由级限流,但本页未读取对应路由,因此不列出该独立限流的参数。
依据:backend-server.js、backend-server.js。
静态缓存与更新行为
setStaticHeaders(res, filePath) 按相对于 distDir 的路径顺序匹配,并设置 Cache-Control。匹配顺序是契约的一部分:例如 assets/ 中的图片先命中目录规则,而非一般图片规则。
| 匹配对象 | 实际缓存策略 | 自托管注意事项 |
|---|---|---|
assets/、fonts/ | public,365 天,immutable | 内容更换需要匹配文件命名与缓存策略 |
favicons/ | public,30 天 | 该目录规则优先于一般图片扩展名规则 |
| PNG/JPG/JPEG/WebP/SVG/ICO | public,7 天 | 源码注释要求变更非哈希图片时更换文件名 |
.html、manifest.webmanifest、指定 SEO 文件 | 浏览器 max-age=0,共享缓存 s-maxage=86400,must-revalidate | 浏览器与边缘缓存的有效期不同 |
| 其他静态文件 | public,1 小时 | 默认兜底策略 |
| SPA 回退响应 | no-store, no-cache, must-revalidate | 不使用静态首页的缓存规则 |
指定 SEO 文件为 llms.txt、llms-full.txt、sitemap.xml、robots.txt。/ 与 /index.html 经静态层返回时,与客户端路由回退响应的缓存头不同,排查旧页面时应先确认请求实际命中了哪一层。
源码注释提及生产环境 Cloudflare 和构建后清理缓存,但本页读取的 Compose 不配置 CDN,前端实现也不是缓存清理器。自托管若添加反向代理或 CDN,需要单独验证其规则与更新流程,不能假设官方站点的清理机制自动适用。
持久化、日志与安全边界
可选限流记录不是永久封禁数据库
SECURITY_BLACKLIST_LOG_FILE_PATH 虽然包含 BLACKLIST,可见实现仅将其用于记录限流事件,并未读取该文件决定是否拒绝请求。启用后,logLimitedIP(ip) 使用 path.join(__dirname, blackListIPLogFilePath) 构造目标路径,必要时创建父目录,然后读取和重写文件。
每条记录由 IP、累计次数和首次记录时间组成。已存在 IP 的更新保留原时间;新 IP 写入当前本地时间及显式 UTC 偏移。文件不存在时允许创建,其他读取错误以及写入错误会记录日志。
默认 Compose 没有声明持久化卷。若需要跨容器重建保留该文件,应另行设计挂载位置与目录权限;本页不提供未经仓库验证的挂载示例。该结论仅针对已读取的限流记录逻辑,不代表项目其他数据也无需持久化。
代理信任与客户端 IP
后端设置 app.set('trust proxy', 1),同时为限流告警记录定义了自己的 getClientIp(req),按以下顺序取值:
cf-connecting-ip;x-forwarded-for按逗号分隔后的第一个值;cf-connecting-ipv6;req.ip。
这里的函数用于限流处理器中的日志与文件记录,不能将其等同于限流库的计数键算法。自托管如果不经过可信的 Cloudflare 或其他代理,应审视入口如何处理外部传入的这些头部;可见函数本身没有校验头部是否来自可信代理。默认不发布后端端口与核对代理头传递规则应作为部署检查的一部分,而非只依据日志中的 IP 判断来源可信。
依据:backend-server.js、backend-server.js。
故障模式、并发与运维检查
| 现象或风险 | 源码依据与排查重点 |
|---|---|
| 改端口后页面不可达 | 检查宿主机映射目标是否与 FRONTEND_PORT 一致;EXPOSE 不改变应用配置 |
| 页面可访问但 API 不通 | 检查前端代理固定的 localhost 目标、两端 BACKEND_PORT 是否一致,以及后端实际进程状态;本页未核实代理错误响应格式 |
| API 返回 429 | 检查是否启用了 SECURITY_RATE_LIMIT;当前窗口为 20 分钟 |
| API 逐渐变慢 | 检查 SECURITY_DELAY_AFTER;配置中的人工延迟最高为 5 秒,不应全部归因于上游网络 |
| 缺失资源没有回退到页面 | 路径末段含点时有意跳过 SPA 回退,以免用 HTML 冒充资源 |
| 更换图片后仍显示旧内容 | 检查图片所在目录及其 7 天、30 天或 365 天缓存规则,优先采用文件名变更 |
| 没有 HTTP 请求日志 | 默认关闭;LOG_HTTP 必须精确等于 'true' |
| 限流日志文件没有出现 | 默认路径为空;只有首次跨入超限状态且路径已配置时才调用文件记录 |
| 文件计数不准确 | 文件更新采用异步读取、修改、整文件写入,没有可见锁或原子更新;并发事件可能互相覆盖 |
限流记录每次更新都读取并重写整个文件,开销随文件增长而增加。父目录创建使用同步文件系统调用,读取与写入使用异步回调。其实现适合作为可选事件记录,不应当作具有事务保证的审计存储。
已读取的限流配置没有显式接入共享存储。多进程或多副本部署的全局配额一致性没有在这些片段中得到证明,不能承诺跨实例共享计数。默认 Compose 也没有副本编排、就绪探测或应用级重试配置。
依据:backend-server.js、frontend-server.js、docker-compose.yml。
扩展与验证建议
基于当前结构,扩展部署时应分别处理以下边界:
- **自定义镜像:**保留锁文件和 workspace 配置的安装顺序,并检查新增运行时模块是否在生产阶段复制清单中。
- **拆分前后端:**先改造固定为
localhost的代理目标,再讨论服务发现和独立容器;现有端口变量不等于完整后端 URL 配置。 - **增加代理或 CDN:**核对代理信任链、客户端 IP 头部及静态缓存;不要覆盖 SPA 回退与缺失资源的区别。
- **增强审计:**如需可靠并发记录,应改造当前整文件读写方式;如需跨副本配额,应单独验证和配置共享限流存储。
本次未读取测试文件,也未执行容器构建或运行验证,因此不声称上述部署路径已通过实测。交付前建议验证:首页加载、客户端路由直接打开、缺失静态文件不返回 SPA 页面、真实 API 转发、启用后的 429 和减速行为,以及日志文件在容器重建后的保留情况。这些是依据源码提出的验证项目,不是仓库已有测试覆盖的陈述。
相关链接
- Docker 镜像构建定义:核对基础镜像、复制范围和启动命令。
- 默认 Compose 部署定义:核对镜像标签、重启策略和端口映射。
- 前端代理与缓存入口:进一步调整静态服务和代理行为。
- 后端保护配置:核对日志、限流和减速实现。功能级 API 与数据源配置应在对应专题中继续展开。