
前面我们把协议转换网关的架构、采集引擎、MQTT 客户端、本地缓存、高可用设计都讲透了。
这一篇把网关从"一段 Python 代码"变成"一个可交付、可运维、可规模化的容器"——Docker 化部署的完整工程实践。
先说一个现场最常见的场景:
甲方机房里已经有 3 台工控机,分别跑着门禁、视频、动环。 你要在其中一台上部署采集服务,但不能"污染"宿主系统——不能装 Python 3.11、不能改系统依赖、不能因为升级采集服务把门禁搞挂。 而且这台机器可能断电重启,重启后采集服务必须自动恢复,不需要人去现场敲命令。
这就是容器化的核心价值:隔离、可复现、自恢复。
维度 | 裸跑(systemd 直接启 Python) | Docker 容器 |
|---|---|---|
环境隔离 | 共享系统 Python,依赖冲突风险 | 独立运行时,互不干扰 |
版本回滚 | 手动备份旧版代码 | docker pull + 切 tag,秒级回滚 |
配置管理 | 配置文件散落在 /etc | 挂载 volume,配置与镜像分离 |
日志 | 自己管 logrotate | Docker logging driver → 统一收集 |
网络 | 直接绑宿主机端口 | 容器网络隔离,可映射、可限制 |
重启策略 | systemd Restart=always | Docker --restart unless-stopped |
多实例 | 端口冲突、PID 文件冲突 | 独立容器,互不干扰 |
交付物 | "代码 + 安装文档" | 一个镜像,一条 docker run |
对于动环监控这种长期运行、要求稳定、部署环境不可控的场景,Docker 几乎是必选项。
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 挂载,不进镜像)# ─── 阶段 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 能自动检测容器是否"活着" |
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 的说明:
host 模式让容器直接用宿主机的网络栈,最简单、延迟最低 .env 文件(敏感信息) # .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=INFOconfig/devices.yaml(设备点表) 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" }
# ... 更多设备config/gateway.yaml(运行参数) 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"#!/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 "$@"#!/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 0Docker 停止容器时会发送 SIGTERM,等待一段时间后再发 SIGKILL。应用必须优雅退出:
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.")容器化后,不要写日志文件到容器内部(容器销毁日志就没了)。输出到 stdout/stderr,由 Docker logging driver 收集:
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')查看日志:
docker logs -f env-gateway-prod --tail 100# 克隆配置
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# 拉取新镜像
docker compose pull
# 滚动重启(不停机)
docker compose up -d --no-deps env-gateway
# 回滚(如果出问题)
docker compose down
docker compose up -d env-gateway:1.1.0# 修改设备点表
vim config/devices.yaml
# 重启容器使配置生效(或发 SIGHUP 热加载)
docker compose restart env-gateway
# 查看是否生效
docker compose logs --tail 20 env-gateway# 进入容器
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如果你有 10 个库房,每个库房一台工控机:
# 所有站点用同一个镜像
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/部署命令:
# 站点 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 删除。