Есть много туториалов, которые рассказывают как хранить логи Rails приложения в ELK, большинство сводится либо к парсингу логов, либо сохранению краткой статистики запроса по UDP в logstash. В этой статье я бы хотел сосредоточиться на переносе абсолютно всех логов приложения в ELK по UDP.
Для примера поднимем Elastic stack локально через докер. Я воспользовался этим репозиторием. Перед началом нам нужно сказать logstash'у, что присылать мы ему будем json и чтобы он этот json преобразовал нам в список полей. Для этого откроем файл logstash/pipeline/logstash.conf и добавим в него следующие строки:
filter {
json {
source => "message"
}
}
Затем командой docker-compose up мы развернем весь стек ELK. После того, как докер запустит контейнер, по адресу http://0.0.0.0:5601/ у нас будет доступна Kibana.
Продемонстрирую процесс интеграции с ELK на специально заготовленном стенде. Я попытался воссоздать небольшое, но реалистичное приложение, которое хранит данные в базе, имеет какое-то API, использует кеш на Redis'е, что-то обрабатывает в фоне и иногда ходит в другие сервисы по HTTP. Итак, начнем с самого начала.
Добавим несколько гемов
# Gemfile
gem 'faraday'
gem 'hiredis'
gem 'httplog'
gem 'logstash-event'
gem 'logstash-logger', github: "motoroller95/logstash-logger"
gem 'redis'
gem 'sidekiq'
Расскажу про два основных гема: logstash-event
и logstash-logger
. Первый добавляет так называемый event, сущность, которая будет представлять одну запись (строчку лога), второй непосредственно сам логер. В процессе эксплуатации логера мной был найден дефект, который я исправил, но т.к. проект уже не поддерживается автором я подключил его из своего репозитория (на всякий случай pull-request висит). Так же тут присутствует еще один интересный гем httplog
, который будет логировать все исходящие HTTP запросы.
Для начала у нас есть небольшая модель
# app/models/post.rb
class Post < ApplicationRecord
# create_table :posts do |t|
# t.string :title
# t.string :content
# t.boolean :published, default: false, null: false
# t.timestamps
# end
end
Далее я добавил пару роутов
# routes.rb
namespace :api do
resources :posts, except: :edit do
post :publish, on: :member
end
resources :external_services, only: %i[index show]
end
Контроллеры
# app/controllers/api/external_services_controller.rb
class Api::ExternalServicesController < ActionController::API
def index
response = Faraday.get('https://jsonplaceholder.typicode.com/posts')
render json: JSON.parse(response.body)
end
def show
response = Faraday.get("https://jsonplaceholder.typicode.com/posts/#{params[:id]}")
render json: JSON.parse(response.body)
end
end
# app/controllers/api/posts_controller.rb
class Api::PostsController < ActionController::API
before_action :find_post, only: %i[edit update destroy publish]
after_action :clear_cache, only: %i[update destroy]
def index
page = (params[:page] || 1).to_i - 1
posts = Rails.cache.fetch("posts:#{page}") do
Post.where(published: true).limit(20).offset(20 * page).as_json
end
render json: posts
end
def create
@post = Post.create!(post_params)
render json: @post
end
def show
render json: @post
end
def update
@post.update!(post_params)
render json: @post
end
def destroy
@post.destroy!
head :ok
end
def publish
PostPublishingJob.perform_later(@post.id)
head :ok
end
private
def find_post
@post = Post.find(params[:id])
end
def clear_cache
Rails.cache.clear
end
def post_params
params.permit(:title, :content)
end
end
Сразу же покажу единственную background-job
# app/jobs/post_publishing_job.rb
class PostPublishingJob < ApplicationJob
def perform(post_id)
Post.find(post_id).update!(published: true)
Rails.cache.clear
end
end
Теперь самое важное ?-? настраиваем логи
# config/application.rb
require_relative 'boot'
require_relative '../lib/logging'
require 'rails/all'
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
logger = LogStashLogger.new(
type: :udp,
host: '0.0.0.0',
port: 5000,
sync: true,
customize_event: ->(event) { Logging::EventTransformer::Server.call(event) }
)
config.logger = logger
config.active_job.queue_adapter = :sidekiq
config.log_tags = %i[uuid]
config.colorize_logging = false
HttpLog.configure do |config|
config.enabled = true
config.logger = logger
end
redis_config = { url: 'redis://127.0.0.1:6379/0', logger: logger }
config.cache_store = :redis_cache_store, redis_config
end
end
А теперь по порядку. Первым делом создаем логер, которому говорим, что будем слать по UDP на 0.0.0.0 по 5000 порту. Я намеренно не стал использовать пакетную отправку логов и сделал sync = true
и вот почему: по протоколу UDP мы можем отправить не более 65535 байт, что с одной стороны очень много, а с другой вызывает трудности. Если в один прекрасный момент приложение попытается записать большой кусок лога (стек-трейс к примеру) и выйдет за пределы 64кб, то мы потеряем все накопленные логи, т.к. отправка в logstash упадет с ошибкой. Конечно, есть опция max_message_size
которой можно ограничить размер пакета, но в таком случае мы просто потеряем часть логов. Опция customize_event
позволяет добавить дополнительные данные в лог. По сути мы создали логер, который мало чем отличается от обычного, разве что пишет не в файл, а в сеть.
Далее укажем Rails использовать наш новосозданный логер, а так же попросим логировать request_id
, чтобы было удобно фильтровать запросы и выключим "разукрашивание" логов. Конфигурация HttpLog
довольно простая и не нуждается в пояснениях. Финальным шагов настроим cache_store и попросим логировать все запросы к кешу.
Теперь про кастомизацию эвентов: LogstashLogger позволяет добавлять пользовательские данные к эвенту. У нас будет два типа данных, в зависимости от того в каком режиме запущено приложение: либо это сервер, либо sidekiq. В обоих случаях полезно будет логировать environment, в котором сейчас запущено приложение
# lib/event_transformer.rb
class Logging::EventTransformer
def self.call(event)
event[:env] = Rails.env
end
end
Затем в случае серверной части мы добавим такой код
class Logging::EventTransformer::Server < Logging::EventTransformer
def self.call(event)
super
event[:service] = 'server'
# Так же тут можно добавить что-нибудь более интересное, но поскольку это приложение очень
# маленькое обойдемся только сервисом. Для примера покажу как это можно сделать
# event[:user_id] = RequestStore[:user_id]
# event[:some_key] = some_value
end
end
Для sidekiq будет такой сетап
class Logging::EventTransformer::Sidekiq < Logging::EventTransformer
def self.call(event)
super
event[:service] = 'sidekiq'
# Воспользуемся тем фактом, что сайдкик устанавливает много полезной информации в
# локальном хранилище треда
Thread.current[:sidekiq_context]&.each { |k, v| event[k] = v }
end
end
Однако, чтобы эвент трансформер сайдкика начал работать нужно произвести еще одну манипуляцию — сконфигурируем sidekiq-сервер:
Sidekiq.configure_server do |_config|
Rails.logger.formatter.instance_variable_set(
'@customize_event',
->(event) { Logging::EventTransformer::Sidekiq.call(event) }
)
Sidekiq.logger = Rails.logger
end
Тут как всегда есть два варианта: хороший, и по-простому. Я выбрал простой и немного покопавшись в исходниках LogstashLogger я нашел где он хранит блок кастомизации эвента и просто его переопределил. Если делать по хорошему, то нужно создать новый логер и настроить все библиотеки на его использование.
Собственно на этом вся настройка закончена, если немного поиграть с приложением, то можно будет увидеть логи в ELK:
Сервер:
и Sidekiq:
А так же эвент "в развертке":
На этом все, храните ваши логи удобно!