前言

如果直接通过UDP连接到服务器,可能不太稳定。最初基于这个考虑将一个Go程序内置到了网关上,在本地处理UDP数据包,转换为JSON或ProtocolBuffer数据后,通过MQTT上报服务器。

最初只有自己远程控制的需求,就就做了一个通过MQTT下发shell脚本,网关上执行命令返回响应的功能,权限和自由度都很高,但也有风险,执行错误、阻塞、异常操作都可能让网关SDK挂掉。

前几个月在有需求的情况下重新实现一些远程控制的功能,这里做个总结,涉及到的内容包括

  • MQTT协议
    • 遗嘱机制
    • 同步执行回调
    • 权限管理
  • Linux系统
    • 系统状态
      • SYS_SYSINFO
      • SYS_UNAME
      • SYS_STATFS64
    • 网络路由
      • 默认网卡
      • 网络延迟
    • systemd与init.d
      • Linux可执行文件格式
      • 开机启动、自动重启与错误回滚
      • 程序升级与日志切割
      • 执行shell命令
  • arm与mipsle架构

MQTT协议

遗嘱机制

遗嘱机制只对异常掉线有效,例如拔掉网线,虽然客户端与服务端在心跳时间内都无法感知到对方,但只要发包或者超时后就会触发,而手动关闭程序会产生握手,无法触发遗嘱机制,导致网关状态与数据库不同步。

解决方式是在程序启动后和关闭前,增加应用层设备上下线推送。

同步执行回调

客户端订阅Topic的回调默认同步执行(paho库),假如在回调中执行耗时很长的shell脚本,会阻塞其他topic回调执行,间接导致ping包超时,网关失联。

解决方式是将耗时操作异步执行,并且对竞争性资源加锁。

权限管理

mosquitto内部有用户认证和Topic权限管理两部分的缓存,起始握手会验证用户账号/密码,发布、订阅消息时会检查用户对Topic的权限。

解决方式是服务端程序和mosquitto之间通过数据库共享用户权限信息。

但是msoquitto认证插件的作者停止维护了……没有解决方式,只能fork一个repo,并冻结一个测试过的mosquitto版本,以防出问题时需要修复。

Linux系统

一般需要监控什么呢?监控系统状态(系统信息、主机名、磁盘空间),网络路由(4g、有线、网络延迟),程序升级与回滚,日志切割,执行shell命令…换句话说,这里需要直接使用系统调用,就像调用内核的API

系统状态

系统调用文件位置:syscall目录下的zsyscall_系统_架构.go的文件

数据结构文件位置:syscall目录下的ztypes_系统_架构.go的文件

这里只讨论linux系统下的arm与mipsle架构,那么涉及到的文件有

  • syscall/ztypes_linux_arm.go
  • syscall/ztypes_linux_mipsle.go
  • syscall/zsyscall_linux_arm.go
  • syscall/zsyscall_linux_mipsle.go

SYS_SYSINFO

获取启动时间、内存、系统负载相关信息

字段含义:http://man7.org/linux/man-pages/man2/sysinfo.2.html

数据结构如下,两个架构的数据结构是一致的

syscall/ztypes_linux_arm.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Sysinfo_t struct {
    Uptime int32
    Loads [3]uint32
    Totalram uint32
    Freeram uint32
    Sharedram uint32
    Bufferram uint32
    Totalswap uint32
    Freeswap uint32
    Procs uint16
    Pad uint16
    Totalhigh uint32
    Freehigh uint32
    Unit uint32
    X_f [8]uint8
}

syscall/ztype_linux_mipsle.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Sysinfo_t struct {
    Uptime int32
    Loads [3]uint32
    Totalram uint32
    Freeram uint32
    Sharedram uint32
    Bufferram uint32
    Totalswap uint32
    Freeswap uint32
    Procs uint16
    Pad uint16
    Totalhigh uint32
    Freehigh uint32
    Unit uint32
    X_f [8]int8
}

这里补充一点关于Load的计算,一般执行top、uptime、htop命令时,会显示系统在最近三个时间点内(1分钟、5分钟、15分钟)的负载,但系统调用取回的却是三个整数,如何转换成常见的浮点数?还是看文档

http://www.hungry.com/~jamie/cs421/unixcpu/loadl.html

总的来说,就是需要除以一个基准值,这里是65536,内核会生成计算后的数值,其他工具如uptime等也是直接读取计算后的结果

SYS_UNAME

获取主机名相关信息

字段含义:http://man7.org/linux/man-pages/man2/uname.2.html

数据结构如下,两个架构的数据结构是不一致,需要增加额外转换函数,由于Go的限制,需要存放到按 名称_系统_架构.go 的形式命名文件才能自动调用

syscall/ztypes_linux_arm.go

1
2
3
4
5
6
7
8
type Utsname struct {
	Sysname    [65]uint8
	Nodename   [65]uint8
	Release    [65]uint8
	Version    [65]uint8
	Machine    [65]uint8
	Domainname [65]uint8
}

syscall/ztype_linux_mipsle.go

1
2
3
4
5
6
7
8
type Utsname struct {
	Sysname    [65]int8
	Nodename   [65]int8
	Release    [65]int8
	Version    [65]int8
	Machine    [65]int8
	Domainname [65]int8
}

SYS_STATFS64

获取文件系统信息

字段含义:http://man7.org/linux/man-pages/man2/statfs.2.html

数据结构如下,两个架构的数据结构是不一致,但计算磁盘空间需要的参数类型一致

syscall/ztypes_linux_arm.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Statfs_t struct {
	Type      int32
	Bsize     int32
	Blocks    uint64
	Bfree     uint64
	Bavail    uint64
	Files     uint64
	Ffree     uint64
	Fsid      Fsid
	Namelen   int32
	Frsize    int32
	Flags     int32
	Spare     [4]int32
	Pad_cgo_0 [4]byte
}

syscall/ztype_linux_mipsle.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Statfs_t struct {
	Type      int32
	Bsize     int32
	Frsize    int32
	Pad_cgo_0 [4]byte
	Blocks    uint64
	Bfree     uint64
	Files     uint64
	Ffree     uint64
	Bavail    uint64
	Fsid      Fsid
	Namelen   int32
	Flags     int32
	Spare     [5]int32
	Pad_cgo_1 [4]byte
}

网络路由

默认网卡

当一个数据包发出时,是通过4G网卡还是通过有线网卡发送?

有了系统状态相关的开发经验,在获取网络路由时首先就想到系统调用获取路由表,复习了路由表相关的内容后开始动手,然而还是想当然了。当我抽丝剥茧,下探到底层时,发现Go并没有提供这种封装,而且不同系统的命令支持不一样,数据结构不一样,系统调用不一样…显然工作量有点超出预期。

这里换一个取巧的方式,尝试建立与远程主机的UDP连接,这时系统会分配一个本地IP与本地端口,然后遍历本地网卡获取IP,与系统分配的IP对比。若一致,则该网卡为网络路由使用的网卡,这样可以就可以轻松获取到默认网卡,而且不需要直接使用系统调用。

网络延迟

需要使用以下两个不在标准库的包

1
2
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"

实现方式是,发起一个ICMP请求,设置超时时间,然后读取响应,从发送数据到接收响应(收到响应或者超时)的总时长即为网络延迟

systemd与init.d

没有systemd的时候才会想念起systemd的好,当你需要管理程序的自启动、错误恢复、升级失败回滚等,就会发现启动一个程序是多么困难。另外程序的返回码、错误输出、标准错误输出等由systemd管理的部分也需要逐一处理,由于是第一次接触,遇到了很多问题

可执行文件格式

elf是linux平台下的默认可执行程序格式,下面是执行file命令,查看nginx可执行文件的输出

1
2
file /usr/sbin/nginx
/usr/sbin/nginx: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=74a2d2803389b209573e71f387ac72a32f0cd771, stripped

如果需要更详细的内容,可以使用 readelf 命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
➜  ~ readelf
Usage: readelf <option(s)> elf-file(s)
 Display information about the contents of ELF format files
 Options are:
  -a --all               Equivalent to: -h -l -S -s -r -d -V -A -I
  -h --file-header       Display the ELF file header
  -l --program-headers   Display the program headers
     --segments          An alias for --program-headers
  -S --section-headers   Display the sections' header
     --sections          An alias for --section-headers
  -g --section-groups    Display the section groups
  -t --section-details   Display the section details
  -e --headers           Equivalent to: -h -l -S
  -s --syms              Display the symbol table
     --symbols           An alias for --syms
  --dyn-syms             Display the dynamic symbol table
  -n --notes             Display the core notes (if present)
  -r --relocs            Display the relocations (if present)
  -u --unwind            Display the unwind info (if present)
  -d --dynamic           Display the dynamic section (if present)
  -V --version-info      Display the version sections (if present)
  -A --arch-specific     Display architecture specific information (if any)
  -c --archive-index     Display the symbol/file index in an archive
  -D --use-dynamic       Use the dynamic section info when displaying symbols
  -x --hex-dump=<number|name>
                         Dump the contents of section <number|name> as bytes
  -p --string-dump=<number|name>
                         Dump the contents of section <number|name> as strings
  -R --relocated-dump=<number|name>
                         Dump the contents of section <number|name> as relocated bytes
  -z --decompress        Decompress section before dumping it
  -w[lLiaprmfFsoRt] or
  --debug-dump[=rawline,=decodedline,=info,=abbrev,=pubnames,=aranges,=macro,=frames,
               =frames-interp,=str,=loc,=Ranges,=pubtypes,
               =gdb_index,=trace_info,=trace_abbrev,=trace_aranges,
               =addr,=cu_index]
                         Display the contents of DWARF2 debug sections
  --dwarf-depth=N        Do not display DIEs at depth N or greater
  --dwarf-start=N        Display DIEs starting with N, at the same depth
                         or deeper
  -I --histogram         Display histogram of bucket list lengths
  -W --wide              Allow output width to exceed 80 characters
  @<file>                Read options from <file>
  -H --help              Display this information
  -v --version           Display the version number of readelf

在Go的标准库里,/src/debug/elf 目录下的包可以读取可执行程序的信息,这里提了这么多,只是为了实现一个功能,获取可执行文件的架构信息。

由于Go支持交叉编译,如果将一个arm架构的程序错误地上传到mipsle架构的设备上,是一个很麻烦的问题,在程序自升级时,需要检查下载的文件,可以结合平台信息做一次检验。

开机启动、自动重启与错误回滚

支持systemd的系统

  • 编写service文件,放置在 /lib/systemd/system 目录下,然后使用systemctl启用开机启动,自动创建 /etc/systemd/system 目录下的软链接
  • 自动重启的条件、重启间隔在service文件中设置,若未设置,默认自动重启、重启间隔为100ms
  • 错误回滚使用ExecStopPost配置的脚本,可以检查程序退出码,执行回滚或者忽略

systemd相关文档:https://www.freedesktop.org/software/systemd/man/systemd.service.html

支持init.d的系统

  • 编写init.d启动脚本,放置在 /etc/init.d 目录下,脚本里需要包括启动优先级、启动命令、停止命令及重启命令,开机启动使用 /etc/init.d/xxxx enable 命令启动
  • 自动重启的条件、重启间隔、错误回滚直接由自己编写的脚本处理并控制
  • 需要配置目标程序的标准输出、标准错误输出,Linux环境下子进程默认继承父进程的标准输出与标准错误输出,在开发时遇到的过程序标准错误输出为nil,一打印日志就crash,无法正常启动

程序升级与日志切割

将这两个东西放在一起讲,是因为它们都利用了Linux文件系统和磁盘存储的特性。

程序升级的实现

  • 下载新文件
  • 重命名老文件
  • 重命名新文件
  • 退出程序,由外部脚本重启

日志切割的实现

  • 程序监听自定义系统信号
  • logroate程序执行日志文件重命名后向程序发送信号
  • 程序收到信号后关闭文件描述符并打开新文件

我们的脚本、命令中都是以文件名引用一个文件,而进程是以文件描述符引用文件。之前遇到过一个问题,手动删除一个容器的日志文件后,磁盘空间并没有释放,执行 docker logs 命令甚至还可以看到持续的日志输出,而重启容器后,日志丢失、磁盘空间释放。

这里引用一段阮一峰博客中的内容:

由于inode号码与文件名分离,这种机制导致了一些Unix/Linux系统特有的现象。

  • 有时,文件名包含特殊字符,无法正常删除。这时,直接删除inode节点,就能起到删除文件的作用。
  • 移动文件或重命名文件,只是改变文件名,不影响inode号码。
  • 打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。因此,通常来说,系统无法从inode号码得知文件名。

更详细的内容可以查看这篇博客:理解inode

执行shell命令

我觉得这篇文章足以涵盖大部分的需求,但是即使读了文章,我还是掉进了坑里

文章地址:Advanced command execution in Go with os/exec

  • 运行命令 vs 运行命令并展示输出:前者会得到一个返回码,后者会得到可执行程序/脚本的标准输出或标准错误输出,若子进程会持续打印结果到标准输出/标准错误输出,则后者会一直阻塞到程序退出为止
  • 若启动子程序的父进程缺失默认标准输出与标准错误输出,子进程也会同样缺失
  • 若需要将日志输出到文件,则需要自定义日志框架的标准输出与标准错误输出,结合文件系统特性实现日志切割

arm与mipsle架构

Go支持了许多不同架构的机器,但并不意味者同一段可以在arm平台上运行的代码能够在mipsle平台上正常执行,必须注意

  • 依赖硬件特性的库可能失效或转为软件实现,例如sync/atomic库、加密库等
  • 栈长度异常,同一段代码出现在arm上可以完整执行,在mipsle上随机停止在某一个位置,但使用函数调用将长段的代码分割后执行却正常
  • 变量捕获异常(间接由栈长度异常导致),同一个变量在arm上可以被 go func(){xxxxx}() 捕获,而在mipsle上却提示为空,将函数封装后,复制变量再执行却正常

还有一个无奈的问题,设计功能时需要考察机器配置,例如磁盘写入速度,在树莓派上能够保持10MB/S,但在一些路由器上,却只有200KB/S,足足相差50倍,同样可以推导其他涉及硬件的性能,例如内存、CPU、网卡等。