В последнем посте я рассказывал о Kubernetes, о том, как ThoughtSpot использует его для собственных нужд по поддержке разработки. Сегодня хотелось бы продолжить разговор о короткой, но от того не менее интересной истории отладки, которая произошла совсем недавно. Статья базируется на том, что containerization != virtualization. К тому же наглядно показывается, как контейнеризированные процессы конкурируют за ресурсы даже при оптимальных ограничениях по cgroup и высокой производительности машины.


image


Ранее мы запускали серии операций, связанных с разработкой b CI/CD, во внутренем кластере Kubernetes. Все бы ничего, да при запуске «докеризированного» приложения неожиданно сильно падала производительность. Мы не скупились: в каждом из контейнеров стояли ограничения по вычислительной мощности и памяти (5 CPU / 30 ГБ RAM), заданные через конфигурацию Pod. На виртуальной машине с такими параметрами все наши запросы из крошечного набора данных (10 Кб) для тестов летали бы. Однако в Docker & Kubernetes на 72 CPU / 512 ГБ RAM мы успевали запустить 3–4 копии продукта, а потом начинались тормоза. Запросы, которые раньше завершались за пару миллисекунд, теперь висели по 1–2 секунде, и это вызывало всевозможные сбои в CI-конвейере задач. Пришлось вплотную заняться отладкой.


Как правило, под подозрением — всевозможные ошибки конфигурации при упаковке приложения в Docker. Однако мы не нашли ничего, что могло бы вызвать хоть какое-либо замедление (если сравнивать с установками на голом железе или виртуальных машинах). С виду все правильно. Далее мы опробовали всевозможные тесты из пакета Sysbench. Проверили производительность ЦП, диска, памяти — все было таким же, как и на голом железе. Некоторые сервисы нашего продукта хранят подробную информацию обо всех действиях: ее потом можно использовать в профилировании производительности. Как правило, при нехватке какого-либо ресурса (ЦП, оперативной памяти, диска, сети) в некоторых вызовах отмечается значительный провал во времени — так мы обнаруживаем, что именно тормозит и где. Однако в данном случае ничего такого не произошло. Временные пропорции не отличались от исправной конфигурации — с той лишь разницей, что каждый вызов был значительно медленнее, чем на голом железе. Ничто не указывало на настоящий источник проблемы. Мы уже были готовы сдаться, как вдруг нашли вот это.


В этой статье автор анализирует похожий загадочный случай, когда два, по идее, легких процесса убивали друг друга при запуске внутри Docker на одной и той же машине, причем ограничения ресурсов выставлялись на весьма скромные значения. Мы сделали два важных вывода:


  1. Основная причина крылась в самом ядре Linux. Из-за структуры кэша-объектов dentry в ядре, поведение одного процесса сильно тормозило вызов ядра __d_lookup_loop, что прямым образом сказывалось на производительности другого.
  2. Автор использовал perf для обнаружения ошибки в ядре. Прекрасное средство отладки, которым мы никогда раньше не пользовались (а жаль!).

perf (иногда его называют perf_events или perf-инструменты; ранее был известен как Performance Counters for Linux, PCL) — это инструмент анализа производительности в Linux, доступный с версии ядра 2.6.31. Утилита управления пользовательским пространством, perf, доступна с командной строки и представляет собой набор подкоманд.

Она осуществляет статистическое профилирование целой системы (ядра и пространства пользователя). Данное средство поддерживает счетчики производительности аппаратной и программной (например, hrtimer) платформы, точки трассировки и динамические пробы (скажем, kprobes или uprobes). В 2012 году два инженера IBM признали perf (наряду с OProfile) одним из двух наиболее используемых инструментов профилирования счетчиков производительности в Linux.

Вот мы и подумали: может, и у нас то же самое? Мы же запускали сотни различных процессов в контейнерах, и во всех было одно и то же ядро. Мы чуяли, что напали на след! Вооружившись perf, повторили отладку, и в итоге нас ждало преинтереснейшее открытие.


Ниже приведены записи perf первых 10 секунд ThoughtSpot, работающего на здоровой (быстрой) машине (слева) и внутри контейнера (справа).
image


Сразу видно, что справа первые 5 вызовов связаны с ядром. Время, в основном, расходуется на пространство ядра, тогда как слева — большая часть времени идет на собственные процессы, выполняемые в пространстве пользователя. Но самое интересное, что все время занимает вызов posix_fadvise.


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

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


image


Это сторонняя библиотека логирования под названием glog. Мы пользовались ей для проекта. Конкретно эта строка (в LogFileObject::Write), наверное, самый критический путь всей библиотеки. Она вызывается для всех событий «запись лога в файл» (log to file), а многие экземпляры нашего продукта пишут лог довольно часто. Беглый взгляд на исходный код подсказывает, что часть fadvise можно отключить, установив параметр --drop_log_memory=false:


 if (file_length_ >= logging::kPageSize) {
   // don’t evict the most recent page
   uint32 len = file_length_ & ~(logging::kPageSize — 1);
   posix_fadvise(fileno(file_), 0, len, POSIX_FADV_DONTNEED);
 }
}

что мы, конечно же, сделали и… в яблочко!


image


То, что раньше отнималло пару секунд, теперь выполняется за 8 (восемь!) миллисекунд. Немножко погуглив, мы нашли вот что: https://issues.apache.org/jira/browse/MESOS-920 и еще это: https://github.com/google/glog/pull/145, что в очередной раз подтвердило нашу догадку об истинной причине торможения. Скорее всего, то же самое происходило и на виртуальной машине/голом железе, но так как у нас было по 1 копии процесса на каждую машину/ядро, то интенсивность вызова fadvise была значительно ниже, чем и объяснялось отсутствие дополнительного потребления ресурсов. Увеличив процессы логирования в 3–4 раза и выделив им одно общее ядро, мы увидели, что это действительно застопорило fadvise.


И в заключение:


Информация эта не нова, но многие почему-то забывают главное: в случаях с контейнерами «изолированные» процессы конкурируют за все ресурсы ядра, а не только за ЦП, оперативную память, дисковое пространство и сеть. А поскольку ядро — это архисложная структура, то сбои могут происходить где угодно (как, например, в __d_lookup_loop из статьи Sysdig). Это, правда, не говорит о том, что контейнеры хуже или лучше традиционной виртуализации. Они — отличный инструмент, решающий свои задачи. Просто помните: ядро — это общий ресурс, и готовьтесь к отладке неожиданных конфликтов в пространстве ядра. Кроме того, такие конфликты — отличная возможность для злоумышленников прорваться через «истонченную» изоляцию и создать скрытые каналы между контейнерами. И, наконец, есть perf — отличное средство, которое покажет, что происходит в системе, и поможет отладить любые проблемы с производительностью. Если планируете запускать высоконагруженные приложения в Docker, то обязательно выделите время на изучение perf.

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


  1. TimsTims
    13.11.2018 23:43

    самый критический путь всей библиотеки. Она вызывается для всех событий «запись лога в файл»
    Т.е. простое отключение логирования помогло? Значит ошибка была не столько в ядре linux, сколько в сторонняя библиотека логирования под названием glog?


    1. gecube
      14.11.2018 00:10
      +8

      нет. Ошибка не библиотеке glog. А просто неудачное стечение обстоятельств, когда возникала внезапная конкуренция за ресурсы ядра.

      Условный контр-пример. Представьте себе, что Вы — пользователь IaaS/PaaS. И Вам нужно развернуть отказоустойчивый кластер hadoop. Вы нарезаете виртуалки, ставите ОСь, устанавливаете софт hadoop. И выясняется, что кластер-то нифига не отказоустойчивый. В чем дело? Ведь и IaaS по паспорту обеспечивает отказоустойчивость (живая миграция ВМ в случае отказа ноды и пр.) и вроде бы hadoop тоже сам по себе отказоустойчивый (он спецом разрабатывался, чтобы быть запущенным на дешевом хламе, которое все время отказывает). А оказывается, что нужно еще выставить виртуалкам правильно antiaffinity, чтобы гипервизор их расталкивал по ВМкам в правильном порядке. При этом не IaaS — плохая и не Хадуп — плохой. А «любая абстракция рано или поздно течет» и приходится разбираться со всем стеком снизу вверх (или сверху вниз).

      Вот такая же история с ядром. Никто заранее не объяснил границы применимости той или иной технологии и получили протекшую абстракцию.


  1. masterspline
    14.11.2018 07:13
    +10

    Я так и не понял, в чем была причина и виноват ли в чем-то Docker (а точнее cgroup). Насколько я понял, при одновременном вызове posix_fadvise() из нескольких процессов/потоков огромного количество раз в секунду, ядро начинало тормозить. Была ли разница только при использовании докера, или достаточно было запустить несколько потоков приложения без изоляции (cgroup)? Предполагаю, что нет и причина тормозов (судя по обилию спинлоков в выводе perf) в одновременном доступе к разделяемым данным в ядре при вызове posix_fadvise() (а одновременный доступ всегда приводит к тормозам, поэтому, конкуренции нужно стараться избегать).

    IMHO, авторам glog изначально нужно было вызывать posix_fadvise(), который приводит к системному вызову и переключению контекста (т.е. даже без полезной деятельности занимает ~1mks на каждый syscall), не на каждую страницу по 4K, а обрабатывать блоками по 1M, хотя бы. Ну и до кучи, чтобы это понять не нужен perf, а достаточно запустить в одном терминале vmstat 2, в другом top (в общем-то с этого и начинается анализ причин тормозов приложения) и увидеть, что система много времени проводит в ядре (vmstat) и на каком системном вызове висит приложение.


    1. amarao
      14.11.2018 18:31

      Тут упор на то, что люди начали использовать контейнеры для CI/CD. У нас в инфраструктуре для этого используются динамически создаваемые виртуалки в openstack, и в подобной ситуации такой проблемы бы не было. Контейнеры мы тоже используем, но только в пределах одной задачи (например, быстрый прогон тестов бэкапов) — между задачами ядро ОС не шарится (а если и шарится, то без привлечения контейнеров, просто на уровне процессов).

      Люди попытались использовать контейнеры, но пришлось допиливать, т.к. изоляция не полная.


  1. past
    14.11.2018 10:50

    Я просто оставлю это здесь.


    1. gecube
      14.11.2018 11:04
      +1

      12 факторов — не панацея. В частности, например, некоторые джава-программисты пишут такие программы, которые в лог кидают СЛИШКОМ много всего. Понятно, что всегда можно отфильтровать… но такое себе. А на ходу (без перезапусука сервиса) переключить уровень логирования не всегда возможно.

      Еще момент — когда берешь уже готовый контейнер с тем же airflow. Там фактически внутри получается несколько служб/скриптов и из лога получается мешанина, причем которую разбить нельзя. Вот и приходится костылись логирование в файл или во внешний сервис типа эластика. Естественно, уходим от этого, но не все сразу.

      Журнал в своём сыром виде обычно представлен текстовым форматом с одним событием на строчку

      тоже спорно. А если все-таки нужен multiline? Выход — паковать сообщение в json или типа того?


      1. vintage
        15.11.2018 05:28
        -1

  1. amarao
    14.11.2018 15:13
    +5

    Буквально на прошлой неделе я сумел устроить OOM на сервере с 8 контейнерами. Каждый контейнер потреблял несколько мегабайт, на сервере было 8Гб (виртуалка). Догадайтесь, сколько kmalloc памяти съедал каждый контейнер? Нет, оно не аккаунтилось cgroup'ами, оно просто прибивало что попало по OOM, в т.ч. за пределами контейнеров.

    И я только тронул волшебный вопрос «kernel memory» на всякие объекты.

    Контейнеры — коммуналка. Если кто-то решит часто ходить в туалет, остальные будут ждать.


    1. gecube
      14.11.2018 18:31

      Можно тест кейс описать полностью? Хочу повторить.


      1. amarao
        14.11.2018 18:33
        +1

        Я не уверен, что это не CVE. Хочу дотестировать в имитации продакшен-среды. Но суть одна и так же — начинаете есть ресурсы ядра из контейнеров, и ядро память ест, а userspace в контейнере — нет.

        fd, ip-адреса, mount'ы, namespace'ы, интерфейсы, pid'ы, сокеты, pipe'ы, etc.


        1. gecube
          14.11.2018 18:37

          Т.е. это вопрос accounting'а ресурсов, которые контейнеры тратят в ядре (и не учитываются в статистике)?


          1. amarao
            14.11.2018 19:06

            Ага. Причём, иногда может быть, что очень сложно понять, куда относить ресурсы. В принципе, мы можем понять, что в конкретную cgroup'у, но иногда не понятно, через что. Например, вложенные namespace'ы — как их аккаунтить по памяти? Это память процесса? Нет. А память всегда на процесы выделяется. А в tmpfs? А в /sys? А память conntrack'а?