上个月我在做一个边缘网关的私有化交付项目,客户提了一个不算复杂但很折磨人的需求:Thingsboard默认的遥测上报格式里有些字段跟他们的平台对接规范对不上,得改源码、重新打jar包,然后部署到客户指定的Linux服务器上,而且明确要求用Docker跑。折腾了一周,把定制jar包、镜像机制、端口映射、时区问题全部踩了一遍之后,我觉得这套流程值得好好记录下来。
这篇文章我会按实际操作过的顺序,把定制jar包的三种来源、Thingsboard镜像的内部机制、替换jar包的两种可行方法、上线后最容易踩的四个坑全部过一遍,最后用MQTTX做一次端到端验证。适合正在做Thingsboard二次开发、或者被要求“把定制版本部署到Docker环境”的兄弟参考,也适合第一次接触Thingsboard私有化交付、想少走弯路的人。
1. 定制jar包之后,为什么还非得用Docker跑
1.1 定制Thingsboard jar包最常见的三种来源
先理清楚“定制jar包”这个概念。我见过的大多数情况,无非是下面三种:
第一种,也是真正意义上的定制,是改了Thingsboard源码之后重新编译。比如改了默认的遥测数据格式、改了规则引擎里某个处理逻辑、加了自定义的REST接口,或者在设备管理模块里加了字段。这种改动通常是在Thingsboard官方仓库上做的,改完在项目根目录执行mvn clean package -DskipTests,最后在application模块的target目录下生成一个thingsboard-<version>-boot.jar,这就是要部署的定制jar包。
第二种比较常见,是不动源码,只改配置后重新打包。比如把默认的内存数据库换成PostgreSQL、修改日志级别、调整某些默认开关。有人习惯直接在源码目录的thingsboard.yml里改好,然后重新打包成jar;也有人直接改jar包里的配置再压缩回去。这种方式本质上还是替换jar,但严格来说不算源码级定制。
第三种是往里面塞了第三方依赖。比如项目要用市面上某个私有协议解析的SDK,或者要用另一个团队封装好的加密库,你需要把依赖打进Spring Boot的可执行jar里。这种打包出来的jar体积往往会大一圈,而且因为内部依赖冲突,踩坑概率比前两种都高。
不管哪种来源,最终落到服务器上的都是一个可执行的boot jar,后续的Docker部署流程是一样的。但要注意:源码级定制和纯配置改动的维护成本完全不同,前者换jar包后需要重新验证整个业务链路,后者只要盯住配置对应的功能模块就行。
1.2 直接java -jar跑和用Docker跑的差异
很多人会问,定制好jar包,直接在Linux上装个JDK,然后java -jar thingsboard.jar跑起来不就完了吗?为什么还非得套一层Docker?
我在客户现场碰到的实际情况是,Thingsboard这种Java应用对运行环境相当敏感。举个例子,Thingsboard 3.x版本要求Java 11或17,如果服务器上原本装的是Java 8,或者同时装了好几个JDK版本,启动脚本选错解释器,整个服务直接起不来。就算JVM版本对了,系统缺字体库、字符集不是UTF-8、时区不对、/data目录权限不对,这些在开发机上完全不是问题的事情,换了台机器就变成一个一个的拦路虎。
Docker的好处就是把环境差异压缩到最小。镜像里JDK版本固定了、系统库固定了、目录权限固定了,连启动命令都由官方启动脚本管理好。我只需要关心三件事:镜像从哪来、jar包怎么换进去、端口怎么映射出来。
另一个实际原因是运维和管理成本。客户现场的运维人员不一定熟悉Java应用,但他们多半会用Docker。docker restart、docker logs、docker ps学个十分钟就能上手。你要是给他部署一个裸Java进程,出了问题他连怎么查看日志、怎么重启都得打电话问你。从交付角度讲,Docker让整个系统的可运维性高了一个台阶。
1.3 什么情况下真没必要上Docker
虽然我推荐用Docker,但也不是没有例外。碰到这两种情况我会建议直接跑裸进程:
一是服务器资源非常紧张。Docker本身虽然轻量,但要常驻dockerd进程,再加上镜像占用的磁盘空间,对那种只剩几百兆内存、几十G小磁盘的机器来说,确实有点奢侈。我见过一个客户的机器配置低得离谱,2G内存还要同时跑数据库和业务服务,这种情况直接把jar包做成systemd服务更划算。
二是离线环境且没有构建镜像的便利条件。如果客户内网完全不连通外网,你又没法把自定义镜像用docker save打包带进去,那么基于官方镜像做定制就会非常别扭。这时候反而是在宿主机装JDK,把jar包scp过去直接跑更省事。
说到底,Docker不是目的,稳定交付才是目的。如果Docker引入的复杂度大于它节省的麻烦,那就别硬上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手之前,先把Thingsboard镜像的“脾气”摸清楚
2.1 官方镜像的目录结构与启动流程
我刚开始做这件事的时候,犯过一个错误:直接把定制jar包用docker cp塞进容器,结果容器启动失败。后来才明白,不先搞清楚官方镜像内部长什么样就去替换文件,等于闭着眼睛拆炸弹。
Thingsboard官方提供了两类镜像,侧重点不一样:
| 镜像 | 适用场景 | 内置组件 |
|---|---|---|
| thingsboard/tb-postgres | 单机部署、快速验证 | PostgreSQL、Thingsboard Server |
| thingsboard/tb-node | 微服务模式部署 | Thingsboard服务节点,不内置数据库 |
对绝大多数中小项目来说,基于tb-postgres做定制就够了。我第一次做的时候先拉了一个原版镜像,进入容器把目录结构翻了个底朝天:
bash复制docker run --rm -it thingsboard/tb-postgres:3.5.1 bash
进去之后重点关注三个位置:
/app/bin,放置可执行jar包和启动脚本。具体文件名不同版本略有差异,有的叫thingsboard.jar,有的叫thingsboard-<version>-boot.jar,我用的3.5.1版本里就是/app/bin/thingsboard.jar。/app/conf,放置thingsboard.conf,这个文件非常关键,后面会细讲。/app/data,放置运行时数据,包括内置PostgreSQL的数据文件。
启动流程大致是这样的:镜像入口脚本会先读取thingsboard.conf和所有带TB_前缀的环境变量,把配置合并后生成一份最终的thingsboard.yml,然后执行数据库初始化(install),最后拉起Thingsboard服务进程。这一步的坑在于:它会用启动脚本重新生成配置,所以你在jar包里写死的thingsboard.yml不见得会被加载,后面第4章专门讲这个。
2.2 环境变量覆盖机制:能不改jar就别改jar
Thingsboard的配置体系有一个很有用的设计:几乎所有thingsboard.yml里的配置项,都可以通过环境变量覆盖,转换规则是把点号换成下划线、字母全部大写,再加TB_前缀。
举个例子,数据库连接配置原本是:
yaml复制spring:
datasource:
url: jdbc:postgresql://localhost:5432/thingsboard
对应的环境变量就是:
bash复制TB_SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard
同理,database.ts.type对应TB_DATABASE_TS_TYPE,database.type对应TB_DATABASE_TYPE。
这个机制的价值在于:很多所谓的“定制”,其实根本不需要改源码、打jar包。如果你只是想把内置H2换成PostgreSQL,或者改一下遥测数据存储策略,用环境变量就能解决。先问自己一句“这个需求是不是改配置就能满足”,能的话就别动源码,省掉一整个编译打包验证的循环。
但如果定制点确实在业务逻辑里,比如要改上报数据格式、要加自定义字段,那就老老实实改源码重新编译。这时候也要知道环境变量和配置文件的优先级问题,不要做完了才发现自己辛辛苦苦改的配置被环境变量覆盖了。
2.3 内存、时区、数据库:三件套先配好
在替换jar包之前,我强烈建议先把三个基础参数想清楚,否则后面容器起不来或者行为不对,你根本分不清是jar包的问题还是环境的问题。
内存是最容易出事的。Thingsboard是Java应用,默认启动脚本会根据容器内存自动设置堆大小,但这不代表你能放任不管。我的习惯是显式设置JAVA_OPTS,比如-Xms1G -Xmx2G,同时把Docker容器的memory限制在3G或4G。注意-Xmx不要跟容器内存限制设得太接近,JVM在运行过程中还有堆外内存、元空间、线程栈的开销,只留几百兆余量很容易被OOM Killer干掉。
时区是另一个高频坑。官方镜像默认用UTC,国内设备上报的时间戳如果是本地时间(东八区),在控制台和数据库里会看到整整差了8个小时的数据。最简单的做法是在环境变量里加上TZ=Asia/Shanghai,同时在JAVA_OPTS里加-Duser.timezone=Asia/Shanghai,双保险。
数据库方面,如果是用tb-postgres镜像做快速验证,内置PostgreSQL是开箱即用的;一旦进入正式环境,我建议还是把数据库独立出来,用环境变量指向外部PostgreSQL。原因是内置数据库的数据和容器生命周期耦合在一起,容器销毁重建后数据恢复很不方便,而且升级镜像时容易误伤数据。
3. 把定制jar包塞进容器:两种可落地的做法
3.1 方案A:写Dockerfile继承官方镜像替换jar
这是我最推荐的方式,适合正式交付。核心思路是:以官方镜像为基础,把定制jar包复制进去,生成一个新的定制镜像。
一个最简Dockerfile长这样:
dockerfile复制FROM thingsboard/tb-postgres:3.5.1
LABEL maintainer="your-team@example.com"
USER root
# 先确认官方镜像里jar包的实际路径
COPY thingsboard-3.5.1-boot.jar /app/bin/thingsboard.jar
RUN chown thingsboard:thingsboard /app/bin/thingsboard.jar
USER thingsboard
注意几个细节。第一,COPY到容器里的jar文件名要和官方镜像里的文件名保持一致,因为启动脚本是按固定路径去找jar的,文件名不匹配会直接导致启动失败。第二,文件的属主要切成thingsboard:thingsboard,否则运行时可能没有权限读取。第三,不要随便RUN chmod 777,保持最小权限。
构建命令很简单:
bash复制docker build -t tb-custom:3.5.1 .
这个方式的优势是镜像可移植性极强。我在一台机器上构建好,docker save -o tb-custom.tar tb-custom:3.5.1,把tar包拷到客户服务器上再docker load,整个环境就完整搬过去了,不需要管客户服务器上有没有Maven、有没有JDK、有没有源代码。
3.2 方案B:不重建镜像,用挂载卷覆盖jar
如果还在调试阶段,频繁改jar包、频繁重建镜像会很浪费时间,用挂载卷覆盖是更快的路径。
bash复制docker run -d --name tb \
-v /data/tb/thingsboard.jar:/app/bin/thingsboard.jar:ro \
-v tb-data:/data \
-p 9090:9090 \
-p 1883:1883 \
thingsboard/tb-postgres:3.5.1
这里把宿主机/data/tb/thingsboard.jar挂载到容器内对应路径,:ro表示只读。每次修改jar包后,只需要替换宿主机上的文件,然后docker restart tb就行。
这个方式适合开发调试,但我不建议用到正式交付阶段。原因有两个:一是挂载只解决jar包替换,其他定制文件(比如自定义扩展目录、授权文件)还是要单独挂载,管理起来很散;二是宿主机上的文件一旦被误删,容器就彻底起不来了,而且别人来接手时看到的是一个靠挂载撑起来的容器,排查问题会很费劲。
3.3 用docker-compose固定整个运行环境
不管是方案A还是方案B,最终我建议都用docker-compose把整个运行环境固定下来。这不仅是写几个参数的问题,而是让环境变得可复制、可版本管理。
下面是一个我实际用过的编排文件(基于方案A构建出的定制镜像):
yaml复制services:
thingsboard:
image: tb-custom:3.5.1
container_name: tb-custom
restart: always
ports:
- "9090:9090"
- "1883:1883"
- "5683:5683/udp"
- "7070:7070"
environment:
- TZ=Asia/Shanghai
- JAVA_OPTS=-Xms1G -Xmx2G -Duser.timezone=Asia/Shanghai
- TB_DATABASE_TYPE=postgres
- TB_DATABASE_TS_TYPE=sql
- TB_DATABASE_HOST=postgres
- TB_DATABASE_PORT=5432
- TB_DATABASE_NAME=thingsboard
- TB_DATABASE_USER=postgres
- TB_DATABASE_PASSWORD=postgres
volumes:
- tb-data:/data
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:15
container_name: tb-postgres-db
restart: always
environment:
- POSTGRES_DB=thingsboard
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
tb-data:
postgres-data:
说几个编排文件里的关键点。
端口映射这里,9090是Web控制台和REST API,1883是MQTT设备接入端口,5683是CoAP,7070是Edge RPC。如果你的环境里只用MQTT,可以不映射后面两个,少暴露一个端口就少一分风险。
depends_on配合healthcheck,确保PostgreSQL先健康起来再启动Thingsboard,不然Thingsboard启动时数据库连不上,又得等重启。这个顺序问题在第一次启动时特别容易踩,因为PostgreSQL初始化需要时间,而Thingsboard启动脚本并不会一直重试数据库连接。
3.4 启动后怎么确认jar包真的换成功了
替换完jar包、容器起来之后,别急着配置设备上报数据,先确认容器里跑的确实是你的定制版本。
我的做法是三步验证。第一步看启动日志:
bash复制docker logs tb-custom | head -n 50
如果定制时改了版本号或者加了启动日志,这里应该能看到特征信息。
第二步对比文件哈希,确保容器里的jar和本地的定制jar完全一致:
bash复制# 本地执行
md5sum thingsboard-3.5.1-boot.jar
# 容器内执行
docker exec tb-custom md5sum /app/bin/thingsboard.jar
两个md5值一致才能说明文件确实换过来了。有时候Docker层缓存原因,构建出来并不是最新代码,这个对比能直接暴露问题。
第三步更关键,是行为验证。比如你定制的是遥测数据格式,那就创建设备、上报一条数据、看返回结果和存储结果。这一步放在第5章用MQTTX完整讲,先用日志和哈希确认基础替换没问题就可以。
4. 定制jar包上线后的真实踩坑记录
4.1 容器起来又立刻退出:内存参数导致OOMKilled
第一次用定制jar包启动容器时,我发现容器总是起来几秒就自己退了。docker ps -a看到的状态是Exited (137)。
排查链路是这样的:
bash复制docker logs tb-custom --tail 50
docker inspect tb-custom --format '{{.State.OOMKilled}}'
日志里一开始还有Thingsboard的启动横幅,然后就断掉了,没有明显的异常堆栈。docker inspect查出来的OOMKilled字段是true,这就基本可以断定是内存问题——容器被内核OOM Killer杀掉了,不是Java进程自己崩溃。
再到宿主机上看内核日志确认:
bash复制dmesg | grep -i oom | tail -n 10
果然能找到对应进程被kill的记录。
原因是我在docker-compose.yml里给容器设了mem_limit: 2g,但JAVA_OPTS里写了-Xmx2G。Java堆上限和容器内存上限几乎一样,加上线程栈、元空间、JIT编译器这些堆外开销,内存瞬间打满,直接触发OOM。
解决办法是把mem_limit提到4G,-Xmx降到2G。或者更稳妥的做法是不显式设置-Xmx,改用-XX:MaxRAMPercentage=60.0,让JVM根据容器限制自动计算堆大小。我个人倾向于在交付阶段用数字写死,这样行为可预测,但一定记住要给JVM留出堆外的余量。
4.2 MQTT端口连不上:1883端口被宿主机占掉
容器终于稳定运行了,但用MQTTX测试时怎么也连不上1883端口,docker ps里端口映射看起来又是对的:
bash复制0.0.0.0:1883->1883/tcp
这时候先别怀疑Thingsboard没启动,先看宿主机上1883端口被谁占用:
bash复制ss -lntp | grep 1883
结果显示宿主机上已经有一个老旧的MQTT Broker进程占了这个端口,Docker映射到1883因为端口冲突而监听失败(但docker ps有时候并不会主动告诉你这个失败细节)。用docker port tb-custom查映射关系,有时会看到映射是空的。
解决方案很简单:把宿主机映射端口改掉,比如:
yaml复制ports:
- "2883:1883"
然后MQTTX连接地址改成服务器IP:2883。顺带提醒,如果设备固件里烧录的是1883端口,改完映射之后固件里的broker地址也得同步改,不然下次设备上线又连不上。
4.3 上报数据时间差8小时:容器时区没对齐
还有一次项目已经要验收了,客户指出设备上报数据里的时间比实际时间晚了8小时。当时第一反应是设备端的问题,因为大部分上报时间戳是设备生成的。排查了一圈才发现,问题出在Thingsboard服务端解析时间戳和展示时间的逻辑上。
验证方法很简单:
bash复制docker exec tb-custom date
显示的是UTC时间,而不是东八区时间。而宿主机上的date显示是正常的北京时间。
处理方式就是在docker-compose.yml的environment里补上时区:
yaml复制environment:
- TZ=Asia/Shanghai
- JAVA_OPTS=-Xms1G -Xmx2G -Duser.timezone=Asia/Shanghai
加完后docker compose up -d重建容器,再docker exec tb-custom date确认已经变成东八区。
这里有个经验值得记住:改时区会影响历史数据的展示,因为时间戳在数据库里存的是UTC,控制台展示时按当前时区换算,改完时区后旧数据在表格里的显示时间会有变化,但底层数据没有被污染。所以在项目开始阶段就把时区定下来是最好的,后面不要反复横跳。
4.4 定制配置没生效:环境变量和配置文件的博弈
这是我觉得整篇里最有价值的一个坑。我一开始为了图省事,直接把改好的thingsboard.yml打进了jar包,启动后发现配置还是旧值。
排查思路是这样的:先确认jar包里的yml确实改了,然后进容器找到启动脚本,看它生成配置的逻辑:
bash复制docker exec tb-custom cat /app/bin/start-tb.sh | grep -n "yml\|conf" | head -n 30
启动脚本的逻辑大概是:先找thingsboard.conf,再去读环境变量,然后把所有配置项合并后动态生成一份/app/thingsboard.yml,覆盖jar包里自带的配置。也就是说,你费劲把配置写进jar包,启动脚本在运行时根本不去读那份打包进去的yml,它用的是自己生成的那份。
搞清楚这个机制之后,解决思路就清楚了:有两种选择。第一,如果你只是改标准配置项,直接转换成环境变量写进docker-compose.yml,这是最干净的。第二,如果你有一些自定义配置项,环境变量无法表达,可以在镜像构建时把配置文件放到启动脚本会加载的位置,或者修改启动脚本让它读取你的配置文件。我不建议直接改启动脚本,除非你很清楚自己在做什么,维护成本会变得很高。
这个坑的本质是:Thingsboard的配置系统是一个“动态生成”的模式,不是“静态读取”的模式。理解了这一点,你在定制配置时就会有意识地选择正确的通道,而不会辛辛苦苦改完发现白忙一场。
5. 用MQTTX做一次端到端验证,确认定制真正生效
5.1 设计一个肉眼可见的定制点
为了确认整套定制流程没有白费,我建议在源码里设计一个容易观察的定制点。最好的例子就是对遥测上报数据做一次“加工”。
我之前做过一个定制:在源码的设备遥测服务里,把设备上报的每个温度值都自动加上"source": "customized"字段,这样无论在控制台、日志还是数据库里,都能一眼看出来定制版本在工作。
这个定制点不需要太复杂,但它必须满足两个条件:第一,覆盖了一条清晰的数据链路(设备接入 -> 服务端解析 -> 存储/展示);第二,数据特征在Web UI里就能看到,而不需要翻代码。
如果你不想改源码,只想验证“替换jar包后服务正常”,那也可以直接用默认功能:上报一条遥测,控制台能看到数据点,就说明整个链路通了。
5.2 控制台创建设备,拿到Access Token
在Thingsboard Web UI里做下面几步:
- 进入“设备”页面,点击右上角“+”新建设备。
- 填写设备名称,比如“test-mqtt-device”。
- 点击刚创建的设备,切到“设备凭证”或“管理凭证”标签页。
- 复制Access Token,这个Token就是设备接入该租户的唯一凭证。
注意保存好Token,别泄露,因为知道Token就等同于可以向你的平台上报数据。如果只是测试,用完后可以删掉这个设备或者重置凭证。
5.3 MQTTX发布一条遥测数据
MQTTX是EMQX出的一个跨平台MQTT客户端,界面友好,适合快速验证。新建连接时按照下面的参数填:
| 参数 | 值 |
|---|---|
| Broker Address | 服务器IP |
| Port | 1883(或你映射后的2883) |
| Client ID | 任意,比如tb-test-01 |
| Username | 上一步复制的Access Token |
| Password | 留空或任意填(Thingsboard不校验密码) |
连接成功后,在消息区域填写Topic和Payload:
- Topic:
v1/devices/me/telemetry - Payload:
{"temperature": 23.5, "humidity": 48}
点击发布,如果Topic和认证信息都对,消息会显示发送成功,Thingsboard会在后台处理这条遥测数据。
5.4 从控制台、日志到数据库的三层验证
消息发布成功只是开始,真正要验证的是定制逻辑有没有生效。
第一层,看Web控制台。进入设备的“最新遥测”页面,应该能看到temperature和humidity的数据点。如果定制点是在数据上附加了字段,比如我们前面说的"source": "customized",这里应该也能看到这个字段。
第二层,看容器日志。如果定制点里有日志输出,用docker logs --tail 50 tb-custom查看,会看到定制逻辑打印的log。这一步能帮助确认请求确实走到了你改过的那段代码里。
第三层,查数据库。如果定制逻辑最终影响的是存储数据,可以直接查PostgreSQL里对应的键值表,看数据的str_v或dbl_v字段是否符合预期。这一步是兜底验证,因为Web页面展示可能经过聚合,数据库里的原始存储才最可靠。
比如我这里查一下键值表:
bash复制docker exec -it tb-postgres-db psql -U postgres -d thingsboard
sql复制select key, str_v, dbl_v, ts from ts_kv where entity_id = '<<设备对应的entity_id>>' order by ts desc limit 10;
如果定制字段出现在结果里,说明整个链路从头到尾都执行了定制逻辑。如果没出现,要么是jar包没有真正替换成功,要么是定制代码没有覆盖到这条处理路径,回到第3章末尾去做文件哈希对比,确认跑的是不是你的jar。
写在交付之后
几次项目做下来,我的体会是:定制jar包这件事本身不难,难的是把“定制”和“运行环境”这两个变量分开。尽量先用环境变量把标准配置搞明白,让非标准的部分只集中在源码和jar包里,这样排错时边界清晰,不用到处猜。
最后再分享一个小技巧:每次替换jar包前,先把容器里的原版文件备份出来,用docker cp tb-custom:/app/bin/thingsboard.jar ./thingsboard.jar.bak。不然你想回滚到官方版本时,发现自己没有原版jar包,就只能重新拉镜像,在离线环境里非常痛苦。备份这件事花不了三分钟,但能帮你省下一个加班的晚上。
