본문 바로가기
Kubernetes

[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #3

by beann 2025. 6. 17.

[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를 파라미터를 통해 선택적으로 제어 하여 운용하면 될 것 같다.

 

코드 참고: https://github.com/kong-sj/external-targets