Blog / DevOps / Spring Boot 4 SRE 모니터링 스택 구축

Spring Boot 4 SRE 모니터링 스택 구축

이 글은 Vultr 서버에서 운영 중인 Spring Boot 4 애플리케이션에 풀 옵저버빌리티 스택(메트릭, 로그, 트레이스)을 구축한 과정을 기록한 것이다. Grafana LGTM 스택을 기반으로 Grafana(시각화), Prometheus(메트릭), Loki(로그), Tempo(트레이스)를 구성했다.

환경
구성요소 버전
OS AlmaLinux 10.1
서버 사양 Vultr (2 CPU cores, 4GB RAM)
Spring Boot 4.x
Java 21 (Amazon Corretto)
Grafana OSS latest
Loki latest
Tempo 2.6.1
Prometheus latest
아키텍처

앱은 컨테이너가 아닌 호스트에서 직접 실행되기 때문에 모니터링 인프라만 Docker로 구성했다.


1. 서버 환경 구성
Docker 설치 (AlmaLinux 10)
bash
sudo dnf install -y docker
sudo systemctl start docker
sudo systemctl enable docker

# docker 그룹에 유저 추가 (재로그인 필요)
sudo usermod -aG docker $USER
newgrp docker
방화벽 포트 오픈
bash
sudo dnf install -y firewalld
sudo systemctl start firewalld
sudo systemctl enable firewalld

sudo firewall-cmd --permanent --add-port=3000/tcp   # Grafana

sudo firewall-cmd --reload
sudo firewall-cmd --list-ports

Grafana만 열어줬는데, 나중에는 Nginx로 프록시 라우팅 처리해주면서 지금은 저 것도 닫았다(...)
Vultr 콘솔의 Firewall Group에서도 동일한 포트를 열어줘야 외부에서 접근 가능하다. Firewall Group을 서버에 연결하는 것도 필수.


2. Docker Compose 구성

docker-compose.yml

yaml
version: '3.9'

services:
  grafana:
    image: grafana/grafana-oss:latest
    container_name: grafana
    volumes:
      - ./grafana/data:/var/lib/grafana
    ports:
      - "3000:3000"
    expose:
      - 3000
    environment:
      - TZ=Asia/Seoul
      - GF_SERVER_ROOT_URL=http://localhost/grafana
      - GF_SERVER_SERVE_FROM_SUB_PATH=true
      - GF_SERVER_DOMAIN=localhost
      - GF_SECURITY_ALLOW_EMBEDDING=true
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_NAME=Main Org.
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
      - GF_AUTH_ANONYMOUS_HIDE_VERSION=true
      - GF_USERS_VIEWERS_CAN_EDIT=false
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    depends_on:
      - prometheus
      - loki
      - grafana_tempo
    networks:
      - sre-net
    mem_limit: 256m
    cpus: "0.25"
  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    volumes:
      - ./prometheus/config:/etc/prometheus
      - ./prometheus/volume:/prometheus
    ports:
      - "9100:9090"
    expose:
      - 9100
    command:
      - '--web.enable-lifecycle'
      - '--enable-feature=exemplar-storage'
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=7d'
    restart: always
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    environment:
      - TZ=Asia/Seoul
    networks:
      - sre-net
    mem_limit: 256m
    cpus: "0.75"
  loki:
    image: grafana/loki:latest
    container_name: loki
    volumes:
      - ./loki/config:/mnt/config
      - ./loki/volume:/loki
    ports:
      - "3100:3100"
    expose:
      - 3100
    command:
      - '--config.file=/mnt/config/loki-config.yml'
    restart: always
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    environment:
      - TZ=Asia/Seoul
    networks:
      - sre-net
    mem_limit: 512m
    cpus: "0.50"
  grafana_tempo:
    image: grafana/tempo:2.6.1
    container_name: grafana_tempo
    volumes:
      - ./tempo/etc:/etc
    command:
      - '--target=all'
      - '--storage.trace.backend=local'
      - '--storage.trace.local.path=/var/tempo'
      - '--config.file=/etc/tempo.yaml'
      - '--auth.enabled=false'
    ports:
      - "14251:14250"
      - "4317:4317"
      - "4318:4318"
      - "3200:3200"
    expose:
      - 14251
      - 4317
      - 4318
      - 3200
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    environment:
      - TZ=Asia/Seoul
    networks:
      - sre-net
    mem_limit: 384m
    cpus: "0.25"

networks:
  sre-net:
    driver: bridge

서버 사양이 넉넉하지 않아 각 컨테이너에 mem_limit을 지정해 메모리 초과를 방지한다. Spring Boot JVM 힙도 반드시 제한해야 한다.

bash
java -Xms256m -Xmx512m -jar app.jar

3. Prometheus 설정

prometheus.yml:

yaml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'spring-boot'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 5s
    static_configs:
      - targets: ['host.docker.internal:8080']

Docker 컨테이너에서 호스트의 앱을 바라볼 때는 host.docker.internal을 사용한다.


4. Loki 설정

loki-config.yml:

yaml
auth_enabled: false   # 반드시 명시 필요

server:
  http_listen_port: 3100
  grpc_listen_port: 9096

ingester:
  wal:
    enabled: true
    dir: /loki/wal
  lifecycler:
    address: 127.0.0.1
    ring:
      kvstore:
        store: inmemory
      replication_factor: 1
    final_sleep: 0s
  chunk_idle_period: 1h
  max_chunk_age: 1h
  chunk_target_size: 1048576
  chunk_retain_period: 30s
  chunk_encoding: snappy

schema_config:
  configs:
    - from: 2024-08-10
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

storage_config:
  tsdb_shipper:
    active_index_directory: /loki/tsdb-index
    cache_location: /loki/tsdb-cache
  filesystem:
    directory: /loki/chunks

compactor:
  working_directory: /loki/compactor
  delete_request_store: filesystem

limits_config:
  reject_old_samples: true
  reject_old_samples_max_age: 168h
  allow_structured_metadata: true
삽질 포인트

Loki 설정에서 꽤 많은 삽질을 했다. 버전이 그동안 올라가서인지, 회사에서 재작년 구축했을 때와는 또 여러가지가 달라져 있었다.

no org id 오류: Grafana에서 Loki 데이터소스 연결 시 X-Scope-OrgID 헤더 누락으로 발생한다. auth_enabled: false를 명시하면 해결.

schema 버전 오류: 최신 Loki는 schema v13 + tsdb store 조합이 필요하다. 기존에 많이 쓰던 boltdb-shipper + v11 조합은 더 이상 권장되지 않는다.

allow_structured_metadata 오류: loki4j appender가 자동으로 structured metadata를 전송하는데, Loki 설정에서 이를 허용해줘야 한다.


5. Tempo 설정
Tempo v2.10 다운그레이드 이유

처음에는 최신 버전인 v2.10을 사용했는데, empty ring 오류가 계속 발생했다. 알고 보니 v2.10부터 Rhythm이라는 새로운 아키텍처가 도입되면서 Kafka가 필수 의존성이 되었다. 서버 사양 문제로 Kafka를 추가하기 어려워 v2.6.1로 다운그레이드했다. 나중에 Aiven Cloud의 Kafka로 재도전해보기로!

tempo.yaml:

yaml
stream_over_http_enabled: true

server:
  http_listen_port: 3200

memberlist:
  join_members: []

distributor:
  receivers:
    jaeger:
      protocols:
        thrift_http:
          endpoint: 0.0.0.0:14268
        grpc:
          endpoint: 0.0.0.0:14250
    zipkin:
      endpoint: 0.0.0.0:9411
    otlp:
      protocols:
        http:
          endpoint: 0.0.0.0:4318
        grpc:
          endpoint: 0.0.0.0:4317

ingester:
  max_block_duration: 5m
  lifecycler:
    ring:
      kvstore:
        store: memberlist
      replication_factor: 1

compactor:
  compaction:
    block_retention: 24h

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/blocks
    wal:
      path: /tmp/tempo/wal

overrides:
  defaults:
    metrics_generator:
      processors: []

6. Spring Boot 4 설정
build.gradle
groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
    implementation 'io.micrometer:micrometer-tracing-bridge-otel'
    implementation 'io.micrometer:micrometer-registry-prometheus'
    implementation 'com.github.loki4j:loki-logback-appender:1.5.2'

    // JDBC SQL 트레이싱 (선택, 나는 OpenTelemetry Agent를 사용했기 때문에 잠깐 넣었다가 뺐다.)
    implementation 'io.opentelemetry.instrumentation:opentelemetry-jdbc:2.25.0-alpha'
}
application-live.yml
yaml
spring:
  application:
    name: lifelog

management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus
  metrics:
    tags:
      application: ${spring.application.name}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5, 0.9, 0.95, 0.99
  prometheus:
    metrics:
      export:
        enabled: true
  # Spring Boot 4의 OTLP 메트릭 자동 export 비활성화 (Prometheus 스크랩 방식 사용)
  otlp:
    metrics:
      export:
        enabled: false
  defaults:
    metrics:
      export:
        enabled: false
  tracing:
    sampling:
      probability: 1.0   # 운영환경에서는 0.1 권장
  opentelemetry:
    tracing:
      export:
        otlp:
          endpoint: http://서버IP:4318/v1/traces
Spring Boot 4 메트릭 삽질 포인트

Spring Boot 4는 OTLP 메트릭 export가 기본으로 활성화된다. Prometheus로 메트릭을 수집하는 경우 OTLP export를 명시적으로 꺼야 한다. 끄지 않으면 Tempo로 메트릭을 보내려다 404 오류가 계속 발생한다.

JDBC SQL 트레이싱 (Kotlin)

spring-boot-starter-opentelemetry는 HTTP 레벨만 자동 계측한다. JDBC SQL을 트레이스에 포함하려면 DataSource를 래핑해야 한다.

kotlin
@Configuration
class OpenTelemetryJdbcConfig(
    private val openTelemetry: OpenTelemetry
) {
    @Bean
    fun otelDataSourceBeanPostProcessor(): BeanPostProcessor {
        return object : BeanPostProcessor {
            override fun postProcessAfterInitialization(bean: Any, beanName: String): Any {
                if (bean is DataSource) {
                    return JdbcTelemetry.create(openTelemetry).wrap(bean)
                }
                return bean
            }
        }
    }
}

이렇게 하면 Grafana Tempo에서 HTTP 요청 span 아래에 실행된 SQL 쿼리가 하위 span으로 표시된다. 이 것은 OpenTelemetry Agent를 사용하면 설정할 필요는 없다.


7. 로그 설정 (logback-spring.xml)
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProperty scope="context" name="appName" source="spring.application.name"/>
    <property name="LOG_PATH" value="/product/lifelog/log"/>
    <property name="LOG_FILE" value="lifelog"/>
    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} trace_id=%X{traceId} span_id=%X{spanId} [%thread] %-5level %logger{60} - %msg%n"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- local/dev: 콘솔 출력만 -->
    <springProfile name="default | dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <!-- live: 콘솔 + 파일 + Loki -->
    <springProfile name="live">
        <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/${LOG_FILE}.log</file>
            <encoder>
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.log</fileNamePattern>
                <maxHistory>30</maxHistory>
                <totalSizeCap>3GB</totalSizeCap>
            </rollingPolicy>
        </appender>

        <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/${LOG_FILE}-error.log</file>
            <filter class="ch.qos.logback.classic.filter.LevelFilter">
                <level>ERROR</level>
                <onMatch>ACCEPT</onMatch>
                <onMismatch>DENY</onMismatch>
            </filter>
            <encoder>
                <pattern>${LOG_PATTERN}</pattern>
                <charset>UTF-8</charset>
            </encoder>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <fileNamePattern>${LOG_PATH}/${LOG_FILE}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
                <maxHistory>30</maxHistory>
                <totalSizeCap>1GB</totalSizeCap>
            </rollingPolicy>
        </appender>

        <appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
            <http>
                <url>http://서버IP:3100/loki/api/v1/push</url>
                <connectionTimeoutMs>5000</connectionTimeoutMs>
                <requestTimeoutMs>5000</requestTimeoutMs>
            </http>
            <format>
                <label>
                    <pattern>application=${appName},host=${HOSTNAME},level=%level</pattern>
                </label>
                <message>
                    <pattern>${LOG_PATTERN}</pattern>
                </message>
                <sortByTime>true</sortByTime>
            </format>
            <sendQueueMaxBytes>41943040</sendQueueMaxBytes>
            <batchMaxItems>1000</batchMaxItems>
            <batchTimeoutMs>5000</batchTimeoutMs>
        </appender>

        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="FILE"/>
            <appender-ref ref="ERROR_FILE"/>
            <appender-ref ref="LOKI"/>
        </root>
    </springProfile>
</configuration>
MDC 키 이름 주의

spring-boot-starter-opentelemetry + micrometer-tracing-bridge-otel 조합에서 MDC에 주입되는 키 이름은 traceId, spanId이다(camelCase). 흔히 볼 수 있는 trace_id, span_id (snake_case)가 아니기 때문에 패턴을 %X{traceId}로 써야 한다. 이 부분도 Spring Boot 2.x, 3.x 과 달라진 점이다.


8. Nginx 리버스 프록시 설정

/etc/nginx/conf.d/grafana.conf:

nginx
server {
    location /grafana {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://localhost:3000/grafana;
        proxy_redirect off;
    }
}
Grafana 서브패스 삽질 포인트

GF_SERVER_ROOT_URL에는 프로토콜과 호스트를 포함한 전체 URL을 입력해야 한다:

text
GF_SERVER_ROOT_URL=http://서버IP/grafana   ← 올바름
GF_SERVER_ROOT_URL=/grafana                ← 리다이렉트 루프 발생

9. Grafana 데이터소스 연동

spring-boot-observability
위 Github Repository의 README.md에 다 나와있다. 그대로 따라하면 된다.

Loki → Tempo 연동 (로그에서 트레이스로)

Grafana → Connections → Loki 데이터소스 → Derived fields:

text
Name:          TraceID
Regex:         trace_id=(\w+)
Internal link: ON
Data source:   Tempo
URL:           ${__value.raw}

이 설정을 하면 Loki에서 로그를 볼 때 trace_id 값을 클릭해 바로 Tempo로 이동할 수 있다.

Tempo → Loki 연동 (트레이스에서 로그로)

Grafana → Connections → Tempo 데이터소스 → Trace to logs:

text
Data source:         Loki
Tags:                service.name → application
Filter by trace ID:  ON
추천 대시보드
대시보드 ID 특징
Spring Boot Observability 17175 Metrics + Traces + Logs 통합
JVM (Micrometer) Spring Boot 3/4 19004 Micrometer 2.x 호환, Spring Boot 4 권장

기존에 많이 사용하던 ID 4701은 Spring Boot 4 / Micrometer 2.x에서 일부 패널이 동작하지 않는다. jvm_threads_statesjvm_threads_states_threads 등 메트릭 이름이 변경되었기 때문이다.


10. 트러블슈팅 정리

구축 과정에서 겪은 오류들을 정리한다.

오류 원인 해결
no org id Loki auth_enabled 기본값 문제 auth_enabled: false 명시
loki4j 400 allow_structured_metadata loki4j가 structured metadata 자동 전송 allow_structured_metadata: true 설정
OTLP metrics 404 Spring Boot 4 OTLP 자동 활성화 management.otlp.metrics.export.enabled: false
Tempo empty ring v2.10 Rhythm 아키텍처 Kafka 의존성 Tempo v2.6.1로 다운그레이드
trace_id 로그에 빈값 MDC 키 이름 불일치 %X{traceId} 사용 (camelCase)
Grafana 무한 리다이렉트 proxy_pass 경로 중복 proxy_pass 끝에 /만, /grafana 제거
OTLP unexpected end of stream HTTP/gRPC 프로토콜 불일치 HTTP는 4318, gRPC는 4317
Grafana application files 로드 실패 GF_SERVER_ROOT_URL 불완전 프로토콜+호스트 포함 전체 URL 입력

마치며

재작년에 회사에서 이미 직접 구축했기 때문에 금방 끝날 줄 알았는데, Spring Boot 4의 변경사항(OTLP 기본 활성화, Micrometer 2.x 메트릭명 변경)과 최신 버전들의 breaking change(Loki schema v13, Tempo v2.10 Rhythm 아키텍처)가 맞물려 상당한 시간이 걸렸다.

완성된 스택은 https://furaiki-lifelog.com/sre에서 확인할 수 있으며, 다음 기능을 제공한다.

  • 메트릭: JVM, HTTP 요청률, 레이턴시(p50/p95/p99), 에러율 (Prometheus)
  • 로그: 레벨 필터링, 키워드 검색, trace_id 연동 (Loki)
  • 트레이스: HTTP + JDBC SQL span 계측 (Tempo)
  • 상호 연동: 로그 → 트레이스, 트레이스 → 로그 클릭 드릴다운
Written by
author
풍우래기

여행을 좋아하는 집돌이 개발자입니다.

블로그에 새로운 글이 발행되었습니다.