본문 바로가기
Kubernetes

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

by beann 2025. 6. 11.

[개발 목적]

개인적으로 다양한 프로젝트 및 테스트를 진행 하면서 Loadbalancer를 재생성 하게되면 도메인이 계속 변경되어 여러 어플리케이션에서 하드코딩된 도메인 주소를 전부 바꿔주기 번거로워 External Name을 사용하였었다.

근데 Loadbalancer도 많아지고 ExternalName도 많아지면서 관리하기 번거로워져 ExternalName의 타겟이 되는 Loadbalancer 주소가 변경되면 이를 ExternalName의 타겟으로 변경해주는 로직을 자동화 하고 싶었다.

(개인 프로젝트용이라 많이 부족하지만 사용하실분은 그대로 사용하심 됩니다. 이왕 개발된거 추가 기능이 필요할 것 같다 하시면 댓글 남겨주시면 참고 하도록 하겠습니다.)

 

* operator-sdk(golang)를 이용하여 개발

 

 

Operator개발 및 빌드 / 배포


1.  신규 Operator 프로젝트 생성

# 프로젝트를 init할 디렉토리 생성
mkdir /root/lb-linker-operator
cd /root/lb-linker-operator

# 현재 디렉토리(/root/lb-linker-operator)에서 실행
# Operator SDK 버전에 따라 Go 플러그인 지정 방식이 약간 다를 수 있습니다.
operator-sdk init \
  --plugins=go/v4 \
  --domain kong-sj.com \
  --repo github.com/kong-sj/lb-linker-operator

 

  • --plugins=go/v4: Go 언어용 오퍼레이터 프로젝트를 생성하며, Kubebuilder v4 레이아웃과 호환되는 플러그인입니다.
  • --domain kong-sj.com: CRD의 API그룹명에 정의되는 부분이기때문에 Operator만 구성하는경우 딱히 의미 없습니다.(예, apiVersion: kong-sj.com/v1)
  • --repo github.com/kong-sj/lb-linker-operator: go.mod의 모듈 이름이 되며, 만약 신규 go 프로젝트에서 해당 코드 참조가 필요하다면 사용하는 경로입니다.(예,import "github.com/kong-sj/lb-linker-operator/controllers")

 

 

 

2.  API 및 컨트롤러 생성 (기존 리소스 감시)

operator-sdk create api \
  --group core \
  --version v1 \
  --kind Service \
  --controller \
  --resource=false
  • --group core --version v1 --kind Service: Service를 감시하는 Operator만 개발할 예정이기에 core.v1.Service를 대상으로 컨트롤러를 생성합니다.
  • --controller: Service의 상태 변화를 감지하는 Controller만 생성합니다.
  • --resource=false: CRD는 개발 안하기에 false로 설정합니다.
  • 컨트롤러 생성이 완료되면 internal디렉토리가 새로 생성됩니다.

 

 

 

3. Reconcile함수에 컨트롤러 로직 구현

internal/controller/service_controller.go 파일의 Reconcile 함수에 필요한 로직을 구현합니다.

아래는 Reconcile함수 및 service_controller.go의 전체 코드입니다.

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"
)

/*
- //+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 서비스를 생성/관리할 네임스페이스 (미리 생성 필요)
)

// 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
}

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
	}

	// 3. 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)

	// 4. 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) // 이름 규칙에 맞게 정제

	// 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)
}

 

 

 

4. 매니페스트 생성

** make 커맨드의 경우 Makefile을 참조하여 실행되기 때문에 반드시 operator-sdk 프로젝트 폴더에서 진행해야 합니다

위 코드에 추가한 마커를 Clusterrole에 적용하기 위해 아래 명령어를 실행합니다.(* 마커에 대한 내용은 코드 상단 주석 참고)

[root@hsj-test12 external-targets]# ls
bin  cmd  config  Dockerfile  go.mod  go.sum  hack  internal  Makefile  PROJECT  README.md  test
[root@hsj-test12 external-targets]# 
[root@hsj-test12 external-targets]# make manifests

 

이후 아래와 같이 config/rbac/role.yaml 파일의 내용이 변경된 것을 확인할 수 있습니다.

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: manager-role
rules:
- apiGroups:
  - ""
  resources:
  - services
  verbs:
  - create
  - delete
  - get
  - list
  - patch
  - update
  - watch
- apiGroups:
  - ""
  resources:
  - services/status
  verbs:
  - get

 

 

 

 

5. Go코드를 컴파일하여 바이너리 생성

make build

 

 

 

6. docker image 빌드를 하기 위해 저장소 추가 및 로그인

export IMG=<레지스트리>/lb-linker-operator:<태그>
# 예) export IMG=sjhong1994/lb-linker-operator:v0.2
make docker-build
docker login

 

 

 

 

7. 빌드된 이미지를 push

make docker-push

 

 

 

 

8. External Name 서비스가 배포될 "external-targets" 네임스페이스 생성

kubectl create ns external-targets

 

 

 

9. 해당 이미지를 통해 배포 진행

make deploy

 

 

 

 

10. 배포된 오퍼레이터 확인

[root@hsj-test12 k8s]# k get pod -n external-targets-system 
NAME                                                   READY   STATUS    RESTARTS   AGE
external-targets-controller-manager-65c95786b4-9jmz4   1/1     Running   0          48s

 

 

 

 

 

 

11. 생성된 External Name 서비스 확인

Before

[root@hsj-test12 ~]# 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             108d
beverage                  ade-svc-test                                          NodePort       198.19.131.43    <none>                                                                 80:32104/TCP             108d
beverage                  tea-svc                                               NodePort       198.19.178.2     <none>                                                                 80:31588/TCP             108d
default                   kubernetes                                            ClusterIP      198.19.128.1     <none>                                                                 443/TCP                  314d
default                   my-sample-lb                                          LoadBalancer   198.19.194.225   default-my-sample-lb-a60c8-106050266-4ec1acc67a01.kr.lb.naverncp.com   80:32130/TCP             5m47s
elk                       elasticsearch                                         NodePort       198.19.157.150   <none>                                                                 9200:31749/TCP           109d
elk                       kibana                                                LoadBalancer   198.19.146.36    elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com             5601:32367/TCP           109d
elk                       logstash                                              ClusterIP      198.19.247.118   <none>                                                                 5044/TCP,9600/TCP        109d
external-targets-system   external-targets-controller-manager-metrics-service   ClusterIP      198.19.222.163   <none>                                                                 8443/TCP                 8m24s
kube-system               coredns                                               ClusterIP      198.19.128.3     <none>                                                                 53/UDP,53/TCP,9153/TCP   314d
kube-system               metrics-server                                        ClusterIP      198.19.213.101   <none>                                                                 443/TCP                  314d

 

 

 

After

[root@hsj-test12 ~]# 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             108d
beverage                  ade-svc-test                                          NodePort       198.19.131.43    <none>                                                                 80:32104/TCP             108d
beverage                  tea-svc                                               NodePort       198.19.178.2     <none>                                                                 80:31588/TCP             108d
default                   kubernetes                                            ClusterIP      198.19.128.1     <none>                                                                 443/TCP                  314d
default                   my-sample-lb                                          LoadBalancer   198.19.194.225   default-my-sample-lb-a60c8-106050266-4ec1acc67a01.kr.lb.naverncp.com   80:32130/TCP             8m34s
elk                       elasticsearch                                         NodePort       198.19.157.150   <none>                                                                 9200:31749/TCP           109d
elk                       kibana                                                LoadBalancer   198.19.146.36    elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com             5601:32367/TCP           109d
elk                       logstash                                              ClusterIP      198.19.247.118   <none>                                                                 5044/TCP,9600/TCP        109d
external-targets-system   external-targets-controller-manager-metrics-service   ClusterIP      198.19.222.163   <none>                                                                 8443/TCP                 11m
external-targets          kibana-elk-ext                                        ExternalName   <none>           elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com             <none>                   2s
external-targets          my-sample-lb-default-ext                              ExternalName   <none>           default-my-sample-lb-a60c8-106050266-4ec1acc67a01.kr.lb.naverncp.com   <none>                   2m23s
kube-system               coredns                                               ClusterIP      198.19.128.3     <none>                                                                 53/UDP,53/TCP,9153/TCP   314d
kube-system               metrics-server                                        ClusterIP      198.19.213.101   <none>                                                                 443/TCP                  314d

Operator는 이미 생성 되어있던 LB를 감지하여 External Name을 생성합니다.

이제 LB를 삭제 하고 재생성 하였을때 External Name은 이를 반영하는지 확인해보겠습니다.

 

ExternalName: my-sample-lb-4-default-ext
Loadbalancer: default-my-sample-lb-4-fb5d0-106068410-da3dbf030d4a.kr.lb.naverncp.com

 

위 LB를 삭제해보겠습니다. 그럼 my-sample-lb-4-default-ext은 이를 반영하는지 확인해보겠습니다.

[root@hsj-test12 k8s]# k delete -f lb-test-4.yaml 
service "my-sample-lb-4" deleted

[root@hsj-test12 k8s]# k get svc
NAME           TYPE           CLUSTER-IP       EXTERNAL-IP                                                            PORT(S)        AGE
kubernetes     ClusterIP      198.19.128.1     <none>                                                                 443/TCP        316d
my-sample-lb   LoadBalancer   198.19.194.225   default-my-sample-lb-a60c8-106050266-4ec1acc67a01.kr.lb.naverncp.com   80:32130/TCP   45h

my-sample-lb-4 LB가 삭제된것이 확인 됩니다. 다시 배포 해보겠습니다.

 

[root@hsj-test12 k8s]# k apply -f lb-test-4.yaml 
service/my-sample-lb-4 created

[root@hsj-test12 k8s]# k get svc
NAME             TYPE           CLUSTER-IP       EXTERNAL-IP                                                              PORT(S)          AGE
kubernetes       ClusterIP      198.19.128.1     <none>                                                                   443/TCP          316d
my-sample-lb     LoadBalancer   198.19.194.225   default-my-sample-lb-a60c8-106050266-4ec1acc67a01.kr.lb.naverncp.com     80:32130/TCP     45h
my-sample-lb-4   LoadBalancer   198.19.184.212   default-my-sample-lb-4-97914-106106618-8566f476a019.kr.lb.naverncp.com   8080:32678/TCP   56s

my-sample-lb-4의 도메인이 바뀌었는데요 ExternalName의 Target도 바뀌었는지 확인 해보겠습니다.

 

[root@hsj-test12 k8s]# k get svc -n external-targets
NAME                         TYPE           CLUSTER-IP   EXTERNAL-IP                                                              PORT(S)   AGE
kibana-elk-ext               ExternalName   <none>       elk-kibana-cbbc9-103153513-956a2f270610.kr.lb.naverncp.com               <none>    45h
my-sample-lb-4-default-ext   ExternalName   <none>       default-my-sample-lb-4-97914-106106618-8566f476a019.kr.lb.naverncp.com   <none>    28h
my-sample-lb-default-ext     ExternalName   <none>       default-my-sample-lb-a60c8-106050266-4ec1acc67a01.kr.lb.naverncp.com     <none>    45h

my-sample-lb-4의 도메인과 동일하게 변경된 것 확인 됩니다.

 

 

 

 

 

개선이 필요할 수도 있는 점


1. 이미 생성되어 있는 External Name의 Target은 관리하지 못한다.

Loadbalacner의 이름 규칙 기반으로 External Name과 LB가 Link되어있기 때문이다.

기존 수동으로 생성한 External Name이 LB의 이름 규칙을 준수하여 생성 되었다면 Operator는 이를 인식 하지만, 임의로 생성한 이름이라면 인식하지 못해 Target이 변경되어도 이를 인식하지 못한다.

  • 이름규칙 기반이 아니라 Annotation패턴을 활용하면 가능할 것 같다.
  • 다만, ExternalName에 대한 관리가 주 목적이니 LB와 이름이 다른 경우 헷갈릴 수 있을 것 같다.

 

 

2. Target이 삭제되어도 External Name은 이를 반영하지 못한다. 다만, 삭제 후 재생성하면 Update 한다.

현재 코드 로직은 LB가 삭제 되어도 이를 로그만 남길뿐 별도의 로직을 수행하지 않도록 짜여있다.

그래서 LB가 삭제되면 External Name은 Target이 있는 것으로 보이긴 하지만 실제로는 처리를 하지 못하는 리소스로 Orphan상태로 된다. 다만 해당 LB가 재생성 되면서 도메인이 변경되면 해당 도메인으로 Target을 변경한다.

    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
    }
  • 추후 개발 예정

 

 

3. 다른 네임스페이스의 External Name은 관리 불가

이부분은 부족하다고 보기 어려울 수 있겠지만, 아래와 같이 네임스페이스가 하드코딩 되어있기 때문에 다른 네임스페이스에 있는 External Name은 관리대상에서 제외된다.(네임스페이스를 중앙화 하여 External Name을 관리하기 위함 - 어차피 External Name은 Namespace에 종속적이지 않기 때문)

const (
	externalTargetsNamespace = "external-targets" // ExternalName 서비스를 생성/관리할 네임스페이스 (미리 생성 필요)
)
  • 애초 개발할때 목적이 ExternalName의 중앙집중화였기 때문에 어차피 네임스페이스에 종속적이지 않은 서비스이니 하나의 네임스페이스에서 관리하는게 나을듯 함

 

4. 모든 LB를 대상으로 ExternalName을 만들어버린다.

Annotaion를 활용해 명시적으로 기록한 Loadbalancer만 관리되도록 Opt-in 방식을 사용하면 될것같다.

  • 추후 개발 예정

 

 

 

다음으로 개발할 내용


1. LoadBalancer가 삭제될 때, 현재 사용 중인 이름 규칙 기반으로 연결된 ExternalName 서비스를 찾아 spec.externalName 필드의 값만 비워주는 로직을 추가

  • 파이널라이저 패턴을 통해 삭제 관리

 

2. Annotaion을 활용해 Loadbalacner의 관리를 선택적으로 운용하는 로직 추가

  •   annotations:
        linker.hsj.com/managed: "true"
  • 예를들면 위와 같은 annotaion이 추가되도록 Reconcile에 로직 추가

 

github: https://github.com/kong-sj/external-targets.git