Praktyczne zastosowanie ELK. Konfigurujemy logstash

Wprowadzenie

Rozwijając kolejny system, natknęliśmy się na konieczność przetwarzania dużej ilości różnorodnych logów. Jako narzędzie wybraliśmy ELK. W tym artykule mówimy o naszym doświadczeniu z konfiguracją tego stosu.

Nie mamy na celu opisywania wszystkich jego możliwości, ale chcemy skupić się na rozwiązaniu praktycznych problemów. Powodem jest to, że pomimo dużej ilości dokumentacji i gotowych obrazów, można napotkać wiele pułapek — przynajmniej u nas się one pojawiły.

Rozwijaliśmy stos przy użyciu docker-compose. Co więcej, mieliśmy dobrze napisany plik docker-compose.yml, który pozwolił nam na niemal bezproblemowe uruchomienie stosu. Wydawało się nam, że zwycięstwo jest blisko, wystarczy tylko dostosować go do naszych potrzeb i wszystko.

Niestety, próba dostosowania systemu do odbierania i przetwarzania logów z naszej aplikacji nie była od razu udana. Dlatego postanowiliśmy, że warto przyjrzeć się każdemu komponentowi osobno, a potem wrócić do ich powiązań.

Zaczęliśmy od logstash.

Środowisko, wdrażanie, uruchamianie Logstash w kontenerze

Do wdrożenia używamy docker-compose, opisywane tutaj eksperymenty przeprowadzono na MacOS i Ubuntu 18.0.4.

Obraz logstash, który został zapisany w naszym początkowym docker-compose.yml, to docker.elastic.co/logstash/logstash:6.3.2

Będziemy go używać do eksperymentów.

Aby uruchomić logstash, napisaliśmy osobny plik docker-compose.yml. Oczywiście można było uruchomić obraz z linii poleceń, ale my rozwiązywaliśmy konkretne zadanie, gdzie wszystko uruchamiamy z docker-compose.

Krótko o plikach konfiguracyjnych

Jak wynika z opisu, logstash można uruchamiać zarówno dla jednego kanału, w takim przypadku trzeba mu przekazać plik *.conf, jak i dla kilku kanałów, wtedy należy przekazać plik pipelines.yml, który z kolei będzie odwoływał się do plików .conf dla każdego kanału.
Wybraliśmy drugą drogę. Wydała nam się bardziej uniwersalna i skalowalna. Dlatego stworzyliśmy pipelines.yml i utworzyliśmy katalog pipelines, w którym będziemy umieszczać pliki .conf dla każdego kanału.

Wewnątrz kontenera znajduje się jeszcze jeden plik konfiguracyjny — logstash.yml. Nie dotykamy go, używamy tak jak jest.

A więc struktura naszych katalogów:

Praktyczne zastosowanie ELK. Konfigurujemy logstash

Aby uzyskać dane wejściowe, przyjmujemy, że to tcp na porcie 5046, a do wyjścia użyjemy stdout.

Oto prosta konfiguracja do pierwszego uruchomienia. Przecież podstawowym celem jest uruchomienie.

Mamy zatem taki plik docker-compose.yml

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      	- elk
    ports:
      	- 5046:5046
    volumes:
      	- ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
	- ./config/pipelines:/usr/share/logstash/config/pipelines:ro

Co tutaj widzimy?

  1. Networks i volumes były wzięte z oryginalnego docker-compose.yml (tego, w którym uruchamiany jest cały stos) i myślę, że znacznie nie wpływają na ogólny obraz.
  2. Tworzymy jeden serwis (services) logstash z obrazu docker.elastic.co/logstash/logstash:6.3.2 i nadajemy mu nazwę logstash_one_channel.
  3. Przekazujemy wewnątrz kontenera port 5046 na ten sam port wewnętrzny.
  4. Mapujemy nasz plik konfiguracyjny kanałów ./config/pipelines.yml na plik /usr/share/logstash/config/pipelines.yml wewnątrz kontenera, skąd zostanie przechwycony przez logstash i robimy go tylko do odczytu, na wszelki wypadek.
  5. Mapujemy również katalog ./config/pipelines, gdzie znajdują się pliki z ustawieniami kanałów, do katalogu /usr/share/logstash/config/pipelines i również robimy go tylko do odczytu.

Praktyczne zastosowanie ELK. Konfigurujemy logstash

Plik pipelines.yml

- pipeline.id: HABR
  pipeline.workers: 1
  pipeline.batch.size: 1
  path.config: "./config/pipelines/habr_pipeline.conf"

Tutaj opisano jeden kanał z identyfikatorem HABR i ścieżkę do jego pliku konfiguracyjnego.

I w końcu plik "./config/pipelines/habr_pipeline.conf"

input {
  tcp {
    port => "5046"
   }
  }
filter {
  mutate {
    add_field => [ "habra_field", "Hello Habr" ]
    }
  }
output {
  stdout {
      
    }
  }

Nie będziemy teraz zagłębiać się w jego opis, spróbujmy uruchomić:

docker-compose up

Co widzimy?

Kontener uruchomił się. Możemy sprawdzić jego działanie:

echo '13123123123123123123123213123213' | nc localhost 5046

I widzimy w konsoli kontenera odpowiedź:

Praktyczne zastosowanie ELK. Konfigurujemy logstash

Ale jednocześnie widzimy także:

logstash_one_channel | [2019-04-29T11:28:59,790][ERROR][logstash.licensechecker.licensereader] Nie można pobrać informacji o licencji z serwera licencji {:message=>"Elasticsearch Unreachable: [http://elasticsearch:9200/][Manticore::ResolutionFailure] elasticsearch", …

logstash_one_channel | [2019-04-29T11:28:59,894][INFO ][logstash.pipeline ] Pipeline uruchomiony pomyślnie {:pipeline_id=>".monitoring-logstash", :thread=>"#"}

logstash_one_channel | [2019-04-29T11:28:59,988][INFO ][logstash.agent ] Pipelines działają {:count=>2, :running_pipelines=>[:HABR, :".monitoring-logstash"], :non_running_pipelines=>[]}
logstash_one_channel | [2019-04-29T11:29:00,015][ERROR][logstash.inputs.metrics ] X-Pack jest zainstalowany na Logstash, ale nie na Elasticsearch. Proszę zainstalować X-Pack na Elasticsearch, aby użyć funkcji monitorowania. Inne funkcje mogą być dostępne.
logstash_one_channel | [2019-04-29T11:29:00,526][INFO ][logstash.agent ] Pomyślnie uruchomiono punkt końcowy API Logstash {:port=>9600}
logstash_one_channel | [2019-04-29T11:29:04,478][INFO ][logstash.outputs.elasticsearch] Uruchamianie sprawdzenia stanu, aby zobaczyć, czy połączenie z Elasticsearch działa {:healthcheck_url=http://elasticsearch:9200/, :path="/"}
logstash_one_channel | [2019-04-29T11:29:04,487][WARN ][logstash.outputs.elasticsearch] Próba przywrócenia połączenia z martwą instancją ES zakończyła się błędem. {:url=«elasticsearch»:9200/, :error_type=LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :error=«Elasticsearch Niedostępny: [http://elasticsearch:9200/][Manticore::ResolutionFailure] elasticsearch»}
logstash_one_channel | [2019-04-29T11:29:04,704][INFO ][logstash.licensechecker.licensereader] Uruchamianie sprawdzenia stanu, aby zobaczyć, czy połączenie z Elasticsearch działa {:healthcheck_url=http://elasticsearch:9200/, :path="/"}
logstash_one_channel | [2019-04-29T11:29:04,710][WARN ][logstash.licensechecker.licensereader] Próba przywrócenia połączenia z martwą instancją ES zakończyła się błędem. {:url=«elasticsearch»:9200/, :error_type=LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :error=«Elasticsearch Niedostępny: [http://elasticsearch:9200/][Manticore::ResolutionFailure] elasticsearch»}

A nasz log ciągle się powiększa.

Tutaj zaznaczyłem na zielono komunikat, że pipeline został uruchomiony pomyślnie, na czerwono — komunikat o błędzie, a na żółto — komunikat o próbie nawiązania kontaktu z elasticsearch:9200.
To się dzieje, ponieważ w logstash.conf, dołączonym do obrazu, jest sprawdzenie dostępności Elasticsearch. Logstash zakłada, że działa w składzie stosu Elk, a my go oddzieliliśmy.

Można pracować, ale to niewygodne.

Rozwiązaniem jest wyłączenie tego sprawdzenia za pomocą zmiennej środowiskowej XPACK_MONITORING_ENABLED.

Wprowadzimy zmianę w docker-compose.yml i uruchomimy ponownie:

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      - elk
    environment:
      XPACK_MONITORING_ENABLED: "false"
    ports:
      - 5046:5046
   volumes:
      - ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
      - ./config/pipelines:/usr/share/logstash/config/pipelines:ro

Teraz wszystko jest w porządku. Kontener gotowy do eksperymentów.

Możemy ponownie wpisać w sąsiednim terminalu:

echo '13123123123123123123123213123213' | nc localhost 5046

I zobaczyć:

logstash_one_channel | {
logstash_one_channel |         "message" => "13123123123123123123123213123213",
logstash_one_channel |      "@timestamp" => 2019-04-29T11:43:44.582Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |            "host" => "gateway",
logstash_one_channel |            "port" => 49418
logstash_one_channel | }

Praca w ramach jednego kanału

Zatem wystartowaliśmy. Teraz możemy poświęcić czas na skonfigurowanie logstash. Na razie nie będziemy ruszać pliku pipelines.yml, zobaczmy, co można uzyskać, pracując z jednym kanałem.

Trzeba powiedzieć, że ogólny zasada pracy z plikiem konfiguracyjnym kanału jest dobrze opisana w oficjalnym podręczniku, oto tutaj
Jeśli chcesz przeczytać po polsku, korzystaliśmy z tego artykułu(ale składnia zapytań tam jest stara, trzeba to uwzględnić).

Przejdźmy etapami od sekcji Input. Zrobiliśmy już pracę z tcp. Co jeszcze może być tutaj interesującego?

Wiadomości testowe, używając heartbeat

Istnieje interesująca możliwość generowania automatycznych wiadomości testowych.
Aby to zrobić, w sekcji input trzeba włączyć wtyczkę heartbeat.

input {
  heartbeat {
    message => "HeartBeat!"
   }
  } 

Włączamy, zaczynamy otrzymywać co minutę

logstash_one_channel | {
logstash_one_channel |      "@timestamp" => 2019-04-29T13:52:04.567Z,
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |         "message" => "HeartBeat!",
logstash_one_channel |        "@version" => "1",
logstash_one_channel |            "host" => "a0667e5c57ec"
logstash_one_channel | }

Chcemy otrzymywać częściej, musimy dodać parametr interval.
W ten sposób otrzymamy wiadomość co 10 sekund.

input {
  heartbeat {
    message => "HeartBeat!"
    interval => 10
   }
  }

Odbieranie danych z pliku

Postanowiliśmy także sprawdzić tryb file. Jeśli wszystko działa poprawnie z plikiem, to być może nie będziemy potrzebować żadnego agenta, przynajmniej do użytku lokalnego.

Według opisu, tryb pracy powinien być podobny do tail -f, tzn. odczytuje nowe linie lub, jako opcja, odczytuje cały plik.

A więc, co chcemy osiągnąć:

  1. Chcemy otrzymywać linie, które są dodawane do jednego pliku log.
  2. Chcemy otrzymywać dane, które są zapisywane w kilku plikach log, mając jednocześnie możliwość rozdzielenia, co skąd pochodzi.
  3. Chcemy sprawdzić, czy po ponownym uruchomieniu logstash nie otrzyma tych danych ponownie.
  4. Chcemy sprawdzić, co się stanie, jeśli wyłączymy logstash, a dane będą nadal zapisywane do plików. Kiedy go uruchomimy ponownie, chcemy otrzymać te dane.

Aby przeprowadzić eksperyment, dodamy jeszcze jeden wiersz do docker-compose.yml, otwierając katalog, w którym umieszczamy pliki.

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      - elk
    environment:
      XPACK_MONITORING_ENABLED: "false"
    ports:
      - 5046:5046
   volumes:
      - ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
      - ./config/pipelines:/usr/share/logstash/config/pipelines:ro
      - ./logs:/usr/share/logstash/input

I zmienimy sekcję input w habr_pipeline.conf

input {
  file {
    path => "/usr/share/logstash/input/*.log"
   }
  }

Uruchamiamy:

docker-compose up

Aby stworzyć i zapisać pliki log, będziemy używać polecenia:

echo '1' >> logs/number1.log

{
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:28:53.876Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |         "message" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number1.log"
logstash_one_channel | }

Tak, działa!

Widzimy, że automatycznie dodano pole path. Oznacza to, że w przyszłości będziemy mogli filtrować zapisane rekordy według tego pola.

Spróbujmy jeszcze raz:

echo '2' >> logs/number1.log

{
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:28:59.906Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |         "message" => "2",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number1.log"
logstash_one_channel | }

A teraz do innego pliku:

 echo '1' >> logs/number2.log

{
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:29:26.061Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |         "message" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number2.log"
logstash_one_channel | }

Świetnie! Plik został przechwycony, ścieżka została prawidłowo określona, wszystko dobrze.

Zatrzymamy logstash i uruchomimy ponownie. Poczekajmy. Cisza. Tzn. Nie otrzymujemy tych zapisów ponownie.

A teraz najodważniejszy eksperyment.

Kładziemy logstash i wykonujemy:

echo '3' >> logs/number2.log
echo '4' >> logs/number1.log

Ponownie uruchamiamy logstash i widzimy:

logstash_one_channel | {
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |         "message" => "3",
logstash_one_channel |        "@version" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number2.log",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:48:50.589Z
logstash_one_channel | }
logstash_one_channel | {
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |         "message" => "4",
logstash_one_channel |        "@version" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number1.log",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:48:50.856Z
logstash_one_channel | }

Hurra! Wszystko zostało przechwycone.

Ale trzeba ostrzec o następującym. Jeśli kontener z logstash zostanie usunięty (docker stop logstash_one_channel && docker rm logstash_one_channel), nie zostanie nic przechwycone. Wewnątrz kontenera została zapisana pozycja pliku, do której został odczytany. Jeśli uruchomisz 'od zera', będzie przyjmował tylko nowe wiersze.

Odczytywanie już istniejących plików

Załóżmy, że pierwszy raz uruchamiamy logstash, ale już mamy logi i chcielibyśmy je przetworzyć.
Jeśli uruchomimy logstash z sekcją input, którą użyliśmy powyżej, to nic nie otrzymamy. Tylko nowe linie będą przetwarzane przez logstash.

Aby zaciągnąć informacje z istniejących plików, należy dodać w sekcji input dodatkowy wiersz:

input {
  file {
    start_position => "beginning"
    path => "/usr/share/logstash/input/*.log"
   }
  }

Jest jednak pewien szczegół, to działa tylko na nowych plikach, których logstash jeszcze nie widział. Dla plików, które już były w zasięgu logstash, zapamiętał ich rozmiar i teraz będzie brał tylko nowe wpisy.

Zatrzymajmy się na tym na badaniu sekcji input. Jest tam jeszcze wiele opcji, ale na nasze dalsze eksperymenty na razie wystarczy.

Routing i transformacja danych

Spróbujmy rozwiązać następujące zadanie, załóżmy, że mamy wiadomości z jednego kanału, część z nich to wiadomości informacyjne, a część to wiadomości o błędach. Różnią się tagami. Jedne to INFO, a inne to ERROR.

Musimy je podzielić na wyjściu. Tzn. wiadomości informacyjne piszemy do jednego kanału, a wiadomości o błędach do drugiego.

W tym celu przechodzimy od sekcji input do filter i output.

Za pomocą sekcji filter rozdzielimy przychodzącą wiadomość, uzyskując z niej hash (pary klucz-wartość), z którym już można pracować, tzn. analizować według warunków. A w sekcji output, wybierzemy wiadomości i wyślemy każdą do swojego kanału.

Analiza wiadomości za pomocą grok

Aby rozbierać tekstowe linie i uzyskać z nich zestaw pól, w sekcji filter jest specjalny plugin — grok.

Nie mając na celu dokładnego opisu (odsyłam do oficjalnej dokumentacji), podam mój prosty przykład.

W tym celu musimy określić format wejściowych linii. U mnie są one takie:

1 INFO message1
2 ERROR message2

Tzn. Identyfikator na pierwszym miejscu, następnie INFO/ERROR, a potem jakieś słowo bez spacji.
Proste, ale wystarczy do zrozumienia zasady działania.

Zatem, w sekcji filter, w pluginie grok musimy określić wzór do analizy naszych linii.

Będzie on wyglądał tak:

filter {
  grok {
    match => { "message" => ["%{INT:message_id} %{LOGLEVEL:message_type} %{WORD:message_text}"] }
   }
  } 

W zasadzie, to jest wyrażenie regularne. Używane są już gotowe wzory, takie jak INT, LOGLEVEL, WORD. Opis ich oraz inne wzory można znaleźć tutaj tutaj

Teraz, przechodząc przez ten filtr, nasza linia przekształci się w hash z trzema polami: message_id, message_type, message_text.

To właśnie one będą wyświetlane w sekcji output.

Routing wiadomości w sekcji output za pomocą komendy if

W sekcji output, jak pamiętamy, zamierzaliśmy podzielić wiadomości na dwa strumienie. Jedne — te, które są iNFO, wyświetlimy na konsoli, a te z błędami zapiszemy do pliku.

Jak podzielić te wiadomości? Warunek zadania już podpowiada rozwiązanie — mamy już wyróżnione pole message_type, które może przyjmować tylko dwie wartości INFO i ERROR. Właśnie na tej podstawie dokonamy wyboru za pomocą operatora if.

if [message_type] == "ERROR" {
        # Tutaj zapisujemy do pliku
       } else
     {
      # Tutaj wyświetlamy w stdout
    }

Opis pracy z polami i operatorami można zobaczyć w tej sekcji oficjalnej dokumentacji.

Teraz, przechodzimy do samego wyjścia.

Wypis na konsolę, tutaj wszystko jest jasne — stdout {}

Natomiast wypis do pliku — pamiętamy, że uruchamiamy to wszystko z kontenera i aby plik, do którego zapisujemy wynik, był dostępny na zewnątrz, musimy otworzyć ten katalog w docker-compose.yml.

Podsumowując:

Sekcja output naszego pliku wygląda tak:

output {
  if [message_type] == "ERROR" {
    file {
          path => "/usr/share/logstash/output/test.log"
          codec => line { format => "custom format: %{message}"}
         }
    } else
     {stdout {
             }
     }
  }

W docker-compose.yml dodajemy jeszcze jeden wolumin do wypisu:

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      - elk
    environment:
      XPACK_MONITORING_ENABLED: "false"
    ports:
      - 5046:5046
   volumes:
      - ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
      - ./config/pipelines:/usr/share/logstash/config/pipelines:ro
      - ./logs:/usr/share/logstash/input
      - ./output:/usr/share/logstash/output

Uruchamiamy, próbujemy, widzimy podział na dwa strumienie.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster