三服务总览

Mill 系统后端跑在一台小内存 VPS 上,由 docker-compose.mill.yml 编排三个容器:MySQL 8.4 存数据,Eclipse Mosquitto 2 做 MQTT broker,Node.js 22 Alpine 镜像跑 API。三者分工和暴露面各不相同:

服务镜像端口profile
stm32-mill-mysqlmysql:8.4不暴露storagemysql-data
stm32-mill-mqtteclipse-mosquitto:28883 (TLS)private-mqttmosquitto-data、mosquitto-log + 配置只读挂载
stm32-mill-apinode: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 缓存或时序库,可以再加 cachemetrics 这样的 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,镜像更小。源码分 srcscripts 两个目录复制,构建上下文限定在 ./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
  • 用应用用户而非 rootMYSQL_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 内网
Mosquitto8883 (TLS)设备直连,需双向证书认证
API3001Nginx 反向代理到 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 文件管理配置。对单机小规模部署足够用,复杂度可控。