Прекрасный момент: система написана, часть тестов автоматизирована, отчеты сгенерированы и даже выявлено несколько дефектов. Выдыхаем и будем думать о дальнейших планах: увеличить тестовое покрытие, добавить стабильности и что-то делать с логированием. Стоп, подождите, вы про логи? Заряжаем 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. Наверняка это увлекательно делать пару раз для демо, но не каждый день во время отладки тестов. Теперь же это один файл, где еще могут (а у нас они есть) логироваться статус подключений, тестовые данные, фоновые операции.
В заключение
ELK — один из основных инструментов для работы с логами, однако мне так и не удалось решить следующие проблемы:
избыточная зависимость: большинство функций не используются, а поддержка и администрирование требуют много времени и ресурсов;
скорость обработки(доставки): автотесты должны быть не только быстрыми, но и стабильными. Сетевые задержки, проблемы с промежуточными буферами приводили к нестабильным тестам.
Поэтому и решил написать своё решение.
Возможно, я просто неправильно приготовил ELK и rsyslog в контексте автоматизации тестирования и был слишком необъективен, так как не люблю тащить что-то огромное ради пары функций, поэтому буду рад, если поделитесь своими подходами по работе с логами в тестах.
ashkraba
Советую попробовать grafana loki, глядишь не придется ничего изобретать)