[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #2 에 이어 필요한 기능을 추가하였다
[추가내용]
현재는 모든 Loadbalancer를 대상으로 ExternalName이 만들어지고 있다.
이것은 굉장히 비효율적이고, 불필요한 로직인 것 같다. 따라서 ExternalName을 통해 관리를 받고싶은 LB만 Annotaion에 값을 추가하여 해당 LB만 Operator를 통해 관리받을 수 있는 로직을 추가해볼 것이다.
- annotations:
linker.hsj.com/managed: "true" - 예를들면 위와 같은 annotaion이 추가되도록 Reconcile에 로직 추가
추가한 코드 및 수정사항
1. 상수로 Annotaion Key, Value를 추가하였다.
2. 서비스의 Annotaion 여부를 확인하는 함수를 추가했다.
3. Loadbalancer 서비스에 Annotaion이 포함되어 있는지 여부를 확인하는 게이트키퍼역할의 코드를 추가하였다.
4. 코드의 가독성을 위해 기존 Reconcil 함수에 포함돼있던 ExternalName삭제 코드를 별도로 분리했다.
1. 상수로 Annotaion Key, Value 추가
const (
externalTargetsNamespace = "external-targets" // ExternalName 서비스를 생성/관리할 네임스페이스 (미리 생성 필요)
lbLinkerFinalizer = "lb-linker/finalizer" // Filnalizer 변수 값 추가
managedAnnotationKey = "linker.hsj.com/managed" // Annotaion Key 추가 변수
managedAnnotationValue = "true" // Annotaion Key에 대한 Value 변수 추가
)
2. 서비스의 Annotaion 여부를 확인하는 함수를 추가
// 서비스에 "managed" 어노테이션이 있는지 확인
func (r *ServiceReconciler) isManagedAnnotaion(service *corev1.Service) bool {
// 어노테이션이 맵 자체가 없으면 false
if service.Annotations == nil {
return false
}
val, ok := service.Annotations[managedAnnotationKey]
return ok && val == managedAnnotationValue
}
3. Loadbalancer 서비스에 Annotaion이 포함되어 있는지 여부를 확인하는 게이트키퍼역할의 코드를 추가
또한 관리 대상에서 제외된 LB에 남아있는 Finalizer로 인한 엣지케이스 관리를 위해 hasFinalizer 변수 추가
// 이 서비스가 Annotaion을 통한 관리 대상인지 확인 True or False
isManaged := r.isManagedAnnotaion(&originalService)
// Annotaion을 추가했다가 나중에 제거하게 되면 Operator의 관리대상에서 제외되나 Finalizer는 그대로 남게 되어 영원히 Terminating상태에 빠질 수 있음
// 따라서 Annotation이 없는 LB의 경우 Finalizer가 있는지 확인하고, 있다면 ExternalName을 제거 후 Finalizer를 제거해줍니다.
hasFinalizer := controllerutil.ContainsFinalizer(&originalService, lbLinkerFinalizer)
if !isManaged {
// 관리 대상이 아닌경우
log.Info("Service is not managed by this operator")
// 이전에 관리 대상이어서 Finalizer가 남아있는 경우
if hasFinalizer {
log.Info("Annotaion is gone, but finalizer exists. Running Cleanup to remove finalizer")
// Finalizer만 제거
controllerutil.RemoveFinalizer(&originalService, lbLinkerFinalizer)
if err := r.Update(ctx, &originalService); err != nil {
log.Error(err, "Failed to remove finalizer during hand-off")
return ctrl.Result{}, err
}
log.Info("Finalizer removed.")
}
return ctrl.Result{}, nil
}
4. 코드의 가독성을 위해 기존 Reconcil 함수에 포함돼있던 ExternalName삭제 코드를 별도로 분리
// Loadbalancer가 삭제될때 이와 연결된 ExternalName도 삭제하는 함수 추가
func (r *ServiceReconciler) cleanupExternalNameService(ctx context.Context, originalservice *corev1.Service, log logr.Logger) error {
log.Info("Starting cleanup for associated ExternalName Service...")
// 1. 이름규칙을 기반으로 삭제할 ExternalName 서비스의 이름을 결정
externalNameServiceName := fmt.Sprintf("%s-%s-ext", originalservice.Name, originalservice.Namespace)
externalNameServiceName = r.sanitizeName(externalNameServiceName)
// 2. 삭제할 ExternalName을 정의
svcToDelete := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: externalNameServiceName,
Namespace: externalTargetsNamespace,
},
}
전체코드는 아래와 같다.
package controller
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
/*
- //+kubebuilder:rbac 마커들은 make manifests명령어를 통해 controller-gen이 자동으로 인식하여 confg/rbac/role.yaml에 정의됨
- external-targets Operator의 Service Object 접근 권한 설정
- Service의 status 필드에 대한 읽기 권한과 Service 자체에 대한 생성, 업데이트, 수정 등에 대한 권한 설정
*/
//+kubebuilder:rbac:groups=core,resources=services/status,verbs=get
//+kubebuilder:rbac:groups=core,resources=services,verbs=create;update;patch;delete;get;list;watch
const (
externalTargetsNamespace = "external-targets" // ExternalName 서비스를 생성/관리할 네임스페이스 (미리 생성 필요)
lbLinkerFinalizer = "lb-linker/finalizer" // Filnalizer 변수 값 추가
managedAnnotationKey = "linker.hsj.com/managed" // Annotaion Key 추가 변수
managedAnnotationValue = "true" // Annotaion Key에 대한 Value 변수 추가
)
// ServiceReconciler reconciles a Service object
type ServiceReconciler struct {
client.Client
Scheme *runtime.Scheme
Log logr.Logger
}
// sanitizeName은 쿠버네티스 오브젝트 이름 규칙 정제
func (r *ServiceReconciler) sanitizeName(name string) string {
name = strings.ToLower(name)
name = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(name, "-")
if len(name) > 63 { // Kubernetes리소스 이른은 63까지만 허용되므로 최대 길이 63자로 제힌
name = name[:63]
}
name = strings.Trim(name, "-") // 이름의 앞뒤에 붙은 하이픈 제거
if name == "" {
// 매우 예외적인 경우, 기본 이름 또는 UUID 사용 고려
return "default-ext-name-" + time.Now().Format("20060102150405")
}
return name
}
// Loadbalancer가 삭제될때 이와 연결된 ExternalName도 삭제하는 함수 추가
func (r *ServiceReconciler) cleanupExternalNameService(ctx context.Context, originalservice *corev1.Service, log logr.Logger) error {
log.Info("Starting cleanup for associated ExternalName Service...")
// 1. 이름규칙을 기반으로 삭제할 ExternalName 서비스의 이름을 결정
externalNameServiceName := fmt.Sprintf("%s-%s-ext", originalservice.Name, originalservice.Namespace)
externalNameServiceName = r.sanitizeName(externalNameServiceName)
// 2. 삭제할 ExternalName을 정의
svcToDelete := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: externalNameServiceName,
Namespace: externalTargetsNamespace,
},
}
// 3. Delete API를 활용하여 ExternalName을 삭제
log.Info("Attempting to delete ExternalName Service", "name", svcToDelete.Name)
if err := r.Delete(ctx, svcToDelete); err != nil {
// ExternalName이 이미 삭제되어 없다면 Notfound에러가 뜸
if apierrors.IsNotFound(err) {
log.Info("Associated ExternalName Service aleady deleted.")
return nil
}
// Notfound에러가 아니라면 재시도를 위해 에러를 반환
log.Error(err, "Failed to deleted ExternalName Service during cleanup.")
return err
}
log.Info("Successfully deleted ExternalName Service.")
return nil
}
// 서비스에 "managed" 어노테이션이 있는지 확인
func (r *ServiceReconciler) isManagedAnnotaion(service *corev1.Service) bool {
// 어노테이션이 맵 자체가 없으면 false
if service.Annotations == nil {
return false
}
val, ok := service.Annotations[managedAnnotationKey]
return ok && val == managedAnnotationValue
}
func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := r.Log.WithValues("service", req.NamespacedName)
// 1. 원본 Service 객체 가져오기
var originalService corev1.Service
if err := r.Get(ctx, req.NamespacedName, &originalService); err != nil {
if apierrors.IsNotFound(err) {
log.Info("Original Service not found. Ignoring since we are not handling deletions in this simplified version.")
return ctrl.Result{}, nil
}
log.Error(err, "Failed to get Original Service")
return ctrl.Result{}, err
}
// 2. Service가 LoadBalancer 타입인지 확인
if originalService.Spec.Type != corev1.ServiceTypeLoadBalancer {
log.Info("Service is not LoadBalancer type, ignoring.", "type", originalService.Spec.Type)
return ctrl.Result{}, nil
}
// ExternalName Service 이름 결정(LB이름 + Namespace + ext) 예) my-sample-lb-3-default-ext
externalNameServiceName := fmt.Sprintf("%s-%s-ext", originalService.Name, originalService.Namespace)
externalNameServiceName = r.sanitizeName(externalNameServiceName)
// 이 서비스가 Annotaion을 통한 관리 대상인지 확인 True or False
isManaged := r.isManagedAnnotaion(&originalService)
// Annotaion을 추가했다가 나중에 제거하게 되면 Operator의 관리대상에서 제외되나 Finalizer는 그대로 남게 되어 영원히 Terminating상태에 빠질 수 있음
// 따라서 Annotation이 없는 LB의 경우 Finalizer가 있는지 확인하고, 있다면 ExternalName을 제거 후 Finalizer를 제거해줍니다.
hasFinalizer := controllerutil.ContainsFinalizer(&originalService, lbLinkerFinalizer)
if !isManaged {
// 관리 대상이 아닌경우
log.Info("Service is not managed by this operator")
// 이전에 관리 대상이어서 Finalizer가 남아있는 경우
if hasFinalizer {
log.Info("Annotaion is gone, but finalizer exists. Running Cleanup to remove finalizer")
// Finalizer만 제거
controllerutil.RemoveFinalizer(&originalService, lbLinkerFinalizer)
if err := r.Update(ctx, &originalService); err != nil {
log.Error(err, "Failed to remove finalizer during hand-off")
return ctrl.Result{}, err
}
log.Info("Finalizer removed.")
}
return ctrl.Result{}, nil
}
// 3. Service가 삭제중인지 확인 (DeletionTimestamp가 0이 아닌경우 삭제중인 것으로 판단)
if !originalService.ObjectMeta.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&originalService, lbLinkerFinalizer) {
log.Info("LoadBalancer Service is being deleted, starting cleanup logic.")
// ExternalName 리소스 삭제 작업
if err := r.cleanupExternalNameService(ctx, &originalService, log); err != nil {
return ctrl.Result{}, err
}
// ExternalName 제거 작업이 성공하면 LoadBalancer에서 finalizer 제거
log.Info("Cleanup finished. Removing finalizer")
controllerutil.RemoveFinalizer(&originalService, lbLinkerFinalizer)
if err := r.Update(ctx, &originalService); err != nil {
log.Error(err, "Failed to remove finalizer")
return ctrl.Result{}, err
}
log.Info("Cleanup finished, removing finalizer.")
}
return ctrl.Result{}, nil
}
// 3a. Service가 삭제중이 아닐 때: Finalizer가 없다면 추가
if !controllerutil.ContainsFinalizer(&originalService, lbLinkerFinalizer) {
log.Info("Adding Finalizer for the Loadbalacner Service.")
controllerutil.AddFinalizer(&originalService, lbLinkerFinalizer)
if err := r.Update(ctx, &originalService); err != nil {
log.Error(err, "Failed to add finalizer")
return ctrl.Result{}, err
}
}
// 4. LoadBalancer의 외부 IP 또는 Hostname 정보 확인
if len(originalService.Status.LoadBalancer.Ingress) == 0 {
log.Info("LoadBalancer Ingress not yet available for Original Service.")
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil // 잠시 후 다시 시도
}
var externalTarget string
ingress := originalService.Status.LoadBalancer.Ingress[0]
if ingress.Hostname != "" {
externalTarget = ingress.Hostname
} else if ingress.IP != "" {
externalTarget = ingress.IP
} else {
log.Info("LoadBalancer Ingress found, but no Hostname or IP available yet for Original Service.")
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
}
log.Info("Found external target for Original Service", "target", externalTarget)
// 5. 기존 ExternalName Service 가져오기 시도
foundExternalNameSvc := &corev1.Service{}
externalNameSvcKey := types.NamespacedName{Name: externalNameServiceName, Namespace: externalTargetsNamespace}
err := r.Get(ctx, externalNameSvcKey, foundExternalNameSvc)
if err != nil {
if apierrors.IsNotFound(err) {
// 5a. ExternalName Service가 없음: 새로 생성
log.Info("ExternalName Service not found. Creating a new one.", "targetNamespace", externalTargetsNamespace, "targetName", externalNameServiceName)
newExternalNameSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: externalNameServiceName,
Namespace: externalTargetsNamespace,
Labels: map[string]string{
"app.kubernetes.io/managed-by": "lb-linker-operator",
"original-service-name": originalService.Name,
"original-service-namespace": originalService.Namespace,
},
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeExternalName,
ExternalName: externalTarget,
},
}
if errCreate := r.Create(ctx, newExternalNameSvc); errCreate != nil {
log.Error(errCreate, "Failed to create new ExternalName Service")
return ctrl.Result{}, errCreate
}
log.Info("Successfully created new ExternalName Service", "externalName", externalTarget)
return ctrl.Result{}, nil
}
// 기타 Get 오류
log.Error(err, "Failed to get ExternalName Service")
return ctrl.Result{}, err
}
// 5b. ExternalName Service가 이미 존재함: spec.externalName 필드 업데이트 확인
if foundExternalNameSvc.Spec.ExternalName != externalTarget {
log.Info("Existing ExternalName Service needs update.", "oldTarget", foundExternalNameSvc.Spec.ExternalName, "newTarget", externalTarget)
// 변경 전 객체의 DeepCopy 생성
beforeUpdateExternalNameSvc := foundExternalNameSvc.DeepCopy()
// 실제 객체 필드 변경
foundExternalNameSvc.Spec.ExternalName = externalTarget
// r.Patch를 사용하여 변경된 부분만 업데이트 진행
if errPatch := r.Patch(ctx, foundExternalNameSvc, client.MergeFrom(beforeUpdateExternalNameSvc)); errPatch != nil {
log.Error(errPatch, "Failed to patch existing ExternalName Service")
return ctrl.Result{}, errPatch
}
log.Info("Successfully patched ExternalName Service's externalName field.", "from", beforeUpdateExternalNameSvc.Spec.ExternalName, "to", foundExternalNameSvc.Spec.ExternalName)
} else {
log.Info("ExternalName Service is already up-to-date.")
}
return ctrl.Result{}, nil
}
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Service{}).
Complete(r)
}
배포후 테스트 진행
apiVersion: v1
kind: Service
metadata:
name: my-sample-lb-4
# annotations:
# linker.hsj.com/managed: "true"
spec:
type: LoadBalancer
selector:
app: MyApp
ports:
- protocol: TCP
port: 8080
targetPort: 9090
위와 같이 annotaion 부분을 주석처리하고 배포를 했을 때 External Name이 함께 생성되지 않아야 한다.
[root@hsj-test12 k8s]# k get svc -A
NAMESPACE NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
beverage ade-svc NodePort 198.19.129.245 <none> 80:30936/TCP 116d
beverage ade-svc-test NodePort 198.19.131.43 <none> 80:32104/TCP 116d
beverage tea-svc NodePort 198.19.178.2 <none> 80:31588/TCP 116d
default kubernetes ClusterIP 198.19.128.1 <none> 443/TCP 322d
default my-sample-lb-4 LoadBalancer 198.19.206.129 default-my-sample-lb-4-4e671-106345391-cfcb6f18c2ee.kr.lb.naverncp.com 8080:30114/TCP 108s
elk elasticsearch NodePort 198.19.157.150 <none> 9200:31749/TCP 117d
elk kibana LoadBalancer 198.19.146.36 elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com 5601:32367/TCP 117d
elk logstash ClusterIP 198.19.247.118 <none> 5044/TCP,9600/TCP 117d
external-targets kibana-elk-ext ExternalName <none> elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com <none> 8d
kube-system coredns ClusterIP 198.19.128.3 <none> 53/UDP,53/TCP,9153/TCP 322d
kube-system metrics-server ClusterIP 198.19.213.101 <none> 443/TCP 322d
my-sample-lb-4를 배포해도 ExternalName이 생성되지 않는 것이 확인된다.
apiVersion: v1
kind: Service
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: | {"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"name":"my-sample-lb-4","namespace":"default"},"spec":{"ports":[{"port":8080,"protocol":"TCP","targetPort":9090}],"selector":{"app":"MyApp"},"type":"LoadBalancer"}}
linker.hsj.com/managed: "true"
creationTimestamp: "2025-06-17T09:42:58Z"
finalizers:
- service.kubernetes.io/load-balancer-cleanup
위와 같이 kubectl edit 커맨드를 통해 Annotaion을 넣고 재배포 해보고 확인 했을 때 External Name이 생성 되어야 한다.
[root@hsj-test12 k8s]# k get svc -A
NAMESPACE NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
beverage ade-svc NodePort 198.19.129.245 <none> 80:30936/TCP 116d
beverage ade-svc-test NodePort 198.19.131.43 <none> 80:32104/TCP 116d
beverage tea-svc NodePort 198.19.178.2 <none> 80:31588/TCP 116d
default kubernetes ClusterIP 198.19.128.1 <none> 443/TCP 322d
default my-sample-lb-4 LoadBalancer 198.19.206.129 default-my-sample-lb-4-4e671-106345391-cfcb6f18c2ee.kr.lb.naverncp.com 8080:30114/TCP 6m25s
elk elasticsearch NodePort 198.19.157.150 <none> 9200:31749/TCP 117d
elk kibana LoadBalancer 198.19.146.36 elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com 5601:32367/TCP 117d
elk logstash ClusterIP 198.19.247.118 <none> 5044/TCP,9600/TCP 117d
external-targets-system external-targets-controller-manager-metrics-service ClusterIP 198.19.231.206 <none> 8443/TCP 47s
external-targets kibana-elk-ext ExternalName <none> elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com <none> 8d
external-targets my-sample-lb-4-default-ext ExternalName <none> default-my-sample-lb-4-4e671-106345391-cfcb6f18c2ee.kr.lb.naverncp.com <none> 33s
kube-system coredns ClusterIP 198.19.128.3 <none> 53/UDP,53/TCP,9153/TCP 322d
kube-system metrics-server ClusterIP 198.19.213.101 <none> 443/TCP 322d
위와 같이 "my-sample-lb-4-default-ext" ExternalName이 생성된 것을 확인할 수 있다.
아래는 Operator의 코드 로직 순서도이다. 나중에 보면 헷갈릴 수 있기에 미리 만들어 놨다.
추후 트러블 슈팅에 참고하면 좋을듯 하다.
회고
처음으로 golang을 통해 K8s Operator를 개발해 보았다.
Operator에 대해 뭔지도 잘 모를때 해당 기능이 필요할 것 같아 무턱대고 개발해보았는데 생각보다 순조롭게 개발되어 만족스러운 경험이다.
앞으로도 쿠버네티스 운영에 필요한 단순한 기능은 Operator를 통해 개발하면 될 것 같고, 내가 개발한 External Name Target 관리 자동화 Operator의 경우 기능이 단순하기 때문에 CRD가 필요 없었으나 앞으로 더 다양한 옵션 및 상황에 따라 선택적 운용이 필요 하다면 CRD기반의 Operator를 파라미터를 통해 선택적으로 제어 하여 운용하면 될 것 같다.
'Kubernetes' 카테고리의 다른 글
[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #4 (0) | 2025.06.20 |
---|---|
[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #2 (0) | 2025.06.13 |
[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #1 (0) | 2025.06.11 |
Kubernetes환경에서 Filebeat + ELK를 활용한 Log Pipeline 구축 #4 Kibana편 (0) | 2025.03.24 |
Kubernetes환경에서 Filebeat + ELK를 활용한 Log Pipeline 구축 #3 Logstash편 (0) | 2025.03.20 |