경고 관리 환경 구축: Fabric8로 자바 애플리케이션에서 쿠버네티스 커스텀 리소스 제어하기

lango·2025년 1월 31일
2

데브옵스(DevOps)

목록 보기
3/3
post-thumbnail

들어가며

이전 에서는 경고 관리 환경 구축을 위해 Prometheus와 AlertManager를 활용하여 인프라 레벨에서 사전에 지정한 경고 정책대로 원하는 대상지로 경고를 라우팅하는 과정을 알아보았는데요. 인프라 환경 설정을 완료했으니 이어서 사전에 정의한 경고 정책 커스텀 리소스를 서버 애플리케이션에서 제어하기 위해서 Fabric8 Kubernetes client이라는 라이브러리를 다루는 과정을 소개하려 합니다.

Fabric8이란?

Fabric8Java 언어로 개발한 애플리케이션에서 Kubernetes를 연계할 수 있도록 지원해주는 클라우드 네이티브 오픈소스에요. 백엔드 애플리케이션의 언어는 Java로, 프레임워크는 Spring Boot로 개발하고 있어서 많은 개발자분들이 사용하고 계시는 Fabric8을 사용했어요.

왜 Fabric8을 선택했나?

쿠버네티스 공식에서 제공하는 Kubernetes Java Client도 있지만, Fabric8을 사용하기로 결정했는데요. 추후 오퍼레이터 패턴을 통해 커스텀 리소스(Custom Resource)를 유연하게 다루어야 하는 비즈니스 요구사항이 나올 수도 있다고 판단하여, Java Operator SDK로의 마이그레이션이 용이한 Fabric8을 택했습니다.

Java Operator SDK는 Fabric8을 기반으로 개발된 SDK이기 때문에 Fabric8의 사용 및 학습 경험이 추후 Java Operator SDK를 활용한 오퍼레이터를 개발하는데 도움을 줄 수 있을 것이라 판단했어요.


애플리케이션에서 경고 정책 커스텀 리소스 제어하기

이번 이번 시리즈의 주제는 경고 관리 환경 구축이니 Fabric8을 통해 이전에 정의해둔 경고 정책(PrometheusRule)과 알림 설정(AlertManagerConfig) 커스텀 리소스를 사용자 요청을 통해 서버 애플리케이션 런타임 환경에서 관리하는 과정을 자세히 살펴보겠습니다.

이전에 예제로 사용했던 Jenkins의 경고 정책을 다시 리마인드해볼까요? 젠킨스 경고정책으로 정의한 PrometheusRule 커스텀 리소스를 보면, 젠킨스의 노드 중 1대 이상이 오프라인 상태일 경우 경고를 발생시키기로 설정했었어요. 그리고 발생된 경고를 슬랙 특정 채널로 전송하도록 AlertmanagerConfig 커스텀 리소르를 정의했었죠.

Jenkins 경고 정책: 젠킨스에서 사용 중인 노드 중 1대 이상이 오프라인 상태가 될 경우 경고를 발생시키고 지정한 슬랙 워크스페이스 2_웹훅 채널에 경고 메시지를 전송한다.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: jenkins-alerts
  namespace: monitoring
  labels:
    release: prometheus
spec:
  groups:
    - name: jenkins-alerts
      rules:
        - alert: Jenkins Node Offline
          expr: jenkins_node_offline_value >= 1
          for: 0m
          labels:
            severity: critical
          annotations:
            summary: Jenkins is Down - offline (instance {{ $labels.instance }})
            description: "Jenkins 노드 {{ $labels.instance }}가 오프라인 상태입니다. 현재 오프라인 노드 수: {{ $value }}대"
---
apiVersion: monitoring.coreos.com/v1alpha1
kind: AlertmanagerConfig
metadata:
  name: alert-config
  namespace: monitoring
  labels:
    release: prometheus
spec:
  # 라우팅 규칙 설정
  route:
    groupBy: ['alertname', 'namespace', 'severity']
    groupWait: 30s
    groupInterval: 1m
    repeatInterval: 1m
    receiver: alert-notifications
    routes:
    - receiver: alert-notifications
      matchers:
      - name: severity
        value: critical
    - receiver: alert-notifications
      matchers:
      - name: severity
        value: warning

  # 대상지(수신자) 설정
  receivers:
  - name: alert-notifications
    slackConfigs:
    - apiURL:
        name: slack-webhook
        key: url
      channel: '#2_웹훅'
      sendResolved: true
      title: |-
        [{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}
      text: |-
        *Alert Details:*
        • Severity: {{ .CommonLabels.severity }}
        • Description: {{ .CommonAnnotations.description }}
        {{- if .CommonLabels.instance }}
        • Instance: {{ .CommonLabels.instance }}
        {{- end }}
        {{- if .CommonLabels.namespace }}
        • Namespace: {{ .CommonLabels.namespace }}
        {{- end }}

Fabric8 의존성 추가하기

이제 Jenkins가 서버 애플리케이션에서 Fabric8을 사용하기 위해 의존성을 추가합니다.

implementation group:'io.fabric8', name: 'kubernetes-client', version:'6.8.1'

KubernetesClient 빈 등록하기

기본적으로 KubernetesClient 빈은 Fabric8의 의존성 등록만으로 자동 구성이 되지만, 아래 우선순위에 따라 환경별로 구분해야할 수도 있어요.

  • 시스템 속성
  • 환경 변수
  • Kube 구성 파일
  • 서비스 계정 토큰 및 마운트된 CA 인증서

일반적으로 k8s config의 기본 구성파일인 ~/.kube/config을 참조할 텐데, 클러스터 내부에서 실행되어 동작할 애플리케이션에서는 자동 구성에 의해 만들어지는 KubernetesClient 빈을 그대로 사용해도 무방해요. 다만, 로컬환경 등 클러스터 외부에서 실행되는 애플리케이션의 경우는 KubernetesClient 빈 속성에 원격 클러스터 정보를 알려주어야 합니다.

@Bean  
public KubernetesClient kubernetesClient() {  
    Config config = new ConfigBuilder()  
          .withMasterUrl("https://[master-url].com")  
          .withTrustCerts(true)  
          .withUsername("developer")  
          .withPassword("developer")  
          .withNamespace("default")  
          .build();
    return new KubernetesClientBuilder().withConfig(config).build();  
}

커스텀 리소스를 관리할 비즈니스 로직 개발하기

이제 클러스터의 monitoring 네임스페이스에 선언해둔 경고 정책 PrometheusRule CR과 알림 설정 AlertmanagerConfig CR을 서버 애플리케이션에서 직접 변경하는 비즈니스 로직을 개발해보죠!

커스텀 리소스 Typed API 만들기

Cheat Sheet를 살펴보면, Fabric8을 통해 K8s api를 사용할 때, 커스텀 리소스 API를 어떻게 제어해야 하는지 친절하게 알려줍니다. Resource Typed API vs. Resource Typeless API 목차에서는 Typed API와 Typeless API에 대한 두 가지 방식을 제공합니다.

Typed API란?

타입 안전성(Strong Type)을 보장해주고 일반적인 fabric8 API와 동일한 형태로 사용 가능합니다.

// CustomResource 클래스 정의 필요 
PrometheusRule rule = client.resources(PrometheusRule.class) .inNamespace(namespace) .withName(name) .get(); 

// 타입 안전한 접근
rule.getSpec().getGroups().get(0).getRules().get(0).setExpr("new_expression");****

Typeless API란?

커스텀 리소스에 대한 결과를 Map으로 관리하며 별도의 클래스 정의없이 관리할 수 있습니다.

GenericKubernetesResource rule = client
    .genericKubernetesResources("monitoring.coreos.com/v1", "PrometheusRule")
    .inNamespace(namespace)
    .withName(name)
    .get();

// Map 기반 접근
Map<String, Object> spec = (Map<String, Object>) rule.get("spec");
List<Map<String, Object>> groups = (List<Map<String, Object>>) spec.get("groups");

커스텀 리소스 매핑할 타입을 굳이 정의하지 않고도 GenericKubernetesResource 타입을 통해 매핑할 수 있는 Typeless API 방식이 좋아보이기도 하지만, 향후 개발 유지보수나 생산성, 코드 가독성 등을 고려했을 때 Typeless API보다는 Typed API가 나을 것 같아 Typed API로 구현하기로 결정했어요. 그렇다면, 커스텀 리소스 매핑 클래스를 직접 정의해봅시다.

Resource Typed API목차를 보면 CronTab이라는 CR에 대한 예제를 볼 수 있어요. 이를 참고하면 쉽게 개발할 수 있답니다.

@Version("v1")
@Group("stable.example.com")
public class CronTab extends CustomResource<CronTabSpec, CronTabStatus> implements Namespaced { }

API 버전과 그룹을 명시해주고 CustomResource를 상속받고 Namespaced 인터페이스의 구현체로 만들어줍니다.

이제 Typed API로 클래스화를 해야 하는데 클래스의 필드가 무엇인지 모르겠네요. 이 때, kubectl get prometheusrule jenkins-alerts -n monitoring -o json 명령을 호출해보면 json 응답을 받을 수 있는데, 1 Depth의 필드를 통해 구조화를 할 수 있어요.

// PrometheusRule
{
    "apiVersion": "monitoring.coreos.com/v1",
    "kind": "PrometheusRule",
    "metadata": {
        ...
    },
    "spec": {
        "groups": [
            {
                "name": "jenkins-alerts",
                "rules": [
                    {
                        "alert": "Jenkins Node Offline",
                        "annotations": {
                            "description": "Jenkins 노드 {{ $labels.instance }}가 오프라인 상태입니다. 현재 오프라인 노드 수: {{ $value }}대",
                            "summary": "Jenkins is Down - offline (instance {{ $labels.instance }})"
                        },
                        "expr": "jenkins_node_offline_value \u003e= 3",
                        "for": "0m",
                        "labels": {
                            "severity": "critical"
                        }
                    }
                ]
            }
        ]
    }
}

// AlertmanagerConfig
{
    "apiVersion": "monitoring.coreos.com/v1alpha1",
    "kind": "AlertmanagerConfig",
    "metadata": {
        ...
    },
    "spec": {
        "receivers": [
            {
                "name": "alert-notifications",
                "slackConfigs": [
                    {
                        "apiURL": {
                            "key": "url",
                            "name": "slack-webhook"
                        },
                        "channel": "2_new_웹훅",
                        "sendResolved": true,
                        "text": "*Alert Details:*\n• Severity: {{ .CommonLabels.severity }}\n• Description: {{ .CommonAnnotations.description }}\n{{- if .CommonLabels.instance }}\n• Instance: {{ .CommonLabels.instance }}\n{{- end }}\n{{- if .CommonLabels.namespace }}\n• Namespace: {{ .CommonLabels.namespace }}\n{{- end }}",
                        "title": "[{{ .Status | toUpper }}{{ if eq .Status \"firing\" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}"
                    }
                ]
            }
        ],
        "route": {
            "groupBy": [
                "alertname",
                "namespace",
                "severity"
            ],
            "groupInterval": "1m",
            "groupWait": "30s",
            "receiver": "alert-notifications",
            "repeatInterval": "1m",
            "routes": [
                {
                    "matchers": [
                        {
                            "name": "severity",
                            "value": "critical"
                        }
                    ],
                    "receiver": "alert-notifications"
                },
                {
                    "matchers": [
                        {
                            "name": "severity",
                            "value": "warning"
                        }
                    ],
                    "receiver": "alert-notifications"
                }
            ]
        }
    }
}

PrometheusRule 및 AlertmanagerConfig 커스텀 리소스의 apiVersion과 kind, metadata 필드에 대한 부분은 모든 리소스가 동일한 필드를 가지는 것 같아서 여기서는 별도로 매핑하지 않고 spec 필드만 클래스화 해볼게요.

먼저 PrometheusRule CR 매핑에 필요한 클래스들을 리스트업해봤어요.

  • PrometheusRule.class
  • PrometheusRuleSpec.class
  • RuleGroup.class
  • Rule.class
@Data
@Group("monitoring.coreos.com")  
@Version("v1")
public class PrometheusRule extends CustomResource<PrometheusRuleSpec, Void> implements Namespaced {}

@Data  
public class PrometheusRuleSpec {  
    private List<RuleGroup> groups;  
}

@Data  
public class RuleGroup {  
    @JsonProperty("name")  
    private String name;  
    @JsonProperty("rules")  
    private List<Rule> rules;  
}

@Data  
public class Rule {  
    @JsonProperty("alert")  
    private String alert;  
    @JsonProperty("expr")  
    private String expr;  
    @JsonProperty("for")  
    private String duration;  
    @JsonProperty("labels")  
    private Map<String, String> labels;  
    @JsonProperty("annotations")  
    private Map<String, String> annotations;  
}

다음으로 AlertmanagerConfig CR 매핑에 필요한 클래스들을 리스트업해봤어요.

  • AlertmanagerConfig.class
  • AlertmanagerConfigSpec.class
  • Route.class
  • Receiver.class
  • Matcher.class
  • SlackConfig.class
  • SecretKeySelector.class
@Data  
@Group("monitoring.coreos.com")  
@Version("v1alpha1")  
public class AlertmanagerConfig extends CustomResource<AlertmanagerConfigSpec, Void> implements Namespaced {}

@Data  
public class AlertmanagerConfigSpec {  
    private Route route;  
    private List<Receiver> receivers;  
}

@Data  
public class Route {  
    private List<String> groupBy;  
    private String groupWait;  
    private String groupInterval;  
    private String repeatInterval;  
    private String receiver;  
    private List<Route> routes;  
    private List<Matcher> matchers;  
}

@Data  
public class Matcher {  
    private String name;  
    private String value;  
}

@Data  
public class Receiver {  
    private String name;  
    private List<SlackConfig> slackConfigs;  
}

@Data  
public class SlackConfig {  
    private SecretKeySelector apiURL;  
    private String channel;  
    private Boolean sendResolved;  
    private String title;  
    private String text;  
}

@Data  
public class SecretKeySelector {  
    private String name;  
    private String key;  
}

커스텀 리소스를 조회하고 변경할 비즈니스 계층 클래스 개발하기

PrometheusRule과 AlertmanagerConfig CR에 매핑될 클래스를 개발했으니 이제 직접 조회하고 변경하는 비즈니스 로직을 개발해볼게요.

// PrometheusRule 조회
public PrometheusRule getPrometheusRule(String name) {  
    return client.resources(PrometheusRule.class)  
       .inNamespace(NAME_SPACE)  
       .withName(name)  
       .get();  
}

// AlertmanagerConfig 조회
public AlertmanagerConfig getAlertmanagerConfig(String name) {  
    return client.resources(AlertmanagerConfig.class)  
       .inNamespace(NAME_SPACE)  
       .withName(name)  
       .get();
}

client.resources DSL을 통홰 커스텀 리소스를 편하게 조회할 수 있습니다. 그리고 해당 커스텀 리소스들을 사용자 요청에 맞게 변경하는 patch 메서드도 작성해볼게요.

// PrometheusRule의 expr 변경
public void updatePrometheusRuleExpression(String name, String experssion) {  
    PrometheusRule prometheusRule = getPrometheusRule(name);  
    prometheusRule.getSpec().getGroups().get(0).getRules().get(0).setExpr(experssion);  
    client.resources(PrometheusRule.class)  
          .inNamespace(NAME_SPACE)  
          .withName(name)  
          .patch(prometheusRule);  
}

// AlertmanagerConfig의 slackConfig 내 채널 변경
public void updateAlertmanagerConfigSlackChannel(String name, String slackChannel) {  
    AlertmanagerConfig alertmanagerConfig = getAlertmanagerConfig(name);  
    alertmanagerConfig.getSpec().getReceivers().get(0).getSlackConfigs().get(0).setChannel(slackChannel);  
    client.resources(AlertmanagerConfig.class)  
          .inNamespace(NAME_SPACE)  
          .withName(name)  
          .patch(alertmanagerConfig);  
}

PrometheusRule CR의 경우는 변경하고 싶은 경고 조건(experssion)을 파라미터로 받아 수정하도록 했으며, AlertmanagerConfig는 대상지 슬랙 채널을 파라미터로 받아 수정하도록 작성했어요. 위 코드대로라면 젠킨스 경고 정책 및 알림 설정은 아래와 같이 변경되어야 합니다.

Before: 젠킨스에서 사용 중인 노드 중 1대 이상이 오프라인 상태가 될 경우 경고를 발생시키고 지정한 슬랙 워크스페이스 2웹훅_ 채널에 경고 메시지를 전송한다.

After: 젠킨스에서 사용 중인 노드 중 2대 이상이 오프라인 상태가 될 경우 경고를 발생시키고 지정한 슬랙 워크스페이스 2_new_웹훅 채널에 경고 메시지를 전송한다.

통합 테스트 코드를 통해 검증하기

이제 작성한 비즈니스 로직이 실제로 지정한 클러스터의 커스텀 리소스 PrometheusRule 및 AlertmanagerConfig를 정상적으로 조회하고 변경하는지 확인해야겠죠? 통합 테스트 코드를 작성해볼게요. 테스트 케이스는 간단하게 PrometheusRule 조회 및 수정, AlertmanagerConfig 조회 및 수정까지 4가지 케이스를 개발했습니다.

@SpringBootTest  
class CustomResourceManagerTest {  
  
    @Autowired  
    private CustomResourceManager customResourceManager;  
  
    // 커스텀 리소스명  
    private static final String PROMETHEUS_RULE_JENKINS = "jenkins-alerts";  
    private static final String ALERTMANAGER_CONFIG_NAME = "alert-config";  
  
    // 기존 데이터 값  
    private static final String ORIGINAL_EXPRESSION = "jenkins_node_offline_value >= 1";  
    private static final String ORIGINAL_SLACK_CHANNEL = "2_웹훅";  
  
    @AfterEach  
    void tearDown() {  
       // 각 테스트 종료 후 원래 값으로 복원  
       customResourceManager.updatePrometheusRuleExpression(PROMETHEUS_RULE_JENKINS, ORIGINAL_EXPRESSION);  
       customResourceManager.updateAlertmanagerConfigSlackChannel(ALERTMANAGER_CONFIG_NAME, ORIGINAL_SLACK_CHANNEL);  
    }  
  
    @Test  
    @DisplayName("[Success] 커스텀 리소스로 등록한 jenkins-alerts PrometheusRule 내 경고 조건을 정상적으로 조회한다.")  
    void getPrometheusRule() {  
       // when  
       PrometheusRule prometheusRule = customResourceManager.getPrometheusRule(PROMETHEUS_RULE_JENKINS);  
       // then  
       Rule result = prometheusRule.getSpec().getGroups().get(0).getRules().get(0);  
       assertAll(  
             () -> assertThat(prometheusRule).isNotNull(),  
             () -> assertThat(prometheusRule.getMetadata().getName()).isEqualTo(PROMETHEUS_RULE_JENKINS),  
             () -> assertThat(result.getAlert()).isEqualTo("Jenkins Node Offline"),  
             () -> assertThat(result.getExpr()).isEqualTo("jenkins_node_offline_value >= 1")  
       );  
    }  
  
    @Test  
    @DisplayName("[Success] PrometheusRule 커스텀 리소스의 경고 조건(expression) 필드를 수정할 수 있다.")  
    void updatePrometheusRule() {  
       // given  
       String newExpression = "jenkins_node_offline_value >= 2";  
       // when  
       customResourceManager.updatePrometheusRuleExpression(PROMETHEUS_RULE_JENKINS, newExpression);  
       // then  
       PrometheusRule updatedJenkinsAlertRules = customResourceManager.getPrometheusRule(PROMETHEUS_RULE_JENKINS);  
       String result = updatedJenkinsAlertRules.getSpec().getGroups().get(0).getRules().get(0).getExpr();  
       assertAll(  
             () -> assertThat(result).isEqualTo("jenkins_node_offline_value >= 2")  
       );  
    }  
  
    @Test  
    @DisplayName("[Success] 커스텀 리소스로 등록한 alert-config AlertmanagerConfig 알림 설정 정보를 정상적으로 조회한다.")  
    void getAlertmanagerConfig() {  
       // when  
       AlertmanagerConfig config = customResourceManager.getAlertmanagerConfig(ALERTMANAGER_CONFIG_NAME);  
       // then  
       SlackConfig slackConfig = config.getSpec().getReceivers().get(0).getSlackConfigs().get(0);  
       assertAll(  
             () -> assertThat(config).isNotNull(),  
             () -> assertThat(config.getMetadata().getName()).isEqualTo(ALERTMANAGER_CONFIG_NAME),  
             () -> assertThat(config.getSpec().getReceivers().get(0).getName()).isEqualTo("alert-notifications"),  
             () -> assertThat(slackConfig.getChannel()).startsWith("2_웹훅")  
       );  
    }  
  
    @Test  
    @DisplayName("[Success] AlertmanagerConfig 커스텀 리소스의 경고 조건(expression) 필드를 수정할 수 있다.")  
    void updateAlertmanagerConfigSlackChannel() {  
       // given  
       String newSlackChannel = "2_new_웹훅";  
       // when  
       customResourceManager.updateAlertmanagerConfigSlackChannel(ALERTMANAGER_CONFIG_NAME, newSlackChannel);  
       // then  
       AlertmanagerConfig updatedAlertmanagerConfig = customResourceManager.getAlertmanagerConfig(ALERTMANAGER_CONFIG_NAME);  
       String result = updatedAlertmanagerConfig.getSpec().getReceivers().get(0).getSlackConfigs().get(0).getChannel();  
       assertAll(  
             () -> assertThat(result).isEqualTo("2_new_웹훅")  
       );  
    }  
  
}

tearDown 메서드를 통해 기존 값으로 롤백하며 테스트 독립성을 지키도록 했어요. 테스트를 수행해보면 정상적으로 4가지 테스트 케이스가 모두 성공하네요!

그리고 통합 테스트가 아닌 프레젠테이션 게층의 Controller 클래스를 두어 실제로 경고 정책 및 알림 설정을 변경하는지 API 테스트를 해보니 정상적으로 PrometheusRule과 AlertmanagerConfig 커스텀 리소스가 변경되는 것도 확인했어요.

이렇게 Prometheus와 AlertManager를 확장하여 Jenkins와 같은 미들웨어를 모니터링하고 정해진 경고 조건(임계값)에 따라 경고를 원하는 대상지까지 발생시키는 것에서부터 사용자 요청별로 경고 조건이나 알림 설정을 런타임 시점에서 자유롭게 변경하는 것까지 고수준의 인프라 레벨부터 저수준의 애플리케이션 레벨까지 개발 작업을 진행해봤습니다.

PoC 과정이 마무리되었으니 실제 개발환경에 맞추어 업무를 진행해야 하는데요. 해야할 작업은 더 많아질 것 같아요. Fabric8을 통해 애플리케이션 레벨에서 k8s api를 활용하기 위해 CRD와 매핑되는 클래스를 정의하여 Typed API 구조를 향후 오퍼레이터 패턴으로 전환시 비용을 최대한 줄일 수 있도록 고민해야 하고, 애플리케이션 레벨에서 아키텍처 설계 단계에서 정한 권한(RBAC)별로 클러스터 접근제어할 수 있는 방법을 강구해야 합니다. 또한 CR 변경 시 애플리케이션에서 실시간 반응을 위한 Watcher 및 이벤트 처리에 대한 것도 많은 시간을 들어야 할 것 같아요.


마치며

시리즈 글을 오랜만에 작성하는데, 의도했던 내용을 두 글로 표현한다는 것이 참 쉽지 않네요.

⏳ 이번 글은 2일동안 6시간을 투자하여 작성했습니다.


참고자료

profile
찍어 먹기보단 부어 먹기를 좋아하는 개발자

1개의 댓글

comment-user-thumbnail
2025년 2월 9일

재미있는 글 잘 읽었습니다!

답글 달기