目标
大家都知道operator是用来自定义控制器来实现对复杂应用的管理,现在通过学习prometheus-operator 的源码,来加深对Opertaor的理解以及学习其代码是如何设计的。
如何使用prometheus-operator
完整prometheus监控系统
要使用前需要先安装opertaor,官方的安装文档如下
https://github.com/prometheus-operator/prometheus-operator/blob/main/Documentation/getting-started/installation.md
文档中介绍了3中安装方法,分别是
- Install using YAML files
- Install using Kube-Prometheus
- Install using Helm Chart
文档中说第二种方式是最简单,安装方法如下
1、下载指定版本的文件
git clone --branch v0.14.0 --single-branch https://github.com/prometheus-operator/kube-prometheus.git
2、执行命令会执行下面的yaml
文件创建CRD
以及namespace
kubectl create -f manifests/setup
3、等待CRD
创建完毕, 如果输出"No resources found"
表示CRD
创建完毕
until kubectl get servicemonitors --all-namespaces ; do date; sleep 1; echo ""; done
4、继续执行命令,创建prometheus
相关的CR
以及operator
kubectl create -f manifests/
执行完命令后,k8s就会启动operator,然后就会根据CR去部署prometheus的各个组件。但是你去查看部署的情况的时候,会发现部分pod失败了,需要手动下载镜像
# 查看pod的启动情况
kubectl get pod -n monitoring
失败的镜像共3个:
grafana/grafana:11.2.0
registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0
registry.k8s.io/prometheus-adapter/prometheus-adapter:v0.12.0
白嫖github action
+ 阿里云仓库下载镜像后,再看pod的情况,已经全部弄好了
查看grafana
的web
页面,默认的用户名密码是admin/admin
kubectl port-forward --address 0.0.0.0 -n monitoring svc/grafana 8005:3000
可以看到grafana
已经能够正常运行了
删除prometheus
命令如下
kubectl delete --ignore-not-found=true -f manifests/ -f manifests/setup
operator文档上有更详细的说明
https://prometheus-operator.dev/docs/platform/platform-guide/
只部署operator
由于弄完上面整个prometheus之后才看到的,就不重复做了,记录下文档位置
https://github.com/prometheus-operator/prometheus-operator/blob/main/README.md
如何调试operator代码
上面部署 Prometheus 的原理是:首先创建 CRD
及 Namespace
,然后部署 Operator Pod
,并创建一系列自定义资源(CR),例如 Prometheus Server
等。Operator Pod
启动后,从 API Server
获取这些 CR
,并循环处理。其处理逻辑是根据 CR
的定义,创建和管理相应的 Pod
、Service
等资源。 当然并不是所有的Pod
都是operator
启动,还有一些直接是以Deployment
的方式部署的,例如kube-state-metrics
、blackbox-exporter
开始准备调试工作前,执行下面的命令把已经启动了的pod
以及创建了的CR
删掉,方便后续调试operator
kubectl delete --ignore-not-found=true -f manifests/
在mainfests
目录关于operator
的yaml
如下,很明显重点关注的应该是这个deployment.yaml
deployment中启动了两个容器
kube-rbac-proxy
是一个 轻量级的反向代理(reverse proxy),用于为 Kubernetes 内部服务提供 RBAC(基于角色的访问控制,Role-Based Access Control) 认证。该镜像由 brancz
维护,并广泛用于 Prometheus 生态中的组件,如 Prometheus Operator
、Kube State Metrics
和 Node Exporter
,以确保只有经过授权的客户端才能访问相关的 metrics。
默认情况下,Prometheus 和其他 Exporter
组件会**直接暴露HTTP
端点(如 /metrics
),这意味着:
- 未经过身份验证的客户端 可能访问这些数据,存在安全风险。
- Kubernetes 原生 RBAC 无法直接控制对
/metrics
端点的访问。
kube-rbac-proxy
通过代理 Prometheus、Exporter 等应用的 /metrics
端点,实现基于 Kubernetes RBAC
规则的身份验证,确保:
- 只有拥有特定 ServiceAccount、Role 或 ClusterRole 权限的用户或服务可以访问这些端点。
现在要关注的是quay.io/prometheus-operator/prometheus-operator:v0.76.2
这个镜像,对应的源码如下
git clone --branch v0.76.2 --single-branch https://github.com/prometheus-operator/prometheus-operator.git
毫无疑问,第一步是看Makefile
,可以看到构建镜像以及二进制文件的命令。由于命令过于明显以及直接,这里就直接给出结论了
要构建镜像,需要先执行make build
命令构建二进制程序,然后再执行make image
构建镜像。
需要构建二进制文件如下,k8s-gen是利用k8s提供的代码生成工具生成Kubernetes 客户端代码。
第一步,先修改编译参数,添加禁用优化标识
第二步修改operator
的Dockerfile
,由于基础镜像是busybox
,需要添加go
、dlv
,从go的官方镜像中复制go sdk
以及dlv
到busybox
中。Dockerfile
修改后如下
ARG ARCH="amd64"
ARG OS="linux"
# 第一阶段:使用 Golang 官方镜像,准备 Go SDK
FROM --platform=$ARCH golang:1.23-alpine AS builder
ENV GOPROXY=https://goproxy.cn,direct
RUN go env -w GOPROXY=https://goproxy.cn,direct && \
go env -w GOSUMDB=off
# dlv
RUN go install github.com/go-delve/delve/cmd/dlv@v1.24.0
FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest as operator
# 复制Go 编译环墨境到镜像中
COPY --from=builder /usr/local/go /usr/local/go
COPY --from=builder /go/bin/dlv /usr/local/go/bin/dlv
# 设置环境变量(如果需要)
ENV PATH="/usr/local/go/bin:${PATH}"
# 使用 root 用户,确保 dlv 能正常运行
USER root
LABEL org.opencontainers.image.source="https://github.com/prometheus-operator/prometheus-operator" \
org.opencontainers.image.url="https://prometheus-operator.dev/" \
org.opencontainers.image.documentation="https://prometheus-operator.dev/" \
org.opencontainers.image.licenses="Apache-2.0"
# 复制 Go 编译的二进制文件
COPY operator /bin/operator
# 复制operator的源码
COPY . /prometheus-operator
ENV GOPROXY=https://goproxy.cn,direct
RUN go env -w GOPROXY=https://goproxy.cn,direct && \
go env -w GOSUMDB=off
# 切换工作目录
WORKDIR /prometheus-operator
# 下载所有依赖包源码
RUN go mod tidy && go mod vendor
# 还原工作目录
WORKDIR /
ENTRYPOINT ["/bin/operator"]
构建镜像的命令如下,构建镜像的时候,会先编译源码的,所以不需要额外执行编译源码的命令
TAG=v0.0.0 make image
其实本篇文章中调试只用到了operator
的镜像,可以执行下面的语句即可
make .hack-operator-image
从docker
导出镜像到containerd
# 导出
docker save -o my-operator.tar quay.io/prometheus-operator/prometheus-operator:v0.0.0
# 导入
ctr -n=k8s.io images import my-operator.tar
# 检查镜像
ctr -n k8s.io i ls | grep operator
回到kube-prometheus
项目中, 修改operator
对应的yaml文件prometheusOperator-deployment.yaml
, 用dlv
命令覆盖entrypoint
除了覆盖命令外,根据你的需要修改容器安全属性,当然不改也是可以的
readOnlyRootFilesystem: false
或者删除yaml后面的非root用户设置
runAsGroup: 65534
runAsNonRoot: true
runAsUser: 65534
我是为了验证一些东西,进入容器中,想要执行go env
命令都不行,因为nobody
用户没有权限操作/tmp
目录.
接着创建operator
相关的资源
kubectl apply -f manifests/prometheusOperator-clusterRoleBinding.yaml
kubectl apply -f manifests/prometheusOperator-clusterRole.yaml
kubectl apply -f manifests/prometheusOperator-deployment.yaml
kubectl apply -f manifests/prometheusOperator-networkPolicy.yaml
kubectl apply -f manifests/prometheusOperator-prometheusRule.yaml
kubectl apply -f manifests/prometheusOperator-serviceAccount.yaml
kubectl apply -f manifests/prometheusOperator-serviceMonitor.yaml
kubectl apply -f manifests/prometheusOperator-service.yaml
同时还要创建一个service
,因为operator没有暴露dlv监听的端口
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/component: controller
app.kubernetes.io/name: prometheus-operator
app.kubernetes.io/part-of: kube-prometheus
app.kubernetes.io/version: 0.76.2
name: prometheus-operator-debug
namespace: monitoring
spec:
type: NodePort # Service 类型
ports:
- name: debug
port: 8005 # Service 内部端口
targetPort: 8005 # Pod 内部应用的端口
nodePort: 30000 # 外部访问的端口
protocol: TCP
selector:
app.kubernetes.io/component: controller
app.kubernetes.io/name: prometheus-operator
app.kubernetes.io/part-of: kube-prometheus
vscode配置如下,需要按实际情况调整映射的路径
{
"version": "0.2.0",
"configurations": [
{
"name": "operator",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "/prometheus-operator",
"port": 30000,
"host": "4c",
"showLog": true,
"trace": "verbose",
"substitutePath": [
{
"from": "${workspaceFolder}", //本地源码路径
"to": "/root/prometheus-operator" //编译operator时,源码所在路径
},
{
"from": "/Users/wy/wy/workspace_go/pkg/mod", // 本地模块路径
"to": "/root/go_path/pkg/mod" // 远程模块路径
},
{
"from": "/opt/homebrew/Cellar/go/1.23.3/libexec/src", // 本地go sdk源码路径
"to": "/root/go/src/" // 远程go sdk源码路径
},
]
}
]
}
然后就可以愉快地看源码了
下面是调试过程中常用到的一些命令
# 别称,快速查看
alias kdescm="kubectl describe -n monitoring pod"
alias kgetm="kubectl get pod -n monitoring"
# 查看容器日志,替换pod的名字
kubectl logs prometheus-operator-94b467d84-n92cm -n monitoring -c prometheus-operator
# 持续查看容器日志,替换pod的名字
kubectl logs -f prometheus-operator-94b467d84-5d52r -n monitoring -c prometheus-operator
# 删除pod, 重新debug,替换pod的名字
kubectl delete pod prometheus-operator-74c7dfbbc5-l62xd -n monitoring
# 进入容器中查看,替换pod的名字
kubectl exec -it -n monitoring prometheus-operator-94b467d84-n92cm -c prometheus-operator -- /bin/sh
# 删除namespace为monitoring中,prometheus-operator-xxxxx的pod
kubectl delete -n monitoring pod $(kubectl get pods -n monitoring --no-headers -o custom-columns=":metadata.name" | grep "^prometheus-operator-")
源码阅读
代码入口
Operator的原理是利用了 Kubernetes 的自定义 API 资源(CRD),来描述我们想要部署的"有状态应用";然后在自定义控制器里,根据自定义 API 对象的变化,来完成具体的部署和运维工作。
prometheus-operator
创建的operator
以及controller如下
var po *prometheuscontroller.Operator
var pao *prometheusagentcontroller.Operator
var ao *alertmanagercontroller.Operator
var to *thanoscontroller.Operator
var kec *kubelet.Controller
以AlertManager
这个CRD为例,看operator
是如何工作的,operator
代码入口是cmd/operator/main.go
关于AlertManager相关的代码很明显
会先验证 CRD
是否已安装在集群中,并检查是否有足够的权限来管理资源,然后才会创建operator
。
operator原理回顾
要看operator
中代码需要先回顾一下Operator
的工作原理。下图取自极客时间中磊哥的《深入剖析Kubernets》第25课深入解析声明式API(二):编写自定义控制器
Operator 通过 Informer 监听 API Server 的资源变更。当资源发生 Add、Update 或 Delete 事件时,Informer 会将变更信息存入 DeltaFIFO 队列,并将事件加入 WorkQueue。
Controller 组件会循环从 WorkQueue 取出资源变更,并调用 Reconcile() 进行业务处理,确保资源状态与期望状态一致。
informer初始化
所以接下来的工作就是:找到informer以及弄清楚controller的Reconcile是如何处理的
回到代码上,New方法中,创建了一个Operator对象、一个ResourceReconciler,并调用了operator的bootstrap方法。
bootstrp
方法中,会创建该operator的需要的多个informer
这几个informer创建的代码都长一个样,区别就是传递的参数有点区别。以第一个informer的代码为例
var err error
c.alrtInfs, err = informers.NewInformersForResource(
informers.NewMonitoringInformerFactories(
config.Namespaces.AlertmanagerAllowList,
config.Namespaces.DenyList,
c.mclient,
resyncPeriod,
func(options *metav1.ListOptions) {
options.LabelSelector = config.AlertmanagerSelector.String()
},
),
monitoringv1.SchemeGroupVersion.WithResource(monitoringv1.AlertmanagerName),
)
if err != nil {
return fmt.Errorf("error creating alertmanager informers: %w", err)
}
informers.NewInformersForResource
、informers.NewMonitoringInformerFactories
这两个方法都是pkg/clinet/informers
中的代码,而informers
包则是有k8s
代码生成工具生成的,是模板代码。
我们要关注的是,哪些是需要手工编写的逻辑代码,例如如何调用informers
包获取informer
对比一下调用代码
唯一需要说明的是第5个参数tweakListOptions
,通常情况下,填写nil就可以了,因为这个参数的主要作用是提供一种机制,允许调用者在执行 Kubernetes API 列表操作时自定义 ListOptions
。这可以用于设置标签选择器、字段选择器、资源版本等,以便过滤和限制返回的资源列表。
创建informer的模板代码已经知道了,本小节剩余的文字可以不用看了,但是都看了还是保留下来凑凑字数吧。下面是创建informer用到的两个函数的理解:
informers.NewMonitoringInformerFactories
返回的是一个实现了FactoriesForNamespaces
接口的对象
// FactoriesForNamespaces is a way to combine several shared informers into a single struct with unified listing power.
type FactoriesForNamespaces interface {
ForResource(namespace string, resource schema.GroupVersionResource) (InformLister, error)
Namespaces() sets.Set[string]
}
实际上,返回的是一个map对象,key是namespace,vlaue是工厂对象,其实现了FactoriesForNamespaces接口
informers.NewInformersForResource
中就是通过遍历该map对象,获取到工厂对象创建对应的informer。最终返回一个包含了多个namespace
对应informer
的ForResource
对象.这里默认的namespace为"" ,表示所有namespace
共用一个informer
。
调谐
informer
创建后,接下来要看看controller
调谐的逻辑了。在main.go
最后会启动前面创建的那堆opertaor
在各个operator
的Run
方法内,又会创建一堆goroutinue
来启动informer
以及ResourceReconciler
,然后还调用了addHandlers
方法.
c.rr
就是在创建operator时创建的ResourceReconciler
o.rr = operator.NewResourceReconciler(
o.logger,
o,
o.metrics,
monitoringv1.AlertmanagersKind,
r,
o.controllerID,
)
但是ResourceReconciler
并不是AlertManager
特有的,是在pkg/operator
包下面的,是被所有operator
复用的。名为调谐器,调谐的逻辑也是由它触发的。其run
方法如下
// Run the goroutines responsible for processing the reconciliation and status
// queues.
func (rr *ResourceReconciler) Run(ctx context.Context) {
// Goroutine that reconciles the desired state of objects.
rr.g.Go(func() error {
for rr.processNextReconcileItem(ctx) {
}
return nil
})
// Goroutine that reconciles the status of objects.
rr.g.Go(func() error {
for rr.processNextStatusItem(ctx) {
}
return nil
})
}
根据CR
,创建POD
对象的逻辑就在rr.processNextReconcileItem(ctx)
中
如图,这部分逻辑是共用的,区别的地方在于这个syncer对象。
prometheus-operator
在实现上用的是statefulSet
来实现pod
的创建的,具体的逻辑就在这个syncer
中。在创建ResourceReconciler
的时候,syncer
入参值就是operator
本身。
而operator
本身是根据CRD
本身定制的,存在与各自的包中。例如该opeator
是在pkg/alertmanager
包下,prometheus sever
的operator
则在pkg/prometheus
包下。
至此,调谐的代码位置以及入口已经知道了,还剩下,调谐是被触发的还没交代。
回看调谐的逻辑,第一步是从队列中获取到数据
item, quit := rr.reconcileQ.Get()
...省略
key := item.(string)
...省略
err := rr.syncer.Sync(ctx, key)
根据informer
的原理可知,informer
通过AddEventHandler
注册事件处理器,当CRD对应的资源有变化的时候,会调用相应的事件数据入队列,然后调谐器从队列中获取数据进行处理。
operator.run
方法启动了一大堆informer之后,调用了operartor.addHandlers()
来完成各个informer
的事件处理器注册.
可以看到,ResourceReconciler
就是对应的事件处理器,其实现了事件处理器对应的三个方法,OnAdd
,OnUpdate
,OnDelete
. 三个方法的最后都会往队列reconcileQ
里面添加数据。
然后在goroutinue
中运行的ResourceReconciler
就能够从reconcileQ
中获取到数据从而调用operator.sync
来完成调谐。
总结
作为一种代码设计的思路,可以参考,prometheus-operator中哪些代码是复用的,哪些是需要定制的。后续需要实现复杂的operator时候,可以抄抄代码。