Develop/spring-batch

[kotlin] Spring-Batch Alert 처리 ( Logback을 이용한 Slack 연동 )

에디개발자 2021. 8. 2. 07:00
반응형

나를 닮았다고 한다...

이번 글에서는 Spring-Batch에서 오류가 났을 경우 에러를 Alert처리하는 글을 정리하겠습니다. 

모든 소스는 Github에 올려두었습니다.

 

Alert처리하는 방법은 여러가지 종류가 있습니다.

  • Prometheus, Alert-Manager를 이용한 방법
  • Slack을 이용한 Webhook 방법
  • 그 외 등등..

이번 글에서는 Slack을 이용한 Webhook 방법에 대해서 정리하겠습니다. 

 

Prometheus, Alert-Manager를 이용한 방법은 추 후에 작성예정입니다. Prometheus, Alert-Manager 구현 방법에 대해서는 여기 를 참조해주시기 바랍니다.

 

적용 방법

Logback을 적용하여 Error 로그가 작성된 경우 Slack으로 Webhook을 날리는 방식입니다.

  • Slack Channel 설정
  • build.gradle.kts 의존성 추가
  • Logback (Slack Appender) 설정
  • 적용 후 Test

 

Slack Channel 설정

Slack에서 Channel을 생성

 

 

Incoming webhook 설정

 

앱 추가버튼을 클릭합니다.

 

Slack에서 추가한 Channel을 선택합니다.

웹후크 URL이 Webhook URL 입니다. 

 

build.gradle.kts 의존성 추가

dependencies {

    // 1)
    // spring batch
    implementation("org.springframework.boot:spring-boot-starter-batch")  

    // 2)
    // slack
    val slackWebhookVersion = "1.4.0"
    implementation("net.gpedro.integrations.slack:slack-webhook:${slackWebhookVersion}")

    // 3)
    // Apache Commons
    val commonsTextVersion = "1.9"
    implementation("org.apache.commons:commons-text:$commonsTextVersion")
    
}

 

1) logback 의존성은 spring-boot-starter-batch에 포함되어있습니다.

 

2) Slack에 Webhook 설정을 위한 의존성을 추가합니다.

3) Json 데이터를 Pretty하게 보여주기 위한 의존성입니다.

 

Logback (Slack Appender) 설정

application.yml

Logback 설정 파일을 지정하는 설정파일을 추가합니다.

logging:
  config: classpath:logs/logback.xml

 

application.yml에 설정한 경로에 파일을 아래와 같이 생성합니다.

 

default-log-constant.xml

<?xml version="1.0" encoding="UTF-8"?>
<!-- 로그 상수 설정 -->
<included>
    <!-- CONSOLE LOG PRINT PATTERN -->
    <property name="CONSOLE_LOG_PATTERN" value="[%d][%highlight(%-5level)] %magenta(%-4relative) [%boldYellow(%t)] %cyan(%logger{30}) [%boldGreen(%method:%line)] - %msg%n%throwable"/>
    <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>

    <!-- LOG FILE PRINT PATTERN -->
    <property name="FILE_LOG_PATTERN" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>

    <!-- 기본 로그 경로 -->
    <property name="DEFAULT_LOG_HOME" value="logs"/>
    <!-- ERROR 로그 경로 -->
    <property name="DEFAULT_ERROR_LOG_HOME" value="logs"/>

    <!-- 운영서버가 아닌 서버의 최대 로그 저장 기간 -->
    <property name="DEFAULT_MAX_HISTORY" value="10" />
    <!-- 운영서버 최대 로그 저장 기간 -->
    <property name="PROD_MAX_HISTORY" value="90" />
    <!-- 최대 로그 파일 사이즈 MB -->
    <property name="DEFAULT_MAX_FILESIZE" value="500MB" />

    <!-- SLACK TITLE -->
    <property name="SLACK_TITLE" value="서비스 장애 감지"/>
    <!-- 기본 BOT NAME -->
    <property name="SLACK_NAME" value="Kotlin-Batch"/>
    <!-- 기본 BOT EMOJI -->
    <property name="SLACK_EMOJI" value=":exclamation:"/>

    <property name="SLACK_CHANNEL" value="kotlin-batch-alert"/>
    <!-- HOOK LOCAL URL -->
    <property name="HOOK_URL" value="https://hooks.slack.com/services/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"/>
</included>
  • SLACK TITLE: Slack Message에 보여질 Title을 설정합니다.
  • SLACK_NAME: Slack Message에 보여질 Name을 설정합니다.
  • SLACK_EMOJI: Slack Message에 보여질 이모티콘을 설정합니다.
  • SLACK_CHANNEL: 이전에 생성한 Channel 명을 입력합니다.
  • HOOK_URL: 이전에 생성한 Webhook Url을 입력합니다.

 

default-log-setting.xml

<?xml version="1.0" encoding="UTF-8"?>
<!-- 로그 APPENDER 및 기본 로그 셋팅 -->
<included>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </layout>
    </appender>

    <appender name="DEFAULT_ERROR_LOG_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${DEFAULT_ERROR_LOG_HOME}/error.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${DEFAULT_ERROR_LOG_HOME}/archived/error.%d{yyyy-MM-dd}.%i.log.zip</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy
                    class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>${DEFAULT_MAX_FILESIZE:-500MB}</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>${DEFAULT_MAX_HISTORY:-10}</maxHistory>
        </rollingPolicy>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <charset>UTF-8</charset>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <appender name="DEFAULT_LOG_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${DEFAULT_LOG_HOME}/api.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${DEFAULT_LOG_HOME}/archived/api.%d{yyyy-MM-dd}.%i.log.zip</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy
                    class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>${DEFAULT_MAX_FILESIZE:-500MB}</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>${DEFAULT_MAX_HISTORY:-10}</maxHistory>
        </rollingPolicy>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>DEBUG</level>
        </filter>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <charset>UTF-8</charset>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <!-- 스프링 프레임워크에서 찍는건 level을 info로 설정 -->
    <logger name="org.springframework" level="INFO"/>
</included>
  • ch.qos.logback.core.ConsoleAppender : 콘솔에 로그를 찍는 설정
  • ch.qos.logback.core.rolling.RollingFileAppender : 여러개의 파일을 순회하면서 로그를 찍는 설정

 

logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="true">
    <include resource="logs/default-log-constant.xml"/>
    <include resource="logs/default-log-setting.xml"/>

    <!-- Slack 메신저에 ERROR 메세지 전송 -->
    <appender name="SLACK" class="me.practice.kotlinbatch.common.slack.appender.SlackAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <level>ERROR</level>
        <enabled>true</enabled>
        <token>${HOOK_URL}</token>
        <channel>${SLACK_CHANNEL}</channel>
        <!-- 에러 타이틀 (기본값: 서비스 장애 감지) -->
        <title>${SLACK_TITLE}</title>
        <!-- 설정 하지 않으면 기본값: App -->
        <botName>${SLACK_NAME}</botName>
        <!-- Emoji Shortcodes 참조  https://emojipedia.org (기본값: :exclamation:)-->
        <botEmoji>${SLACK_EMOJI}</botEmoji>
    </appender>
    <!-- Slack 메신저 에러 비동기 전송 처리 -->
    <appender name="ASYNC_SLACK" class="ch.qos.logback.classic.AsyncAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <appender-ref ref="SLACK" />
    </appender>

    <logger name="org.hibernate.validator" level="INFO"/>

    <!-- hibernate SQL 보기 -->
    <logger name="org.hibernate.SQL" level="debug" additivity="false"/>
    <logger name="org.hibernate.type" level="trace" additivity="false"/>

    <!--dev의 기본 패키지 로그 레벨은 DEBUG로 잡고 appender 등록, 기존의 default-log-setting.xml 파일 내의 spring 로그 레벨은 info로 등록-->
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="DEFAULT_LOG_APPENDER"/>
        <appender-ref ref="DEFAULT_ERROR_LOG_APPENDER"/>
        <appender-ref ref="ASYNC_SLACK"/>
    </root>
</configuration>

SlackAppender를 추가하는 설정입니다. 

Slack 메신저 에러를 비동기 전동 처리하는 설정입니다.

 

me.practice.kotlinbatch.common.slack.appender.SlackAppender

Slack에 전송할 메시지를 생성합니다.

package me.practice.kotlinbatch.common.slack.appender

import ch.qos.logback.classic.Level
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.classic.spi.StackTraceElementProxy
import ch.qos.logback.core.UnsynchronizedAppenderBase
import ch.qos.logback.core.util.ContextUtil
import me.practice.kotlinbatch.common.slack.utils.JsonUtils
import me.practice.kotlinbatch.common.slack.utils.MDCUtils
import net.gpedro.integrations.slack.SlackApi
import net.gpedro.integrations.slack.SlackAttachment
import net.gpedro.integrations.slack.SlackField
import net.gpedro.integrations.slack.SlackMessage
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.util.ObjectUtils
import java.net.SocketException
import java.net.UnknownHostException
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

class SlackAppender(
    private var enabled: Boolean = false,
    private var token: String? = null,
    private var channel: String? = null,
    private var level: Level? = null,
    private var title: String? = null,
    private var botName: String? = null,
    // https://slackmojis.com/
    private var botEmoji: String? = null
): UnsynchronizedAppenderBase<ILoggingEvent>() {

    override fun doAppend(eventObject: ILoggingEvent?) {
        super.doAppend(eventObject)
    }

    override fun append(loggingEvent: ILoggingEvent) {
        if (loggingEvent.level.isGreaterOrEqual(level)) {
            if (enabled) {
                toSlack(loggingEvent)
            }
        }
    }

    private fun toSlack(loggingEvent: ILoggingEvent) {
        if (loggingEvent.level.isGreaterOrEqual(level)) {
            val fields: MutableList<SlackField> = ArrayList()
            val slackAttachment = SlackAttachment()
            slackAttachment.setFallback("장애 발생")
            slackAttachment.setColor("danger")
            slackAttachment.setTitle(loggingEvent.formattedMessage)

            if (!ObjectUtils.isEmpty(MDCUtils[MDCUtils.AGENT_DETAIL_MDC])) {
                val agentDetail = SlackField()
                agentDetail.setTitle("사용자 환경정보")
                agentDetail.setValue(JsonUtils.toPrettyJson(MDCUtils[MDCUtils.AGENT_DETAIL_MDC]))
                agentDetail.isShorten = false
                fields.add(agentDetail)
            }

            val date = SlackField()
            date.setTitle("발생 시간")
            date.setValue(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
            date.isShorten = true
            fields.add(date)

            val hostName = SlackField()
            hostName.setTitle("호스트명")
            hostName.setValue(getHostName())
            hostName.isShorten = true
            fields.add(hostName)

            if (!ObjectUtils.isEmpty(MDCUtils[MDCUtils.HEADER_MAP_MDC])) {
                val headerInformation = SlackField()
                headerInformation.setTitle("Http Header 정보")
                headerInformation.setValue(JsonUtils.toPrettyJson(MDCUtils[MDCUtils.HEADER_MAP_MDC]))
                headerInformation.isShorten = false
                fields.add(headerInformation)
            }
            if (!ObjectUtils.isEmpty(MDCUtils[MDCUtils.PARAMETER_MAP_MDC])) {
                val bodyInformation = SlackField()
                bodyInformation.setTitle("Http Body 정보")
                bodyInformation.setValue(MDCUtils[MDCUtils.PARAMETER_MAP_MDC])
                bodyInformation.isShorten = false
                fields.add(bodyInformation)
            }
            slackAttachment.setFields(fields)

            val slackMessage = SlackMessage("")
            slackMessage.setChannel("#$channel")
            slackMessage.setUsername("${getBotName()} - ${getTitle()}")
            // slackMessage.setIcon(botEmoji)
            slackMessage.setIcon(getBotEmoji())
            slackMessage.setAttachments(listOf(slackAttachment))
            val slackApi = SlackApi(token)
            slackApi.call(slackMessage)
        }
    }

    /**
     * Gets host name.
     *
     * @return the host name
     */
    fun getHostName(): String? {
        try {
            return ContextUtil.getLocalHostName()
        } catch (e: UnknownHostException) {
            e.printStackTrace()
        } catch (e: SocketException) {
            e.printStackTrace()
        }
        return ""
    }

    /**
     * Gets stack trace.
     *
     * @param stackTraceElements the stack trace elements
     * @return the stack trace
     */
    fun getStackTrace(stackTraceElements: Array<StackTraceElementProxy>?): String? {
        if (stackTraceElements == null || stackTraceElements.isEmpty()) {
            return null
        }
        val sb = StringBuilder()
        for (element in stackTraceElements) {
            sb.append(element.toString())
            sb.append("\n")
        }
        return sb.toString()
    }

    fun getTitle(): String? {
        return if(ObjectUtils.isEmpty(title)) "서비스 장애 감지" else title
    }

    fun setTitle(title: String?) {
        this.title = title
    }

    fun getBotName(): String? {
        return if(ObjectUtils.isEmpty(botName)) "App" else botName
    }

    fun getBotEmoji(): String? {
        return if(ObjectUtils.isEmpty(botEmoji)) ":exclamation:" else botEmoji
    }

    fun setBotEmoji(botEmoji: String?) {
        this.botEmoji = botEmoji
    }

}

 

Slack 메세지를 Pretty하게 도와주는 클래스 파일입니다.

JsonUtils

package me.practice.kotlinbatch.common.slack.utils

import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.apache.commons.text.StringEscapeUtils

class JsonUtils {

    private var gson: Gson = GsonBuilder()
        .setPrettyPrinting()
        .setLenient()
        .create()
    private var mapper: ObjectMapper = ObjectMapper()

    companion object {

        /**
         * Gets instance.
         *
         * @return the instance
         */
        private fun getInstance() = JsonUtils()

        fun getGson() = getInstance().gson

        fun getObjectMapper() = getInstance().mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)

        /**
         * From json t.
         *
         * @param <T>     the type parameter
         * @param jsonStr the json str
         * @param cls     the cls
         * @return the t
        </T> */
        fun <T> fromJson(jsonStr: String, cls: Class<T>?): T {
            return getGson().fromJson(jsonStr, cls)
        }

        /**
         * To pretty json string.
         *
         * @param json the json
         * @return the string
         */
        fun toPrettyJson(json: String?): String? {
            if (json == null) return null
            return StringEscapeUtils.unescapeJava(getGson().toJson(fromJson(json, Any::class.java)))
        }

        /**
         * To ObjectMapper pretty json string
         * @param any
         * @return
         */
        @Throws(JsonProcessingException::class)
        fun toMapperPrettyJson(any: Any?): String = getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(any)
    }
}

MDCUtils

package me.practice.kotlinbatch.common.slack.utils

import org.slf4j.MDC

/**
 * httpRequest가 존재하는 동안 데이터를 유지시키기 위한 유틸
 * http://logback.qos.ch/manual/mdc.html
 */
class MDCUtils {

    companion object {
        private val mdc = MDC.getMDCAdapter()

        /**
         * The constant HEADER_MAP_MDC.
         */
        val HEADER_MAP_MDC = "HEADER_MAP_MDC"

        /**
         * The constant PARAMETER_MAP_MDC.
         */
        val PARAMETER_MAP_MDC = "PARAMETER_MAP_MDC"

        /**
         * The constant AGENT_DETAIL_MDC.
         */
        val AGENT_DETAIL_MDC = "AGENT_DETAIL_MDC"

        /**
         * Set.
         *
         * @param key   the key
         * @param value the value
         */
        operator fun set(key: String, value: String) {
            mdc.put(key, value)
        }

        /**
         * Get string.
         *
         * @param key the key
         * @return the string
         */
        operator fun get(key: String): String? {
            return mdc[key]
        }
    }

}

 

적용 후 테스트

import org.apache.logging.log4j.LogManager

@Configuration
class SimpleJobConfiguration(
    val log = LogManager.getLogger()

    @Bean("${BatchItem.SIMPLE}_READER")
    @StepScope
    fun reader(@Value("#{jobParameters[pageSize]}") pageSize: Int?): QuerydslPagingItemReader<Person> {
        val reader = QuerydslPagingItemReader(entityManagerFactory) { personRepository.findAllInBatch() }
        reader.pageSize = pageSize!!
        log.error("[Test] Batch Error....!")  // Slack Test!!
        return reader
    }
}

여기서 중요한 점은 logback을 사용하는 log를 사용해야만 합니다. import문을 반드시 확인해주세요!

 

Test를 위해 log.error 메세지를 임의로 발생시켜 테스트하면 Slack Channel에 아래와 같이 Alert이 나타는 것을 확인할 수 있습니다.

Slack Channel에서 발생한 메시지

반응형