最近发现家里的 mini 主机上的虚拟机频繁重启,有时候宿主机也会重启,但是由于虚拟机都是设置的开机自动启动,所以重启后自己不去查看 Uptime 的话还不一定知道虚拟机重启过。基于这个事情,我打算开发一套简单的服务器监控应用到自己的网站,目前已经上线一段时间了,现在说一下这个服务的开发过程。

Demo 演示页面:https://tendcode.com/monitor/demo

架构介绍

先看一下架构图:

  • 客户端:使用 HTTP 请求定期向服务端上报服务器的信息
  • 服务端:提供接口供客户端调用,将客户端上报的信息进行校验和处理,存入数据库;并且提供接口展示服务器信息
  • 前端UI:定期自动请求服务端数据,刷新页面,展示数据

关于这种服务器监听,有的也叫服务器探针,很多探针为了数据的实时性,采用了 WebSocket 的方式,让客户端和服务端进行连接,实时上报数据。但是我感觉这种监控不需要太高频率的上报,3秒左右的频率已经跟实时没差了。

参考项目(主要参考了该项目的 UI 界面和数据更新方式):ServerStatus

前端页面开发

UI 主题设计

说实话,很多时候开发一个新功能,后端的实现其实很快就写完了,大部分的时候都是花在了前端的 UI 设计上面。我又是对 UI 有偏执的人,经常对一些边边角角都要调试的舒服才行,但是奈何自己也不是前端工程师,所以很多时候就要花费大量精力去调整。

但是还好,在我做这个项目的时候,我已经找到了参考的 UI 页面,而且非常好的信息是,这个页面是基于 bootstrap3 的组件设计的,而我的博客是 bootstrap4 所以基本是可以直接拿了稍加修改就可以了。

先看原版页面

再来看看我自己改后的页面效果,主打一个简洁扁平:

展开显示服务器详情:

暗色模式:

移动端适配

数据刷新机制

前端的数据刷新机制很简单,就是使用 js 设置了一个定时器,每隔N秒(我设置的3秒)请求一次服务端的数据,然后渲染成 html 内容更新到页面。

具体代码见 html 中:

<script>
    // 页面打开执行一次
    get_servers("{{ csrf_token }}", "{% url 'monitor:get_server_list' %}");
    // 每隔3秒执行一次
    setInterval(() => get_servers("{{ csrf_token }}", "{% url 'monitor:get_server_list' %}"), 3000);
</script>

这里有几个小细节需要注意:

细节1:将服务端状态转换成 css 样式,以便显示不同的状态

比如服务器传递的状态的字段是 online 或者 offline,这个时候就可以利用 css 样式,把 online 设置成绿色, offline 设置成红色。

在 js 代码里面类似这样:

let status_bg = 'bg-success';
// 根据状态使用不同的颜色的点
if (status !== "online") {
    status_bg = 'bg-danger'
}

进度条的颜色也是同样的原理,根据服务端返回的不同的数值,给进度条设置不同的颜色即可。

细节2:保持详情页展开状态

由于每条数据的详情的展开还是关闭状态是由 html 的属性决定的,所以每次刷新数据的时候,必须保留原本的状态才行,不然会出现用户明明点开了一条数据的详情,结果数据自动刷新给关闭了。

具体实现 js 如下:

let is_show = '';
// 保留原来的展开状态
if ($('#more-info-' + i).hasClass('show')) {
    is_show = 'show'
}

先拿到每条数据的展开状态,然后在刷新的时候回写回去,这样就可以保证页面只是数据在变动,状态不变。

后端服务开发

后端需要创建一个模型用来管理和存放主机的信息。

模型设计

直接看源码:

class MonitorServer(models.Model):
    name = models.CharField('名称', max_length=30, unique=True, help_text='用于看板中显示')
    interval = models.IntegerField('上报间隔', default=5,
                                   help_text='上报间隔时间,超过这个时间的两倍还没有更新数据就标记为离线状态,单位:秒')
    sort_order = models.IntegerField('排序', default=99, help_text='自定义排序依据')
    push_url = models.CharField('推送地址', max_length=60,
                                help_text='客户端推送的地址,为了支持代理推送或者本地推送')
    username = models.CharField('用户名', max_length=10, unique=True,
                                help_text='推送用户名,唯一')
    password = models.CharField('密码', max_length=10, unique=True,
                                help_text='第一次添加后自动生成密钥,更改后会重新生成密钥')
    secret_key = models.CharField("加密Key", max_length=64, blank=True, null=True,
                                  help_text='保存后自动生成')
    secret_value = models.CharField("密钥", max_length=256, blank=True, null=True,
                                    help_text='保存后自动生成')
    data = models.TextField('上报数据', blank=True, null=True, help_text='json格式')
    active = models.BooleanField('是否有效', help_text='用来过滤,无效的不显示', default=True)

    create_date = models.DateTimeField(verbose_name='创建时间', auto_now_add=True)
    update_date = models.DateTimeField(verbose_name='更新时间', auto_now=True)

    class Meta:
        verbose_name = '监控服务'
        verbose_name_plural = verbose_name
        ordering = ['sort_order']

    def __str__(self):
        return self.name

一些关键字段的解释:

  • name :用来显示主机的名称,唯一
  • interval :上报频率,这个数据是客户端上报上来的数据,就是让客户端告知服务端上报频率是多久,然后服务端根据这个频率去判断客户端是否掉线,规则就很简单,超过这个时间的两倍还没有更新数据就标记为离线状态
  • sort_order :用来调整显示的顺序
  • push_url :推送地址,给客户端定义固定的推送地址,这个主要是用来生成密钥,让客户端自己解析出地址,可以让其他人看不到推送地址
  • username :用户名,用来生成密钥的
  • password :密码,用来生成密钥的
  • secret_key :密钥Key,保存的时候自动随机生成一个,不需要输入
  • secret_value :保存的时候自动生成的
  • data :上报数据,json格式
  • active :激活状态,用来管理哪些主机需要显示

自动生成密钥

虽然上面填写了一堆字段,但是有的字段是可以随意填写的,比如用户名和密码,随便填写不用重复就行。密钥数据是会自动生成的。

密钥的生产逻辑:创建数据保存后,自动随机生成一个 secret_key,然后把 push_urlusernamepasswordsecret_key 四个信息加密得到 secret_value,并且,每次只要前面三个数据变动了,secret_keysecret_value 都会重新生成。

具体看这个自动生成密钥的代码:

def save(self, *args, **kwargs):
    if not self.pk or self._fields_have_changed(['username', 'password', 'push_url']):
        # 如果是首次添加数据或者密码字段发生变化,则生成随机32位密码
        secret_key = str(uuid.uuid4()).replace('-', '')[:32]
        plain_text = f'{self.username}::{self.password}::{self.push_url}'
        cipher = AESCipher(secret_key)
        secret_value = cipher.encrypt(plain_text)
        self.secret_value = secret_value
        self.secret_key = secret_key
    super().save(*args, **kwargs)

def _fields_have_changed(self, fields):
    if self.pk:
        # 如果是更新数据,则检查指定字段是否发生变化
        original_instance = MonitorServer.objects.get(pk=self.pk)
        for field in fields:
            if getattr(self, field) != getattr(original_instance, field):
                return True
        return False
    return True  # 如果是首次添加数据,返回 True,表示字段已更改

加解密类的实现:

import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

class AESCipher:
    def __init__(self, _key):
        self.key = _key

    def encrypt(self, _plaintext):
        _cipher = AES.new(self.key.encode(), AES.MODE_CBC)
        _plaintext = pad(_plaintext.encode(), AES.block_size)
        _encrypted_text = _cipher.iv + _cipher.encrypt(_plaintext)
        return base64.b64encode(_encrypted_text).decode()[:128]

    def decrypt(self, ciphertext):
        ciphertext = base64.b64decode(ciphertext)
        iv = ciphertext[:AES.block_size]
        _cipher = AES.new(self.key.encode(), AES.MODE_CBC, iv)
        _decrypted_text = unpad(_cipher.decrypt(ciphertext[AES.block_size:]), AES.block_size)
        return _decrypted_text.decode()

注意:为了保证密钥的长度不会太长,因此对用户名和密码长度做了严格限制。

只需要给客户端提供 secret_keysecret_value 信息即可,客户端可以实现跟服务端一样的加解密方法,把其他信息解析出来。

上报数据接口设计

  • 请求地址:/monitor/server/push,解析密钥后得到
  • 请求方法:POST
  • 请求头:
    • Content-Type:application/json
    • Push-Username:用户名,解析密钥后得到
    • Push-Password:密码,解析密钥后得到
    • Push-Key:密钥Key
    • Push-Value:密钥值
  • 请求体:JSON格式,需要提供数据采集的所有字段,并且格式满足规范,否则上报失败

请求体参考:

{
    "interval": 6,
    "uptime": 699196,
    "system": "darwin-23.1.0-arm64-darwin-14.1.2",
    "cpu_cores": 8,
    "cpu_model": "",
    "cpu": 25.59598494338249,
    "load_1": "3.52",
    "load_5": "3.78",
    "load_15": "4.04",
    "memory_total": 17179869184,
    "memory_used": 14047379456,
    "swap_total": 11811160064,
    "swap_used": 10225909760,
    "hdd_total": 494384795648,
    "hdd_used": 261241573376,
    "network_in": "7.70K",
    "network_out": "7.90K",
    "process": 557,
    "thread": 4038,
    "tcp": 0,
    "udp": 0,
    "version": "",
    "client_version": "0.1.3"
}

展示数据接口设计

  • 请求地址:/monitor/server 需要认证,管理员才能访问
  • 请求方法:GET
  • 返回体:json

返回格式:

{
    "code": 0,
    "error": "",
    "message": "",
    "data": {
        "list": [
            {
                "interval": 6,
                "uptime": "2 \u5929",
                "system": "darwin-23.1.0-arm64-darwin-14.1.2",
                "cpu_cores": 8,
                "cpu_model": "",
                "cpu": 21.9,
                "load_1": "3.22",
                "load_5": "3.14",
                "load_15": "3.37",
                "memory_total": "16.0G",
                "memory_used": "12.92G",
                "swap_total": "8.0G",
                "swap_used": "6.98G",
                "hdd_total": "460.43G",
                "hdd_used": "198.66G",
                "network_in": "81.20K",
                "network_out": "51.97K",
                "process": 570,
                "thread": 3959,
                "tcp": 0,
                "udp": 0,
                "version": "",
                "client_version": "0.1.1",
                "memory": 80.8,
                "hdd": 43.1,
                "status": "offline",
                "name": "Macbook",
                "date": "2024-04-02T22:33:40.884"
            }
        ]
    }
}

通过获取 code 是否为0判断接口是否成功。

客户端开发

客户端可以使用 Python 开发,也可以使用其他语言,比如 Golang 开发。客户端的本质就是采集主机数据,然后进行上报。只要按照服务端给定的接口上报主机信息就可以。

我自己期初用 Python 开发了一个版本,但是考虑到 Python 的运行需要运行环境,这就给客户端的部署增加了很大的负担。因此最后使用 Golang 开发了客户端,具体开发查看 服务器监控应用(2):使用 Golang 开发客户端

下一步

后续打算做一下通知服务,就是当服务器掉线后通知给指定的管理员,很多探针都会有这个功能,事件通知对于监控的确是一个必须的功能。

目前想到的几个需求:

  1. 可以选择要通知的服务器
  2. 使用定时任务定时检查
  3. 掉线的机器一天只会通知几次,可以设置一下通知规则,避免无脑发通知
  4. 可以记录当天发送过的通知次数,超过次数就停止通知
  5. 通知方式可以多样,当然邮件通知是最基本的,直接使用Django配置的邮箱服务器就行,其他通知方式暂时没想好有没有必要,可以先预留一下第三方通知

版权声明:如无特殊说明,文章均为本站原创,转载请注明出处

本文链接:https://tendcode.com/subject/article/server-status-1/

许可协议:署名-非商业性使用 4.0 国际许可协议