前言 两年之前玩过 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: - 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: debugMode: "true" databaseType: postgres databaseDSN: postgres://***:***@master.ds.svc.cluster.local:5432/***?sslmode=disable logLevel: "5" grafanaDomain: "***" ---
接下来挂载到环境变量就非常容易了
envFrom: - 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.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 的变动得等下次发版才能看,这里我拿出 noaHandler 的截图,效果是一样的
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 引号生成策略
CocoaDataEngine.Dialect().SetQuotePolicy(1 )
同一/不同命名空间下 pod 的通信 k8s 里面有着叫做 coredns 的组件,是个 dns 服务器,同一命名空间下使用 pod 的名字即可访问,当然需要注意主机 DNS 不要有搜索域
名称是在 Service 里面定义的
--- apiVersion: v1 kind: Service metadata: name: mars-server-status-monitor labels: app: mars-server-status-monitor namespace: cloud spec: selector: app: mars-server-status-monitor ports: - name: mars-server-status-monitor port: 8080 - 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 的配置(其实刚刚已经看过了),注意看里面的注释
--- 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" ) 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 给面板使用
--- 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 exemplarTraceIdDestinations: - datasourceUid: PBFA97CFB590B2093 name: traceID ---
挂载方法是先挂 volumes 再挂
volumeMounts: - 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 - 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 - 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 ,像上面一样挂载到指定位置即可,这里需要做预配数据源,如下:
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: - name: grafana-volume-server-status-monitor mountPath: /var/lib/grafana subPath: grafana
Docker 两阶段打包 通过两阶段打包可以有效缩小镜像文件大小,同时解决 CI 宿主环境没有 Go 环境的问题,方法是写两个 FROM ,参考
FROM golang:1.21 .0 -alpine3.18 AS builderWORKDIR /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 alpineLABEL 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/shanghaiWORKDIR /app COPY --from=builder /go/src/mars-server-status-monitor /app/mars-server-status-monitor ENV startMode=runProdEXPOSE 8080 /tcpENTRYPOINT /app/mars-server-status-monitor ${startMode}
滚动升级和节点选择 像这样配置
spec: strategy: type: RollingUpdate rollingUpdate: maxSurge: 25 % maxUnavailable: 25 % 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:前端那边后面还会调整一下,再做做美化之类的