Прекрасный момент: система написана, часть тестов автоматизирована, отчеты сгенерированы и даже выявлено несколько дефектов. Выдыхаем и будем думать о дальнейших планах: увеличить тестовое покрытие, добавить стабильности и что-то делать с логированием. Стоп, подождите, вы про логи? Заряжаем ELK, агенты сбора, выделяем ресурсы. Как нет ресурсов? Так, может быть, rsyslog в конце концов? И его нельзя? Звучит как тестовая задача на собеседовании, однако можно ли в такой ситуации обойтись без дополнительных инструментов, да еще и интегрировать работу с логами в систему автоматизации тестирования?

Привет, Хабр! Меня зовут Олег, и я работаю в команде по инфраструктурной разработке в Tarantool: мы развиваем библиотеки и сервисы, которые помогают разработчикам удобно и эффективно тестировать свой код. Также я преподаю на кафедре информатики и вычислительной математики в Самарском университете, ну а в свободное время являюсь ментором на платформе GetMentor.

Сегодня хочется затронуть одну из интересных тем в контексте автоматизации тестирования — работу с логами. Расскажу о схеме взаимодействия, основанную на Python и модуле subprocess, хотя изначально реализация была сделана на Lua + С, так что используемые механизмы вполне удачно переносятся на другие языки. И да, забегая вперед — я тоже теперь в шоке, что работа с логами может быть быстрой.

Постановка проблемы

Представим, что мы проводим тестирование системы с несколькими компонентами. В процессе тестирования система автоматизации работает с одним или несколькими компонентами на одном физическом сервере. Для большинства функциональных тестов достаточно просто аккуратно запускать и останавливать процессы компонентов и взаимодействовать с ним по API. Однако для расширения покрытия тестов может потребоваться анализ логов — обнаружение ошибок, артефактов фоновых задач, отладочных сообщений и прочего. 

Компоненты выводят информацию в стандартные потоки вывода (stdout) и ошибок (stderr), что соответствует принципам The Twelve-Factor App. При этом мы можем работать с несколькими компонентами, включая систему автоматизации, которая также может вести логирование внутренних состояний. Наша цель — собрать все логи в один файл для последующего анализа, подобно тому, как сквозной идентификатор X-Request-ID используется для объединения всех контекстов по запросу. При этом мы хотим создавать тесты, основанные на анализе логов во время проведения тестирования: если в логе появилась строка “Segmentation fault”, то тест считается непройденным.

Пожелания к форматированию файла достаточно просты:

  • нестрогая упорядоченность по времени (от старых к новым);

  • префикс компонента (чтобы логи можно было удобно грепать).

Простой компонент

Роль условного сервера или мини-приложения будет выполнять обычный Python-скрипт (server.py), который каждую секунду пишет в stdout и stderr c помощью функции print и преднастроенного логгера:

# server.py

import time
import sys
import logging

from datetime import datetime

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)

formatter = logging.Formatter("%(asctime)s [%(levelname)s]> %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)

while True:
    # YYYY-MM-DD HH:MM:SS,000
    now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
    print(f"{now} [PRINTER]> Today was a good day!")
    logger.info("I can't believe today was a good day")
    time.sleep(1)

Запустив наш сервер, увидим ожидаемый вывод:

2024-05-24 11:23:12,988 [PRINTER]> Today was a good day!
2024-05-24 15:23:12,988 [INFO]> I can't believe today was a good day
2024-05-24 11:23:13,990 [PRINTER]> Today was a good day!
2024-05-24 15:23:13,990 [INFO]> I can't believe today was a good day
2024-05-24 11:23:14,991 [PRINTER]> Today was a good day!
2024-05-24 15:23:14,991 [INFO]> I can't believe today was a good day

Простой запуск процесса и чтение вывода

Возвращаемся к нашей системе тестирования. При запуске процесса, помимо указания subprocess.PIPE для stdout и stderr, не забудем флаг -u (или переменную окружения PYTHONUNBUFFERED) для отключения буферизации потоков вывода:

# reader.py

import sys
import os
import subprocess

p = subprocess.Popen(
    args=[sys.executable, '-u', 'server.py'],
    stderr = subprocess.PIPE,
    stdout = subprocess.PIPE
)

P.S. Для разных языков и систем буферизация отключается по-разному, стоит помнить об этом.

Теперь читаем не более buffer_size байт из файлового дескриптора стандартного вывода. Переводим в формат utf-8 и разбиваем по символу LF (‘\n’). Выводим на печать, добавляя какой-нибудь префикс:

# reader.py
...
buffer_size = 1024
try:
    while True:
        stdout_chunks = os.read(p.stdout.fileno(), buffer_size)
        formatted_chunks = stdout_chunks.decode('utf-8').split('\n')
        for line in formatted_chunks:
            if line:
                print(f"server | {line}")
        time.sleep(2)
except KeyboardInterrupt:
    p.terminate()

# output:
# server | 2024-05-24 12:36:45,336 [PRINTER]> Today was a good day!
# server | 2024-05-24 12:36:46,337 [PRINTER]> Today was a good day!
# server | 2024-05-24 12:36:47,338 [PRINTER]> Today was a good day!
# server | 2024-05-24 12:36:48,343 [PRINTER]> Today was a good day!
# server | 2024-05-24 12:36:49,347 [PRINTER]> Today was a good day!
# server | 2024-05-24 12:36:50,351 [PRINTER]> Today was a good day!

Мы молодцы — теперь весь stdout с нашего сервера можем наблюдать в терминале. Единственное, что нам мешает сейчас, — основной процесс выполнения заблокирован. Здесь на помощь приходит модуль threading и класс Thread.

class Reader(Thread):
    def __init__(self, fd: int, name: str):
        super().__init__(self)
        self.fd = fd
        self.name = name
        self.daemon = True
        self.running = True
        self._buffer_size = 1024
        self._output_delay = 1

    def run(self):
        while self.running:
            try:
                stdout_chunks = os.read(self.fd, self._buffer_size)
                formatted_chunks = stdout_chunks.decode('utf-8').split('\n')
                for line in formatted_chunks:
                    if line:
                        print(f"{self.name} | {line}")
            except Exception as e:
                print(f'Reader[{self.name}] failed: ' + str(e))

    def join(self):
        self.running = False
        return super().join()

Добавляем несколько специфичных нюансов, таких как остановка бесконечного цикла через флаг self.running, режим демона и название треда.

Теперь при запуске двух потоков (для stdout и stderr) мы явно пропишем сообщение из основного процесса, чтобы увидеть отсутствие блокировки. Для корректной остановки мы добавим обработку исключения KeyboardInterrupt — при его появлении необходимо аккуратно завершить работу процесса и двух тредов.

try:
    stdout_reader = Reader(p.stdout.fileno(), 'server_stdout')
    stderr_reader = Reader(p.stderr.fileno(), 'server_stderr')

    stdout_reader.start()
    stderr_reader.start()
    while True:
        print('main | not blocked')
        time.sleep(2)
except KeyboardInterrupt:
    stdout_reader.join()
    stderr_reader.join()
    p.terminate()

# output:
# main | not blocked
# server_stdout | 2024-05-24 13:10:53,851 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:10:53,851 [INFO]> I can't believe today was a good day
# server_stdout | 2024-05-24 13:10:54,853 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:10:54,853 [INFO]> I can't believe today was a good day
# main | not blocked
# server_stdout | 2024-05-24 13:10:55,854 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:10:55,854 [INFO]> I can't believe today was a good day

Читаем логи основного процесса и проворачиваем хитрости с файловыми дескрипторами

Запустить процесс и просто прочитать его stdout/stderr — это довольно тривиальная задача. Однако мы все еще хотим видеть сквозной лог-файл с логами системы автоматизации. Запустить Reader для чтения sys.stdout и sys.stderr не выйдет. На помощь приходят все те же pipes.

Используем логгер из файла server.py, добавив маленькую хитрость:

r, w = os.pipe()
os.dup2(w, sys.stderr.fileno())

Добавляем чтение stderr от основного процесса и пишем в него что-то:

# reader.py

r, w = os.pipe()
os.dup2(w, sys.stderr.fileno())

try:
    stdout_reader = Reader(p.stdout.fileno(), 'server_stdout')
    stderr_reader = Reader(p.stderr.fileno(), 'server_stderr')
    stderr_reader_main = Reader(r, 'main_stderr')

    stdout_reader.start()
    stderr_reader.start()
    stderr_reader_main.start()

    while True:
        print('main | not blocked')
        logger.info('yep, not blocked')
        time.sleep(2)

# output:
# main | not blocked?
# main_stderr | 2024-05-24 17:24:01,031 [INFO]> yep, not blocked
# server_stdout | 2024-05-24 13:24:01,047 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:24:01,047 [INFO]> I can't believe today was a good day
# server_stdout | 2024-05-24 13:24:02,047 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:24:02,047 [INFO]> I can't believe today was a good day
# main | not blocked?
# main_stderr | 2024-05-24 17:24:03,031 [INFO]> yep, not blocked
# server_stdout | 2024-05-24 13:24:03,049 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:24:03,049 [INFO]> I can't believe today was a good day
# server_stdout | 2024-05-24 13:24:04,051 [PRINTER]> Today was a good day!
# server_stderr | 2024-05-24 17:24:04,051 [INFO]> I can't believe today was a good day

Чтобы получить то же самое, но в файле, просто необходимо рядом с print указать файл и записывать в него:

def run(self):
    while self.running:
        try:
          ...
          if line:
              print(f"{self.name} | {line}")
              file.write(f"{self.name} | {line}\n") # <--
        except Exception as e:
          ...

Теперь в нашем распоряжении полноценный захват логов, внешних компонент и собственной системы.

Логи есть, а что с ними делать-то?

Вы можете записать все в файл как артефакт тестирования, выполнять поиск прямо по ходу теста, исследовать сложное поведение систем во время конкретного теста простым грепом и много что еще. Притом это решение портируемое, так как опирается на базовые механизмы межпроцессной коммуникации (pipes) и вытесняющей многозадачности (threads).

Например, разработчику понадобилось получить часть логов про RAFT (интересное про репликацию можете почитать в статье моего коллеги). Ранее ему необходимо было бегать по всей файловой системе: ls, cat, grep, find. Наверняка это увлекательно делать пару раз для демо, но не каждый день во время отладки тестов. Теперь же это один файл, где еще могут (а у нас они есть) логироваться статус подключений, тестовые данные, фоновые операции.

Сквозной лог-файл от трех компнент(bob, frank, john - инстансы Tarantool) и простой grep по ключевому слову
Сквозной лог-файл от трех компнент(bob, frank, john - инстансы Tarantool) и простой grep по ключевому слову

В заключение

ELK — один из основных инструментов для работы с логами, однако мне так и не удалось решить следующие проблемы:

  • избыточная зависимость: большинство функций не используются, а поддержка и администрирование требуют много времени и ресурсов;

  • скорость обработки(доставки): автотесты должны быть не только быстрыми, но и стабильными. Сетевые задержки, проблемы с промежуточными буферами приводили к нестабильным тестам.

Поэтому и решил написать своё решение.

Возможно, я просто неправильно приготовил ELK и rsyslog в контексте автоматизации тестирования и был слишком необъективен, так как не люблю тащить что-то огромное ради пары функций, поэтому буду рад, если поделитесь своими подходами по работе с логами в тестах.

Комментарии (1)


  1. ashkraba
    24.06.2024 11:03
    +4

    Советую попробовать grafana loki, глядишь не придется ничего изобретать)