현재 운영중인 서비스에서는 중요 로그의 경우 DB에 따로 기록하고 있지만 그외 로그(nginx, woker, talk etc…)는 전부 syslog로만 관리하고 있다.

로그의 크키가 하루 수십GB가 되다보니 분석에 있어서 시간이 오래 걸렸다. (무려 GoAccess를 사용중이다!)

이제 방대해져가는 로그를 별도의 로그서버 스택을 따로 구성하여 저장하고 관리하도록 서버 구축을 진행해봤다.

가장 유명하고 레퍼런스도 많은 ELK스택을 이용하여 서버 구축을 진행하기로 했다.

nginx 로그의 경우 rsyslog를 통해 특정 서버에 적재한뒤(서버에 바로 달라붙어서 tail grep 할때 필요하다는 선임개발자분의 의견에따라!!) logstash로 보내고 그 외 로그들은 바로 filebeat를 통해 logstash로 보낸뒤 logstash에서 파싱된 포맷을 elasticsearch에 적재하는 형태로 구성하고자 했다.

우선 공식홈페이지에서 호환 가능한 최신 버전을 먼저 확인했다.

https://www.elastic.co/kr/support/matrix

현재 서버는 centos7 버전을 사용중이므로 구축 진행일 기준 가장 최신 버전인 7.9 버전을 설치했다!

우선 rpm에 es pgp 키를 추가한다.

# Import the Elasticsearch PGP Key
$ rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

그런 다음 레포 정보를 만들고…

# vim /etc/yum.repos.d/elasticsearch.repo
[elasticsearch]
name=Elasticsearch repository for 7.x packages
baseurl=https://artifacts.elastic.co/packages/7.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=0
autorefresh=1
type=rpm-md

es를 설치!

# install elasticsearch
$ yum install --enablerepo=elasticsearch elasticsearch

설치가 잘 되었으면 etc에 있는 설정 파일을 환경에 맞게 잘 설정한다.

$ vim /etc/elasticsearch/elasticsearch.yml

설정중에 보안관련 옵션은 꼭 넣도록 하자!

# generate p12
$ bin/elasticsearch-certutil cert -out config/elastic-certificates.p12 -pass ""

p12 인증서를 만들고 아래와 같이 설정파일에 보안 옵션을 활성화 시킨다!

인증서는 es의 다른 노드와 tls 통신을 할때 필요하다!

인증서 설정이 잘 되었으면 이제 아이디와 패스워드도 발급한다.

# generate id / passwrod
$ bin/elasticsearch-setup-passwords auto

자동으로 생성할지 아니면 직접 입력할지 물어보는데 나는 귀찮아서 자동생성을 하였다! (알아서 길게 잘 만들어준다!)

이렇게 생성된 아이디와 비밀번호는 잘 보관해야한다.

이제 elasticsearch를 실행시켜본다.

# start elasticsearch
$ systemctl start elasticsearch

잘 실행이 된다면 다음으로 logstash를 설치해보자.

logstash도 역시 7.9 버전으로 설치를 진행했다.

마찬가지로 rpm에 es pgp 키를 추가!

# Import the Elasticsearch PGP Key
$ rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

바로 레포 정보를 만든다.

# vim /etc/yum.repos.d/logstash.repo
[logstash-7.x]
name=Elastic repository for 7.x packages
baseurl=https://artifacts.elastic.co/packages/7.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

그런 다음 logstash를 설치!

# install logstash
$ yum install logstash

설치후 마찬가지로 설정을 한다.

$ vim /etc/logstash/logstash.yml

pipeline의 성능 관련 옵션은 적절히 수정하는게 좋다.

이제 설치가 잘 되었는지 확인해보기위해 실행시켜본다.

# start logstash
$ systemctl start logstash

잘 설치가 되었으면 이제 kibana를 설치한다.

kibana도 마찬가지로 7.9버전을 설치한다.

먼저 pgp를 추가하고

# Import the Elasticsearch PGP Key
$ rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

레포 정보를 만든다.

# vim /etc/yum.repos.d/kibana.repo
[kibana-7.x]
name=Kibana repository for 7.x packages
baseurl=https://artifacts.elastic.co/packages/7.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

저장하고 kibana 설치!

# install kibana
$ yum install kibana

설치가 되었으면 이제 설정 파일을 수정한다.

$ vim /etc/kibana/kibana.yml

여기서 server.port와 server.host, elasticsearch.hosts를 환경에 맞게 설정한다.

그런 다음 elasticsearch.username, elasticsearch.password에 아까 elasticsearch에서 만든 아이디와 비밀번호를 입력한다.

나는 kibana_system을 사용했다.

추가로 ssl 인증서가 있다면 server.ssl.enabled을 활성화 시킨다음 certificate와 key의 경로를 입력하자.

모든 설정이 끝났으면 kibana를 실행하여 설치가 제대로 되었는지 확인한다.

# start kibana
$ systemctl start kibana

이제 kibana로 접속해보면 아래와 같이 로그인 화면이 뜬다.

elasticsearch에서 만든 계정으로 로그인하면 된다.

이제 남은 작업은 log를 보내줄 filebeat설치와 logstash pipeline을 작성하는 것이다.

우선 filebeat를 먼저 설치한다.

filebeat 역시 7.9버전을 설치한다.

마찬가지로 먼저 pgp를 추가한다.

# Import the Elasticsearch PGP Key
$ rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

그런 다음 레포 정보를 만든다.

# vim /etc/yum.repos.d/elastic.repo
[elastic-7.x]
name=Elastic repository for 7.x packages
baseurl=https://artifacts.elastic.co/packages/7.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

레포 정보를 저장했으면 바로 설치한다.

# install filebeat
$ yum install filebeat

설치가 완료되었으면 설정 파일을 수정한다.

filebeat에서 logstash로 연결되어야 하기때문에 output.logstash의 host를 수정하고 활성화시킨다.

$ vim /etc/filebeat/filebeat.yml

filebeat.config.modules을 사용한다면

아래와 같이 리스트를 확인할 수 있다.

$ filebeat modules enable system
$ filebeat modules list

filebeat.inputs에서는 source가 되는 입력을 설정할 수 있는데 우리는 여러 노드에서 다양한 종류의 source를 사용하기 때문에 각각 따로 수정했다.

filebeat.inputs:
- type: log
  paths:
    - /var/log/system.log
    - /var/log/wifi.log
- type: log
  paths:
    - "/var/log/nginx/*"
  fields:
    service: nginx
  fields_under_root: true

위 예제와 같이 원하는 source에 따라 type과 fields를 추가하여 작업할 수 있다.

filebeat가 지원하는 type은 아래와 같다.

설정이 끝나고 제대로 설치가 되었으면 이제 실행시켜본다.

# start filebeat
$ systemctl start filebeat

filebeat까지 설치가 제대로 끝났으면 이제 logstash의 파이프라인을 작성하는 일만 남았다!

설치 도중 중복되는 과정들이 있었는데 같은 단일서버 내에서 설치하게 된다면 생략해도 되는 작업들이 있다. 가령… pgp추가라던지..

이제 마지막으로 할일인 logstash의 파이프라인은 넘어오는 source에 따라 작업할게 무궁무진하다.

input {
  beats {
    port => 5044
    host => "0.0.0.0"
  }
}
filter {
  if [fileset][module] == "nginx" {
    if [fileset][name] == "access" {
      grok {
        match => { "message" => ["%{IPORHOST:[nginx][access][remote_ip]} - %{DATA:[nginx][access][user_name]} \[%{HTTPDATE:[nginx][access][time]}\] \"%{WORD:[nginx][access][method]} %{DATA:[nginx][access][url]} HTTP/%{NUMBER:[nginx][access][http_version]}\" %{NUMBER:[nginx][access][response_code]} %{NUMBER:[nginx][access][body_sent][bytes]} \"%{DATA:[nginx][access][referrer]}\" \"%{DATA:[nginx][access][agent]}\""] }
        remove_field => "message"
      }
      mutate {
        add_field => { "read_timestamp" => "%{@timestamp}" }
      }
      date {
        match => [ "[nginx][access][time]", "dd/MMM/YYYY:H:m:s Z" ]
        remove_field => "[nginx][access][time]"
      }
      useragent {
        source => "[nginx][access][agent]"
        target => "[nginx][access][user_agent]"
        remove_field => "[nginx][access][agent]"
      }
      geoip {
        source => "[nginx][access][remote_ip]"
        target => "[nginx][access][geoip]"
      }
    }
    else if [fileset][name] == "error" {
      grok {
        match => { "message" => ["%{DATA:[nginx][error][time]} \[%{DATA:[nginx][error][level]}\] %{NUMBER:[nginx][error][pid]}#%{NUMBER:[nginx][error][tid]}: (\*%{NUMBER:[nginx][error][connection_id]} )?%{GREEDYDATA:[nginx][error][message]}"] }
        remove_field => "message"
      }
      mutate {
        rename => { "@timestamp" => "read_timestamp" }
      }
      date {
        match => [ "[nginx][error][time]", "YYYY/MM/dd H:m:s" ]
        remove_field => "[nginx][error][time]"
      }
    }
  }
}
output {
  elasticsearch {
    hosts => localhost
    manage_template => false
    index => "%{[@metadata][beat]}-%{[@metadata][version]}-%{+YYYY.MM.dd}"
  }
}

위 예제와 같이 nginx module를 사용했다면 넘어오는 module 정보를 이용하여 분기가 가능하다.

또한 filebeat에서 넘어오는 fields를 이용하여 아래와 같이 분기도 가능하다.

# vim /etc/logstash/conf.d/nginx-pipeline.conf
filter {
...
    if [service] == "nginx"{
        grok {
        }
    }
...

그리고 grok패턴을 이용하면 logstash에서 파싱을 쉽게 할 수 있는데 패턴의 종류는 아래와 같다.

USERNAME [a-zA-Z0-9._-]+
USER %{USERNAME}
INT (?:[+-]?(?:[0-9]+))
BASE10NUM (?<![0-9.+-])(?>[+-]?(?:(?:[0-9]+(?:\.[0-9]+)?)|(?:\.[0-9]+)))
NUMBER (?:%{BASE10NUM})
BASE16NUM (?<![0-9A-Fa-f])(?:[+-]?(?:0x)?(?:[0-9A-Fa-f]+))
BASE16FLOAT \b(?<![0-9A-Fa-f.])(?:[+-]?(?:0x)?(?:(?:[0-9A-Fa-f]+(?:\.[0-9A-Fa-f]*)?)|(?:\.[0-9A-Fa-f]+)))\b

POSINT \b(?:[1-9][0-9]*)\b
NONNEGINT \b(?:[0-9]+)\b
WORD \b\w+\b
NOTSPACE \S+
SPACE \s*
DATA .*?
GREEDYDATA .*
QUOTEDSTRING (?>(?<!\\)(?>"(?>\\.|[^\\"]+)+"|""|(?>'(?>\\.|[^\\']+)+')|''|(?>`(?>\\.|[^\\`]+)+`)|``))
UUID [A-Fa-f0-9]{8}-(?:[A-Fa-f0-9]{4}-){3}[A-Fa-f0-9]{12}

# Networking
MAC (?:%{CISCOMAC}|%{WINDOWSMAC}|%{COMMONMAC})
CISCOMAC (?:(?:[A-Fa-f0-9]{4}\.){2}[A-Fa-f0-9]{4})
WINDOWSMAC (?:(?:[A-Fa-f0-9]{2}-){5}[A-Fa-f0-9]{2})
COMMONMAC (?:(?:[A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2})
IPV6 ((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?
IPV4 (?<![0-9])(?:(?:25[0-5]|2[0-4][0-9]|[0-1]?[0-9]{1,2})[.](?:25[0-5]|2[0-4][0-9]|[0-1]?[0-9]{1,2})[.](?:25[0-5]|2[0-4][0-9]|[0-1]?[0-9]{1,2})[.](?:25[0-5]|2[0-4][0-9]|[0-1]?[0-9]{1,2}))(?![0-9])
IP (?:%{IPV6}|%{IPV4})
HOSTNAME \b(?:[0-9A-Za-z][0-9A-Za-z-]{0,62})(?:\.(?:[0-9A-Za-z][0-9A-Za-z-]{0,62}))*(\.?|\b)
HOST %{HOSTNAME}
IPORHOST (?:%{HOSTNAME}|%{IP})
HOSTPORT %{IPORHOST}:%{POSINT}

# paths
PATH (?:%{UNIXPATH}|%{WINPATH})
UNIXPATH (?>/(?>[\w_%!$@:.,-]+|\\.)*)+
TTY (?:/dev/(pts|tty([pq])?)(\w+)?/?(?:[0-9]+))
WINPATH (?>[A-Za-z]+:|\\)(?:\\[^\\?*]*)+
URIPROTO [A-Za-z]+(\+[A-Za-z+]+)?
URIHOST %{IPORHOST}(?::%{POSINT:port})?
# uripath comes loosely from RFC1738, but mostly from what Firefox
# doesn't turn into %XX
URIPATH (?:/[A-Za-z0-9$.+!*'(){},~:;=@#%_\-]*)+
#URIPARAM \?(?:[A-Za-z0-9]+(?:=(?:[^&]*))?(?:&(?:[A-Za-z0-9]+(?:=(?:[^&]*))?)?)*)?
URIPARAM \?[A-Za-z0-9$.+!*'|(){},~@#%&/=:;_?\-\[\]]*
URIPATHPARAM %{URIPATH}(?:%{URIPARAM})?
URI %{URIPROTO}://(?:%{USER}(?::[^@]*)?@)?(?:%{URIHOST})?(?:%{URIPATHPARAM})?

# Months: January, Feb, 3, 03, 12, December
MONTH \b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\b
MONTHNUM (?:0?[1-9]|1[0-2])
MONTHNUM2 (?:0[1-9]|1[0-2])
MONTHDAY (?:(?:0[1-9])|(?:[12][0-9])|(?:3[01])|[1-9])

# Days: Monday, Tue, Thu, etc...
DAY (?:Mon(?:day)?|Tue(?:sday)?|Wed(?:nesday)?|Thu(?:rsday)?|Fri(?:day)?|Sat(?:urday)?|Sun(?:day)?)

# Years?
YEAR (?>\d\d){1,2}
HOUR (?:2[0123]|[01]?[0-9])
MINUTE (?:[0-5][0-9])
# '60' is a leap second in most time standards and thus is valid.
SECOND (?:(?:[0-5]?[0-9]|60)(?:[:.,][0-9]+)?)
TIME (?!<[0-9])%{HOUR}:%{MINUTE}(?::%{SECOND})(?![0-9])
# datestamp is YYYY/MM/DD-HH:MM:SS.UUUU (or something like it)
DATE_US %{MONTHNUM}[/-]%{MONTHDAY}[/-]%{YEAR}
DATE_EU %{MONTHDAY}[./-]%{MONTHNUM}[./-]%{YEAR}
ISO8601_TIMEZONE (?:Z|[+-]%{HOUR}(?::?%{MINUTE}))
ISO8601_SECOND (?:%{SECOND}|60)
TIMESTAMP_ISO8601 %{YEAR}-%{MONTHNUM}-%{MONTHDAY}[T ]%{HOUR}:?%{MINUTE}(?::?%{SECOND})?%{ISO8601_TIMEZONE}?
DATE %{DATE_US}|%{DATE_EU}
DATESTAMP %{DATE}[- ]%{TIME}
TZ (?:[PMCE][SD]T|UTC)
DATESTAMP_RFC822 %{DAY} %{MONTH} %{MONTHDAY} %{YEAR} %{TIME} %{TZ}
DATESTAMP_RFC2822 %{DAY}, %{MONTHDAY} %{MONTH} %{YEAR} %{TIME} %{ISO8601_TIMEZONE}
DATESTAMP_OTHER %{DAY} %{MONTH} %{MONTHDAY} %{TIME} %{TZ} %{YEAR}
DATESTAMP_EVENTLOG %{YEAR}%{MONTHNUM2}%{MONTHDAY}%{HOUR}%{MINUTE}%{SECOND}

# Syslog Dates: Month Day HH:MM:SS
SYSLOGTIMESTAMP %{MONTH} +%{MONTHDAY} %{TIME}
PROG (?:[\w._/%-]+)
SYSLOGPROG %{PROG:program}(?:\[%{POSINT:pid}\])?
SYSLOGHOST %{IPORHOST}
SYSLOGFACILITY <%{NONNEGINT:facility}.%{NONNEGINT:priority}>
HTTPDATE %{MONTHDAY}/%{MONTH}/%{YEAR}:%{TIME} %{INT}

# Shortcuts
QS %{QUOTEDSTRING}

# Log formats
SYSLOGBASE %{SYSLOGTIMESTAMP:timestamp} (?:%{SYSLOGFACILITY} )?%{SYSLOGHOST:logsource} %{SYSLOGPROG}:
COMMONAPACHELOG %{IPORHOST:clientip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:timestamp}\] "(?:%{WORD:verb} %{NOTSPACE:request}(?: HTTP/%{NUMBER:httpversion})?|%{DATA:rawrequest})" %{NUMBER:response} (?:%{NUMBER:bytes}|-)
COMBINEDAPACHELOG %{COMMONAPACHELOG} %{QS:referrer} %{QS:agent}

# Log Levels
LOGLEVEL ([A|a]lert|ALERT|[T|t]race|TRACE|[D|d]ebug|DEBUG|[N|n]otice|NOTICE|[I|i]nfo|INFO|[W|w]arn?(?:ing)?|WARN?(?:ING)?|[E|e]rr?(?:or)?|ERR?(?:OR)?|[C|c]rit?(?:ical)?|CRIT?(?:ICAL)?|[F|f]atal|FATAL|[S|s]evere|SEVERE|EMERG(?:ENCY)?|[Ee]merg(?:ency)?)

파이프라인을 통해 잘 파싱된 로그가 elasticsearch에 적재될려면 앞서 만들었던 아이디와 패스워드를 같이 넣어줘야한다.

output elasticsearch에 user와 password 필드를 추가하여 저장한다.

output {
  elasticsearch {
    hosts => "elasticsearch:9200"
    user => "user"
    password => "pass"
    action => "index"
    manage_template => false
    index => "%{[@metadata][target_index]}"
  }
}

마지막으로 모든 설정이 끝났으면 이제 로그가 잘 적재되고 있는것을 kibana에서 확인할 수 있다.

kibana에서 elasticsearch에 적재된 파일 패턴을 필터링하여 보면 아래와 같이 확인할 수 있다.

약 3일동안 nginx 로그가 5억 5천만개 가량 쌓인걸 볼수 있다.

이제 nginx뿐만 아니라 다양한 종류의 로그들을 적재하고 kibana visualize를 사용하여 분석할 수도 있게 되었다.

구축하는 동안 여러 레퍼런스를 참고하였으며 즐겁게 작업할 수 있었다.