适用:500-1000 台 IoT 设备的中小项目(牧场、园区、农业、冷链等)
架构:单机 Docker Compose,9 个服务,4C8G 起步
关键词:ThingsBoard CE 4.3 / ChirpStack 4.10 / PostgreSQL 16 + TimescaleDB / Redis / Caddy / Prometheus / Grafana
0. 一句话架构
牧场主手机/H5 + PC浏览器 + LoRa网关(4G回传)
↓ HTTPS / MQTT over TLS
Caddy 反向代理 (80/443/8883, Let's Encrypt 自动证书)
↓
┌────────────┬────────────┬────────────┐
│ ranch-h5 │ tb-node │ ranch-h5 │
│ (Express) │ (TB 4.3.1) │ -frontend │
│ :3000 │ :9090 │ (Nginx) │
└────┬───────┴──────┬─────┴────────────┘
│ │ JDBC
↓ ↓
┌────┴────┐ ┌──────┴──────────┐
│ redis │ │ postgres (PG16 │
│ :6379 │ │ + TimescaleDB) │
└─────────┘ └─────────────────┘
ChirpStack LoRa 栈(gateway-bridge 1700UDP → chirpstack:8080 → tb-node:1883)
Prometheus + Grafana 监控
1. 服务器与资源规划
1.1 推荐规格
| 规格 | 配置 | 设备规模 |
|---|---|---|
| 最低测试 | 2核4G + 50G SSD(不可生产) | ≤50 台 |
| ⭐ 推荐生产 | 4核8G + 100G SSD | 500~1000 台 |
| 高配 | 8核16G + 500G SSD | 2000~5000 台 |
| 集群 | 3台 4C8G (K8s) | 5000+ |
⚠️ 别用突发性能实例(阿里云 t5/t6、腾讯云 S 系列共享型),CPU 积分机制会让持续负载限速到 10-20%。
1.2 4C8G 资源分配
| 组件 | 内存 | 说明 |
|---|---|---|
| tb-node | -Xmx3G |
JVM 堆占物理内存 30-40%,不超 50% |
| PostgreSQL | shared_buffers=2G |
占物理内存 25% |
| Redis | maxmemory 256M |
只做缓存,LRU 淘汰 |
| ChirpStack 栈 | ~200MB | 整体很轻 |
| H5 后端 (PM2 2 instance) | ~300MB | |
| Caddy + Nginx | ~50MB | |
| Prometheus + Grafana | ~300MB | |
| 系统预留 | ~1.5G | 别吃满 |
1.3 磁盘与容量
挂载:/ 50G + /data 100G+(独立数据盘)
容量公式:每台设备每天 ≈ 57KB(5分钟一次 × 200B),500 设备 × 90 天 ≈ 2.5GB 原始遥测,TimescaleDB 压缩后约 300MB。100G 数据盘足够;要保留 1 年原始数据上 200G。
1.4 网络策略
公网只开 3 个端口:
| 端口 | 用途 |
|---|---|
| 80/tcp | HTTP(Caddy 自动跳 HTTPS / LE 验证) |
| 443/tcp | HTTPS(H5 + TB UI + REST + WS) |
| 8883/tcp | MQTT over TLS(LoRa 网关公网回传) |
坚决不开:5432 (PG) / 6379 (Redis) / 9090 (TB 直接) / 8080 (ChirpStack) / 9090 (Prometheus) / 3000 (H5 后端) / 3001 (Grafana)
2. Docker Compose 完整部署
2.1 目录结构
/opt/ranch/
├── docker-compose.prod.yml
├── .env # 敏感变量,chmod 600
├── .env.example
├── caddy/Caddyfile
├── tb/thingsboard.conf
├── postgres/postgresql.conf
├── chirpstack/
│ ├── chirpstack.toml
│ └── gateway-bridge.toml
├── ranch-h5/
│ ├── Dockerfile
│ ├── ecosystem.config.js
│ └── src/
├── ranch-h5-frontend/dist/
├── prometheus/{prometheus.yml,alert_rules.yml}
├── scripts/{backup.sh,restore.sh}
└── data/{postgres,redis,tb,caddy-data,prometheus,grafana,backups}
2.2 .env 关键变量
bash
DOMAIN=ranch.example.com
LE_EMAIL=admin@example.com
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$(openssl rand -base64 24)
POSTGRES_DB=thingsboard
CHIRPSTACK_PG_DB=chirpstack
CHIRPSTACK_PG_PASSWORD=$(openssl rand -base64 24)
REDIS_PASSWORD=$(openssl rand -base64 24)
TB_VERSION=4.3.1
TB_JAVA_OPTS=-Xms2G -Xmx3G -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -XX:+ExitOnOutOfMemoryError
H5_JWT_SECRET=$(openssl rand -hex 32)
TB_API_KEY=your_tb_api_key_generated_from_tb_ui
BACKUP_DIR=/data/backups
DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=xxx
2.3 docker-compose.prod.yml(9 服务 + 1 网络)
yaml
name: ranch-prod
networks:
ranch-net:
external: true # docker network create ranch-net
services:
# 1. PostgreSQL 16 + TimescaleDB
postgres:
image: timescale/timescaledb:2.17.2-pg16
restart: always
networks: [ranch-net]
ports: ["127.0.0.1:5432:5432"] # 仅本地
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- /opt/ranch/data/postgres:/var/lib/postgresql/data
- /opt/ranch/postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
command: postgres -c config_file=/etc/postgresql/postgresql.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
deploy:
resources: { limits: { cpus: "2.0", memory: 3500M } }
logging: { driver: json-file, options: { max-size: "50m", max-file: "3" } }
# 2. Redis 7
redis:
image: redis:7-alpine
restart: always
networks: [ranch-net]
command: >
redis-server --appendonly yes --appendfsync everysec
--maxmemory 256mb --maxmemory-policy allkeys-lru
--requirepass ${REDIS_PASSWORD} --tcp-keepalive 60
volumes: [/opt/ranch/data/redis:/data]
deploy: { resources: { limits: { cpus: "0.5", memory: 400M } } }
# 3. ThingsBoard CE
tb-node:
image: thingsboard/tb-node:${TB_VERSION}
restart: always
networks: [ranch-net]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
ports:
- "127.0.0.1:9090:9090" # 仅本地,由 Caddy 反代
- "0.0.0.0:1883:1883" # MQTT(内网 ChirpStack)
- "0.0.0.0:8883:8883" # MQTT over TLS
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
CACHE_TYPE: redis
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD}
JAVA_OPTS: ${TB_JAVA_OPTS}
DATABASE_TS_TYPE: sql
DATABASE_TS_LATEST_TYPE: sql
volumes:
- /opt/ranch/data/tb:/data
- /opt/ranch/tb/thingsboard.conf:/config/thingsboard.conf:ro
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:9090/actuator/health || exit 1"]
interval: 30s
start_period: 120s
deploy: { resources: { limits: { cpus: "3.0", memory: 4000M } } }
# 4. ChirpStack
chirpstack-gateway-bridge:
image: chirpstack/chirpstack-gateway-bridge:4.1.3
restart: always
networks: [ranch-net]
ports: ["0.0.0.0:1700:1700/udp"]
volumes:
- /opt/ranch/chirpstack/gateway-bridge.toml:/etc/chirpstack-gateway-bridge/chirpstack-gateway-bridge.toml:ro
chirpstack:
image: chirpstack/chirpstack:${CHIRPSTACK_VERSION:-4.10.0}
restart: always
networks: [ranch-net]
depends_on: [postgres]
ports: ["127.0.0.1:8080:8080"]
environment:
POSTGRESQL_DSN: "host=postgres port=5432 dbname=${CHIRPSTACK_PG_DB} user=${CHIRPSTACK_PG_USER} password=${CHIRPSTACK_PG_PASSWORD} sslmode=disable"
REDIS_SERVER: "redis://:${REDIS_PASSWORD}@redis:6379"
INTEGRATION_MQTT_ENABLED: "true"
INTEGRATION_MQTT_SERVER: "tcp://tb-node:1883"
volumes: [/opt/ranch/chirpstack/chirpstack.toml:/etc/chirpstack/chirpstack.toml:ro]
# 5. H5 后端
ranch-h5-backend:
build: { context: /opt/ranch/ranch-h5, dockerfile: Dockerfile }
restart: always
networks: [ranch-net]
depends_on:
tb-node: { condition: service_healthy }
postgres: { condition: service_healthy }
ports: ["127.0.0.1:3000:3000"]
environment:
NODE_ENV: production
PORT: ${H5_API_PORT}
TB_URL: http://tb-node:9090
TB_API_KEY: ${TB_API_KEY}
JWT_SECRET: ${H5_JWT_SECRET}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
volumes: [/opt/ranch/ranch-h5/logs:/app/logs]
healthcheck: { test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"], interval: 15s }
deploy: { resources: { limits: { cpus: "0.7", memory: 500M } } }
# 6. H5 前端
ranch-h5-frontend:
image: nginx:1.27-alpine
restart: always
networks: [ranch-net]
volumes:
- /opt/ranch/ranch-h5-frontend/dist:/usr/share/nginx/html:ro
- /opt/ranch/ranch-h5-frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
# 7. Caddy 反代
caddy:
image: caddy:2-alpine
restart: always
networks: [ranch-net]
ports: ["80:80", "443:443", "8883:8883"]
volumes:
- /opt/ranch/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- /opt/ranch/data/caddy-data:/data
- /opt/ranch/data/caddy-config:/config
environment: { DOMAIN: ${DOMAIN}, EMAIL: ${LE_EMAIL} }
# 8. Prometheus
prometheus:
image: prom/prometheus:v2.54.1
restart: always
networks: [ranch-net]
ports: ["127.0.0.1:9090:9090"]
volumes:
- /opt/ranch/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- /opt/ranch/prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro
- /opt/ranch/data/prometheus:/prometheus
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--storage.tsdb.retention.time=30d", "--web.enable-lifecycle"]
deploy: { resources: { limits: { cpus: "0.5", memory: 400M } } }
# 9. Grafana
grafana:
image: grafana/grafana:11.2.0
restart: always
networks: [ranch-net]
depends_on: [prometheus]
ports: ["127.0.0.1:3001:3000"]
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: your_strong_grafana_password
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- /opt/ranch/data/grafana:/var/lib/grafana
- /opt/ranch/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
# 可选:exporter
node-exporter:
image: prom/node-exporter:v1.8.2
restart: always
networks: [ranch-net]
pid: host
volumes: ["/proc:/host/proc:ro", "/sys:/host/sys:ro", "/:/rootfs:ro"]
command: ["--path.procfs=/host/proc", "--path.sysfs=/host/sys", "--path.rootfs=/rootfs",
"--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"]
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.15.0
restart: always
networks: [ranch-net]
environment:
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
2.4 启动流程
bash
# 1. 首次创建网络
docker network create ranch-net
# 2. 创建数据目录
sudo mkdir -p /opt/ranch/data/{postgres,redis,tb,caddy-data,caddy-config,prometheus,grafana,backups}
sudo chown -R $USER:$USER /opt/ranch
# 3. 首次跑 TB schema 安装(--loadDemo 会带演示数据,正式上线时去掉)
docker compose -f /opt/ranch/docker-compose.prod.yml run --rm -it tb-node install-tb.sh --loadDemo
# 4. 启动
docker compose -f /opt/ranch/docker-compose.prod.yml up -d
# 5. 看 TB 启动(首次 3-5 分钟做 schema 迁移)
docker compose -f /opt/ranch/docker-compose.prod.yml logs -f tb-node
# 看到 "Started ThingsBoardApplication" 才算起完
🚫 生产禁止
docker compose down -v:会删除所有数据卷(PG/Redis/TB 全没)。日常用stop/restart/up -d。
3. ThingsBoard 性能调优
3.1 JVM 参数(写到 .env 的 TB_JAVA_OPTS)
| 服务器 | JVM 参数 |
|---|---|
| ⭐ 4C8G | -Xms2G -Xmx3G -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/heapdump.hprof -Djava.security.egd=file:/dev/./urandom |
| 8C16G | -Xms6G -Xmx6G -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35 |
-Xms = -Xmx 避免运行时扩缩;ExitOnOutOfMemoryError 让 OOM 时 Docker 自动重启;HeapDumpOnOutOfMemoryError 留证据。
3.2 thingsboard.conf 关键配置
ini
# 缓存(设备/凭证 10 分钟过期,最新遥测 1 小时)
cache.specs.devices.maxSize: 10000
cache.specs.devices.timeSinceLastAccess: 600
cache.specs.latestTelemetry.maxSize: 100000
cache.specs.latestTelemetry.timeSinceLastAccess: 3600
# HikariCP 连接池
spring.datasource.hikari.maximum-pool-size: 20
spring.datasource.hikari.minimum-idle: 5
# 规则引擎 / 告警
actors.rule-engine.thread-pool-size: 4
alarms.batch.size: 100
alarms.batch.max-delay: 1000
# 异步写遥测(关键性能点)
sql.attributes.batch_size: 1000
sql.ts.batch_size: 1000
sql.ts.batch_threads: 2
# Calculated Fields(v4.3)
calculated_fields.thread-pool-size: 2
calculated_fields.eval-pool-size: 4
# 日志级别生产用 WARN,排查临时改 DEBUG
logger.level.root: WARN
logger.level.org.thingsboard: WARN
⚠️ 规则链 Debug 模式生产必关!Debug 模式每条消息都写 event,流量大能写爆盘。临时开 10-15 分钟排查完立刻关。
3.3 TimescaleDB 启用与策略
bash
# 启用扩展(一次)
docker exec -it postgres psql -U postgres -d thingsboard -c \
"CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;"
# 数据保留:90 天后自动删原始遥测
docker exec -it postgres psql -U postgres -d thingsboard <<'SQL'
SELECT add_retention_policy('ts_kv', INTERVAL '90 days');
-- 压缩:7 天后自动压缩(5-10 倍节省)
ALTER TABLE ts_kv SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'entity_id, key',
timescaledb.compress_orderby = 'ts DESC'
);
SELECT add_compression_policy('ts_kv', INTERVAL '7 days');
-- 连续聚合:每小时平均电量(仪表板趋势图直接读这个,不用扫原始数据)
CREATE MATERIALIZED VIEW ts_kv_hourly_battery
WITH (timescaledb.continuous) AS
SELECT entity_id, key,
time_bucket(INTERVAL '1 hour', ts) AS hour_bucket,
avg(long_v) AS avg_battery,
min(long_v) AS min_battery,
max(long_v) AS max_battery
FROM ts_kv WHERE key = 'batteryLevel'
GROUP BY entity_id, key, time_bucket(INTERVAL '1 hour', ts);
SELECT add_continuous_aggregate_policy('ts_kv_hourly_battery',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
SELECT add_retention_policy('ts_kv_hourly_battery', INTERVAL '1 year');
SQL
ts_kv用bool_v/str_v/long_v/dbl_v/json_v分字段存值,聚合数值用long_v或dbl_v。GPS 建议在设备端或 ChirpStack 解码器里拆成独立 double key。
3.4 PostgreSQL 参数(postgresql.conf)
ini
max_connections = 100
shared_buffers = 2GB # 物理内存 25%
effective_cache_size = 5GB # 物理内存 50-75%
work_mem = 16MB # (RAM*0.25)/max_connections/2
maintenance_work_mem = 512MB
checkpoint_completion_target = 0.9
checkpoint_timeout = 15min
random_page_cost = 1.1 # SSD
effective_io_concurrency = 200 # SSD 并发
jit = off # IoT 简单查询,JIT 反而开销大
log_min_duration_statement = 1000 # 超 1s 记录
autovacuum = on
autovacuum_max_workers = 3
8C16G:max_connections=200, shared_buffers=4GB, effective_cache_size=12GB, work_mem=32MB, maintenance_work_mem=1GB, max_wal_size=8GB。
3.5 Redis 关键配置
ini
maxmemory 256mb
maxmemory-policy allkeys-lru
appendonly yes # AOF 持久化
appendfsync everysec
Redis 存的是可重建缓存(原始数据在 PG),256MB 足够 1000+ 设备。挂了大不了重建缓存,不用给太大内存。
4. HTTPS 与反向代理
Caddy vs Nginx 选哪个 ?Caddy 自动 HTTPS 配置 3 行搞定证书+反代+WS;Nginx 静态/缓存/gzip 更成熟。推荐 Caddy 占 80/443 做 TLS 终结,Nginx 做前端静态文件托管,两者不冲突。
4.1 Caddyfile(子域名方案,新手推荐)
caddy
# 主站
ranch.example.com {
tls {env.EMAIL}
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# WebSocket(先匹配)
@ws_path path /ws/*
handle @ws_path {
reverse_proxy http://ranch-h5-backend:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Connection "upgrade"
header_up Upgrade "websocket"
transport http { read_timeout 300s write_timeout 300s }
}
}
# H5 API
@api_path path /api/*
handle @api_path {
reverse_proxy http://ranch-h5-backend:3000
}
# 其它走前端
handle /* {
reverse_proxy http://ranch-h5-frontend:80
}
}
# TB 管理后台(独立子域名,避免 context_path 坑)
tb.ranch.example.com {
tls {env.EMAIL}
encode zstd gzip
reverse_proxy http://tb-node:9090 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Connection "upgrade"
header_up Upgrade "websocket"
}
}
DNS 两条 A 记录:ranch.example.com + tb.ranch.example.com → 同一公网 IP。
4.2 防火墙
bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 8883/tcp
sudo ufw allow 1700/udp
sudo ufw enable
# 同时检查云厂商安全组!
# 阿里云/腾讯云/AWS 的安全组是独立的,开了 ufw 安全组没放行还是进不来
5. H5 后端生产化
5.1 PM2 配置
js
// ecosystem.config.js
module.exports = {
apps: [{
name: 'ranch-h5-api',
script: './src/app.js',
instances: 2,
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '250M',
restart_delay: 3000,
max_restarts: 10,
min_uptime: '10s',
env: { NODE_ENV: 'production', PORT: 3000 },
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true,
}]
};
5.2 Dockerfile(多阶段 + 非 root + tini)
dockerfile
# 阶段 1:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY . .
# 阶段 2:运行
FROM node:20-alpine AS runtime
WORKDIR /app
RUN addgroup -S ranch && adduser -S ranch -G ranch
RUN mkdir -p /app/logs && chown -R ranch:ranch /app
RUN npm install -g pm2 && apk add --no-cache tini
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/ .
RUN chown -R ranch:ranch /app
USER ranch
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["pm2-runtime", "ecosystem.config.js"]
pm2-runtime专门为容器设计:前台运行 + 正确转发信号(SIGTERM 优雅退出)。普通pm2 start会 fork 后台,容器一启动就退出。
5.3 Nginx 前端配置
nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
gzip_comp_level 6;
# 静态资源长缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# index.html 不缓存
location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; }
# SPA 路由回退
location / { try_files $uri $uri/ /index.html; }
# 禁止访问隐藏文件
location ~ /\. { deny all; }
}
5.4 健康检查 + 限流
js
// /api/health
router.get('/api/health', async (req, res) => {
const health = { status: 'ok', timestamp: new Date().toISOString(), services: {} };
try { await tb.checkConnectivity(); health.services.thingsboard = 'connected'; }
catch { health.services.thingsboard = 'disconnected'; health.status = 'degraded'; }
try { await db.query('SELECT 1'); health.services.database = 'connected'; }
catch { health.services.database = 'disconnected'; health.status = 'degraded'; }
res.status(health.status === 'ok' ? 200 : 503).json(health);
});
// 限流
const globalLimiter = rateLimit({ windowMs: 60*1000, max: 100 });
const loginLimiter = rateLimit({ windowMs: 60*1000, max: 5, skipSuccessfulRequests: true });
// CORS 严格限定
const corsOptions = {
origin: process.env.NODE_ENV === 'production'
? ['https://ranch.example.com']
: ['http://localhost:5173'],
credentials: true
};
app.use(helmet());
app.use(cors(corsOptions));
app.use(globalLimiter);
app.use('/api/auth/login', loginLimiter);
6. 备份与恢复
💀 没有验证过恢复的备份 = 没有备份。每月做一次恢复演练。
6.1 backup.sh
bash
#!/bin/bash
# 用法:/opt/ranch/scripts/backup.sh
# Cron: 0 3 * * * /opt/ranch/scripts/backup.sh >> /var/log/ranch-backup.log 2>&1
set -euo pipefail
BACKUP_ROOT="/opt/ranch/data/backups"
DATE=$(date +%Y%m%d_%H%M%S)
KEEP_LOCAL_DAYS=7
COMPOSE_FILE="/opt/ranch/docker-compose.prod.yml"
ENV_FILE="/opt/ranch/.env"
DINGTALK_WEBHOOK="${DINGTALK_WEBHOOK:-}"
S3_REMOTE="ranch-s3:"
source "${ENV_FILE}" 2>/dev/null || true
BACKUP_DIR="${BACKUP_ROOT}/${DATE}"
mkdir -p "${BACKUP_DIR}"
exec > >(tee -a "${BACKUP_DIR}/backup.log") 2>&1
notify() {
[ -n "${DINGTALK_WEBHOOK}" ] && curl -s -X POST "${DINGTALK_WEBHOOK}" \
-H 'Content-Type: application/json' \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[Ranch Backup] $1: $2\"}}" >/dev/null 2>&1 || true
}
trap 'notify FAIL "Backup FAILED at '"${DATE}"'"' ERR
# 1. 备份 ThingsBoard DB
docker exec postgres pg_dump -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -Fc --blobs --no-owner \
> "${BACKUP_DIR}/thingsboard.dump"
# 2. 备份 ChirpStack DB
docker exec postgres pg_dump -U "${POSTGRES_USER}" -d "${CHIRPSTACK_PG_DB}" -Fc --blobs --no-owner \
> "${BACKUP_DIR}/chirpstack.dump"
# 3. TB 实体导出(仪表板 / 规则链 / 设备)
mkdir -p "${BACKUP_DIR}/tb-entities"
TB_JWT=$(curl -s -X POST "http://127.0.0.1:9090/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"tenant@thingsboard.org","password":"your_tenant_password"}' \
| grep -o '"token":"[^"]*"' | cut -d'"' -f4)
if [ -n "${TB_JWT}" ]; then
curl -s "http://127.0.0.1:9090/api/tenant/dashboards?pageSize=1000" -H "X-Authorization: Bearer ${TB_JWT}" > "${BACKUP_DIR}/tb-entities/dashboards.json"
curl -s "http://127.0.0.1:9090/api/rule-chains?pageSize=100" -H "X-Authorization: Bearer ${TB_JWT}" > "${BACKUP_DIR}/tb-entities/rulechains.json"
curl -s "http://127.0.0.1:9090/api/tenant/devices?pageSize=1000" -H "X-Authorization: Bearer ${TB_JWT}" > "${BACKUP_DIR}/tb-entities/devices.json"
fi
# 4. 备份配置
cp "${COMPOSE_FILE}" "${BACKUP_DIR}/"
cp "${ENV_FILE}" "${BACKUP_DIR}/"
cp -r /opt/ranch/{caddy,tb,postgres,prometheus} "${BACKUP_DIR}/" 2>/dev/null || true
# 5. 打包
cd "${BACKUP_ROOT}"
tar czf "${DATE}.tar.gz" "${DATE}"
rm -rf "${BACKUP_DIR}"
SHA=$(sha256sum "${DATE}.tar.gz" | awk '{print $1}')
echo "${SHA} ${DATE}.tar.gz" > "${DATE}.tar.gz.sha256"
# 6. 清理本地旧备份
find "${BACKUP_ROOT}" -name "*.tar.gz*" -mtime +${KEEP_LOCAL_DAYS} -delete
# 7. 异地备份(rclone 配置 S3/COS/OSS)
if command -v rclone &>/dev/null && rclone listremotes | grep -q "^${S3_REMOTE}"; then
rclone copy "${BACKUP_ROOT}/${DATE}.tar.gz" "${S3_REMOTE}/ranch-backups/"
rclone copy "${BACKUP_ROOT}/${DATE}.tar.gz.sha256" "${S3_REMOTE}/ranch-backups/"
rclone delete "${S3_REMOTE}/ranch-backups/" --min-age "30d" || true
fi
notify SUCCESS "Backup ${DATE}.tar.gz done, sha256=${SHA:0:16}..."
rclone 配置示例(腾讯云 COS):
bash
rclone config
# name> ranch-s3
# Storage> 5 (S3)
# provider> 28 (Tencent COS)
# 填 access_key / secret_key / endpoint
rclone lsd ranch-s3: # 测试
6.2 restore.sh
bash
#!/bin/bash
# 用法:./restore.sh /path/to/20250101_030000.tar.gz [--yes]
# 流程:确认 → 备份当前 → 停服务 → pg_restore → 恢复配置 → 启动 → 健康检查
set -euo pipefail
[ $# -lt 1 ] && { echo "Usage: $0 <backup.tar.gz> [--yes]"; exit 1; }
BACKUP_ARCHIVE="$1"
ASSUME_YES="${2:-}"
COMPOSE_FILE="/opt/ranch/docker-compose.prod.yml"
ENV_FILE="/opt/ranch/.env"
RESTORE_DIR="/tmp/ranch-restore-$$"
source "${ENV_FILE}" 2>/dev/null || true
[ ! -f "${BACKUP_ARCHIVE}" ] && { echo "[ERROR] not found: ${BACKUP_ARCHIVE}"; exit 1; }
# 校验 SHA
[ -f "${BACKUP_ARCHIVE}.sha256" ] && (cd "$(dirname ${BACKUP_ARCHIVE})" && sha256sum -c "$(basename ${BACKUP_ARCHIVE}.sha256)")
# 二次确认
if [ "${ASSUME_YES}" != "--yes" ]; then
echo "⚠️ This will OVERWRITE existing data with ${BACKUP_ARCHIVE}"
read -p "Type 'YES' to continue: " C; [ "$C" = "YES" ] || { echo Aborted.; exit 1; }
fi
# 0. 先备份当前状态(万一恢复错能回滚)
SAFETY="/opt/ranch/data/backups/pre-restore-$(date +%Y%m%d_%H%M%S)"
mkdir -p "${SAFETY}"
docker exec postgres pg_dump -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -Fc --no-owner > "${SAFETY}/thingsboard.dump" || true
docker exec postgres pg_dump -U "${POSTGRES_USER}" -d "${CHIRPSTACK_PG_DB}" -Fc --no-owner > "${SAFETY}/chirpstack.dump" || true
# 1. 解压
mkdir -p "${RESTORE_DIR}"
tar xzf "${BACKUP_ARCHIVE}" -C "${RESTORE_DIR}"
DUMP_DIR=$(ls -d "${RESTORE_DIR}"/*/ | head -1)
# 2. 停应用服务(保 PG/Redis)
cd /opt/ranch
docker compose -f "${COMPOSE_FILE}" stop tb-node chirpstack chirpstack-gateway-bridge \
ranch-h5-backend ranch-h5-frontend caddy prometheus grafana 2>/dev/null || true
sleep 5
# 3. 终止连接
docker exec postgres psql -U "${POSTGRES_USER}" -d postgres -c "
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '${POSTGRES_DB}' AND pid <> pg_backend_pid();" || true
# 4. 恢复 ThingsBoard DB
docker exec postgres psql -U "${POSTGRES_USER}" -d postgres -c "DROP DATABASE IF EXISTS ${POSTGRES_DB};"
docker exec postgres psql -U "${POSTGRES_USER}" -d postgres -c "CREATE DATABASE ${POSTGRES_DB} OWNER ${POSTGRES_USER};"
docker exec postgres psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;" 2>/dev/null || true
cat "${DUMP_DIR}/thingsboard.dump" | docker exec -i postgres pg_restore -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" --no-owner
# 5. 恢复 ChirpStack DB(同流程)
# 6. 启动 + 健康检查
docker compose -f "${COMPOSE_FILE}" up -d
for i in {1..30}; do
curl -fsS "http://127.0.0.1:9090/actuator/health" 2>/dev/null | grep -q '"status":"UP"' && { echo "✅ tb-node UP"; break; }
sleep 10
done
rm -rf "${RESTORE_DIR}"
echo "✅ Restore done. Safety: ${SAFETY}"
6.3 恢复演练流程
- 按量付费开一台同配置测试机
- 装 Docker + Compose,初始化空环境
- rclone 下载最新备份
- 跑
restore.sh backup.tar.gz --yes - 登录 TB 对比设备数、最近遥测、规则链、仪表板
- 记录演练日志
6.4 crontab
cron
# 每日 3 点备份
0 3 * * * /opt/ranch/scripts/backup.sh >> /var/log/ranch-backup.log 2>&1
# 每周日 4 点 VACUUM FULL(可选)
# 0 4 * * 0 docker exec postgres vacuumdb -U postgres --all --analyze
7. 监控与告警
7.1 Prometheus 抓取
prometheus.yml:
yaml
global: { scrape_interval: 15s, evaluation_interval: 15s }
rule_files: ["alert_rules.yml"]
scrape_configs:
- job_name: 'thingsboard'
metrics_path: '/actuator/prometheus'
static_configs: [{ targets: ['tb-node:9090'] }]
- job_name: 'node'
static_configs: [{ targets: ['node-exporter:9100'] }]
- job_name: 'postgres'
static_configs: [{ targets: ['postgres-exporter:9187'] }]
- job_name: 'redis'
static_configs: [{ targets: ['redis-exporter:9121'] }] # 需另部署
- job_name: 'prometheus'
static_configs: [{ targets: ['localhost:9090'] }]
- job_name: 'caddy'
static_configs: [{ targets: ['caddy:2019'] }]
7.2 关键告警规则
alert_rules.yml:
yaml
groups:
- name: ranch-infra
rules:
- alert: InstanceDown
expr: up == 0
for: 1m
labels: { severity: critical }
annotations: { summary: "服务 {{ $labels.job }} 挂了!" }
- alert: DiskAlmostFull
expr: (node_filesystem_size_bytes{fstype!~"tmpfs|fuse.lxcfs|squashfs"} - node_filesystem_avail_bytes) / node_filesystem_size_bytes > 0.85
for: 5m
labels: { severity: warning }
- alert: DiskCritical
expr: (node_filesystem_size_bytes{fstype!~"tmpfs|fuse.lxcfs|squashfs"} - node_filesystem_avail_bytes) / node_filesystem_size_bytes > 0.92
for: 2m
labels: { severity: critical }
annotations: { summary: "磁盘超 92%,即将写满!" }
- alert: HighCPU
expr: 100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 10m
- alert: HighMemory
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > 0.90
for: 5m
- alert: TBJvmHeapHigh
expr: jvm_memory_used_bytes{application="thingsboard",area="heap"} / jvm_memory_max_bytes{application="thingsboard",area="heap"} > 0.85
for: 5m
- alert: TBRuleEngineErrors
expr: rate(rule_engine_error_total[5m]) > 0.5
for: 3m
- alert: TBMessagesDropped
expr: rate(queue_dropped_total[5m]) > 10
for: 2m
- alert: PGConnectionHigh
expr: pg_stat_activity_count > 80
for: 5m
- alert: High5xxRate
expr: rate(caddy_http_response_status_count_total{code=~"5.."}[5m]) / rate(caddy_http_response_total[5m]) > 0.05
for: 3m
7.3 告警通知
最简方案:Grafana 配 DingDing Notification Channel(UI 点几下即可)。
- 钉钉群 → 智能群助手 → 添加机器人 → 选"加签"或"自定义关键词"
- Grafana → Alerting → Contact points → Type: DingDing → URL: 钉钉 webhook
7.4 Grafana 面板布局
| 区域 | 关键面板 |
|---|---|
| JVM (tb-node) | 堆内存、GC 次数/耗时、线程数、启动时间 |
| PostgreSQL | 当前连接数、TPS、缓存命中率、表大小、慢查询数、WAL 速率 |
| Redis | 内存、命中率、连接数、evicted keys、AOF 重写 |
| TB 业务 | 设备在线数、MQTT 连接数、规则引擎消息速率、告警 Active 数、CF 触发次数 |
7.5 日志速查
bash
docker logs -f tb-node --tail=100 # TB 日志
docker logs tb-node 2>&1 | grep -iE 'error|exception' | tail -50 # 错误
docker logs --since 1h tb-node # 最近 1 小时
8. 安全加固清单
| 类别 | 项 |
|---|---|
| API Key | v4.3 生成时只显示一次,立刻存 .env;最小权限;不写前端;不提交 Git;90 天轮换 |
| 默认密码 | sysadmin@thingsboard.org / tenant / postgres / redis / chirpstack / grafana 全部改 |
| JWT 密钥 | TB security.jwt.token.key 改成自己的 64 字节随机;H5 JWT_SECRET 32 字节 |
| Docker | 容器非 root(USER ranch);不映射 /var/run/docker.sock;只读 rootfs;每月 docker compose pull 更新 |
| SSH | PermitRootLogin no + PasswordAuthentication no + 改端口 + fail2ban |
| 数据库 | PG/Redis/ChirpStack 数据库不暴露公网;强密码;ChirpStack 用独立用户 |
| TB 用户权限 | 牧场主 = Customer User(只看自家);H5 后端 = 专用 API Key;不给客户 Tenant Admin |
| 审计日志 | TB Audit Log 默认开启,定期导出 |
| MQTT TLS | 公网必须开 TLS(Access Token 明文传输,路由器/运营商能抓到) |
| 定期升级 | 订阅 TB Security Advisories;补丁版本小、安全且自动迁移;升级前必备份 |
ini
# SSH /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
Port 2222
bash
sudo apt install -y fail2ban
sudo systemctl enable fail2ban --now
ini
# TB JWT 配置
security.jwt.token.key: "your_64_byte_hex_key_generated_by_openssl_rand_hex_32"
security.jwt.token.expirationTime: 9000000 # 2.5 小时
security.jwt.token.refreshExpTime: 604800000 # 7 天
9. 上线 Checklist(40 项)
服务器基础(8 项)
- SSH 禁用 root + 禁用密码 + 改端口 + fail2ban
- 防火墙/安全组只开 22/80/443/8883/1700udp
- 时区 Asia/Shanghai
- swap 配置(4G)
- ulimits(nofile 65535)
- 主机名 ranch-prod-01
- 内核参数(vm.swappiness=10, tcp_tw_reuse=1)
Docker 环境(4 项)
- Docker ≥ 26.x + Compose v2
- 数据卷挂到独立数据盘
- 自定义 bridge 网络 ranch-net
- 日志 json-file + max-size 轮转
数据库(6 项)
- PG16 + TimescaleDB 扩展已启用(
\dx验证) - postgresql.conf 按模板调优
- ts_kv 转 hypertable
- 保留 90 天 + 压缩 7 天 + 连续聚合策略
- ChirpStack 独立库和用户
- 恢复演练至少成功过一次
ThingsBoard(8 项)
- TB ≥ 4.3.1,启动无 ERROR/Schema 失败
- 默认密码全改
- JWT 密钥已替换
-
TB_QUEUE_TYPE=in-memory(单机够用) - Caffeine/HikariCP 已调优
- 日志级别 WARN,所有规则链 Debug 模式已关闭
- H5 后端专用 API Key + 最小权限
- Tenant 配置已调(默认告警 TTL、队列大小等)
ChirpStack(4 项)
- ChirpStack v4.10 启动正常,admin 密码已改
- Gateway Bridge 配置完成,网关 ACTIVE
- Service Profile / Device Profile 已建
- MQTT Integration 已配,payload 正确转发到 TB
H5 前后端(6 项)
- .env 填好(JWT_SECRET、TB_API_KEY、数据库密码),无硬编码
- PM2 cluster 模式 + max_memory_restart
- Docker 化 + 非 root
- 前端 Nginx SPA 路由 + 静态缓存
- Caddy SSL 证书已签
- API 限流 + CORS 限定 + Helmet 已开
监控备份(4 项)
- Prometheus + Grafana 启动,所有 target UP
- Grafana 4 面板已导入
- 备份 cron 已加,钉钉通知已测通
- 告警规则已配,已模拟触发验证
业务验证(6 项)
- 真实项圈上线,GPS/电量 5 分钟上报正常
- Geofencing CF 工作(牛进出栏状态变化正确)
- 越界告警触发 + 蜂鸣/电击 Downlink 正常
- 4G 手机打开 H5,地图/列表/告警/详情正常
- 越界告警通知正常
- 频控生效
10. 压力测试
目标:500 台 × 5 分钟/次 = 约 1.7 msg/s。TB 官方 4C8G 单机能力是 1万-5万 msg/s,余量充足。
bash
# 安装 mqtt-bench
wget https://github.com/krylovsk/mqtt-benchmark/releases/download/v1.0.1/mqtt-bench-linux-amd64
chmod +x mqtt-bench && sudo mv mqtt-bench /usr/local/bin/
# 压测(压测时把间隔缩短到 5s = 60倍速)
mqtt-bench \
--broker=tcp://你的IP:1883 \
--clients=500 --count=60 --interval=5 \
--topic=v1/devices/me/telemetry \
--username=TEST_DEVICE_TOKEN \
--qos=1 --format=json \
--message='{"latitude":43.82,"longitude":87.61,"batteryLevel":85,"rssi":-70}'
压测期间观察指标
| 指标 | 健康 | 告警 | 危险 |
|---|---|---|---|
| CPU | <50% | 70% | >85% |
| 内存 | <60% | 80% | >90% |
| TB JVM 堆 | <70% | 85% | >95% |
| PG 连接数 | <30 | 60 | >80 |
| 规则引擎 p99 延迟 | <500ms | 1s | >5s |
| 磁盘 | <60% | 85% | >92% |
水平扩展(单机扛不住时)
- TB 集群:多 tb-node + Kafka 队列 + K8s 编排
- PG 读写分离:主库写 + 1-2 只读副本
- ChirpStack 独立机器:>3000 设备时拆分
5000 台以下不建议折腾集群,直接升配到 8C16G 或 16C32G 更便宜简单。
11. 故障排查手册
排查铁律:先看日志 !
docker logs tb-node --tail=20090% 的问题能找到答案。
Q:设备不上报遥测
- ChirpStack UI → Gateways → 网关是否 last seen 几秒前
docker logs chirpstack-gateway-bridge --tail=50看 UDP 包- ChirpStack UI → Device → LoRaWAN frames(DevEUI/AppKey 配错、解码器报错、Region 频段)
- 手动订阅:
docker exec -it chirpstack mosquitto_sub -h tb-node -p 1883 -u TOKEN -t v1/devices/me/telemetry -v - 临时开规则链 Debug 模式查卡点
Q:告警不触发
- 看 CF 状态:Device → Calculated Fields,safeZoneStatus 是否更新
- 检查 Alarm Rules(key 名/阈值/severity),看是否被 Clear 条件误清
- Alarms 页面查历史(是否创建了但被自动 Clear)
Q:H5 打不开
docker logs caddy --tail=50(证书是否签成功)docker logs ranch-h5-backend --tail=50(Express 报错)curl https://你的域名/api/healthcurl https://你的域名/(Nginx 是否返 index.html)
Q:数据库膨胀 / TB 变卡
bash
# 最大的表
docker exec -it postgres psql -U postgres thingsboard -c "
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;"
# ts_kv chunk 状态
docker exec -it postgres psql -U postgres thingsboard -c "
SELECT hypertable_name, COUNT(*) AS chunks,
pg_size_pretty(SUM(chunk_total_size_bytes)) AS total_size
FROM timescaledb_information.chunks GROUP BY hypertable_name;"
Q:OOM / TB 频繁重启
dmesg | grep -i oom看内核 OOM Killer- JVM 堆是否太大挤压 PG/Redis
- Redis
maxmemory是否被突破 - Docker
mem_limit是否太低 - 是否有大消息 payload(固件升级/图片传输)
Q:证书过期
- Caddy 自动续期失败:日志看 ACME 错误
echo | openssl s_client -connect domain:443 -servername domain 2>/dev/null | openssl x509 -noout -dates- Nginx 方案:
certbot renew --dry-run
Q:TB 升级后起不来
- 别慌别瞎操作 ,看日志:
docker logs tb-node --tail=200 - Schema migration 失败 → 用升级前备份 restore
- 紧急回滚:改
.env的TB_VERSION旧 tag,docker compose up -d tb-node
12. 运维命令速查
bash
# Docker Compose
COMPOSE=/opt/ranch/docker-compose.prod.yml
docker compose -f $COMPOSE up -d # 启动
docker compose -f $COMPOSE stop # 停止
docker compose -f $COMPOSE restart tb-node # 重启某服务
docker compose -f $COMPOSE up -d --build ranch-h5-backend # 重建
docker compose -f $COMPOSE pull && docker compose -f $COMPOSE up -d # 升级
docker compose -f $COMPOSE ps # 状态
docker compose -f $COMPOSE logs -f tb-node # 实时日志
docker exec -it tb-node bash
docker exec -it postgres psql -U postgres thingsboard
# TB
docker logs -f tb-node --tail=100
docker logs tb-node 2>&1 | grep -iE 'error|exception' | tail -50
curl -s http://127.0.0.1:9090/actuator/health | jq .
# PG
SELECT count(*) FROM pg_stat_activity;
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle in transaction' AND xact_start < NOW() - INTERVAL '1 hour';
# Redis
docker exec -it redis redis-cli -a $REDIS_PASSWORD
INFO memory
INFO stats
DBSIZE
# 证书
echo | openssl s_client -connect ranch.example.com:443 -servername ranch.example.com \
2>/dev/null | openssl x509 -noout -dates -subject
docker exec caddy caddy list-certificates
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
# 备份
/opt/ranch/scripts/backup.sh
ls -lh /opt/ranch/data/backups/
/opt/ranch/scripts/restore.sh /opt/ranch/data/backups/20250101_030000.tar.gz
# TB 升级流程
/opt/ranch/scripts/backup.sh
vim /opt/ranch/.env # 改 TB_VERSION
docker compose -f $COMPOSE pull tb-node
docker compose -f $COMPOSE up -d tb-node
docker compose -f $COMPOSE logs -f tb-node # 看 "Started ThingsBoardApplication"
# 系统
htop; df -h; free -h; iostat -x 1; ss -tlnp
docker stats --no-stream
13. 高危坑点 TOP 10
- TB 升级前必须备份------容器启动自动 schema 迁移,失败 TB 起不来,没备份就得手动修库
- JVM 堆不超过物理内存 50% ------
-Xmx=6G在 8G 机器上 = PG/Redis/系统没内存 = 整体 swap 卡爆 - PG
shared_buffers25% 而非越大越好------PG 依赖 OS Page Cache,要给系统留一半缓存 - 遥测必须设保留策略------500 设备 90 天不清理 = 几十 GB,VACUUM 跑不动
- 备份必须做恢复演练------备份可能 0 字节、pg_dump 静默失败、关键表漏了
- 生产禁止
docker compose down -v------会删所有数据卷(PG/Redis/TB 全没) - 规则链 Debug 模式生产必关------每条消息写 debug event,1天几个 G 撑爆盘
- 公网 MQTT 必须开 TLS------Access Token 明文,Wireshark 能抓
- API Key 最小权限 ------H5 后端 key 只给
READ_TELEMETRY/READ_DEVICES,别给全权限 - TB 镜像不映射
docker.sock------/var/run/docker.sock映射进容器 = 给 root
14. FAQ
Q:生产一定要 K8s 吗?
不需要。500-1000 台单机 Compose 完全够用,运维成本低。K8s 是 5000+/高可用场景才需要。
Q:TimescaleDB 必装吗?
强烈建议。压缩率 5-10 倍、自动保留、连续聚合,不装 3 个月后 ts_kv 几十 GB。
Q:Caddy vs Nginx?
新手选 Caddy(自动 HTTPS 极简);前端静态用 Nginx(缓存/gzip 更成熟)。两个都用不冲突。
Q:JVM 堆多大?
物理内存 30-40%,不超过 50%。4C8G 给 2-3G;8C16G 给 4-6G。Grafana 看 Full GC 后老年代稳定在 50-70% 即可。
Q:遥测保留多久?
原始 90 天,小时聚合 1 年,天聚合永久。合规要 3 年就存聚合数据,原始 GPS 不必存那么久。
Q:本地备份够吗?
必须异地! 硬盘坏/误删/机房故障/勒索病毒都能一起没。对象存储 100G 几块钱/月。
Q:单机能扛多少设备?
4C8G + SSD,每设备 5 分钟一次遥测,轻松 500-1000 台。牧场 500 台 = 1.7 msg/s,利用率不到 1%。
Q:MQTT 公网要 TLS 吗?
必须。 设备 Access Token 每次连接都裸奔,TLS 是免费且必要的。
Q:ChirpStack 和 TB 要分开吗?
小项目放一起(200MB 内存而已),3000+ 设备再拆分。
Q:H5 后端 Docker 还是 PM2 直跑?
PM2 直跑没毛病,但 Docker 三个好处:环境一致、故障自愈、部署标准化。不熟 Docker 就 PM2。
Q:H5 怎么零停机部署?
前端:换 dist 目录 + symlink 切换;后端:pm2 reload all(要正确处理 SIGTERM)。TB 升级做不到零停机(要 schema 迁移),凌晨 2 点操作,停机 1-3 分钟。
Q:出问题第一件事?
看日志 。docker logs tb-node --tail=200 能告诉你 90% 的答案。不要猜。
附录:参考资源
- ThingsBoard 官方文档:https://thingsboard.io/docs/
- ThingsBoard GitHub Issues:https://github.com/thingsboard/thingsboard/issues
- ThingsBoard 论坛:https://groups.google.com/forum/#!forum/thingsboard
- ChirpStack 文档:https://www.chirpstack.io/docs/
- TimescaleDB 文档:https://docs.timescale.com/
- Caddy 文档:https://caddyserver.com/docs/
- PGTune(PG 参数生成器):https://pgtune.leopard.in.ua/
核心心法:备份 + 监控 + 恢复演练 = 生产的三大支柱。少一项都是裸奔。