Как небольшой «тюнинг» Talos Linux увеличил производительность NVMe SSD в 2.5 раза

5f9620791331d7706c5554b1578f3cc9.png

Предыстория

Недавно я начал готовить очередной Kubernetes кластер на Bare Metal серверах для одного из наших проектов дабы съехать с Google Cloud и снизить расходы на инфраструктуру примерно в 4 раза, получив при этом в 4 раза больше ресурсов vCPU/RAM/SSD (да и производительность сетевых дисков в облаках оставляют желать лучшего).

В качестве ОС я решил взять горячо мной любимую Talos Linux, которая позволяет очень просто развернуть Kubernetes-кластер на любом окружении, легко обновлять его компоненты и поддерживать конфигурацию в одинаковом состоянии на всех узлах кластера благодаря декларативности и иммутабельности.

Развернуть всё это я решил в Hetzner на серверах Dell PowerEdge R6615 (линейка DX182). Конфигурация каждого сервера выглядит так:

  • 1x AMD EPYC™ GENOA 9454P (96 vCPU, Zen 4)

  • 384GB DDR5 ECC RAM (12×32GB)

  • 2×960G SATA SSD в RAID1 используя Dell PERC H755 контроллер. На него непосредственно и устанавливался Talos Linux (эта ОС не поддерживает mdadm).

  • 2×7.68TB U.2 PCIe NVMe SSD Samsung PM9A3 — для Linstor хранилища в Kubernetes

  • 2×10G NIC (для 10G интернет-аплинка)

  • 2×25G NIC (для приватной сети, объединены с помощью LACP bonding)

Красота

Красота

У Hetzner также есть линейка AX162 с тем же AMD 9454P процессором, но существенно дешевле и на более дешевых компонентах.

Например, там используется материнская плата ASRock Rack, интерфейс BMC которой сильно хуже чем iDRAC в серверах Dell. К слову, Hetzner по какой-то причине вообще не даёт доступ к BMC ASRock Rack, а вот к iDRAC — пожалуйста. На Reddit есть топик, в котором обсуждаются проблемы со стабильностью серверов этой линейки, так что подумайте дважды, если захотите брать такие сервера в аренду.

Также, в отличие от Dell, в серверах линейки AX162 не зарезервировано питание, поскольку БП всего один.

Сервер линейки AX162

Сервер линейки AX162

А вот так выглядит выбранный стэк технологий для Kuberrnetes-кластера:

  • Cilium в качестве CNI с включенным eBPF, kube-proxy replacement, native routing, BBR, XDP и прочими плюшками

  • Linstor+DRBD в async-режиме для хранения поверх LVM Stripe.

  • VictoriaMetrics K8S Stack для мониторинга

  • VictoriaLogs для сбора, хранения и анализа логов

  • FluxCD чтобы декларативно менеджить это всё используя GitOps-подход.

Метод тестирования NVMe SSD дисков

Перед тем как запускать кластер в production, стоит как следует его протестировать, это включает в себя не только failover-тесты чтобы понять как ведёт себя кластер в тех или иных ситуациях, но и performance-бенчмарки процессора, памяти, сети и дисков. Это поможет нам в будущем понять как обновления ОС, ядра и других компонентов влияют на производительность со временем. Да и в целом не помешало бы понять, на что наше железо способно, особенно в сравнении с облаками (подробности в конце статьи). Вот на дисках мы и остановимся в этой статье.

Кстати, перед этими тестами я обновил прошивку NVMe SSD-дисков, а также всех других компонентов, включая BMC, BIOS, сетевых карт и прочего. Также поменял размер сектора на дисках с 512 байт до 4 килобайт.

В качестве метода тестирования я выбрал, на мой взгляд, достаточно объективный набор тестов, который репрезентативен для большинства типичных нагрузок в современных кластерах (СУБД, такие как Postgres и ClickHouse, объектные хранилища S3, и т.п.):

fio -name=randwrite_fsync -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=randwrite -bs=4k -numjobs=1 -iodepth=1 -fsync=1
fio -name=randwrite_jobs4 -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=randwrite -bs=4k -numjobs=4 -iodepth=128 -group_reporting
fio -name=randwrite -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=randwrite -bs=4k -numjobs=1 -iodepth=128
fio -name=write -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=write -bs=4M -numjobs=1 -iodepth=16
fio -name=randread_fsync -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=randread -bs=4k -numjobs=1 -iodepth=1 -fsync=1
fio -name=randread_jobs4 -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=randread -bs=4k -numjobs=4 -iodepth=128 -group_reporting
fio -name=randread -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=randread -bs=4k -numjobs=1 -iodepth=128
fio -name=read -filename=/dev/nvme0n1 -output-format=json -ioengine=libaio -direct=1 -runtime=60 -randrepeat=0 -rw=read -bs=4M -numjobs=1 -iodepth=16

Взял я этот набор тестов здесь. Кстати, это довольно интересная статья от @vitalif советую почитать.

Обратите внимание, что fio тесты выполняются напрямую на блочном устройстве /dev/nvme0n1, чтобы исключить влияние файловой системы на результаты. Также перед каждым отдельным вызовом fio выполнялась команда blkdiscard -f /dev/nvme0n1 для того чтобы исключить влияние предыдущего теста на следующий.

Я подготовил удобный docker-образ maxpain/fio:3.38, который включает в себя последнюю версию fio, а также вышеупомянутый набор тестов в скрипте /run.sh, который я любезно позаимствовал у @kvaps и немного модицифировал. Он работает 8 минут и выдаёт CSV строчку, которую можно легко вставить в Google-табличку, наподобие этой, сразу же получив наглядные графики и полную картину по Bandwidth/IOPS/Latency.

Пример запуска в Docker:

docker run -it --rm --entrypoint /run.sh --privileged maxpain/fio:latest /dev/nvme0n1

Пример запуска в Kubernetes (после запуска подов заходим внутрь с помощью kubectl exec):

kind: DaemonSet
apiVersion: apps/v1
metadata:
  name: fio-test
  namespace: debug
spec:
  selector:
    matchLabels:
      app: fio-test
  template:
    metadata:
      labels:
        app: fio-test
    spec:
      terminationGracePeriodSeconds: 0
      containers:
        - name: fio
          image: maxpain/fio
          command:
            - sleep
            - infinity
          securityContext:
            privileged: true

Тестирование в разных ОС

Изначально я хотел измерить влияние DRBD поверх LVM на производительность I/O по сравнению с RAW-диском, но обманул сам себя, сразу же запустив тесты на Talos Linux и приняв их как должное. В какой-то момент, сравнивая получившиеся цифры с результатами тестирования @kvapsна куда более медленных и старых серверах я удивился тому, что мои результаты получились куда хуже.

Я решил протестировать производительность RAW диска на Debian 12, скомпилировав ту же самую LTS-версию ядра Linux 6.6.54, которая используется в Talos Linux v1.8.1, вот что получилось:

bddb939e7b197454758b4e9baaeada2f.png3edbf1800fcd176a0f40db8f9d566040.png

Я решил на всякий случай сравнить всякие системные параметры вроде scheduler и cpu governor (на обеих системах стоял performance), но все они были одинаковыми.

Что за мистика? Очевидно, дело в конфигурации ядра. Я решил скомпилировать Linux ядро под Debian используя kernel config от Talos Linux и получил снижение производительности подобно тому, что я видел непосредственно в Talos Linux!

IOMMU

Искать параметр в конфиге ядра, который так сильно убивает производительность, это как искать иголку в стоге сена, поскольку в kernel config’e более 6000 строк, а если файла два? Как понять какой именно параметр влияет? А если влияющих параметров несколько?

В решении такой непростой задачи мне помог скрипт scripts/diffconfig из состава ядра Linux, он получает на вход 2 конфига и на выдаёт diff вроде такого:

-AMD_XGBE_HAVE_ECC y
 NUMA_BALANCING y -> n
 NUMA_EMU y -> n
 NVIDIA_WMI_EC_BACKLIGHT m -> n
 NVME_AUTH n -> y
 NVME_CORE m -> y
+IMA_LOAD_X509 n

Diff-файл на выходе получился достаточо объёмным и всё ещё не представлялось возможным вручную найти злополучный параметр.

На помощь пришёл ChatGPT, а именно новая модель o1-preview, которая с удовольствием проглотила вышеупомянутый огромный diff-файл и, на моё удивление, с первой же попытки выдала проблемный параметр:

IOMMU настройки по умолчанию:

В Talos Linux включен параметр CONFIG_IOMMU_DEFAULT_DMA_STRICT=y, в то время как в Debian используется CONFIG_IOMMU_DEFAULT_DMA_LAZY=y. Режим strict в IOMMU заставляет ядро немедленно выполнять сброс кэшей IOMMU при каждом связывании и отвязывании DMA (то есть при каждом вводе-выводе), что приводит к дополнительной нагрузке на систему и может значительно снизить производительность ввода-вывода при интенсивных операциях, таких как тестирование IOPS.

   Рекомендуемое действие: Изменить настройку CONFIG_IOMMU_DEFAULT_DMA_STRICT=y на CONFIG_IOMMU_DEFAULT_DMA_LAZY=y в конфигурации ядра Talos Linux, чтобы соответствовать настройке в Debian и уменьшить накладные расходы на операции DMA.

Однако, можно не пересобирать ядро, а вместо этого передать kernel аргумент iommu.strict=0:

config IOMMU_DEFAULT_DMA_LAZY
bool «Translated — Lazy»
help
Trusted devices use translation to restrict their access to only
DMA-mapped pages, but with «lazy» batched TLB invalidation. This
mode allows higher performance with some IOMMUs due to reduced TLB
flushing, but at the cost of reduced isolation since devices may be
able to access memory for some time after it has been unmapped.
Equivalent to passing »iommu.passthrough=0 iommu.strict=0» on the
command line.

И да, этот параметр действительно вернул производительность I/O в Talos Linux на значения уровня Debian!

Кстати, Talos — не единственная ОС, которая столкнулась с подобной проблемой. Например, судя по этому баг-репорту, в Ubuntu 24.04 из-за этой же настройки у некоторых людей падала производительность в 3.5 раза! Но уже не дисков, а сети:

iommu.strict=1 (strict): 233464.914 TPS
iommu.strict=0 (lazy): 835123.193 TPS

Что-то мне подсказывает что и у GPU в AI/ML-кластерах будут проблемы из-за этой настройки.

Разработчики Talos Linux оперативно поменяли дефолтное значение этого параметра, так что в новых версиях этой ОС не потребуется передавать iommu.strict=0 для исправления проблем с производительностью.

Что ж, эту проблему я нашёл достаточно быстро и легко, но это ещё не конец мучений.

Security-патчи и spec_rstack_overflow

Мне было любопытно, как на производительность NVMe SSD дисков влияет версия Linux ядра, поэтому я скомпилировал все ядра начиная с Linux 6.1, заканчивая 6.11, и вот что получилось:

56b056b9c6227227c2e533782e4247c9.png765d460eb44761c59e4aebd7124ae809.png25b793fd4c46ae53228c56500e75ae45.png

Как такое возможно? Почему в 6.2 и 6.3 в два раза больше IOPS? А почему в 6.1 такие же значения как и у 6.4 и более новых ядрах? Мне не пришло ничего в голову лучше чем сделать git bisect, и скомпилировав ещё 15 разных вариантов ядра я нашёл злополучный коммит.

Оказывается, в процессорах AMD нашли уязвимость Speculative Return Stack Overflow (SRSO), заплатка для которой очень влияет на производительность I/O.

Так как я всегда обновляю микрокод процессоров благодаря официальным Talos extensions amd-ucode и intel-ucode, собирая кастомные образы на factory.talos.dev, я могу себе позволить отключить софтверный патч этой уязвимости используя kernel аргумент spec_rstack_overflow=microcode.

Можно было бы воспользоваться mitigations=off, но я не рекомендую это делать, по крайней мере по моим тестам никакого прироста производительности по сравнению с spec_rstack_overflow=microcode я не получил.

Всё же остаётся вопрос, почему в ядрах 6.2 и 6.3 этого security-патча нет, а в 6.1 есть? Всё просто, 6.1 — Long Term Support (LTS) релиз, а 6.2 и 6.3 — End Of Life (EOL).

Влияние каждого параметра по отдельности

Я решил проверить, насколько сильно каждый kernel arg параметр влияет производительность I/O, вот что получилось:

5032b9f1e466d4f7b9f31f76f3054d45.pngda9e583f2e7028bb9b6f7dc3f8ee4001.pnga4deb030657cb632255b5c1fa29b2e49.png0045214ab07960aa92858783792cabf3.png

Помимо всего прочего, я очень рекомендую на любых Bare Metal инсталляциях использовать Performance CPU Governor (kernel аргумент cpufreq.default_governor=performance). Также интересно, что новый scaling драйвер amd_pstate не повлиял на I/O. От mitigations=off также нет никакого прироста по сравнению с отключением SRSO-патча.

Итог

Благодаря такому вот примитивному тюнингу мне удалось вернуть производительность I/O в Talos Linux на уровень ванильного Debian, как бы иронично это не звучало. Ну или почти удалось:

9b414cdaa55ea2fa3f54b049cec53529.png

По всем остальным тестам паритет, а вот в Rand 4K T1Q128 Talos Linux отстаёт на 70K IOPS (14%).

Ядро Talos Linux сконфигурировано согласно гайдлайнам KSPP (Kernel self-protection project), поэтому там включен Page Table Isolation (PTI). Отключить его Talos Linux не позволяет (и правильно делает), а вот в Debian я решил его включить и проверить влияние PTI на I/O — производительность снизилась с 520K IOPS до 480K.

Вот итоговый набор kernel args, который может «разблокировать» иопсы:

machine:
  install:
    extraKernelArgs:
      - cpufreq.default_governor=performance
      - amd_pstate=active
      - iommu=off
      - spec_rstack_overflow=microcode

У меня нет виртуализации, поэтому я решил отключить iommu вовсе, что дало ещё небольшой прирост.

Я не сомневаюсь что есть ещё уйма способов затюнить Linux, но это уже за гранью моих текущих знаний, да и в целом я доволен результатом.

Бонус

В качестве хранилища в Kubernetes я решил использовать одно из самых производительных решений на текущий момент — Linstor. Для Kubernetes есть удобный оператор — Piraeus Operator.

Я создал два StorageClass — nvme-lvm-local и nvme-lvm-replicated-async. В первом случае мы монтируем thick LVM volume непосредственно в под, не используя при этом DRBD репликацию, поскольку множество современных СУБД умеют сами реплицироваться и лучше этим пользоваться, поскольку это более эффективный подход. Второй же использует асинхронную DRBD репликацию на другой сервер. Чаще всего такой подход используется для приложений, которые сами реплицироваться не умеют.

В случае с DRBD-репликацией поды всегда работают с данными локально благодаря настройкам volumeBindingMode: WaitForFirstConsumer и linstor.csi.linbit.com/allowRemoteVolumeAccess: "false", что позволило выжать максимальную производительность не смотря на репликацию.

Результаты получились следующими:

2b8254e3096cb89d1f9f2a7f935bed3d.pngba113a942152290318989048f7650350.pnge7ff7779afa34c99d91164ace3784c52.pngb818b6c3b7301aeb01ffd71d1ae7122f.png

Google-таблица с подробностями находится тут.

Спасибо за внимание!

© Habrahabr.ru