[개발 목적]
개인적으로 다양한 프로젝트 및 테스트를 진행 하면서 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에 로직 추가
'Kubernetes' 카테고리의 다른 글
[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #3 (2) | 2025.06.17 |
---|---|
[Golang] External Name의 Target 자동 업데이트 Operator 개발기 #2 (0) | 2025.06.13 |
Kubernetes환경에서 Filebeat + ELK를 활용한 Log Pipeline 구축 #4 Kibana편 (0) | 2025.03.24 |
Kubernetes환경에서 Filebeat + ELK를 활용한 Log Pipeline 구축 #3 Logstash편 (0) | 2025.03.20 |
Kubernetes환경에서 Filebeat + ELK를 활용한 Log Pipeline 구축 #2 Filebeat편 (0) | 2025.03.19 |