首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Docker 化部署 Modbus TCP 采集服务:网口温湿度传感器容器化接入实践

Docker 化部署 Modbus TCP 采集服务:网口温湿度传感器容器化接入实践

原创
作者头像
BJ盛世宏博小程
发布于 2026-09-21 17:42:38
发布于 2026-09-21 17:42:38
1010
举报

Docker 化部署 Modbus TCP 采集服务:网口温湿度传感器容器化接入实践

前面我们把协议转换网关的架构、采集引擎、MQTT 客户端、本地缓存、高可用设计都讲透了。

这一篇把网关从"一段 Python 代码"变成"一个可交付、可运维、可规模化的容器"——Docker 化部署的完整工程实践。

先说一个现场最常见的场景:

甲方机房里已经有 3 台工控机,分别跑着门禁、视频、动环。 你要在其中一台上部署采集服务,但不能"污染"宿主系统——不能装 Python 3.11、不能改系统依赖、不能因为升级采集服务把门禁搞挂。 而且这台机器可能断电重启,重启后采集服务必须自动恢复,不需要人去现场敲命令。

这就是容器化的核心价值:隔离、可复现、自恢复。


一、为什么用 Docker 而不是裸跑

维度

裸跑(systemd 直接启 Python)

Docker 容器

环境隔离

共享系统 Python,依赖冲突风险

独立运行时,互不干扰

版本回滚

手动备份旧版代码

docker pull + 切 tag,秒级回滚

配置管理

配置文件散落在 /etc

挂载 volume,配置与镜像分离

日志

自己管 logrotate

Docker logging driver → 统一收集

网络

直接绑宿主机端口

容器网络隔离,可映射、可限制

重启策略

systemd Restart=always

Docker --restart unless-stopped

多实例

端口冲突、PID 文件冲突

独立容器,互不干扰

交付物

"代码 + 安装文档"

一个镜像,一条 docker run

对于动环监控这种长期运行、要求稳定、部署环境不可控的场景,Docker 几乎是必选项。


二、项目结构

代码语言:javascript
复制
env-gateway/
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── config/
│   ├── devices.yaml          # 设备点表配置
│   ├── mqtt.yaml             # MQTT 连接配置
│   └── gateway.yaml          # 网关运行参数
├── src/
│   ├── main.py               # 入口
│   ├── modbus_poller.py      # Modbus TCP 采集引擎
│   ├── mqtt_client.py        # MQTT 客户端
│   ├── point_mapper.py       # 点表映射
│   ├── local_cache.py        # SQLite 本地缓存
│   ├── backfill.py           # 断网补传
│   └── health.py             # 健康检查
├── scripts/
│   ├── entrypoint.sh         # 容器启动脚本
│   └── healthcheck.sh        # 健康检查脚本
└── data/
    └── cache.db              # SQLite(volume 挂载,不进镜像)

三、Dockerfile:多阶段构建

代码语言:javascript
复制
# ─── 阶段 1:构建 ───
FROM python:3.11-slim AS builder

WORKDIR /build

# 系统依赖(编译 pymodbus / cryptography)
RUN apt-get update && apt-get install -y \
    gcc \
    libffi-dev \
    libssl-dev \
    && rm -rf /var/lib/apt/lists/*

# 先复制依赖文件(利用 Docker 层缓存)
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# ─── 阶段 2:运行 ───
FROM python:3.11-slim

WORKDIR /app

# 运行时系统依赖(不需要 gcc)
RUN apt-get update && apt-get install -y \
    ca-certificates \
    sqlite3 \
    && rm -rf /var/lib/apt/lists/*

# 从 builder 复制已安装的 Python 包
COPY --from=builder /root/.local /root/.local

# 复制应用代码
COPY src/ ./src/
COPY config/ ./config/
COPY scripts/ ./scripts/

# 创建数据目录
RUN mkdir -p /data && chmod 755 /data

# 环境变量
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

# 非 root 用户运行(安全)
RUN useradd -m -u 1000 envgw && chown -R envgw:envgw /app /data
USER envgw

# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD /app/scripts/healthcheck.sh

# 启动
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
CMD ["python", "-m", "src.main"]

关键设计点:

设计

原因

多阶段构建

最终镜像不含 gcc 等编译工具,体积小、攻击面小

非 root 用户

容器被攻破时,攻击者没有 root 权限

PYTHONUNBUFFERED=1

日志实时输出,不缓冲(方便 docker logs 查看)

先 COPY requirements.txt

依赖不变时,Docker 层缓存命中,构建快

健康检查

Docker 能自动检测容器是否"活着"


四、docker-compose.yml:编排一切

代码语言:javascript
复制
version: "3.9"

services:
  env-gateway:
    build:
      context: .
      dockerfile: Dockerfile
    image: env-gateway:1.2.0
    container_name: env-gateway-prod
    restart: unless-stopped

    # 网络模式:host(Modbus TCP 需要直接访问局域网设备)
    network_mode: "host"

    # 环境变量
    env_file:
      - .env

    # 挂载卷
    volumes:
      # 配置(只读)
      - ./config:/app/config:ro
      # 本地缓存数据库(读写,持久化)
      - ./data:/data
      # 日志(可选,挂载到宿主机)
      - ./logs:/app/logs

    # 资源限制
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "0.5"
        reservations:
          memory: 64M
          cpus: "0.1"

    # 日志驱动
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

    # 标签(运维用)
    labels:
      - "com.archive.service=env-gateway"
      - "com.archive.version=1.2.0"
      - "com.archive.env=production"

关于 network_mode: host 的说明:

  • Modbus TCP 采集需要直接访问局域网内的传感器(192.168.10.x)
  • 如果用默认的 bridge 网络,容器 IP 是 172.17.x.x,需要端口映射和路由
  • host 模式让容器直接用宿主机的网络栈,最简单、延迟最低
  • 代价:端口冲突风险(确保宿主机没有其他服务占用 502 等端口)

五、配置管理:镜像与配置分离

1. .env 文件(敏感信息)

代码语言:javascript
复制
# .env(不进 Git)
MQTT_BROKER_HOST=mqtt.tencentcloudmq.com
MQTT_PORT=8883
MQTT_USERNAME=ckafka-xxxx#env_producer
MQTT_PASSWORD=xxxxxxxxxxxxxxxx
MQTT_CLIENT_ID=gateway-site-a-001

INFLUX_URL=http://localhost:8086
INFLUX_TOKEN=xxxxxxxxxxxx
INFLUX_ORG=archive
INFLUX_BUCKET=env_monitor

GATEWAY_ID=gateway-site-a-001
LOG_LEVEL=INFO

2. config/devices.yaml(设备点表)

代码语言:javascript
复制
poll_interval: 5
timeout: 2
retry_count: 2
concurrent_devices: 15

devices:
  - id: "TH-A3-01"
    name: "库房A3-温湿度"
    ip: "192.168.10.21"
    port: 502
    slave_id: 1
    enabled: true
    registers:
      - { name: "temperature", address: 40001, count: 1, type: "INT16", scale: 0.1, target: "temp" }
      - { name: "humidity", address: 40002, count: 1, type: "INT16", scale: 0.1, target: "rh" }
      - { name: "pm25", address: 40003, count: 1, type: "UINT16", scale: 1, target: "pm25" }

  - id: "TH-A3-02"
    name: "库房A3-温湿度-2"
    ip: "192.168.10.22"
    port: 502
    slave_id: 1
    enabled: true
    registers:
      - { name: "temperature", address: 40001, count: 1, type: "INT16", scale: 0.1, target: "temp" }
      - { name: "humidity", address: 40002, count: 1, type: "INT16", scale: 0.1, target: "rh" }

  # ... 更多设备

3. config/gateway.yaml(运行参数)

代码语言:javascript
复制
gateway:
  id: "gateway-site-a-001"
  site_id: "site-tianjin-001"
  room_id: "archive-3f"

  # 变化过滤
  deadband:
    temperature: 0.2
    humidity: 0.5
    pm25: 5

  # 本地缓存
  cache:
    db_path: "/data/cache.db"
    max_unsent_batch: 50
    cleanup_days: 7

  # MQTT
  mqtt:
    topic_prefix: "env"
    qos_telemetry: 1
    qos_event: 1
    keepalive: 60

  # 健康检查
  health:
    port: 9090
    path: "/health"

六、entrypoint.sh:启动脚本

代码语言:javascript
复制
#!/bin/bash
set -e

echo "========================================="
echo "Env Gateway Protocol Converter"
echo "Version: ${GATEWAY_VERSION:-unknown}"
echo "Gateway ID: ${GATEWAY_ID:-unknown}"
echo "========================================="

# 等待网络就绪
echo "Waiting for network..."
for i in $(seq 1 10); do
    if ping -c 1 -W 1 192.168.10.1 > /dev/null 2>&1; then
        echo "Network is ready."
        break
    fi
    echo "  attempt $i/10 failed, retrying..."
    sleep 2
done

# 检查配置文件
echo "Checking configuration..."
if [ ! -f /app/config/devices.yaml ]; then
    echo "ERROR: devices.yaml not found!"
    exit 1
fi

if [ ! -f /app/config/gateway.yaml ]; then
    echo "ERROR: gateway.yaml not found!"
    exit 1
fi

# 检查数据目录
if [ ! -w /data ]; then
    echo "ERROR: /data is not writable!"
    exit 1
fi

# 初始化 SQLite(如果不存在)
echo "Initializing local cache..."
python -m src.local_cache init

# 启动主程序
echo "Starting gateway..."
exec "$@"

七、healthcheck.sh:容器健康检查

代码语言:javascript
复制
#!/bin/bash

# 检查进程是否存活
if ! pgrep -f "src.main" > /dev/null; then
    echo "FAIL: main process not running"
    exit 1
fi

# 检查能否连接 Modbus 设备(取第一个设备的 IP)
FIRST_DEVICE_IP=$(python -c "
import yaml
with open('/app/config/devices.yaml') as f:
    cfg = yaml.safe_load(f)
print(cfg['devices'][0]['ip'])
" 2>/dev/null)

if [ -n "$FIRST_DEVICE_IP" ]; then
    # 尝试 TCP 连接(不是 Modbus 请求,只是端口连通性)
    if ! timeout 2 bash -c "echo > /dev/tcp/$FIRST_DEVICE_IP/502" 2>/dev/null; then
        echo "WARN: cannot reach $FIRST_DEVICE_IP:502"
        # 注意:这里返回 0(健康),因为设备可能暂时离线
        # 真正的健康判断由应用内部的采集成功率决定
    fi
fi

# 检查 SQLite 数据库是否正常
if ! sqlite3 /data/cache.db "SELECT 1 FROM message_queue LIMIT 1" > /dev/null 2>&1; then
    echo "WARN: SQLite database may be locked or corrupted"
fi

echo "OK"
exit 0

八、Python 主程序适配容器

1. 优雅退出(SIGTERM 处理)

Docker 停止容器时会发送 SIGTERM,等待一段时间后再发 SIGKILL。应用必须优雅退出:

代码语言:javascript
复制
import signal
import sys

class Gateway:
    def __init__(self):
        self.running = False
        self.poller = ModbusPoller(...)
        self.mqtt_client = MqttClient(...)
        self.cache = LocalCache('/data/cache.db')

    def start(self):
        self.running = True
        signal.signal(signal.SIGTERM, self.handle_sigterm)
        signal.signal(signal.SIGINT, self.handle_sigterm)

        # 启动采集循环
        asyncio.run(self.run_loop())

    def handle_sigterm(self, signum, frame):
        logger.info(f"Received signal {signum}, shutting down gracefully...")
        self.running = False

    async def run_loop(self):
        while self.running:
            try:
                data = await self.poller.poll_all()
                for device_data in data:
                    if device_data.get('error'):
                        logger.warning(f"{device_data['device_id']}: {device_data['error']}")
                        continue

                    # 变化过滤
                    if not self.should_report(device_data):
                        continue

                    # 发布到 MQTT
                    try:
                        self.mqtt_client.publish(device_data)
                    except Exception:
                        # MQTT 发布失败,写入本地缓存
                        self.cache.store(device_data)

                # 尝试补传
                self.backfill.try_backfill()

            except Exception as e:
                logger.error(f"Poll loop error: {e}")

            await asyncio.sleep(self.poll_interval)

        # 退出前清理
        logger.info("Closing connections...")
        self.mqtt_client.disconnect()
        self.cache.close()
        logger.info("Gateway stopped.")

2. 日志输出到 stdout

容器化后,不要写日志文件到容器内部(容器销毁日志就没了)。输出到 stdout/stderr,由 Docker logging driver 收集:

代码语言:javascript
复制
import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
    stream=sys.stdout  # 关键:输出到 stdout
)

logger = logging.getLogger('env-gateway')

查看日志:

代码语言:javascript
复制
docker logs -f env-gateway-prod --tail 100

九、运维操作速查

1. 部署

代码语言:javascript
复制
# 克隆配置
git clone https://git.company.com/env/env-gateway-config.git /opt/env-gateway
cd /opt/env-gateway

# 复制环境变量
cp .env.example .env
vim .env  # 填入实际值

# 启动
docker compose up -d

# 查看状态
docker compose ps
docker compose logs -f

2. 升级

代码语言:javascript
复制
# 拉取新镜像
docker compose pull

# 滚动重启(不停机)
docker compose up -d --no-deps env-gateway

# 回滚(如果出问题)
docker compose down
docker compose up -d env-gateway:1.1.0

3. 修改配置

代码语言:javascript
复制
# 修改设备点表
vim config/devices.yaml

# 重启容器使配置生效(或发 SIGHUP 热加载)
docker compose restart env-gateway

# 查看是否生效
docker compose logs --tail 20 env-gateway

4. 排查问题

代码语言:javascript
复制
# 进入容器
docker exec -it env-gateway-prod bash

# 手动测试 Modbus 连接
pip install pymodbus
python -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('192.168.10.21', port=502)
c.connect()
r = c.read_holding_registers(40001, count=2, slave=1)
print(r.registers)
c.close()
"

# 查看 SQLite 缓存
sqlite3 /data/cache.db "SELECT COUNT(*) FROM message_queue WHERE sent=0;"
sqlite3 /data/cache.db "SELECT * FROM message_queue WHERE sent=0 ORDER BY sample_time DESC LIMIT 5;"

# 查看 MQTT 连接
docker exec env-gateway-prod netstat -an | grep 8883

十、多站点部署:一个镜像,N 个配置

如果你有 10 个库房,每个库房一台工控机:

代码语言:javascript
复制
# 所有站点用同一个镜像
env-gateway:1.2.0

# 每个站点不同的配置
site-a/
  ├── .env          # MQTT 密码、Influx Token
  ├── config/
  │   ├── devices.yaml  # 这个站点的 20 台设备
  │   └── gateway.yaml  # site_id, room_id
  └── data/         # 本地缓存

site-b/
  ├── .env
  ├── config/
  └── data/

部署命令:

代码语言:javascript
复制
# 站点 A
cd /opt/env-gateway/site-a
docker compose up -d

# 站点 B
cd /opt/env-gateway/site-b
docker compose up -d

镜像版本统一,配置各站独立,升级只换镜像不碰配置。


十一、常见返工点

问题

后果

正确做法

用 latest tag

升级后行为变化,无法回滚

固定版本号 1.2.0

数据目录不挂载 volume

容器重建,缓存数据全丢

/data 挂载宿主机或 volume

配置写进镜像

换设备就要重新构建镜像

配置通过 volume 挂载

不限制资源

采集服务吃光内存,宿主机 OOM

deploy.resources.limits

不处理 SIGTERM

Docker stop 等 10 秒超时强杀,缓存数据可能损坏

优雅退出

日志写文件不挂 volume

容器删了日志没了

输出到 stdout

用 bridge 网络不配端口映射

容器访问不到局域网设备

network_mode: host

root 用户运行

容器逃逸风险

创建非 root 用户

不设置 restart policy

宿主机重启后服务不自动起来

restart: unless-stopped

健康检查只检查进程

进程在但采集已死

检查 Modbus 连通性或最近一次采集时间


十二、一句话总结

Docker 化部署的本质不是"把 Python 装进容器",而是把采集服务变成一个有版本、有边界、有自恢复能力的可交付单元——配置与代码分离、数据持久化到 volume、SIGTERM 优雅退出、健康检查自动检测、一个镜像管所有站点。

当甲方说"我们要再加 5 个库房"时,你不需要去现场装环境——docker compose up -d,5 分钟交付一个站点的完整采集能力。这才是容器化在动环监控中的真正价值。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • Docker 化部署 Modbus TCP 采集服务:网口温湿度传感器容器化接入实践
    • 一、为什么用 Docker 而不是裸跑
    • 二、项目结构
    • 三、Dockerfile:多阶段构建
    • 四、docker-compose.yml:编排一切
    • 五、配置管理:镜像与配置分离
      • 1. .env 文件(敏感信息)
      • 2. config/devices.yaml(设备点表)
      • 3. config/gateway.yaml(运行参数)
    • 六、entrypoint.sh:启动脚本
    • 七、healthcheck.sh:容器健康检查
    • 八、Python 主程序适配容器
      • 1. 优雅退出(SIGTERM 处理)
      • 2. 日志输出到 stdout
    • 九、运维操作速查
      • 1. 部署
      • 2. 升级
      • 3. 修改配置
      • 4. 排查问题
    • 十、多站点部署:一个镜像,N 个配置
    • 十一、常见返工点
    • 十二、一句话总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档