前言

两年之前玩过 k8s ,搭了一套 k8s 集群(见:k8s豹之篇——搭建自己的 k8s 集群,实战部署 LuckyBlog 和 LuckyTalk 服务),来公司之后一直负责各个生产环境的 k8s 集群的运维,先是从运维的角度对 k8s 的理解深入了不少,最近又整了一套服务器运行状态监控,从开发的角度对 k8s 又有了一些新的理解,在这里记录一下

项目概述

简介

之前在这篇文章提到过一点:如何用超简单方法写一个Prometheus的Exporter ,之前做的是一个单体应用,由于公司之前的监控平台必须有外网连接才能登录,登录了之后才能拿到 json 格式的数据,而很多客户没有给服务器开放外网,导致没有办法登录到监控平台去拿数据,另一方面,由于是单体应用,需要手动部署,所以如果有哪里需要部署,需要我自己去上传、运行,很是麻烦。而公司对于 k8s 应用有着自动部署的平台,鼠标点一下就能部署,所以就想着一方面想脱离公司的监控平台直接拿到运行状态数据,一方面把自己的应用也做成 k8s 的,方便实施去部署,避免自己一个个去部署的尴尬和麻烦

各个服务器的运行状态是使用 Prometheus Exporter 导出的,之前是单独部署的 Grafana ,这次需要我们把 Grafana 做到 k8s 里面去,同时,为了整合到 OA 平台之内,我们需要自己做一套鉴权,而不是使用 Grafana 自带的鉴权方式,为了实现这个,我们需要自己实现一套网关,按照公司 OA 平台的鉴权方式对用户进行鉴权,有权限的用户允许查看 Grafana 监控面板,而没有权限的用户我们返回一个 401 未认证,不允许他查看

细节

从上面的需求来看应该不难,不过真正上手去做的时候就一头雾水了,感谢公司三位大佬在我开发过程中的指点和耐心解答,一点一点踩坑最终搞定了

另外由于公司技术栈是 Java + Springboot + MyBatis-Plus ,有非常之多的公共组件,对于 Javaer 来说,很多东西 Ctrl+C / Ctrl+V 一下就能搞定,而公司主写 Go 的就我一个,所以基础组件乃至 CI/CD 都需要自己去搞定

设计

简单画了一下,如下,Grafana 从 k8s 内不同 namespace 内的 Prometheus 采集数据,形成监控面板,前面自己做了一个网关,与同一 namespace 下的认证组件进行通信,实现后端的鉴权,通过 istio 将网关的服务暴露出去,供访客进行访问,Grafana 的服务不暴露出去,由于 OA 平台是多租户设计,不同访客属于不同的租户,网关需要能够区分不同租户的访客,不同租户下可以有不同的面板,同一租户下的访客要进行权限限制,只有被授予了权限的用户才可以使用,线上数据库有 pgsql 和 Oracle 两种,都需要兼容。认证组件对公司内直接调用 http 或 grpc 接口认证,对公司外的应用使用 cas 进行鉴权,能够从鉴权接口拿到用户的权限信息和租户信息

设计图

开发

从 configmap 获取运行所需的信息

不同环境,数据库连接信息等信息都是不同的,就我这次的项目而言,需要获取运行模式(prod / dev),数据库类型(pgsql / Oracle),数据库连接信息 DSN,日志信息等级和域名。单体二进制应用可以把启动需要的配置文件写在一个设置文件里面(ini / yaml 等等格式),通过读取这个配置文件就可以获取到启动所需的信息,而 k8s 没有这样的文件写,需要通过 configmap 来获取配置,这里不得不感叹 java 生态之好,springboot 那边直接写个 application.yml 文件,不用挂载,在 configmap 挂一个这样的文件就行,而 go 这边做法有两个,一是挂载 configmap 到某个文件,从这个位置解析配置文件拿数据,另一种做法是把配置写到环境变量,然后从环境变量里面去拿数据,springboot 那套属于第一种,我考虑了一下,感觉写到配置文件之后可读性比较差(因为编辑器里面没法很好的解析,格式被前面 k8s 的配置打乱了),假如不存在配置项嵌套的话,选择第二种方式会好一些,两种做法分别如下:

方式一:挂载配置文件

这里以项目中的 Grafana 举例,它是去读配置文件的,配置项非常多,且存在嵌套关系,所以用第一种方式就优于第二种方式

敏感信息使用(***)表示

---
apiVersion: v1
kind: ConfigMap
metadata:
name: mars-server-status-monitor-grafana-config
namespace: cloud
data:
grafana.ini: |
[server]
domain = ***
root_url = http://***/v1/grafana/
serve_from_sub_path = false
[security]
admin_user = ***
admin_password = ***
admin_email = luckykeeper@luckykeeper.site
allow_embedding = true
[users]
# Default UI theme ("dark" or "light")
default_theme = dark
# Default UI language (supported IETF language tag, such as en-US)
;default_language = zh-CN
[paths]
# folder that contains provisioning config files that grafana will apply on startup and while running.
provisioning = /etc/grafana/provisioning
---

然后在 deployment 里面这样配置,subpath 防止覆盖同一目录的其它文件

volumeMounts:
# 都需要以 subPath 挂载,防止覆盖文件
# https://www.cnblogs.com/xuxinkun/p/10032376.html
# grafana 配置文件
- name: mars-server-status-monitor-grafana-configmap
mountPath: /etc/grafana/grafana.ini
subPath: grafana.ini

方式二:从环境变量读取

这里以项目中自己写的网关举例,它是从环境变量读配置文件的,配置项不多,不存在嵌套关系,所以用第二种方式就优于第一种方式

敏感信息使用(***)表示

这种方式提供的话,里面配置项需要是字符串形式,所以像 bool , int 之类的需要读取之后再转回去,这里数据库连接信息 Go 需要 DSN 格式的,cloud 命名空间下有一个叫 global 的全局配置文件,里面有数据库连接信息的配置,不过因为公司技术栈是 java 的原因,里面的连接信息是 JDBC 格式的,所以没法从全局配置里面取

apiVersion: v1
kind: ConfigMap
metadata:
name: mars-server-status-monitor
namespace: cloud
data:
# 启动模式: 调试模式: true / 生产环境: false 【生产环境务必不要使用调试模式启动】
# 不提供这个参数启动默认会以生产环境模式启动
# 正式环境请务必保持该项为 false
# 这个值请以文本格式提供(即带"")
debugMode: "true"
# 数据库类型
# "oracle | postgres"
databaseType: postgres
# 数据库连接信息,DSN 格式
# postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full
# 如果不提供这个参数,将会抛出错误,拒绝启动
databaseDSN: postgres://***:***@master.ds.svc.cluster.local:5432/***?sslmode=disable
# 日志级别
# Debug:5 | Info:4 | Warn: 3 | Error:2 | Fatal:1
# 选定某个日志级别时,该级别及小于这个数字的级别的日志将会显示
# 如果不提供这个参数,默认值为:5
# 这个值请以文本格式提供(即带"")
logLevel: "5"
# 域名,不需要带协议,示例:"***"
grafanaDomain: "***"
---

接下来挂载到环境变量就非常容易了

envFrom: # envFrom 将所有 ConfigMap 的数据定义为容器环境变量(k8s版本要求>= v1.6)
- configMapRef:
name: mars-server-status-monitor

configMapRef 下写 configmap 的名称即可,然后程序启动的时候从环境变量取数据即可

checkpoint

k8s 通过 checkpoint 检查应用运行的状态,http 状态码 200 表示正常,异常的时候 k8s 会自动重启 pod ,可以利用这一机制,在访问 checkpoint 接口的时候对数据库等组件进行检查(比如测试数据库连通性),失败的时候返回 500 ,使得其自动重启,deployment 里面这样写

containers:
- name: mars-server-status-monitor
image: ***
readinessProbe:
httpGet:
port: 8080
path: /***/checkpoint/ready
initialDelaySeconds: 30
periodSeconds: 30
livenessProbe:
httpGet:
port: 8080
path: /***/checkpoint/liveness
initialDelaySeconds: 60 # 容器启动后多久开始检测,设置稍长一点,避免还没完全启动就开始检测
timeoutSeconds: 10 # 表示多少秒内没响应,就认为超时
periodSeconds: 60 # 探测周期,多少秒探测一次
failureThreshold: 5 # 连续探测几次表示失败

日志信息和控制台带颜色输出

kubectl logs 的控制台是支持颜色输出的,但是 logrus 和 gin 都不能识别出来,需要强制指定颜色输出,另外为了让 log 的格式好看,需要自定义一个 logrus 的 logger

// logrus
logrus.SetOutput(os.Stdout)
logrus.SetLevel(logrus.Level(LogLevel))
logrus.SetFormatter(&logrus.TextFormatter{
DisableColors: false,
TimestampFormat: "2006-01-02 15:03:04",
ForceColors: true,
FullTimestamp: true,
})

// gin
// 强制日志颜色化
gin.ForceConsoleColor()

效果如下:

image-20231112151059056

gin 的变动得等下次发版才能看,这里我拿出 noaHandler 的截图,效果是一样的

image-20231112151228144

pgsql 和 Oracle 数据库的兼容

对于多个数据库的情况,手写 sql 是不可取的,效率实在太低,需要用 orm ,java 有 mybatis ,而 go 有 gorm, ent-go, xorm 等等,ent-go 很多人说不错,看了下感觉有点复杂,gorm 有个认识的在用,xorm 身边没有人用,网上说的人也不多,但是看了下文档感觉还不错,同时 gitea 也是用的 xorm ,于是我最后选择用 xorm

xorm 在同时有 pgsql 和 Oracle 的时候需要注意,Oracle 数据库默认大写,而 xorm 的默认策略会加上引号,导致生成的 SQL 限定小写,于是 SQL 就无效了,需要调整一下 xorm 的 SQL 引号生成策略

// 取消大小写敏感
// https://pkg.go.dev/xorm.io/xorm@v1.0.1/dialects#QuotePolicy
// https://www.cnblogs.com/bartggg/p/13066944.html
CocoaDataEngine.Dialect().SetQuotePolicy(1)

同一/不同命名空间下 pod 的通信

k8s 里面有着叫做 coredns 的组件,是个 dns 服务器,同一命名空间下使用 pod 的名字即可访问,当然需要注意主机 DNS 不要有搜索域

名称是在 Service 里面定义的

---
apiVersion: v1
kind: Service
metadata:
# 就是这里的 name
name: mars-server-status-monitor
labels:
app: mars-server-status-monitor
# 命名空间
namespace: cloud
spec:
selector:
app: mars-server-status-monitor
ports:
# ports 这边用容器的名称来选择服务,这里把网关和 grafana 两个服务开放到了集群内部
- name: mars-server-status-monitor
# 同一命名空间访问:http://mars-server-status-monitor:8080
port: 8080
# 不同命名空间的访问:"http://mars-server-status-monitor.cloud.svc.cluster.local:3000"
# 应用名.命名空间.svc.cluster.local:端口 这样的形式 , svc => service , cluster.local 集群名称
- name: mars-server-status-monitor-grafana
port: 3000
---

这里和公司的认证组件访问也是使用这样的方式,http 直接走内部访问即可

而 grafana 配置的 Prometheus 数据源也是如此,数据源的 url 这样填写:http://prometheus.monitoring.svc.cluster.local:9090

结合 istio 网关对外开放指定的服务

需要建立一个 VirtualService ,配置如下

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: mars-server-status-monitor
namespace: cloud
spec:
hosts:
- "*"
gateways:
- cloud-gateway
http:
- match:
- uri:
prefix: /mars-server-status-monitor
route:
- destination:
host: mars-server-status-monitor
port:
number: 8080

最后访问的路径 http://domain/backstage/mars-server-status-monitor/ ,不过需要注意的是应用拿不到 backstage 的 istio 网关前缀,拿到的路径是 /mars-server-status-monitor/* 需要注意不能在自己的根路径下提供服务,而是在自己项目名下的路径开展服务

另外注意 route 下的配置,对比前面 Service 的配置里面这里只有 8080 的配置,这样就只把网关服务对外开放而没有把 Grafana 直接开放出来

Grafana 的反代和鉴权

这里是比较坑的地方了,Grafana 的反代只支持写死一个域名,另外像上面不在根目录服务的时候还需要特殊的配置,平台在多租户的情况下很多时候配置了不同的域名来区分不同的租户,这种时候就得来点儿花招了

先来看 Grafana 的配置(其实刚刚已经看过了),注意看里面的注释

---
# Grafana 配置
# 需要修改下面的 domain 为外部访问地址
# 多租户多域名情况下写 100 租户的地址(只能写死一个)
# sub_path: https://community.grafana.com/t/problem-with-reverse-proxy-after-update-to-grafana-10-0/89589/2
apiVersion: v1
kind: ConfigMap
metadata:
name: mars-server-status-monitor-grafana-config
namespace: cloud
data:
grafana.ini: |
[server]
# 配置了访问时候的域名
domain = ***.yunzainfo.com
# 配置了访问时候 Grafana 的路径
root_url = http://***/backstage/mars-server-status-monitor/v1/grafana/
# 需要关闭 serve_from_sub_path ,才能正常提供服务(从自定义的路径加载 js css 文件,而不是根目录)
serve_from_sub_path = false
[security]
# 内置的管理员账号
admin_user = ***
# 内置的管理员密码
admin_password = ***
admin_email = luckykeeper@luckykeeper.site
# 允许嵌入
allow_embedding = true
[users]
# Default UI theme ("dark" or "light")
default_theme = dark
# Default UI language (supported IETF language tag, such as en-US)
;default_language = zh-CN
[paths]
# folder that contains provisioning config files that grafana will apply on startup and while running.
# 预置数据
provisioning = /etc/grafana/provisioning
---

接着咱们来看正常的反代是怎么做的,和 Nginx 差不多,路由组里面前面传什么,这里我们就给传过去就是了,而 Grafana 这里就特殊一些

// 开发用反向代理
func DevReverseProxy(c *gin.Context, target string) {
proxyUrl, _ := url.Parse(target)
c.Request.URL.Path = c.Param("name") // 替换反代路径传递给 Prometheus
proxy := httputil.NewSingleHostReverseProxy(proxyUrl)
proxy.ServeHTTP(c.Writer, c.Request)
}

而 Grafana 这里就不能这么做了,Grafana 使用服务账户的 token 鉴权,我们给每个租户创建一个 token ,在访问的时候先给用户鉴权,鉴权不通过返回 401 ,鉴权通过的时候给他的请求加上这个 token 的 header 头,再进行反代,帮助他的请求通过 Grafana 的鉴权,逻辑大概是这样,具体代码这里就不放了

ps:这里原先最开始写的时候是 http 状态码一律 200,具体请求情况得去看业务码和说明,我是感觉这样的话好一些(http 200+post 一把梭流派(doge)),不过公司项目统一是 200/401/500 模式,前端是通过状态码做的拦截而不是业务码来做的统一拦截,于是后来改成了这样

我这里做的后端鉴权,前端他们也实现了一套鉴权,还是比较靠谱的,不过公司有些项目只做了前端鉴权,之前还在学校的时候就发现有些模块直接 postman 请求一下就绕过了,呱!

Grafana 预置面板和数据源,以及数据持久化

需要先预置 Prometheus 数据源,官方文档写的相当之简陋,像下面这样做,Prometheus 数据源写 k8s 里面的域名,注意固定一个 UID 给面板使用

---
# Grafana DataSource 配置
apiVersion: v1
kind: ConfigMap
metadata:
name: mars-server-status-monitor-grafana-config-datasource
namespace: cloud
data:
datasource.yaml: |
apiVersion: 1

datasources:
- name: Prometheus
type: prometheus
url: http://prometheus.monitoring.svc.cluster.local:9090
jsonData:
httpMethod: POST
manageAlerts: true
prometheusType: Prometheus
cacheLevel: 'High'
disableRecordingRules: false
# incrementalQueryOverlapWindow: 10m
exemplarTraceIdDestinations:
# Field with internal link pointing to data source in Grafana.
# datasourceUid value can be anything, but it should be unique across all defined data source uids.
- datasourceUid: PBFA97CFB590B2093
name: traceID
---

挂载方法是先挂 volumes 再挂

    volumeMounts:
# 预配 Prometheus 数据源
- name: mars-server-status-monitor-grafana-configmap-datasource
mountPath: /etc/grafana/provisioning/datasources/datasource.yaml
subPath: datasource.yaml
# 自动导入面板配置
- name: mars-server-status-monitor-grafana-configmap-dashboard-config
mountPath: /etc/grafana/provisioning/dashboards/dashboardconfig.yaml
subPath: dashboardconfig.yaml
# 面板配置 json ,和上面的 yaml 搭配使用
- name: mars-server-status-monitor-grafana-configmap-dashboard-k8s
mountPath: /tmp/grafana/dashboardk8s.json
subPath: dashboardk8s.json
- name: mars-server-status-monitor-grafana-configmap-dashboard-node
mountPath: /tmp/grafana/dashboardnode.json
subPath: dashboardnode.json
volumes:
# 持久卷数据存储
- name: grafana-volume-server-status-monitor
persistentVolumeClaim:
claimName: grafana-volume-server-status-monitor
# grafana 设置
- name: mars-server-status-monitor-grafana-configmap
configMap:
name: mars-server-status-monitor-grafana-config
# 数据源设置
- name: mars-server-status-monitor-grafana-configmap-datasource
configMap:
name: mars-server-status-monitor-grafana-config-datasource

然后去配置面板,把面板的 json 复制出来也挂载到 configmap ,像上面一样挂载到指定位置即可,这里需要做预配数据源,如下:

  # Grafana DashBoard - config
apiVersion: v1
kind: ConfigMap
metadata:
name: mars-server-status-monitor-grafana-config-dashboard-config
namespace: cloud
data:
dashboardconfig.yaml: |
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
updateIntervalSeconds: 10
options:
path: /tmp/grafana

这样 Grafana 就会去 /tmp/grafana 这个文件夹去找面板的配置文件,所有预配置的数据源都不允许任何用户删除,前面说的给租户使用的服务账户只有 Viewer 权限,这样就防止客户乱点删掉配置了

数据持久化需要 NFS 或者 CEPH 存储,需要分别配置 deployment 和 pvc,如下

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: grafana-volume-server-status-monitor
namespace: cloud
spec:
storageClassName: "rook-ceph-block"
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
---
volumeMounts:
# 都需要以 subPath 挂载,防止覆盖文件
# https://www.cnblogs.com/xuxinkun/p/10032376.html

# 持久存储卷
- name: grafana-volume-server-status-monitor
mountPath: /var/lib/grafana
subPath: grafana

Docker 两阶段打包

通过两阶段打包可以有效缩小镜像文件大小,同时解决 CI 宿主环境没有 Go 环境的问题,方法是写两个 FROM ,参考

# 适用于 Go 打包镜像,其它 Go 语言项目可套用此模板
FROM golang:1.21.0-alpine3.18 AS builder

WORKDIR /go/src/

COPY ./ /go/src/

RUN apk --update add tzdata wget && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata && \
go env -w GOPROXY=https://goproxy.cn,direct && \
go mod tidy && \
go build && \
chmod +x ./mars-server-status-monitor

FROM alpine

LABEL mars-server-status-monitor.image.author="Luckykeeper<https://luckykeeper.site|luckykeeper@luckykeeper.site|https://github.com/luckykeeper>"
LABEL maintainer="Luckykeeper<https://luckykeeper.site|luckykeeper@luckykeeper.site|https://github.com/luckykeeper>"
ENV TZ Asia/shanghai
WORKDIR /app
COPY --from=builder /go/src/mars-server-status-monitor /app/mars-server-status-monitor
# ENV startMode=runProd debugMode=runProd databaseDSN="null" logLevel="5"
ENV startMode=runProd
EXPOSE 8080/tcp
ENTRYPOINT /app/mars-server-status-monitor ${startMode}

滚动升级和节点选择

像这样配置

spec:
# 滚动升级策略
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # 一次可以添加多少个Pod
maxUnavailable: 25% # 滚动更新期间最大多少个Pod不可用

replicas: 1
selector:
matchLabels:
app: mars-server-status-monitor
version: devp
template:
metadata:
labels:
app: mars-server-status-monitor
version: devp
annotations:
traffic.sidecar.istio.io/excludeOutboundIPRanges: 0.0.0.0/0
spec:
serviceAccount: cloud
# 多租户时,选择非数据库节点(选择应用节点)
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node
operator: NotIn
values:
- postgres

CI / CD

Gitea + Drone CI

因为公司的 Jenkins 版本非常之老,不支持流水线和 Go 插件,于是最开始使用了 Gitea + Drone 的方式打包,类似 GitHub Action 的写法,使用 trigger 可以筛选发起 CI 的条件,使用 plugins/docker 插件可以做 docker 的自动构建,这里给大家看下 docker 的怎么写

---
kind: pipeline
name: 【发版】mars-server-status-monitor打包上传镜像

trigger:
event:
- tag

steps:
- name: (发版)发布到公司仓
image: plugins/docker
settings:
username:
from_secret: HARBORADMIN
password:
from_secret: HARBORPASSWORD
registry: ***
insecure: true
repo: ***/mars-server-status-monitor
tags: ${DRONE_TAG##v}
add_host:
- ***:***

CD 后续切换到 Jenkins 了,这边就没有做,配合 ssh 和 scp 组件很好做

Jenkins CI/CD

这边就痛苦很多,因为公司的 Jenkins 很老,需要用 shell 脚本来做 CI/CD ,自己参考前辈的 java 发版的 shell 改了一个,docker 打完之后把 deployment service virtualservice 和 configmap 文件复制上去,针对平台改下里面的域名配置(sed)替换,之后重启 pod ,重启 pod 可以用 label 来选择

kubectl delete pods -n cloud -l app=mars-server-status-monitor

成果

折腾了半天,肯定要看看做成了什么样子,上图!

ps:前端那边后面还会调整一下,再做做美化之类的

image-20231112145904137

image-20231112150031181

image-20231112150213209