三服务总览
Mill 系统后端跑在一台小内存 VPS 上,由 docker-compose.mill.yml 编排三个容器:MySQL 8.4 存数据,Eclipse Mosquitto 2 做 MQTT broker,Node.js 22 Alpine 镜像跑 API。三者分工和暴露面各不相同:
| 服务 | 镜像 | 端口 | profile | 卷 |
|---|---|---|---|---|
| stm32-mill-mysql | mysql:8.4 | 不暴露 | storage | mysql-data |
| stm32-mill-mqtt | eclipse-mosquitto:2 | 8883 (TLS) | private-mqtt | mosquitto-data、mosquitto-log + 配置只读挂载 |
| stm32-mill-api | node:22-alpine(本地构建) | 3001 | 无(默认必启) | stm32-mill-ota |
MySQL 不向宿主暴露端口,只在 docker 网络内被访问;Mosquitto 只对外开 8883 TLS,1883 留给内网;API 容器走 build 本地构建镜像,外部访问统一走宿主 Nginx 反向代理到 3001。
Docker profiles:按需组合
三个服务里只有 MySQL 和 Mosquitto 带了 profiles,API 不挂任何 profile,属于默认必启的核心:
stm32-mill-mysql:
profiles:
- storage
stm32-mill-mqtt:
profiles:
- private-mqtt
docker compose up 不带 --profile 时,MySQL 和 Mosquitto 不会被拉起,只启动 API。本地调试或纯前端联调时不需要拉起整套数据栈,启动更快、占内存更少。
生产部署则显式激活两个 profile:
docker compose -f docker-compose.mill.yml \
--profile storage --profile private-mqtt \
up -d --build
profile 命名按职责分:storage 是数据存储层,private-mqtt 是私有 broker。未来如果加 Redis 缓存或时序库,可以再加 cache、metrics 这样的 profile,组合方式不变。
MySQL 小内存调优
VPS 内存有限,MySQL 8.4 默认配置在 1G 内存机器上会吃掉一半以上。compose 文件通过 command 传启动参数做压制:
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --performance-schema=${STM32_MILL_MYSQL_PERFORMANCE_SCHEMA:-OFF}
- --innodb-buffer-pool-size=${STM32_MILL_MYSQL_INNODB_BUFFER_POOL_SIZE:-64M}
- --max-connections=${STM32_MILL_MYSQL_MAX_CONNECTIONS:-40}
- --table-open-cache=${STM32_MILL_MYSQL_TABLE_OPEN_CACHE:-200}
- --tmp-table-size=${STM32_MILL_MYSQL_TMP_TABLE_SIZE:-16M}
- --max-heap-table-size=${STM32_MILL_MYSQL_MAX_HEAP_TABLE_SIZE:-16M}
- --sort-buffer-size=${STM32_MILL_MYSQL_SORT_BUFFER_SIZE:-256K}
- --read-buffer-size=${STM32_MILL_MYSQL_READ_BUFFER_SIZE:-256K}
- --read-rnd-buffer-size=${STM32_MILL_MYSQL_READ_RND_BUFFER_SIZE:-512K}
- --join-buffer-size=${STM32_MILL_MYSQL_JOIN_BUFFER_SIZE:-256K}
关键参数的取舍:
- performance-schema=OFF:性能采样框架自身吃内存,关掉省几十 MB。工业物联网场景查询模式固定,不需要采样做分析
- innodb-buffer-pool-size=64M:默认 128M,压到 64M 对单库几十万行遥测数据够用,热数据基本能命中
- max-connections=40:默认 151,工业场景并发连接数很低(API 连接池 limit=5),40 留足余量
- tmp-table-size / max-heap-table-size=16M:内存临时表上限,GROUP BY 排序时用
- sort/read/join buffer=256K-512K:每连接会话级 buffer,压到最小档,40 连接下最坏 20M
容器整体内存上限 mem_limit: 384m,留出 buffer pool 之外的线程栈、查询缓存、连接管理开销。字符集统一 utf8mb4 + utf8mb4_unicode_ci,避免存 emoji 或生僻字被 3 字节 utf8 截断。
Mosquitto TLS 与配置挂载
Mosquitto 2.x 默认只监听本地,对外开 8883 TLS 端口需要在 mosquitto.conf 里显式声明。配置文件、ACL、密码、证书全部只读挂载:
stm32-mill-mqtt:
image: eclipse-mosquitto:2
ports:
- "8883:8883"
volumes:
- ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./mqtt/aclfile:/mosquitto/config/aclfile:ro
- ./mqtt/passwordfile:/mosquitto/config/passwordfile:ro
- ./mqtt/certs:/mosquitto/certs:ro
- mosquitto-data:/mosquitto/data
- mosquitto-log:/mosquitto/log
挂载策略分两类:
- 只读绑定挂载(:ro):配置和凭证类文件,容器不能改,所有修改走宿主 git 部署
- 命名卷:
mosquitto-data存持久订阅状态,mosquitto-log存日志,容器重建不丢
只读挂载是安全护栏。即便容器被攻破,攻击者也无法改写 passwordfile 或证书做横向移动。
外部设备(STM32 流量计)通过 8883 TLS 双向认证连入,API 服务走 docker 内网 1883 明文,两边隔离。
API 服务:内网 MQTT 与 OTA 卷
API 容器用本地 Dockerfile 构建,不复用预构建镜像:
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
COPY scripts ./scripts
EXPOSE 3001
CMD ["node", "src/server.js"]
--omit=dev 不装 devDependencies,镜像更小。源码分 src 和 scripts 两个目录复制,构建上下文限定在 ./services/stm32-mill-api,避免把整个仓库打进镜像。
API 访问 MQTT 走 docker 内网域名,不绕外网 8883:
environment:
MQTT_URL: "${STM32_MILL_MQTT_URL:-mqtt://stm32-mill-mqtt:1883}"
MQTT_CLIENT_ID: "stm32-mill-api"
stm32-mill-mqtt 是 compose 文件里定义的容器名,docker 内置 DNS 解析。走内网 1883 而不是公网 8883,省掉 TLS 握手开销和证书续期带来的连接中断。
OTA 固件单独挂命名卷:
volumes:
- stm32-mill-ota:/data/ota
固件文件由部署脚本上传到宿主,再通过 API 上传接口写入。用命名卷而不是绑定挂载,是为了让容器重建后固件不丢,部署流水线不需要重新上传历史版本。
环境变量强制校验
compose 文件对敏感配置用 ${VAR:?error} 语法做强制校验,缺值直接报错退出:
environment:
MYSQL_PASSWORD: "${STM32_MILL_DB_PASSWORD:?STM32_MILL_DB_PASSWORD must be set in .env}"
DB_PASSWORD: "${STM32_MILL_DB_PASSWORD:?STM32_MILL_DB_PASSWORD must be set in .env}"
MQTT_PASSWORD: "${STM32_MILL_MQTT_PASSWORD:?STM32_MILL_MQTT_PASSWORD must be set in .env}"
JWT_SECRET: "${STM32_MILL_JWT_SECRET:?STM32_MILL_JWT_SECRET must be set in .env (>=32 chars)}"
四种写法按校验强度递进:
${VAR}:未设置时为空字符串,静默通过${VAR:-default}:未设置时用默认值${VAR:?error}:未设置时报错并退出,error 信息打到 stderr${VAR-default}:未设置(包括空字符串)时用默认值
数据库密码、MQTT 密码、JWT 密钥这类绝不能空的配置走 :?,启动时校验。非敏感配置如 topic 名称、端口号走 :- 给默认值。这种做法比在应用代码里 if (!process.env.JWT_SECRET) throw ... 更早暴露问题:容器还没起进程就先挂了,CI 流水线立即红。
MySQL 健康检查
MySQL 容器配了 healthcheck,用 mysqladmin ping 探活:
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
interval: 10s
timeout: 5s
retries: 10
几个细节:
$$转义:compose 文件里$$表示字面$,避免被 compose 自己的变量插值解析掉。最终容器内执行的是mysqladmin ping -h 127.0.0.1 -u$MYSQL_USER -p$MYSQL_PASSWORD --silent- 用应用用户而非 root:
MYSQL_USER是只读写stm32_mill库的应用账号,健康检查走这个账号能验证应用实际可用的权限链路 - 10s 间隔 + 10 次重试:总窗口 100 秒,覆盖 MySQL 首次初始化建表的时间
健康状态会被 docker inspect 读到。CI 部署流水线在 docker compose up 后轮询 .State.Health.Status,healthy 才算启动成功。API 容器没有配 healthcheck,靠应用层 /health HTTP 接口探活。
旧数据卷保留策略
compose 文件末尾声明了四个命名卷:
volumes:
mysql-data:
mosquitto-data:
mosquitto-log:
stm32-mill-ota:
命名卷的生命周期独立于容器。docker compose down 默认不删卷,只有显式加 --volumes 才清掉。这意味着:
- 容器重建不丢数据:
docker compose up --build重建 MySQL 容器,mysql-data卷保留,原有数据库、用户、表结构都在 - 配置变更不破坏运行时:MQTT 的 passwordfile、certs 走绑定挂载(git 管理),但订阅状态在
mosquitto-data卷里,不受配置更新影响 - 回滚安全:部署失败
docker compose down后重新up,数据状态和部署前一致
MySQL 卷的注释里特别说明沿用旧的共享卷:
# 继续使用旧共享卷,优先保证 STM32_Mill 原有数据库不迁移、不丢失。
MYSQL_PASSWORD: "${STM32_MILL_DB_PASSWORD:?...}"
这套部署是从早期单容器 MySQL 演进来的,老数据库直接挂到新容器上,不做 dump/restore,避免数据迁移风险。
网络隔离
三个服务的暴露面按层级收紧:
| 服务 | 端口暴露 | 外部访问路径 |
|---|---|---|
| MySQL | 无 | 不对外,只服务 docker 内网 |
| Mosquitto | 8883 (TLS) | 设备直连,需双向证书认证 |
| API | 3001 | Nginx 反向代理到 443,外部不直连 |
MySQL 完全不映射端口,通过 docker 内网别名 mysql 被访问。API 容器的 DB_HOST 默认 stm32-mill-mysql,走内部 DNS。即便 VPS 被入侵,没装 mysql client 也连不上数据库。
Mosquitto 只暴露 8883 TLS 端口,1883 明文端口只在 docker 内网可见。设备走公网 8883 双向认证,API 走内网 1883 明文,两边隔离。
API 容器映射 3001 到宿主机,外部访问走 Nginx 反向代理从 443 转发到 127.0.0.1:3001。3001 端口服务于 Nginx upstream 和本地健康检查(http://127.0.0.1:3001/health),不直接对设备或前端暴露。配合 VPS 防火墙只放行 22/80/443/8883,3001 对公网不可达。
小结
这套 compose 文件的设计取向是「小内存能跑、敏感配置不漏、容器重建不丢数据」。
- Docker profiles 让存储层和 broker 可选,本地调试和生产部署共用一份文件
- MySQL 压到 384M 内存上限,buffer pool 64M、连接数 40,适配 1G VPS
- Mosquitto 配置和凭证只读挂载,命名卷保留订阅状态
- API 走内网 MQTT,省外网回环
${VAR:?error}强制校验敏感配置,缺值启动即失败- 命名卷独立于容器生命周期,重建不丢数据
没有引入额外的编排工具或 secrets manager,靠 compose 原生能力和 .env 文件管理配置。对单机小规模部署足够用,复杂度可控。