How-to
June 16, 2019

Как мы логи собираем

Небольшой рассказ-руководство о том, как чуть лучше познать Elasticsearch, FluentD, себя и мир.

Привет, мир!

Сегодня я хочу рассказать вам о том, как я настраивал EFK-стек для nginx, чтобы парсить кастомные access- и errors- логи.

Сразу быстро пробежимся по составляющим стека, чтобы было общее понимание:

  • Elasticsearch - поисковая база данных, которая хранит все собираемые нами метрики.
  • FluentD и FluentBit - агрегаторы логов, которые занимаются их парсингом, маршрутизацией и прочими крутыми вещами.
  • Kibana - веб-морда с возможность тонко настроить вывод информации из нашего Эластика в виде различных графиков.


Пару слов о структуре каталогов:

Для себя я определил достаточно удобную структуру каталогов для работы с docker-compose. В корне создается каталог docker, внутри которого находятся:

.
├── build # тут у нас лежит то, что билдится (спс, Кэп!)
│   └── fluentd # для каждого сервиса - отдельный каталог
├── conf # тут находятся конфигурационные файлы
│   ├── alertmanager
│   ├── alertmanager_bot
│   ├── apm
│   ├── fluentd
│   └── prometheus
├── data # в этом каталоге мы складываем бекапы, чтобы не грустить после "docker-compose down"
│   ├── elasticsearch
│   ├── grafana
│   └── kibana
└── docker-compose.yml # Сам композ-файл

Я не призываю всех и каждого соблюдать такую структуру, но мне кажется, хранить их в таком виде - вполне здравая мысль.


Цель: научится парсить access.log и errors.log nginx'a, узнавать и там, и там откуда наш клиент, получать информацию о его ОС, браузере... Короче, все то, что делает Filebeat или даже немного больше.

Разворачиваем стек

Создадим сеть в докере, а затем запилим вот такой docker-compose.yml:

version: '3'

services:

 fluentd:
    build: ./build/fluentd
    container_name: fluentd
    volumes:
      - ./fluentd/conf:/fluentd/etc
    expose:
      - "24224"
      - "24224/udp"
    depends_on:
    - elasticsearch
    networks:
      test:
        ipv4_address: 192.168.1.111
      
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.0.0
    container_name: elasticsearch
    expose:
      - "9200"
    environment:
      - node.name=es01
      - cluster.initial_master_nodes=es01
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    networks:
      test:
        ipv4_address: 192.168.1.102
    
   kibana:
    image: docker.elastic.co/kibana/kibana:7.0.0
    restart: always
    container_name: kibana
    expose:
      - "5601"
    environment:
      SERVER_NAME: yourdomain.com
    depends_on:
    - elasticsearch
    networks:
      test:
        ipv4_address: 192.168.1.103

networks:
  test:
    external: true

А для nginx на хосте была создана вот такая конфигурация /etc/nginx/conf.d/kibana.conf

upstream  kibana {
    server 192.168.1.103:5601;
}


# Редирректим на 443 порт, чтобы у нас был SSL и все было красиво
server {
    listen 80; 
    server_name yourdomain.com;
    location / {
        return 301 https://yourdomain.com$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    auth_basic "Login to continue"; #Добавляем базовую аутентификацию, чтобы к нашей кибане имели доступ только мы
    auth_basic_user_file /etc/nginx/.htpasswd; 
    satisfy any;
    allow 122.222.111.252; #указываем доверенные IP, с которых не будем спрашивать логин/пароль
    deny all;

# Указываем пути к ssl-сертификату
    include /etc/nginx/ssl_params;
    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

# Пишем логи
    access_log                /var/log/nginx/elk-access.log;
    error_log                 /var/log/nginx/elk-error.log;

    client_max_body_size 20m;
    ignore_invalid_headers off;

    location / {
       proxy_http_version 1.1;
       proxy_read_timeout 300;
       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_pass http://kibana;
    }
}

Так же создадим Dockerfile для Fluentd (в нашей структуре каталогов - в /docker/build/fluentd/), в котором добавим библиотеку GeoIP и навернем поверх пару плагинов:

FROM fluent/fluentd:v1.5-debian-1

USER root

RUN apt-get update \
 && apt-get install -y libgeoip-dev libcurl4-openssl-dev ruby-dev libc6 libtool autoconf automake build-essential 

RUN fluent-gem install fluent-plugin-elasticsearch \
 && fluent-gem install fluent-plugin-geoip

USER fluent

После чего проверим конфигурацию nginx (nginx -t), запустим наш маленьких стек (docker-compose up) и перезагрузим конфигурацию nginx'a (nginx reload).


Отлично! Теперь, если все было сделано правильно, у нас под адресу yourdomain.com доступна Kibana. Но вот незадача - стоит нам сделать docker-compose down, и все данные из контейнеров будут утеряны безвозвратно.

Для того, чтобы этого избежать, отредактируем docker-compose.yml и примонтируем внутрь контейнера область жесткого диска:

# Для Kibana: 
    volumes:
      - ./data/kibana/export.json:/usr/share/kibana/export.json:ro
# Для elasticsearch:
    volumes:
      - ./data/elasticsearch:/usr/share/elasticsearch/data

Окей, так уже гораздо лучше!

Осталось создать конфигурационный файл для FluentD (docker/conf/fluentd/fluent.conf):

<source>
  @type forward
  format nginx
  port 24224
  bind 0.0.0.0
</source>

<match *.**>
  @type copy
  <store>
    @type elasticsearch
    host elasticsearch
    port 9200
    logstash_format true
    logstash_prefix test
    logstash_dateformat %Y%m%d
    include_tag_key true
    type_name access_log
    tag_key @log_name
    flush_interval 1s
  </store>

  <store>
    @type stdout
  </store>
</match>

Теперь наш FluentD будет просто слушать порт и перекидывать все поступившие записи в Stdout и в Elasticsearch.


Fluent Bit

Теперь поставим на хост FluentBit и настроим его.

По умолчанию Fluent Bit умеет работать только с access.log nginx'a, поэтому нам нужен парсер для errors.log. Откроем и изучим каталог встроенных парсеров (/etc/td-agent-bit/parsers.conf): как можно заметить, парсинг выполняется по регулярным выражениям.

Кстати, есть очень удобный инструмент для написания регулярных выражений для Ruby-приложений - Rubular.

Добавим наш парсер для errors.log:

[PARSER]
    Name   nginx_error
    Format regex
    Regex ^(?<time>.*) \[(?<log_level>\w+)\] (?<pid>\d+).(?<tid>\d+): (?<message>.*), client: (?<remote>.*), server: (?<server>.*)$
    Time_Key time
    Time_Format %Y/%m/%d %H:%M:%S

После чего отредактируем td-agent-bit.conf

[SERVICE]
    Flush        5
    Daemon       Off
    Log_Level    info
    Parsers_File parsers.conf
    Plugins_File plugins.conf
    HTTP_Server  Off
    HTTP_Listen  127.0.0.1
    HTTP_Port    2020

[INPUT]
    Name tail
    Tag  nginx.access
    Path /var/log/nginx/app-access.log
    Parser nginx
    Interval_Sec 1

[INPUT]
    Name tail
    Tag  nginx.errors
    Path /var/log/nginx/app-bigbird-error.log
    Parser nginx_error
    Interval_Sec 1

[OUTPUT]
    Name  forward
    Match *
    Host 192.168.1.111
    Port 24224

Теперь можно перезапустить FluentBit и наслаждаться тем, как резво подтягиваются данные в интерфейсе Kibana. Но любой, кто хоть раз пользовался FileBeat'ом от Эластика заметит, что количество собираемых данных ничтожно мало - тут тебе ни удобоваримой инфы о браузере, ни о ОС, ни геолокации... Но все это можно легко исправить!

Я написал небольшой скриптик на lua, который на основе содержимого поля "agent" определяет, какой ОС и каким браузером пользуется клиент. Взять его можно на GitHub.

Подключим этот скрипт в наш td-agent-bit.conf, добавив пару фильтров в наш папйплайн обработки логов:

[FILTER]
    Name lua
    Match nginx.access
    script agentparser.lua
    call defineOS 

[FILTER]
    Name lua
    Match nginx.access
    script agentparser.lua
    call defineBrowser

И после перезапуска FluentBit мы видим это:

Успех!


Но этого мне было мало, и я решил, что было бы неплохо, если б мы могли видеть request_time каждого пользователя. Благо, nginx позволяет кастомизировать формат своего access.log

Подправим nginx.conf, добавив туда новый формат для лога:

log_format timed_combined '$remote_addr $remote_user [$time_local] '
    				'"$request" $status $body_bytes_sent '
    				'"$http_referer" "$http_user_agent" '
    				'$request_time $upstream_response_time';

И присвоим его access-логу нашего приложения.

Так же добавим еще один парсер в /etc/td-agent-bit/parsers.conf:

[PARSER]
    Name   nginx-new
    Format regex
    Regex ^(?<remote>[^ ]*) (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>\S+)(?: +(?<path>[^\"]*?)(?: +\S*)?)?" (?<code>[^ ]*) (?<size>[^ ]*)(?: "(?<referer>[^\"]*)" "(?<agent>[^\"]*)" (?<request_time>[^ ]*) (?<responce_time>[^ ]*))?$
    Time_Key time
    Time_Format %d/%b/%Y:%H:%M:%S %z

И изменим парсер в td-agent-bit.conf

GeoIP. Узнаем, где наши пользователи.

На самом деле, FluentBit хорош всем, и особо любопытные могли уже задаться вопросом - "Так, погодите, а зачем вообще использовать FluentD, когда FluentBit может точно так же слать логи в Elasticsearch?". И действительно, зачем?..

На самом деле, причина в том, что плагин GeoIP доступен только для "старшего брата" (на момент написания заметки, 31.05.2019).

Именно поэтому вернемся к FluentD (а именно - к его конфигурационному файлу).

<source>
  @type forward
  port 24224
  bind 0.0.0.0
</source>

<filter nginx.**>
  @type geoip
  geoip_lookup_keys remote
  backend_library geoip2_c
  <record>
    city            ${city.names.ru["remote"]} # можно заменить 'ru' на 'en', если в этом есть необходимость
    country         ${country.iso_code["remote"]}
    country_name    ${country.names.ru["remote"]}
    region_code     ${subdivisions.0.iso_code["remote"]}
    region_name     ${subdivisions.0.names.ru["remote"]}
    location        '[ ${location.longitude["remote"]},${location.latitude["remote"]} ]' #именно в таком порядке. См. документацию ElasticSearch для того, чтобы узнать подробности.
  </record>
  skip_adding_null_record true # если не удается установить геолокацию для какого-либо ip-адреса, поля не добавляются
</filter>

<match nginx.**> # Фильтруем записи по тэгу, чтобы в эластик не падал всякий мусор
  @type copy
  <store>
    @type elasticsearch
    host 192.168.1.102
    port 9200
    logstash_format true
    logstash_prefix nginx
    logstash_dateformat %Y%m%d
    include_tag_key true
    type_name access_log
    flush_interval 0.1s
  </store>
# все еще оставляем вывод в stdout для дебага
  <store>
    @type stdout
  </store>
</match>

После чего рестартанем контейнер с FluentD и увидим, что у нас пишутся гео-координаты, выставляется город, страна и т.д.


Kibana и Elasticsearch. Настройка дашбордов и немного боли.

Я не буду тут сильно вдаваться в подробности о том, как настраивать визуализации и дашборды. Могу сказать лишь то, что если вы совсем ничего не понимаете - поставьте какой-нибудь Filebeat, экспортируйте из него дашборды и потыкайтесь в них.

Но рано или поздно вы столкнетесь с несоответствием типов данных (например, если в нашем примере попробуете создать на основе данных визуализацию в виде карты, на которой точками будут отмечены ваши клиенты). Проблема заключается в том, что Elastic занимается динамической типизацией индексов. Что еще хуже - их нельзя изменить. И ладно бы мы писали в один индекс, но ведь они у нас в logstash-формате (т.е. вместо одного жирного индекса nginx у нас куча индексов поменьше: nginx-20190529, nginx-20190530, nginx-20190531...) Что же тогда делать?

Тогда делать temlates. Если говорить простыми словами, template - это свод правил, который распространяется на все индексы, соответствующие его маске.

Идем в Kibana Dev Tools и пишем:

PUT _template/nginx
{
    "index_patterns" : [
      "nginx-*" #та самая маска
    ],
    "mappings" : { # а как раз тут мы и задаем жесткое соотвествие между полем и его типом
        "properties": {
            "location": {
          "type": "geo_point"
           }
        }
     }
  }


Итого:

  • Распарсили кастомный access.log;
  • Так же распарсили errors.log;
  • Знаем информацию о ОС и браузере клиента;
  • Знаем его приблизительную геолокацию;
  • Имеем чуть большее представление о том, как работают индексы в Эластике и как изменить маппинг.

На этом сегодня все, удачных экспериментов!