背景

由于最近TS在AI圈再次伟大,秉承着深入学习TypeScript的想法,重新下载回了Screep,准备再次体验下这款使用JS脚本驱动的游戏

但是Screep在公服上竞争激烈,国人很容易被针对,遂部署了对应的私服进行娱乐,并且拉上了自己的小伙伴

既然拥有了私服,那么就拥有了原始的数据,且在Screep上步入更后期的优化,需要有相对应的数据支撑来指导,以及联动异常告警

于是便有了搭建一份指标看板的想法

image-hnxk-zdto-xlbx.png

目标

  1. 对于核心Screep中的资源、人力、矿物等相关数据进行统计,查看趋势

  2. 当Screep中的资源、人力、矿物出现异常时,联动告警

  3. 使用TypeScript写一个Node.js服务对MongoDB的数据进行采集,来提升对于TypeScript的熟练度

  4. 对于服务链路,涉及到的中间件具有可复用性,能后续把其他监控、告警、看板接入进来

拆解

  1. 数据采集器,用于采集数据

  2. 时序数据存储库,用于存储采集器每秒从MongoDB采集出的数据

  3. 通过看板工具,看板平台,将时序数据直观的展现

  4. 一个支持时序数据为底的告警平台

设计

整体设计

image-vohh-r518.png

Q: 为什么采用Grafana和Prometheus?能带来什么更好的优势?

A: 因为Prometheus是一个很成熟的时序数据库,可以在多个地方被复用,且对于已有的k3s集群,他可以直接接管k3s的集群监控,而Grafana则是和Prometheus共生的一个监控看板,有着极高的支持度,且允许配置告警到SMTP上,组成完美链路供后续复用

指标设计

数据库基础数据/字段

仅展示Screep私服中所用到的MongoDB的字段

MongoDB数据

User表

字段

字段描述

示例

lastUsedCpu

最后一次的CPU用量

12

money

当前的账户金额

10000000000

rooms

房间列表

["W8N8", "W9N8", "W9N9"]

gcl

全局控制等级

13743553

RoomObj表

字段

字段描述

示例

room

此obj所属房间

W9N9

type

所属obj类型

controller

body

身体组件

[{"type": "move", "hits": new NumberInt("100")}, {"type": "carry", "hits": new NumberInt("100")}, {"type": "carry", "hits": new NumberInt("100")}]

hits

当前生命力

1000

hitsMax

最大生命力

1000

name

obj名称

upgrader

store

存储内容

{"energy": new NumberInt("0"), "K": new NumberInt("132")}

user

所属用户Id

68fc790c5f2d4c004106120c

数据指标设计

整体数据指标氛围用户相关、房间相关

对于用户相关有:用户已使用CPU、用户GCL等级、总房间数量、用户总资金

对于房间相关有:用户总Creep数,用户建筑成本,用户Creep成本,用户房间总矿物量,用户房间总能量

实施

实施Map图如下,由数据采集模块、数据存储模块、报表展示模块、告警配置模块组成

Screep-export数据采集暴露

对于Screep-export,为自实现项目,链接如下

https://github.com/txuw/screep-export

其主要采用Node.js实现的一个轻量级项目,采用从 MongoDB 数据库中提取 Screep 游戏数据,并将其转换为 Prometheus 监控系统可识别的指标格式,具体相关介绍已在ReadMe中讲解

选型思考

为什么对于整体项目采用Node.js和最轻量级的Express进行项目实现?

因为整体数据采集器应该是很轻量,高效的,对于轻量级、强生态的语言和Node.js比的只有Python,但是我更想学习TS,所以采用Node.js

而Express的选用更是切合轻量的概念,因为本身不是很需要很重的框架,只是做一层数据的Convert,怎么轻怎么来,而且在实现后也果真也非常轻,非常高效,内存稳定CPU消耗低

而且更底层的实现更利于我学习TS的核心思想,而不是在于对框架的学习上

框架学习

env的引入

通过dotenv的使用,会处理项目根目录下的一个.env文件,读入以其中的键值对后,注册到全局变量process.env上,使得变量能被全局使用

相较于Java的Springboot等框架的yaml的依赖注入,.env会更为轻量级

MongoDB框架的配置

Node.js的生态原生支持MongoDB,而且受JS/TS的特性,像MongoDB这种文档型数据库,其中的不定类型字段,更适配于TS,而且基于原先Java的习惯,和常规后端习惯,进行了连接池的配置相当好用

/**
 * MongoDB 客户端连接选项
 */
export const clientOptions: MongoClientOptions = {
  maxPoolSize: 50, // 最大连接数(根据你的并发需求调整)
  minPoolSize: 5, // 最小保持的连接数
  maxIdleTimeMS: 30000, // 空闲连接30秒后关闭
  connectTimeoutMS: 10000, // 10秒连接超时
  socketTimeoutMS: 45000, // 45秒操作超时
  serverSelectionTimeoutMS: 5000, // 5秒服务器选择超时
  retryWrites: true,
  retryReads: true,
};

Prometheus框架的使用

  1. 定义指标 (Define Metrics):创建 CounterGaugeHistogramSummary 的实例来代表你想要监控的系统状态。

  2. 注册指标 (Register Metrics):将这些指标实例注册到一个 Registry 中。

  3. 更新指标 (Update Metrics):在你的应用逻辑中,根据事件(如收到请求、任务完成等)更新这些指标的值。

  4. 暴露端点 (Expose Endpoint):创建一个 HTTP 端点(通常是 /metrics),当 Prometheus 服务器访问此端点时,返回 Registry 中所有指标的当前状态,格式为 Prometheus 的文本格式。

对于Registry,是所有指标的入口,负责收集所注册的指标,并调用配置好的业务逻辑,转化为Prometheus的指标格式

prom-client提供了一个全局默认的 Registry ,也可以自己指定防止冲突

而对于Gauge 来说,通常用于记录数值,供Prometheus采集,也是最常用的一个类型

        // 定义
        private readonly usersEnergyGauge: Gauge<string>;
        this.usersEnergyGauge = new Gauge({
            name: 'screep_users_energy',
            help: 'Total number of active users energy',
            labelNames: ['userName','room'],
            registers: [this.registry],
        });
        // 使用
        for (let energyCount of this.getEnergyCount(energyObjs)) {
            this.usersEnergyGauge
                .labels(energyCount.userName,energyCount.room)
                .set(energyCount.totalEnergy)
        }

而其中的Label,会存储到Prometheus中,便于后续使用时,进行分组,聚合等操作,整体格式为

# HELP screep_users_energy Total number of active users energy
# TYPE screep_users_energy gauge
screep_users_energy{userName="txuw",room="W1N1"} 124379
screep_users_energy{userName="txuw",room="W2N1"} 276387
screep_users_energy{userName="txuw",room="W2N2"} 383366
screep_users_energy{userName="txuw",room="W5N3"} 1845
screep_users_energy{userName="obsidianlyg",room="W8N8"} 942916
screep_users_energy{userName="obsidianlyg",room="W9N8"} 799846
screep_users_energy{userName="obsidianlyg",room="W9N9"} 750360
screep_users_energy{userName="Dylan",room="W5N8"} 469791
screep_users_energy{userName="Dylan",room="W4N9"} 4544
screep_users_energy{userName="Dylan",room="W1N8"} 6954

TS学习

从个人的学习角度,在其中领略到的几个TS特性

对于不定类型结构的字段判定

        let mineralsObjs = roomObjs.filter(obj => {
            if('store' in obj && obj.store && typeof obj.store === 'object'){
                // 获取 store 对象的所有键,排除 energy,检查是否还有其他属性
                return Object.keys(obj.store).some(key => key !== 'energy');
            }
            return false;
        })

在这段代码中,obj传递进来时是一个any类型,但是TypeScript可以通过'store' in obj 来判断obj是否具有 store这个属性

对比于Java的类反射,所需要涉及到的类序列的反向解析产生的性能代价和操作难度来说,还是方便了很多

此外获取整个类结构的字段,只需要Object.keys(obj.store)便可以获取一个不定类的字段string,相当方便

而对于TypeScript这类解释型语言,其产生的代价也很低,性能也很高

对于TypeScript中 for of语法

        for (let structCost of this.getStructCostInfo(structObjs)) {
            this.usersStructCostGauge
                .labels(structCost.userName,structCost.room,structCost.structType)
                .set(structCost.totalCost)
        }

其功能和Java中的迭代器类似,但是TypeScript中是通过Symbol.iterator方法即可通过语法糖调用

而Java是需要implements Iterable 并实现hasNext() 和 next() 两个独立方法后,会在编译时被转化为一个循环语句

变量定义之 constletvar

这三个变量的定义涉及到变量的作用域,以及变量的赋值/声明

var 是一个旧的变量定义方式,他的作用域会被提升到全局function,不会只作用在当前for循环等,且可被重复赋值/声明 导致意外Bug

let 是ES6引入的新方式,是受限的作用域,只作用在{}代码块内部(例如 if, for, while 或者直接用 {} 包裹的代码块),无法被重复声明

constlet差不多,但是他无法被重复赋值/声明,且变量内存地址唯一,和Java中的常量一致

import和export的类加载逻辑

在TypeScript中每个文件都是一个Module,比Java的类的概念更高一层

每文件内部有自己的作用域。在一个文件中定义的变量、函数、类,默认情况下在其他文件中是不可见的。

模块可以选择性地通过 export 关键字,将其内部的成员(变量、函数、类、接口等)暴露给其他模块。

其他模块可以通过 import 关键字,来获取并使用这些被暴露的成员

Prometheus数据入库/存储与查询原理

数据入库模式

Prometheus支持推、拉两种方式

对于可以自己暴露EndPoint的服务,Prometheus支持拉模式,通过访问服务EndPoint,来拉取数据

对于无法暴露但是可以向Prometheus发送数据的服务,可以使用推模式,就是将数据Push到Prometheus的GateWay上,进行入库

数据存储优势

内存数据结构

https://cloud.tencent.com/developer/article/1827750

磁盘数据结构

https://cloud.tencent.com/developer/article/1827751

数据查询原理

数据插入原理

https://cloud.tencent.com/developer/article/1827752

数据查询原理

https://cloud.tencent.com/developer/article/1827753

Grafana+Prometheus的集群部署

基于K3S和helm进行Grafana和Prometheus的集群安装

需要注意安装时如果存在其他的Prometheus的服务会产生冲突

# helm安装Prometheus的仓库
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
# 更新仓库
helm repo update
# 安装Prometheus集群
helm install prometheus prometheus-community/kube-prometheus-stack   --namespace monitoring   --values values.yaml

注意其中的values,需要按个人所需调整

# Grafana 配置
grafana:
  # 建议修改为你的密码
  adminPassword: ""
  
  # 使用 longhorn 进行数据持久化
  persistence:
    enabled: true
    storageClassName: "longhorn" # 指定使用 longhorn【根据自身需要调整】
    size: 10Gi # Grafana 的数据盘,

  # 资源请求限制调到所需
  resources:
    requests:
      cpu: 500m
      memory: 1Gi
    limits:
      cpu: 1
      memory: 2Gi

  # 使用 NodePort 方便在开发环境访问
  service:
    type: NodePort

  # 开启SMTP
  envFromSecret: "grafana-smtp-credentials"
  grafana.ini:
    server:
      root_url: https://grafana.txuw.top
    smtp:
      enabled: true
      host: "smtpdm.aliyun.com:465"    
      user: ""             
      password: ${SMTP_PASSWORD}         
      from_address: ""
      from_name: "Grafana"
      skip_verify: false
      starttls_policy: "NoStartTLS"

# Prometheus 配置
prometheus:
  prometheusSpec:
    # 配置数据保留时间
    retention: 15d
    # 配置最大存储磁盘量
    retentionSize: "70GiB"
    # 副本数设置为1,非高可用
    replicas: 1

    # 资源请求限制调到所需
    resources:
      requests:
        cpu: 500m
        memory: 2Gi
      limits:
        cpu: 2
        memory: 4Gi

    # Prometheus 数据持久化,使用 longhorn
    storageSpec:
      volumeClaimTemplate:
        spec:
          storageClassName: "longhorn" # 指定使用 longhorn
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 30Gi # Prometheus 的数据盘

# Alertmanager 配置
alertmanager:
  # 1. 开启 Alertmanager
  enabled: true
  
  # 为 Alertmanager 配置持久化和低资源
  alertmanagerSpec:
    storage:
      volumeClaimTemplate:
        spec:
          storageClassName: "longhorn"
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 5Gi 
    resources:
      requests:
        cpu: 500m
        memory: 512Mi
      limits:
        cpu: 1
        memory: 1Gi

# 其他核心组件也调低资源
kube-state-metrics:
  resources:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      cpu: 500m
      memory: 512Mi

prometheus-node-exporter:
  resources:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      cpu: 500m
      memory: 512Mi

注意事项

  1. 当Prometheus的服务产生冲突时,会导致Prometheus主服务无法启动,反复重启

  2. 当配置资源量过小时,会导致Prometheus主服务无法启动,反复重启

Grafana看板绘制

image-inqs-8kd3.png

所示图中,即为使用了screep_users_energy 指标

通过Delta函数,判断10min内整体的资源变化率,而后按照userName,room分组即可得出每个用户的房间下的资源变化率

而后的Max by用于聚合screep-export服务在k3s环境中重发时,引发的pod变更导致id变化的问题

最后的options,则是用于让显示的指标显示 userName [room],便于查看

当长期处于0值以下时,就说明房间一直在负开支,需要优化房间的具体资源配比

具体screep_users_energy 的数值可以参考Screep-export项目中的代码实现

    // 筛选出roomObjs中的energy Obj
    let energyObjs = roomObjs.filter(obj => 'store' in obj && 'energy' in obj.store)
    // 获取指标数据
    getEnergyCount( energyObjs :any[]){
        // 使用对象作为临时存储
        const grouped: Record<string, {
            room: string;
            userName: string ;
            totalEnergy: number;
        }> = {};

        for (let energyObj of energyObjs) {
            const energy = energyObj.store?.energy as number || 0;
            const username = energyObj.user?.toString() || '';
            const roomName = energyObj.room || '';
            const key = `${username}_${roomName}`;

            if (!grouped[key]) {
                grouped[key] = {
                    room: roomName,
                    userName: username,
                    totalEnergy: 0
                };
            }

            grouped[key].totalEnergy += energy;
        }

        return Object.values(grouped)
    }

Grafana配置阿里云SMTP

https://www.aliyun.com/product/directmail

开通SMTP

需要进入阿里云的邮件推送服务,开通邮件推送,其中有固定的免费额度,目前看起来是用不完的

域名配置

而后需要进行域名配置,具体教程如下

https://help.aliyun.com/zh/direct-mail/user-guide/how-to-configure-sending-domain-names

完成后参考下方

配置发信地址

具体教程如下

https://help.aliyun.com/zh/direct-mail/user-guide/setup-sender-addresses

这个也就是后续用到的用户名,需要把对应的状态都点亮,不然好像不好使

获取对应信息配置到Grafana中

获取对应的推送服务地址

https://help.aliyun.com/zh/direct-mail/smtp-endpoints

然后在上面grafana安装时的values.yaml中可以看到对应的SMTP相关配置

其中的host就是阿里云的SMTP服务,而user和password就是上面发信地址以及SMTP密码

  # 开启SMTP
  envFromSecret: "grafana-smtp-credentials"
  grafana.ini:
    server:
      root_url: https://grafana.txuw.top
    smtp:
      enabled: true
      host: "smtpdm.aliyun.com:465"    
      user: ""             
      password: ${SMTP_PASSWORD}         
      from_address: ""
      from_name: "Grafana"
      skip_verify: false
      starttls_policy: "NoStartTLS"

配置正常后就可以在Grafana中发信了

Grafana告警规则配置

需要先在联络点钟配置上每个人的告警邮箱,在配置时可以进行测试发送,防止SMTP配置有问题不知道

而后就是在报警规则中新建各个规则

比如此处,我需要告警总资源量为0时,进行告警通知,就可以如下配置,因为可能一个人会有多个房间,他会有多个room组,需要取Min,则是存在一个0,就发送告警

展望

  • 计算出房间的整体资源利用率指标[这是个非常复杂的指标,涉及到资源的流向,损耗比等,相当难]

  • 引入战争相关的指标[目前还没有具体的代码实现,也就用不到对应的指标]