本文已超过一年。较旧的文章可能包含过时内容。请检查页面中的信息自发布以来是否已失效。
编写 Pod 标签的控制器
Operator 已被证明是在 Kubernetes 中运行有状态分布式应用的优秀解决方案。像 Operator SDK 这样的开源工具提供了构建可靠且易于维护的 Operator 的方法,从而更容易地扩展 Kubernetes 和实现自定义调度。
Kubernetes Operator 在集群内运行复杂的软件。开源社区已经为 Prometheus、Elasticsearch 或 Argo CD 等分布式应用构建了许多 Operator。即使在开源之外,Operator 也可以帮助为您的 Kubernetes 集群带来新功能。
Operator 是一组自定义资源和一组控制器。控制器负责监视 Kubernetes API 中特定资源的变化,并通过创建、更新或删除资源来做出响应。
Operator SDK 最适合构建功能齐全的 Operator。尽管如此,您也可以用它来编写单个控制器。本文将引导您编写一个 Go 语言的 Kubernetes 控制器,该控制器将为具有特定注解的 Pod 添加一个 pod-name
标签。
为什么我们需要一个控制器来实现这个功能?
最近我参与了一个项目,需要创建一个 Service,将流量路由到 ReplicaSet 中的特定 Pod。问题在于 Service 只能通过标签选择 Pod,而 ReplicaSet 中的所有 Pod 都具有相同的标签。有两种方法可以解决这个问题
- 创建不带 selector 的 Service,并直接管理该 Service 的 Endpoints 或 EndpointSlices。我们需要编写一个自定义控制器,将我们 Pod 的 IP 地址插入到这些资源中。
- 为 Pod 添加一个具有唯一值的标签。然后我们就可以在 Service 的 selector 中使用这个标签。同样,我们需要编写一个自定义控制器来添加这个标签。
控制器是一个控制循环,用于跟踪一个或多个 Kubernetes 资源类型。上述选项 2 中的控制器只需跟踪 Pod,这使得实现更简单。这就是我们将通过编写一个 Kubernetes 控制器来实现的选项,该控制器会为我们的 Pod 添加 pod-name
标签。
StatefulSets 原生支持此功能,通过为集合中的每个 Pod 添加 pod-name
标签实现。但如果我们不想或不能使用 StatefulSets 怎么办?
我们很少直接创建 Pod;通常我们使用 Deployment、ReplicaSet 或其他更高级别的资源。我们可以在 PodSpec 中指定要添加到每个 Pod 的标签,但不能使用动态值,因此无法复制 StatefulSet 的 pod-name
标签。
我们尝试使用可变准入 Webhook。当有人创建 Pod 时,Webhook 会为 Pod 打上包含 Pod 名称的标签。令人失望的是,这不起作用:并非所有 Pod 在创建之前都有名称。例如,当 ReplicaSet 控制器创建 Pod 时,它会将 namePrefix
发送到 Kubernetes API 服务器,而不是 name
。API 服务器会在将新 Pod 持久化到 etcd 之前生成一个唯一的名称,但这只在我们调用准入 Webhook 之后。因此,在大多数情况下,我们无法通过可变准入 Webhook 知道 Pod 的名称。
一旦 Pod 在 Kubernetes API 中存在,它大部分是不可变的,但我们仍然可以添加标签。甚至可以从命令行执行此操作
kubectl label my-pod my-label-key=my-label-value
我们需要监视 Kubernetes API 中任何 Pod 的变化,并添加我们想要的标签。与其手动执行此操作,不如编写一个控制器来为我们完成。
使用 Operator SDK 引导(Bootstrapping)控制器
控制器是一个协调循环(reconciliation loop),它从 Kubernetes API 读取资源的期望状态,并采取行动使集群的实际状态更接近期望状态。
为了尽快编写这个控制器,我们将使用 Operator SDK。如果尚未安装,请按照官方文档进行。
$ operator-sdk version
operator-sdk version: "v1.4.2", commit: "4b083393be65589358b3e0416573df04f4ae8d9b", kubernetes version: "v1.19.4", go version: "go1.15.8", GOOS: "darwin", GOARCH: "amd64"
让我们创建一个新目录来编写控制器
mkdir label-operator && cd label-operator
接下来,让我们初始化一个新的 operator,我们将向其添加一个控制器。为此,您需要指定一个 domain 和一个 repository。domain 用作自定义 Kubernetes 资源所属组的前缀。由于我们不会定义自定义资源,所以 domain 不重要。repository 将是我们即将编写的 Go 模块的名称。按照约定,这就是您将存储代码的仓库。
举例来说,我运行的命令是
# Feel free to change the domain and repo values.
operator-sdk init --domain=padok.fr --repo=github.com/busser/label-operator
接下来,我们需要创建一个新的控制器。这个控制器将处理 Pod 而非自定义资源,因此无需生成资源代码。让我们运行此命令来搭建所需的代码
operator-sdk create api --group=core --version=v1 --kind=Pod --controller=true --resource=false
现在我们有了新文件:controllers/pod_controller.go
。此文件包含一个 PodReconciler
类型,其中有两个我们需要实现的方法。第一个是 Reconcile
,目前看起来是这样
func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = r.Log.WithValues("pod", req.NamespacedName)
// your logic here
return ctrl.Result{}, nil
}
每当 Pod 被创建、更新或删除时,都会调用 Reconcile
方法。Pod 的名称和 namespace 位于该方法作为参数接收的 ctrl.Request
中。
第二个方法是 SetupWithManager
,目前看起来是这样
func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
// Uncomment the following line adding a pointer to an instance of the controlled resource as an argument
// For().
Complete(r)
}
SetupWithManager
方法在 operator 启动时被调用。它用于告知 operator 框架我们的 PodReconciler
需要监视哪些类型。为了使用 Kubernetes 内部使用的相同 Pod
类型,我们需要导入其部分代码。所有 Kubernetes 源码都是开源的,因此您可以在自己的 Go 代码中导入任何部分。您可以在 Kubernetes 源码中或pkg.go.dev 上找到完整的可用包列表。要使用 Pod,我们需要 k8s.io/api/core/v1
包。
package controllers
import (
// other imports...
corev1 "k8s.io/api/core/v1"
// other imports...
)
让我们在 SetupWithManager
中使用 Pod
类型,告知 operator 框架我们要监视 Pod
func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}).
Complete(r)
}
继续之前,我们应该设置控制器所需的 RBAC 权限。在 Reconcile
方法上方,我们有一些默认权限
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=pods/finalizers,verbs=update
我们不需要所有这些权限。我们的控制器永远不会与 Pod 的 status 或其 finalizers 交互。它只需要读取和更新 Pod。让我们移除不必要的权限,只保留所需的。
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;update;patch
现在我们准备编写控制器的协调逻辑了。
实现协调逻辑
以下是我们希望 Reconcile
方法完成的工作
- 使用
ctrl.Request
中的 Pod 名称和 namespace 从 Kubernetes API 获取 Pod。 - 如果 Pod 具有
add-pod-name-label
注解,则为 Pod 添加pod-name
标签;如果缺少该注解,则不添加标签。 - 在 Kubernetes API 中更新 Pod,以持久化所做的更改。
让我们为注解和标签定义一些常量
const (
addPodNameLabelAnnotation = "padok.fr/add-pod-name-label"
podNameLabel = "padok.fr/pod-name"
)
协调函数的第一步是,从 Kubernetes API 获取我们正在处理的 Pod
// Reconcile handles a reconciliation request for a Pod.
// If the Pod has the addPodNameLabelAnnotation annotation, then Reconcile
// will make sure the podNameLabel label is present with the correct value.
// If the annotation is absent, then Reconcile will make sure the label is too.
func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := r.Log.WithValues("pod", req.NamespacedName)
/*
Step 0: Fetch the Pod from the Kubernetes API.
*/
var pod corev1.Pod
if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
log.Error(err, "unable to fetch Pod")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
当 Pod 被创建、更新或删除时,将调用我们的 Reconcile
方法。在删除的情况下,我们对 r.Get
的调用将返回特定错误。让我们导入定义此错误的包
package controllers
import (
// other imports...
apierrors "k8s.io/apimachinery/pkg/api/errors"
// other imports...
)
现在我们可以处理这个特定错误,并且——由于我们的控制器不关心已删除的 Pod——明确忽略它
/*
Step 0: Fetch the Pod from the Kubernetes API.
*/
var pod corev1.Pod
if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
if apierrors.IsNotFound(err) {
// we'll ignore not-found errors, since we can get them on deleted requests.
return ctrl.Result{}, nil
}
log.Error(err, "unable to fetch Pod")
return ctrl.Result{}, err
}
接下来,让我们编辑 Pod,以便仅当注解存在时,动态标签才存在
/*
Step 1: Add or remove the label.
*/
labelShouldBePresent := pod.Annotations[addPodNameLabelAnnotation] == "true"
labelIsPresent := pod.Labels[podNameLabel] == pod.Name
if labelShouldBePresent == labelIsPresent {
// The desired state and actual state of the Pod are the same.
// No further action is required by the operator at this moment.
log.Info("no update required")
return ctrl.Result{}, nil
}
if labelShouldBePresent {
// If the label should be set but is not, set it.
if pod.Labels == nil {
pod.Labels = make(map[string]string)
}
pod.Labels[podNameLabel] = pod.Name
log.Info("adding label")
} else {
// If the label should not be set but is, remove it.
delete(pod.Labels, podNameLabel)
log.Info("removing label")
}
最后,让我们将更新后的 Pod 推送到 Kubernetes API
/*
Step 2: Update the Pod in the Kubernetes API.
*/
if err := r.Update(ctx, &pod); err != nil {
log.Error(err, "unable to update Pod")
return ctrl.Result{}, err
}
将更新后的 Pod 写入 Kubernetes API 时,存在自我们首次读取该 Pod 以来,它已被更新或删除的风险。在编写 Kubernetes 控制器时,我们应该记住,我们不是集群中唯一的参与者。发生这种情况时,最好的做法是通过重新排队事件来从头开始协调。我们正是要这样做
/*
Step 2: Update the Pod in the Kubernetes API.
*/
if err := r.Update(ctx, &pod); err != nil {
if apierrors.IsConflict(err) {
// The Pod has been updated since we read it.
// Requeue the Pod to try to reconciliate again.
return ctrl.Result{Requeue: true}, nil
}
if apierrors.IsNotFound(err) {
// The Pod has been deleted since we read it.
// Requeue the Pod to try to reconciliate again.
return ctrl.Result{Requeue: true}, nil
}
log.Error(err, "unable to update Pod")
return ctrl.Result{}, err
}
记住在方法结束时成功返回
return ctrl.Result{}, nil
}
就这样!现在我们准备好在集群上运行控制器了。
在集群上运行控制器
要在集群上运行控制器,我们需要运行 operator。为此,您只需 kubectl
。如果您手头没有 Kubernetes 集群,我建议您使用 KinD (Kubernetes in Docker) 在本地启动一个。
在您的机器上运行 operator 只需这个命令
make run
几秒钟后,您应该看到 operator 的日志。请注意,我们控制器的 Reconcile
方法已被调用,处理了集群中所有已运行的 Pod。
让我们保持 operator 运行,并在另一个终端中创建一个新的 Pod
kubectl run --image=nginx my-nginx
operator 应该会快速打印一些日志,表明它对 Pod 的创建以及随后的状态变化做出了反应
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
让我们检查 Pod 的标签
$ kubectl get pod my-nginx --show-labels
NAME READY STATUS RESTARTS AGE LABELS
my-nginx 1/1 Running 0 11m run=my-nginx
让我们为 Pod 添加一个注解,以便控制器知道要为它添加动态标签
kubectl annotate pod my-nginx padok.fr/add-pod-name-label=true
请注意,控制器立即做出了反应,并在日志中生成了新的一行
INFO controllers.Pod adding label {"pod": "default/my-nginx"}
$ kubectl get pod my-nginx --show-labels
NAME READY STATUS RESTARTS AGE LABELS
my-nginx 1/1 Running 0 13m padok.fr/pod-name=my-nginx,run=my-nginx
太棒了!您刚刚成功编写了一个 Kubernetes 控制器,能够为集群中的资源添加带有动态值的标签。
控制器和 operator,无论大小,都可以成为您 Kubernetes 之旅的重要组成部分。现在编写 operator 比以往任何时候都更容易。可能性是无限的。
下一步是什么?
如果您想深入研究,我建议从在集群内部署您的控制器或 operator 开始。Operator SDK 生成的 Makefile
将完成大部分工作。
在将 Operator 部署到生产环境时,实现可靠的测试始终是个好主意。迈向这一目标的第一步是编写单元测试。这篇文档将指导您为 Operator 编写测试。我为我们刚刚编写的 Operator 编写了测试;您可以在这个 GitHub 仓库中找到我的所有代码。
如何了解更多信息?
Operator SDK 文档详细介绍了如何进一步实现更复杂的 Operator。
在为更复杂的用例建模时,仅使用作用于内置 Kubernetes 类型的单个控制器可能不够。您可能需要使用自定义资源定义 (CRD)和多个控制器来构建更复杂的 Operator。Operator SDK 是一个很棒的工具,可以帮助您完成此操作。
如果您想讨论如何构建 Operator,请加入Kubernetes Slack 工作区中的#kubernetes-operator 频道!