본문 바로가기
모티터링도구

opentelemetry logback appender

by 아이티.파머 2025. 9. 3.
728x90

opentelemetry logback appender

우리는 로그를 확인하기위해 System.out.print 명령어를 이용하여 로그를 확인하였다. 하지만 시간이 흐름에 따라 콘솔기록 뿐만 아니라 파일로 기록을 남기기 위해 common-log 부터 log4j 까지 그리고 지금은 logback, slf4j 을 사용한다.

모놀리틱 환경에서 분산서비스(MSA) 환경에 접어 들면서 로그를 한곳에서 모아보거나 흩어져있는 로그들을 취합하고 추적하기 위한 기술들이 사용되게 되는데 그중에 한가지를 소개한다.

opentelemetry-logback-appender 라는 모듈이다. 아직 정식버전은 아니고 -alpha 버전만 존재한다.

“다운로드 링크” →https://mvnrepository.com/artifact/io.opentelemetry.instrumentation/opentelemetry-logback-appender-1.0

아직 안정화된 버전이 아님으로 공식지원하지 않고 있기때문에 버전업되긴 하지만 alpha version만 존재한다.

해당 기술을 사용하기 위해서는 opentelemetry-collector-contrib 를 사용하여한다.

open telemetry collector contrib이란 ?

간단하게 말해서 중앙집중식관리를 위해 사용한다. 이는 로그 뿐만아니라 tracing, span, matric 등을 중앙에서(중앙집중관리) 관리하고 이를 수집 가공(필터 및 데이터 재가공)하여 원하는 플랫폼으로 다시 내보낼수있다.
이렇게 하면 어플리케이션 코드에서 수정하지 않고도 데이터를 보강하거나, 필터링 하여 다른 플렛폼으로 전송할 수있다. (벤더독립성, 다양한 데이터 형식 지원)

이렇게 가능한 이유는 OTLP(Open Telemetry Protocol) 을 사용하기에 가능하다.

예를들어 개발환경에서는 log 데이터를 Loki로 전송하여 확인 하지만, 운영에서는 Elastic으로 보낼수 있으며 Matric 또한 개발은 prometheus 운영은 datadog으로 보낼수있다.

오픈텔레메트리 logback-appener 에대해 살펴보다가 오픈텔레메트리 Collector에 대해 살펴보았다. 다시 본론으로 돌아가서 logback-appender에대해 알아보겠습니다.

위에 언급한바와 같이 어플리케이션에서 생성되는 log를 otlp-collector-contrib 으로 보내기 위한 라이브러리 입니다

 

(1) application 에서 발생된 로그를 중앙 관리 하는 otel-collector4317 (grpc) 로 로그 데이터를 전송합니다. 로그 데이터 전송시 Otlp(Open Telemetry Protocol)을 사용합니다.

(2) 중앙관리 하는 otel-collector-**contrib** 서버에서 다시 Loki Server 의 3100 Port 로 데이터를 전송한다.

  • 이렇게 하는 이유는 위에서 언급한 바와 같이 데이터를 수집 하고 가공하고 처리하는 부분에서 Application 에 의존하지 않고 중앙관리 서버에서 필터링 할 수 있다.
  • otel collector contrib server 기능(https://github.com/open-telemetry/opentelemetry-collector-contrib)
    • 기능수신자(Receivers)
    • 프로세서 (Processor)
    • 내보내기 (Exporter)

Otel configuraion

contrib 확장판으로 Loki 데이터 전송 부분 설정 내역을 확인 해보겠습니다.

[otlphttp/logs], [debug], [file] 세가지 에 대해 logs를 내보내기(exporter) 기능을 수행합니다.

 

(1) 로그가 발생되는 Application 입니다.

(2) collector exporter 에서 file 로 설정하였기 때문에 실제 otel-data.json이라는 파일로 전송로그가 남게 됩니다.

(3) [otelhttp/logs] 로그를 http 프로토콜을 이용하여 http://loki:3100/otlp 로키의 otlp 수신 인드포인트로 전송합니다.

(4) [debug] 로그를 실행중인 서버 혹은 컨테이너의 콘솔로에도 보이도록 합니다.

(5) [file] 로그를 지정한 위치의 디렉토리에 otel josn format (otel-data.json)의 형태로 기록되도록 합니다.

  • file로 남은 기록은 filebit 와 logstash 를 이용하여 다른 3rd party (elastic..splunk) 로 전송 할 수 있습니다.

Loki 로 전송된 로그 확인

(1)콘솔 화면에서 기록된 내용을 (2) otel-data.json에 기록된걸 확인 할 수 있습니다. 또한 Grafana 에서 Loki 연동후 로그 기록을 살펴보면 (3)번과 같이 콘솔에서 보는 로그를 그라파나에서도 확인 가능합니다.

OTLP format Log Sample

otlp 의 json format으로 전송되는 예시

해당 JSON 데이터는 Application에서 발생된 Log 데이터이며, json 형태로 oteltelemetry-collector server 로 전송된다.

{
    "resourceLogs": [
        {
            "resource": {
                "attributes": [
                    {
                        "key": "service.name",
                        "value": {
                            "stringValue": "serving-service"
                        }
                    },
                    {
                        "key": "telemetry.sdk.language",
                        "value": {
                            "stringValue": "java"
                        }
                    },
                    {
                        "key": "telemetry.sdk.name",
                        "value": {
                            "stringValue": "opentelemetry"
                        }
                    },
                    {
                        "key": "telemetry.sdk.version",
                        "value": {
                            "stringValue": "1.49.0"
                        }
                    }
                ]
            },
            "scopeLogs": [
                {
                    "scope": {
                        "name": "com.example.ServingController"
                    },
                    "logRecords": [
                        {
                            "observedTimeUnixNano": "1756865225308683000",
                            "timeUnixNano": "1756865225308000000",
                            "traceId": "b3c75ac26b161ca27bfbdad04349a573",
                            "spanId": "82d6b39bb87533c5",
                            "flags": 1,
                            "severityText": "ERROR",
                            "severityNumber": 17,
                            "body": {
                                "stringValue": "failed to fetch user info from serving-service exception=No instances available for user-service stacktrace=java.lang.IllegalStateException: No instances available for user-service\n\tat org.springframework.cloud.loadbalancer.blocking.client.BlockingLoadBalancerClient.execute(BlockingLoadBalancerClient.java:78)\n\tat org.springframework.cloud.client.loadbalancer.LoadBalancerInterceptor.intercept(LoadBalancerInterceptor.java:55)\n\tat org.springframework.http.client.InterceptingClientHttpRequest$InterceptingRequestExecution.execute(InterceptingClientHttpRequest.java:88)\n\tat com.example.RestTemplateConfig$TracePropagationInterceptor.intercept(RestTemplateConfig.java:61)\n\tat org.springframework.http.client.InterceptingClientHttpRequest$InterceptingRequestExecution.execute(InterceptingClientHttpRequest.java:88)\n\tat org.springframework.http.client.InterceptingClientHttpRequest.executeInternal(InterceptingClientHttpRequest.java:72)\n\tat org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48)\n\tat org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:81)\n\tat org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:900)\n\tat org.springframework.web.client.RestTemplate.execute(RestTemplate.java:801)\n\tat org.springframework.web.client.RestTemplate.getForObject(RestTemplate.java:415)\n\tat com.example.ServingController.serve(ServingController.java:49)\n\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:580)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:258)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:191)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:991)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:896)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:110)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:116)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:398)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:903)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1769)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1189)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:658)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)\n\tat java.base/java.lang.Thread.run(Thread.java:1583)\n"
                            }
                        }
                    ]
                },
                {
                    "scope": {
                        "name": "org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer"
                    },
                    "logRecords": [
                        {
                            "observedTimeUnixNano": "1756865225301357000",
                            "timeUnixNano": "1756865225300000000",
                            "traceId": "b3c75ac26b161ca27bfbdad04349a573",
                            "spanId": "75c6d148a26c1c90",
                            "flags": 1,
                            "severityText": "WARN",
                            "severityNumber": 13,
                            "body": {
                                "stringValue": "No servers available for service: user-service"
                            }
                        }
                    ]
                }
            ]
        }
    ]
}

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%-5level) %clr([${spring.application.name:-}]){yellow} %clr([%15.15t]){faint} %clr([%X{traceId:-},%X{spanId:-}]){magenta} %clr(%-40.40logger{39}){cyan} : %msg%n</pattern>
        </encoder>
    </appender>

    <!-- OpenTelemetry OTLP appender -->
    <appender name="OTLP" class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender">
        <!-- OpenTelemetryAppender.install()에 의해 자동으로 설정됨 -->
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="OTLP"/>
    </root>

</configuration>

OpenTelemetryLoggingConfig 설정

package com.example.servingservice.config;

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

/**
 * Opentelemetry 활성화 
 */
@Component
public class OpenTelemetryLoggingConfig implements InitializingBean {

    private static final Logger logger = LoggerFactory.getLogger(OpenTelemetryLoggingConfig.class);

    private final OpenTelemetry openTelemetry;

    public OpenTelemetryLoggingConfig(OpenTelemetry openTelemetry) {
        this.openTelemetry = openTelemetry;
        logger.info("✅ OpenTelemetryLoggingConfig 생성됨, OpenTelemetry instance: {}", openTelemetry);
    }

    @Override
    public void afterPropertiesSet() {
        try {
            logger.info("🔥 OpenTelemetryAppender.install() 호출 시작");
            OpenTelemetryAppender.install(this.openTelemetry);
            logger.info("✅ OpenTelemetryAppender.install() 성공!");

            // 테스트 로그 전송
            logger.info("📤 이것은 OpenTelemetry로 전송될 테스트 로그입니다!");
            logger.warn("⚠️ 이것은 WARNING 레벨 테스트 로그입니다!");
            logger.error("❌ 이것은 ERROR 레벨 테스트 로그입니다!");

        } catch (Exception e) {
            logger.error("💥 OpenTelemetryAppender.install() 실패: {}", e.getMessage(), e);
        }
    }
}