О чем статья
Расследуем «заговор» разработчиков HDD и ищем причину проблем с записью трафика PT NAD
PT Network Attack Discovery (PT NAD) — инструмент для выявления аномальной сетевой активности и целенаправленных атак. Он захватывает трафик со скоростями 100 Мбит/с — 10 Гбит/с, индексирует и хранит его исходные копии в формате PCAP. Трафик разбирается на сессии (TCP/UDP/ICMP/etc-соединения), о которых можно получить метаинформацию: время начала и окончания соединения, количество переданных данных, используемые протоколы и др. Для хранения сессий обычно используется массив HDD. Таким образом, пользователи PT NAD могут скачивать дампы конкретных соединений в формате PCAP для анализа и изучения сторонними приложениями, например Wireshark.

Однажды клиент обратился к нам с проблемой: трафик успешно пишется, но со временем утилизация дисков достигает 100% и данные начинают записываться с потерями. В качестве хранилища он использовал RAID 0, собранный из двух HDD с производительностью 250 MB/s. Скорость трафика — 350 MB/s. По идее, диски должны были справляться, поэтому клиент решил, что проблема в PT NAD. А у нас появилась другая догадка, и мы решили ее проверить…
Как пишется трафик
Когда дампы трафика заполняют 90% хранилища, начинается процесс ротации: старые сессии удаляются, чтобы 10% памяти всегда оставались свободными. Таким образом, хранилище содержит записи сессий за последние N дней (N зависит от размера хранилища и объема поступающего в PT NAD сетевого трафика).
Сессии в хранилище объединяются в файлы размером примерно 1 GiB: запись в них происходит последовательно, блоками по 2 MiB. При этом используется Direct I/O — флаг открытия файла O_DIRECT. Также для поиска конкретной сессии в файле с трафиком предусмотрены небольшие индексные файлы.
Если хранилище не успевает записывать данные, часть информации будет теряться. Например, для трафика 10G нужны стабильные 1,25 GB/s или более.
Сразу ответим на логичный вопрос: почему мы не рассматриваем SSD-диски для хранения трафика? Потому что серверные SSD не только быстрее, но намного дороже HDD, а для решения подобных задач их нужно много. Речь идет о десятках терабайт данных.
Подготовка к тестированию
Для исследования проблемы мы собрали тестовый стенд:
- Взяли сервер с 8 одинаковыми дисками по 8 TB. Скорость их записи по характеристикам — 255 MB/s. Через аппаратный RAID-контроллер (с включенным кэшем) собрали массив RAID 6 с полезной емкостью 48 TB. Поскольку данные пишутся большими блоками и последовательно, мы получаем Full Stripe Writes и максимальную скорость записи. Теоретическая скорость записи в этот массив должна достигать 255 MB/s × 6, т. е. приблизительно 1,5 GB/s.
- В хранилище создали раздел файловой системы ext4.
- В качестве «сети» для тестов взяли двухпортовую сетевую карту: в один порт проигрывается трафик с помощью tcpreplay, второй слушает PT NAD.
- азвернули на сервере PT NAD и ограничили его функциональность: отключили все, кроме записи дампов в хранилище.
Для эмуляции трафика использовали tcpreplay + netmap:
sudo tcpreplay -K -i ens2f0 --topspeed -l 0 --netmap --nm-delay=5 --unique-ip /home/administrator/pcaps/syslog_1mb.pcap.
В таком режиме tcpreplay проигрывает трафик со скоростью 9832,78 Mbps, 2490070.41 pps.
Кто виноват?
Мы запустили и оставили сервер примерно на сутки, а затем проанализировали полученные данные. Выяснилось, что запись идет нестабильно: примерно четыре часа хранилище работает нормально, а потом скорость записи начинает отставать от скорости трафика. Затем цикл повторяется (см. рис. 3).

- DPI traffic, DPI drops — статистика по захвату трафика до обработки и записи
- PCAP writer — статистика по записи дампов в хранилище
- PCAP writer buffers use — статистика по буферам записи. Видим, как появляются дропы записи
- IO Bytes, IO Counts, IO utilization — IO-статистика по хранилищу от Linux
- Free disk space — показатель свободного пространства в хранилище. Видим, что он держится на отметке 10%
Чтобы убедиться, что проблема здесь не в PT NAD, мы решили протестировать хранилище утилитой fio. Взяли версию 3.36 и подобрали конфиг так, чтобы процесс записи выглядел максимально похоже на наш продукт:
[write-test]
ioengine=libaio
rw=write
bs=2048k
iodepth=8
fallocate=truncate
direct=1
filesize=1073741824
nrfiles=10000
openfiles=12
group_reporting=1
numjobs=2
unique_filename=1
directory=/pcaps/fio_tests
Далее мы запустили тест, в рамках которого два процесса писали по 10 ТБ каждый (см. рис. 4).

В отличие от PT NAD, fio не ограничена входящим трафиком и пишет с максимальной скоростью. При этом хранилище изначально не было пустым. На старте скорость утилиты держалась в районе 1,3 GB/s, а затем начала проседать вплоть до 660 MB/s.
Делаем вывод: PT NAD не виноват :) fio показывает примерно такие же результаты: да, в начале цикла скорость утилиты была повыше, но у PT NAD есть упор в трафик (tcpreplay выдает 10G, не более). При этом теоретические 1,5 GB/s fio показывает только на пустом хранилище.
В поисках корня зла мы решили сменить файловую систему. Развернули на хранилище XFS и снова запустили тест с PT NAD (см. рис. 5).

Снова видим колебания, но не такие значительные. Средняя скорость записи за сутки и на ext4, и на XFS — около 1 GB/s. Этого недостаточно для трафика 10G, если полностью писать его на диск (помним, что нам нужны стабильные 1,25 GB/s). При этом теоретическая скорость записи на диски в RAID 6 вообще должна держаться около 1,5 GB/s. В целом XFS работает стабильнее: просадок в два раза нет, но изначальная скорость меньше.
Далее мы решили провести еще несколько тестов с другими конфигурациями. Разобрали RAID и настроили обособленную запись в диски: два ext4, три XFS и три ZFS (без сжатия). На каждый из них попадает примерно 156 МБ/с (см. рис. 6–8).



С каждой конфигурацией наблюдаем аналогичные циклы: когда утилизация диска достигает 100%, появляются дропы по записи. XFS и ext4 и идут практически на равных, но у ext4 один длинный цикл понижения производительности, а у XFS — два коротких. При этом в RAID 6 XFS работает стабильнее, а ZFS показывает более высокий IO utilization.
Кроме того, мы протестировали RAID 0: его теоретическая скорость записи должна быть выше, чем у RAID 6. В нашем случае это целых 2 GB/s (считаем по формуле: количество дисков умножить на производительность одного диска).

Отметим, что в какие-то моменты даже RAID 0 не успевает записывать весь трафик. Ради интереса мы также протестировали RAID 10: там скорость еще ниже.
О чем недоговаривают производители
Пора поговорить об устройстве HDD. Грубо говоря, это вращающийся блин, на котором в плоскости по направлению вращения расположены дорожки с данными.
В кольце на внешней дорожке HDD помещается больше всего секторов, и вращаются они быстрее. По мере заполнения диска секторов становится все меньше, а скорость их вращения снижается. Как результат, скорость записи начинает проседать.
В нашем хранилище этот эффект проявляется в виде тех самых циклов, которые наглядно видны на представленных выше графиках. Для проверки гипотезы мы решили провести тест с помощью утилиты dd: записать данные сразу на диск, минуя файловую систему и подбирая разные смещения (чем больше смещение, тем ближе к центру диска).
Мы взяли одиночный HDD на 8 ТБ и установили в dd следующие флаги:
- direct — для прямой записи, минуя кэш Linux,
- seek_bytes — для выбора смещения на диске.
Команда для запуска:
administrator@debian:~$ sudo dd if=/dev/zero of=/dev/sdh1 bs=2M count=1000 oflag=direct,seek_bytes seek=4000000000000
2097152000 bytes (2.1 GB, 2.0 GiB) copied, 9.2265 s, 227 MB/s
seek_bytes | Скорость записи | Примечание |
0 | 264 MB/s | Запись в начало диска |
4000000000000 | 227 MB/s | Запись в середину диска по объему (по радиусу это ближе к внешней границе, т. к. на внешних кольцах помещается больше данных, чем на внутренних) |
6000000000000 | 189 MB/s | |
7000000000000 | 156 MB/s | |
7700000000000 | 133 MB/s | Запись в конец диска |
Скорость записи варьируется от 133 до 264 MB/s — почти в два раза! Дело в том, что, когда мы пишем данные через файловую систему, она сама определяет смещения и распределяет информацию по диску. При этом снижения скорости на внутренних дорожках все равно не избежать. Теоретически, если мы всегда оставляем на HDD определенный процент свободного места, файловая система должна выделять под него пространство с максимальным смещением (в физическом центре диска). В этом случае деградация будет не так сильно заметна. Но разные файловые системы ведут себя по-разному.
Например, файловая система может в первую очередь использовать более быструю часть диска, а самые медленные, «центральные» 10% держать всегда свободными. Либо система может начать запись с середины диска, постепенно уходя на внешние и внутренние дорожки — тогда скорость будет более постоянной.
Проще говоря, при покупке HDD мы ожидаем, что указанная в спецификации скорость будет стабильна, но на самом деле никто этого не гарантирует. К примеру, WD делает в своих спецификациях следующую сноску: «Up to stated speed. 1 MB/s = 1 million bytes per second. Based on internal testing; performance may vary depending upon host device, usage conditions, drive capacity, and other factors».

Пишем скрипт
После тестов на одиночном HDD мы решили проверить эффект замедления на хранилище. Собрали 10 дисков по 4 TB в RAID 6 и написали на Python скрипт-обертку над dd. Он делает тестовые записи данных в разные участки хранилища и выводит результаты.
Список доступных в системе девайсов можно посмотреть с помощью команды lsblk. Обратите внимание: прямая запись через dd сломает файловую систему и данные на хранилище! Тестируйте только на данных, которые не жалко потерять :)
$ ./test_storage.py -d /dev/sda --yes
seek=0 (0.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 12.6719 s, 1.7 GB/s
seek=1600090065920 (5.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 12.4469 s, 1.7 GB/s
seek=3200180131840 (10.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 12.6146 s, 1.7 GB/s
seek=4800270197760 (15.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 12.6342 s, 1.7 GB/s
seek=6400360263680 (20.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 13.0093 s, 1.6 GB/s
seek=8000450329600 (25.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 13.1088 s, 1.6 GB/s
seek=9600540395520 (30.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 13.3502 s, 1.6 GB/s
seek=11200630461440 (35.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 13.6182 s, 1.5 GB/s
seek=12800720527360 (40.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 13.9915 s, 1.5 GB/s
seek=14400810593280 (45.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 14.3044 s, 1.5 GB/s
seek=16000900659200 (50.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 14.6606 s, 1.4 GB/s
seek=17600990725120 (55.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 14.9608 s, 1.4 GB/s
seek=19201080791040 (60.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 16.3047 s, 1.3 GB/s
seek=20801170856960 (65.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 16.2788 s, 1.3 GB/s
seek=22401260922880 (70.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 16.8956 s, 1.2 GB/s
seek=24001350988800 (75.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 17.8074 s, 1.2 GB/s
seek=25601441054720 (80.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 18.8904 s, 1.1 GB/s
seek=27201531120640 (85.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 20.1645 s, 1.0 GB/s
seek=28801621186560 (90.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 21.9175 s, 957 MB/s
seek=30401711252480 (95.0%): 20971520000 bytes (21 GB, 20 GiB) copied, 23.0649 s, 909 MB/s
seek=31980829802496 (99.9%): 20971520000 bytes (21 GB, 20 GiB) copied, 25.0934 s, 836 MB/s
Результаты теста показывают, что в случае с RAID эффект замедления работает так же, как и на одиночных дисках.
***
На этом наше расследование — все! Резюмируем:
- Скорость записи на HDD непостоянна. Нельзя полагаться на спецификации и теоретические расчеты — перепроверяйте реальную производительность.
- Среднюю, минимальную и максимальную скорость записи можно точно определить только с помощью теста на полное заполнение хранилища.
- Если вам нужно писать данные с ротацией и без потерь, скорость потока не должна превышать минимальную скорость записи. Для достижения более высокой скорости советуем создавать мощный RAID с большим количеством высокопроизводительных дисков.
Скрипт
#!/usr/bin/python3
import subprocess
import re
from argparse import ArgumentParser
parser = ArgumentParser(prog='Storage test')
parser.add_argument('-d', '--device', help='device for test, i.e /dev/sdb', required=True)
parser.add_argument('--bs', help='block size for dd. Default is 2M.', default='2M')
parser.add_argument('--bc', help='block count for dd. Default is 10000.', default='10000')
parser.add_argument('--steps', help='number of steps exclude last. Default is 20.', default=20)
parser.add_argument('--alignment', help='alignment of write operations. Default is 1024.', default=1024)
parser.add_argument('--yes', help='agreement that script WILL DESTROY the file system or any data on testing device', action='store_true', default=False)
def shell_exec(cmd: str):
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', shell=True)
ret_code = proc.wait()
if ret_code != 0:
raise RuntimeError(f'"{cmd}" return {ret_code}')
return proc.communicate()
def get_device_size(dev: str):
stdout, stderr = shell_exec(f'sudo blockdev --getsize64 {dev}')
return int(stdout)
def parse_size(size: str):
units = {"B": 1, "KB": 2**10, "K": 2**10, "MB": 2**20, "M": 2**20, "GB": 2**30, "G": 2**30, "TB": 2**40, "T": 2**40}
size = size.upper()
if not ' ' in size:
size = re.sub(r'([KMGT]B?|B)', r' \1', size)
if not ' ' in size:
return int(size)
number, unit = [string.strip() for string in size.split()]
return int(float(number)*units[unit])
def make_step(args, seek: int, dev_size : int):
dd_cmd = f'sudo dd if=/dev/zero of={args.device} bs={args.bs} count={args.bc} oflag=direct,seek_bytes seek={seek}'
stdout, stderr = shell_exec(dd_cmd)
percent = seek / dev_size * 100
out = stderr.split(sep='\n')[2]
print(f"seek={seek} ({percent:.1f}%): {out}")
if __name__ == "__main__":
args = parser.parse_args()
if not args.yes:
print(f'Script WILL DESTROY the file system or any data on "{args.device}".')
print(f'Use "--yes" option if you agree with this.')
exit(1)
dev_size = get_device_size(args.device)
dev_size_in_blocks = dev_size // args.alignment
step_size_in_blocks = dev_size_in_blocks // args.steps
for step in range(0, args.steps):
seek = step * step_size_in_blocks * args.alignment
make_step(args, seek, dev_size)
step_write_size = parse_size(args.bs) * int(args.bc)
last_seek = dev_size - step_write_size
last_seek = last_seek - (last_seek % args.alignment)
make_step(args, last_seek, dev_size)