Вышел новый релиз Varnish Cache 7.7, в котором добавлен параметр, позволяющий ускорить время ответа и исключить внезапные зависания, вызванные большими вычислениями в момент обработки клиентского запроса. В этой статье я расскажу о расследовании самого загадочного инцидента в моей практике, по результатам которого я предложил внести изменения в механизм инвалидации кэша Varnish.
Вместо предисловия
Была суббота, 5 часов утра. Меня разбудили уведомления на телефоне. В общем чате MS Teams, где обсуждаются аварии, требующие немедленного решения, DevOps-инженеры пытались восстановить доступность сайта, который внезапно перестал открываться в браузерах клиентов. Помогла перезагрузка Varnish Cache. Через месяц ситуация повторилась. На удивление — опять в субботу, опять рано утром. Через несколько недель — снова. Причина такой внезапной недоступности была неясна. Ещё более удивительным был одинаковый тайминг: Varnish переставал отвечать в выходные утром.
В компании, где я работаю, используют Varnish Cache, чтобы ускорить загрузку веб-сайта и снизить нагрузку на бэкенды. Varnish Cache на какое-то время сохраняет у себя в кэше страницу веб-сайта и при повторном запросе отдаёт её из кэша без обращения к бэкенду. Однако, при сочетании некоторых факторов Varnish может внезапно перестать отвечать, делая веб-сайт, который он призван ускорить, полностью недоступным на продолжительное время.

Это поведение актуально во всех версиях вплоть до 7.6. В релизе 7.7 был добавлен параметр ban_any_variant, который регулирует порядок применения банов. Дефолтное значение этого параметра в версии 7.7 оставляет текущее поведение почти без изменений. Но в версии 8.0 значение будет сброшено в ноль, и поведение банов изменится так, что полностью устранит проблему, описанную в этой статье.
Я не DevOps-инженер, и в мои обязанности не входит разбор причин проблем с сайтом и Varnish. Также у меня не было ранее опыта работы с Varnish. Всё, что у меня было — исходный код Varnish Cache, метрики, логи и бесконечное любопытство.
Поиск взаимных блокировок
Когда система внезапно перестаёт отвечать, она может «зависнуть» по разным причинам: взаимные блокировки (deadlock), продолжительная блокировка при совместном доступе к ресурсам, нехватка ресурсов, бесконечный цикл и тому подобное. Если система хорошо покрыта метриками, такие отклонения от нормальной работы должны отразиться на их динамике.
Я создал новую панель в Grafana, добавил на неё все метрики Varnish и начал искать аномалии. Первое, что бросилось мне в глаза — счётчик n_expired внезапно переставал обновляться за несколько минут до того, как Varnish переставал отвечать.

После того как n_expired переставал обновляться, постепенно начинало расти количество потоков. Когда количество потоков достигало лимита, Varnish переставал принимать новые подключения, и веб-сайт становился недоступным.

Я быстро пробежался по исходным кодам Varnish в поисках того, как обновляется счётчик n_expired. Выяснилось, что существует отдельный поток, который удаляет из кэша объекты с истекшим временем жизни — TTL. На каждый удалённый объект поток делает инкремент n_expired. Поскольку в кэш постоянно добавляются новые объекты, а старые удаляются по истечении TTL, при нормальной работе Varnish n_expired должен постоянно возрастать. Если он перестал увеличиваться — значит, поток, отвечающий за удаление объектов, чем-то заблокирован.
Заметив в коде обильное использование мьютексов, я предположил, что может возникать взаимная блокировка (deadlock). Мы использовали версию 5. На GitHub я нашёл несколько issues про потенциальные deadlock-и, которые были исправлены. Мы обновили Varnish до 6.6 и думали, что проблема решена. Через неделю сработал алерт: сайт снова оказался недоступен. Всё так же — рано утром, в выходной день.
Доказательство, что это не deadlock
После того как апгрейд Varnish до версии 6.6 не помог, появились сомнения — а всё ли мы делаем правильно? Поводов для сомнений было не так много: текущая конфигурация Varnish работала исправно годами.
Поскольку Varnish переставал отвечать после того, как достигал лимита по потокам, я предложил увеличить пул потоков, который показался мне недостаточным. После увеличения количества потоков вдвое, в ближайшую субботу проблема воспроизвелась. Но в этот раз Varnish восстановился сам — до того, как его успели перезагрузить. Этого было достаточно, чтобы понять: причина — не во взаимных блокировках.
Тот факт, что причиной зависания Varnish были не deadlock-и — хорошая новость, но она не приближала к пониманию причины такого поведения. Я решил глубже разобраться в исходных кодах, как работает Varnish. Всё, что изучил, — описал ниже в деталях, которых нигде не найти.
Разбираемся с внутренним устройством Varnish Cache
Как Varnish кэширует ответы бэкенда
Varnish использует URL запрашиваемой страницы, чтобы подсчитать его хеш, который будет являться ключом для поиска объектов в key-value-структуре данных. Из этой структуры, по ключу, мы можем получить кэшируемый объект. Это ещё не сама запрашиваемая страница. Поскольку по одному URL бэкенд может отдавать разные варианты страниц в зависимости от заголовков запроса, в кэшируемом объекте хранится список этих вариантов. Перебор всех вариантов и поиск того, который соответствует запросу, выполняется линейно!
Например, на схеме ниже два клиента посылают запрос на одну и ту же страницу, но с разными значениями заголовка Accept-Language. Этим заголовком клиенты могут определить, на каком языке они хотят получить текст страницы. Поскольку URL один и тот же, оба запроса будут использовать один и тот же ключ для поиска объекта в кэше. После того как объект найден, выбор подходящего варианта выполняется простым проходом по списку вариантов. Если страница на нужном языке найдена (заголовок Accept-Language в сохранённом объекте совпадает со значением в запросе), Varnish вернёт её клиенту. Если нет, Varnish переотправит запрос на бэкенд, а после получения ответа добавит его в кэш — в список вариантов данной страницы. При этом, конечно, бэкенд должен поддерживать заголовок Accept-Language.

В самом линейном поиске по вариантам нет ничего изначально плохого. Линейный поиск работает быстрее других алгоритмов на небольших объёмах данных, если эти данные локальны. Здесь абстрактный Computer Science разбивается о конкретный Computer Engineering, где есть кэш-линии, блоки предсказания и предвыборки.
Ниже привожу упрощённую структуру, описывающую объект в кэше Varnish:
struct objhead {
struct lock mtx;
VTAILQ_HEAD(,objcore) objcs; // список вариантов
};
objcs — список вариантов закэшированного объекта. VTAILQ_HEAD — макрос для определения двусвязного списка. mtx — мьютекс для операций со списком.
Упрощённая версия кода, выполняющего поиск подходящего объекта/варианта в кэше, выглядит так:
struct objhead *oh = lookup(digest); // digest это хеш от url
for(oc =oh->objcs->first; oc; oc->next) { // начинаем цикл по всем вариантам
if(VRY_Match(req, oc)) { // сравним заголовки Vary объекта и запроса
break;
}
}
Unlock(oh->mtx); // лок на mtx взят внутри функции lookup
if(oc != NULL) {
// вариант найден!
} else {
// перенаправить запрос на бэкенд
}
lookup — возвращает объект из кэша по ключу, вычисленному как хеш от адреса запрашиваемого ресурса. Если объект не найден, lookup вставит пустой объект. То есть в любом случае lookup возвращает указатель на objhead. Также lookup берёт лок на мьютекс mtx, поэтому в вызывающем коде есть Unlock, но нет вызова Lock.
Поскольку oh->mtx залочен, другие потоки, обслуживающие параллельные запросы на тот же самый ресурс, не могут начать проход по списку вариантов, пока текущий поток не закончит проход и не отпустит лок на мьютекс. Забегая вперёд — это как раз та блокировка, которая "убивает" Varnish на минуты, а иногда и дольше.
Но чтобы это произошло, одного большого списка вариантов недостаточно. Проход по списку даже из тысячи вариантов выполнится быстро. Однако, помимо проверки — подходит ли вариант под текущий запрос, в этом цикле также выполняется проверка на баны.
Что такое бан и как они применяются
Varnish кэширует страницы на время TTL (time-to-live). Пока TTL не истечёт, Varnish будет отдавать контент запрашиваемой страницы из кэша, даже если на бэкенде уже появилась новая версия страницы. Баны — один из механизмов уведомления Varnish о том, что нужно удалить объект (или его отдельный вариант) из кэша и получить свежую версию от бэкенда.
Например, можно инвалидировать объект в кэше по URL:
ban("obj.http.url == " + req.url);
При добавлении баны не применяются сразу. Они добавляются в список, где в начале находится последний добавленный бан. Применение бана происходит при выдаче объекта из кэша. Чтобы понять, какие баны нужно применить к каким объектам, между ними устанавливаются ссылки в момент добавления.
В самом начале, при старте Varnish, список банов состоит из одного начального элемента — бана, который ничего не банит, а служит заглушкой для связи добавляемых объектов со списком банов. Я отмечу его буквой C — Completed (завершённый).

Теперь, если обратиться к страницам /page1 и /page2, Varnish добавит их в кэш и свяжет баны с объектами следующим образом:

В бане есть список объектов, в который добавляются новые объекты, для которых этот бан был самым новым на момент добавления в кэш, то есть первым в списке банов. Также у объектов есть ссылка на бан, который был первым в списке в момент добавления объекта в кэш. Проверять объект нужно только на те баны, которые более новые по сравнению с тем, на который ссылается объект. Эти ссылки не статичны — они обновляются каждый раз при проверке объекта на бан, чтобы объект ссылался на самый свежий бан, на который он был проверен.
Допустим, мы хотим уведомить Varnish, что на бэкенде появилась более свежая версия страницы /page1. Добавим бан на /page1. Бан добавится в список, но сам объект пока останется в кэше — до того момента, пока к этой странице не обратятся снова. Для наглядности на схемах ниже я добавил указание версий закэшированных страниц — v1, v2 — чтобы было видно, что это не тот же объект, а более свежая версия v2, полученная от бэкенда, по сравнению с предыдущей версией v1. Также я не буду указывать связи от объектов к банам для упрощения схемы, а изменения состояния буду отмечать цветом.

Теперь обратимся к странице /page1. Varnish найдёт в кэше объект /page1, проверит его на все баны — от начала списка до бана (исключая его), с которым объект /page1 связан. Поскольку объект подпадает под критерии бана, он будет удалён из кэша. Varnish получит новую версию от бэкенда и свяжет новый объект с первым баном в списке.

Допустим, теперь на бэкенде обновилась /page2, и мы хотим уведомить об этом Varnish. Добавляем бан на /page2.

Если после этого обратиться к странице /page2, Varnish инвалидирует её в кэше и запросит у бэкенда новую версию. Проверка на баны выполняется с начала списка, а поскольку самый первый бан инвалидирует /page2, то на второй бан проверка уже выполняться не будет. После получения новой версии страницы от бэкенда состояние будет следующим.

Как видно, с последним баном в списке теперь не связан ни один объект, поэтому Varnish удалит его.

Запросим теперь страницу /page1. Varnish проверит её на бан для /page2 — объект не подходит под критерии бана, значит, он останется в кэше и будет доставлен клиенту. Однако Varnish свяжет проверенный объект с самым новым баном — первым в списке — отражая тот факт, что объект был проверен на все активные баны и ни один из них не инвалидировал его.

Поскольку последний бан полностью обработан и не имеет ссылок на объекты, он будет удалён.

В итоге все баны будут применены, а кэш Varnish обновится новыми версиями страниц. В списке останется только один бан — как маркер того, с каким последним баном были проверены объекты в кэше.
Заголовок Vary - как кэшируются вариации страниц
В начале статьи я уже упомянул про вариации объектов. Теперь опишу подробнее, как они работают.
Как определить, что бэкенд варьирует ответ в зависимости от дополнительных параметров запроса? Для этого бэкенд в ответе возвращает заголовок Vary.
-
Vary: Accept-Language — ответ будет варьироваться в зависимости от заголовка Accept-Language в запросе. Бэкенд вернёт страницу на языке, указанном в этом заголовке.
-
Vary: Accept-Encoding — ответ будет варьироваться в зависимости от заголовка Accept-Encoding. Клиент может запросить ресурс в сжатом (gzip, deflate и т. д.) или несжатом виде.
-
Vary: User-Agent — бэкенд, возможно, будет парсить заголовок User-Agent и отдавать ответ, оптимизированный под тип устройства, указанный в нём.
И так с любым другим заголовком, по которому бэкенд решит варьировать ответ. Поэтому если между клиентом и бэкендом (сервером) стоит кэширующее устройство, то кэшировать ресурсы нужно не только по URL, но и с учётом заголовков, указанных в Vary в ответе от бэкенда. По сути, заголовок запроса, указанный в Vary, становится вторым ключом, после URL, для поиска объекта в кэше.
Но есть нюанс: если на первом этапе поиск объекта в кэше по URL выполняется почти за константное время (Varnish по умолчанию использует critbit-дерево, оно же radix tree, работающее быстрее, чем O(log n)), то поиск варианта выполняется за линейное время — O(n), поскольку для хранения вариантов объекта используется простой двусвязный список.

Теперь нарисуем связь вариантов с банами. Как уже отмечалось выше, ссылка существует не только от бана к списку объектов (вариантов), но и от самих объектов к бану. На схеме ниже вариант ресурса на английском языке привязан к бану C. Это значит, что при запросе этого варианта, прежде чем отдать его клиенту, Varnish должен проверить его на более новые баны — ban1 и ban2.

Как видно, временная сложность операции поиска нужного варианта ограничена сверху величиной m × n, где n — количество банов, а m — количество вариантов.
Ранее был приведён псевдокод выборки варианта. Теперь покажу более полную версию — с добавленной проверкой на баны внутри цикла.
BAN_CheckObject — функция, которая проверяет текущий вариант на все новые баны. Если вариант забанен, он удаляется, и цикл переходит к следующему варианту. Этот процесс продолжается до тех пор, пока не выполнятся оба условия:
-
Вариант не инвалидирован новыми банами — BAN_CheckObject возвращает false.
-
Вариант подходит под заголовки запроса — VRY_Match возвращает true.
oh = lookup(digest); // digest = hash(req->url)
for(oc =oh->objcs->first; oc; oc->next) {
if (BAN_CheckObject(oc, req)) { // проверим, забанен ли объект oc
EXP_Remove(oc, NULL);
continue;
}
if(VRY_Match(req, oc)) { // сравним заголовки Vary объекта и запроса
break;
}
}
Unlock(oh->mtx);
if(oc != NULL) {
// вариант найден!
} else {
// перенаправить запрос на бэкенд
}
И ещё раз отмечу: всё время, пока в цикле перебираются варианты, на мьютексе oh->mtx удерживается лок.
В чем опасность Vary: User-Agent
Если вы никогда не анализировали заголовок User-Agent, то, возможно, вариативность этого заголовка станет для вас сюрпризом. За сутки наш HAProxy логирует почти 20 тысяч уникальных значений User-Agent, а отдельно взятую страницу за это же время может посетить до 1000 уникальных User-Agent. Любое изменение версии патча — и это уже новая строка. Вот, например, User-Agent моего браузера:
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
Чтобы сильно снизить hit rate и наполнить Varnish большим количеством вариантов, достаточно добавить на бэкенде такую безобидную строчку в заголовок ответа:
Vary: User-Agent
Теперь при обращении к одному и тому же ресурсу Varnish будет сохранять для каждого User-Agent свою версию. И в комбинации с большим TTL этот список может вырасти до значительных размеров.

Некэшируемые объекты
Не все ответы от бэкенда можно кэшировать. Некоторые из них привязаны к конкретному клиенту и содержат приватную информацию — это так называемые stateful-ресурсы. Другие обновляются при каждом запросе, например содержат серверное время, — поэтому кэшировать их тоже нельзя.
Для заранее известных некэшируемых страниц Varnish можно сконфигурировать так, чтобы он сразу перенаправлял запросы на бэкенд, минуя шаг поиска в кэше, и не сохранял ответы в кэш.
sub vcl_recv {
if (req.url ~ "/checkout/") {
return (pass);
}
}
Для других ресурсов Varnish может определить, что ресурс некэшируемый, по специальному заголовку Cache-Control в ответе от бэкенда. Вот пример конфигурации Varnish, которая помечает такие ответы как некэшируемые, если они содержат заголовок Cache-Control: no-store
sub vcl_backend_response {
if (beresp.http.cache-control ~ "no-store") {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
}
}
В конфигурации выше интересна строчка:
set beresp.ttl = 120s;
Что это за TTL? TTL чего? Это TTL маркера некэшируемого ресурса, который временно сохраняется в кэше. Зачем он нужен? Чтобы избежать так называемого упорядочивания параллельных запросов к некэшируемым ресурсам.
Что происходит, когда Varnish получает одновременно несколько запросов на ресурс, который отсутствует в кэше? Varnish отправит на бэкенд только один запрос, а остальные добавит в лист ожидания. Когда будет получен ответ от бэкенда, все запросы из листа ожидания переиспользуют этот ответ. Эта фича называется request coalescing. Но что произойдёт, если в ответе от бэкенда указано, что ресурс некэшируемый и TTL = 0? Запросы из листа ожидания не смогут его использовать, и тогда Varnish снова посылает первый запрос из списка ожидания на бэкенд. И таким образом Varnish будет обрабатывать запросы из листа ожидания последовательно. Этот побочный эффект называется request serialization. Чтобы его избежать, Varnish вставляет маркер в кэш, что данный ресурс некэшируемый. В этом случае запросы, для которых найден этот маркер в кэше, не добавляются в лист ожидания и перенаправляются на бэкенд сразу.
Что же это за “маркер” некэшируемого объекта (ресурса)? В Varnish их 2 типа: hit-for-miss и hit-for-pass. Разница между ними в том, что hit-for-miss может превратиться в кэшируемый объект, если ответ от бэкенда изменился — в заголовке больше не указано, что ресурс некэшируемый. В отличие от hit-for-miss, hit-for-pass попадает в кэш на всё время TTL, и пока он не истечёт, запросы на этот ресурс не будут кэшироваться. По умолчанию в новых версиях Varnish используется hit-for-miss.
Как эти объекты реализованы? Это всё те же объекты, которые используются для хранения вариантов кэшируемого контента, с той лишь разницей, что у них нет контента ресурса (тела ответа), и выставляется флаг, что это hit-for-miss или hit-for-pass.

Работа с такими маркерами некэшируемого контента практически ничем не отличается от работы с кэшируемым контентом. Varnish, после того как нашёл объект в кэше, пробегается по всем его вариантам (напомню, это просто список) и проверяет каждый вариант на баны. После того как подходящий вариант найден, Varnish проверяет установленные флаги (опции) у этого варианта. Если он отмечен как hit-for-miss или hit-for-pass, запрос сразу перенаправляется на бэкенд. Если такие флаги не установлены — содержимое объекта отправляется клиенту (предварительно убедившись, конечно, что TTL не истёк).
Условия для зависания Varnish
Состояние, при котором Varnish зависает
Теперь, используя все знания о работе Varnish, приведенные выше, расскажу, почему у нас зависал Varnish.
Мы используем TTL = 900 секунд для кэшируемых объектов и 1 день для некэшируемых. TTL в один день выбран не случайно. Вот скриншот из официального блога компании Varnish Software:

(ссылка: https://info.varnish-software.com/blog/hit-for-miss-and-why-a-null-ttl-is-bad-for-you)
В статье предлагается сохранять маркеры для некэшируемых объектов на один день (24 часа).
Также мы используем заголовок Vary: User-Agent. В сочетании с большим TTL это создаёт условия для накопления большого количества вариаций одного ресурса. Ниже ещё раз приведу схему, иллюстрирующую возможное состояние связей между банами и вариантами.

Если при таком состоянии отправить в Varnish запрос на любой из вариантов, все они будут инвалидированы первым баном в списке, и Varnish переотправит запрос на бэкенд.
Сложность такой операции составляет 3 × 1 = 3, где 3 — количество вариантов, а 1 — количество банов. Все три варианта проверяются только на один бан, потому что первый бан в списке их инвалидирует.
А какое должно быть состояние, чтобы сложность операции достигла m × n? Бан, который инвалидирует выбранный объект, не должен находиться в начале списка. На схеме ниже, после того как был добавлен ban3, в список добавилось ещё некоторое количество банов.

Допустим, что только ban3 инвалидирует запрашиваемый объект. Теперь сложность выборки варианта из кэша будет m × n, где m — количество вариантов, n — количество банов. Перебирая все варианты в списке, Varnish будет проверять каждый из них на баны — начиная с первого в списке и до того момента, пока не дойдёт до бана, к которому привязан вариант, либо до того, который его инвалидирует. Если вариант попадает под бан — Varnish удаляет его и переходит к следующему.
В итоге Varnish удалит все варианты и перенаправит запрос на бэкенд. Если Varnish накопит большое количество банов и объектов с большим количеством вариантов, которые ещё не были проверены на эти баны, выборка такого объекта из кэша может занять значительное время.
Поэтому для зависания Varnish достаточно, чтобы накопилась критическая масса объектов с большим количеством вариантов, большой бан-лист, и появился специфичный трафик.
Почему зависания Varnish обычно происходили в выходные утром
В предыдущем абзаце я описал состояние, при котором возможно зависание Varnish. Первой предпосылкой к появлению такого состояния у нас стали изменения на сайте — с определённого момента бОльшее количество страниц стало отдаваться как некэшируемые, что, в свою очередь, приводило к накоплению в кэше hit-for-miss с TTL в 24 часа.
Теперь расскажу, когда в кэше Varnish может накопится максимальное количество таких объектов.
В рабочие дни команды постоянно что-то деплоят. Каждый деплой вызывает очистку всего кэша в Varnish, поэтому объекты с большим TTL обычно не доживают до своей экспирации — они удаляются при следующем деплое. Однако по пятницам мы стараемся избегать деплоев. Это позволяло Varnish в течение всего дня накапливать объекты с большим TTL.
Также, мы добавляем баны с постоянной скоростью в течение суток, включая выходные. При этом ночью трафик существенно снижается. Объект, запрошенный вечером, может быть повторно запрошен только утром.
Если Varnish к концу пятницы накопил длинный список вариантов какого-то ресурса, а затем — за ночь — накопил длинный список банов, включая тот, который инвалидирует этот ресурс, то с первым утренним запросом в субботу его ждёт большая работа: пройтись по всем вариантам, проверить каждый из них на десятки или сотни тысяч банов.
Таким образом, утро субботы, ровно как и утро воскресенья — это самый вероятный момент возникновения состояния, которое приводит к зависанию Varnish при определённом трафике.
Метрики показывали: в моменты зависания в Varnish накапливалось до 300 000 активных банов.
Допустим, также накопилось 1000 вариантов одного ресурса (из-за большого TTL). В худшем случае, если бан, инвалидирующий объект, находится ближе к концу списка активных банов, Varnish придётся выполнить:
300 000 × 1 000 = 300млн. тестов "бан-объект".
При скорости обработки 2 миллиона тестов в секунду, это займёт примерно 2,5 минуты.
То есть клиент, пожелавший открыть эту страницу в браузере, увидел бы её через 2,5 минуты.
И всё это время другие запросы на ту же страницу будут ждать завершения первого, потому что он удерживает лок на мьютекс. Каждый такой «заблокированный» запрос занимает отдельный поток. Новые потоки будут создаваться, пока не достигнется лимит на количество потоков. В этот момент Varnish перестаёт обрабатывать новые запросы и вэбсайт становится недоступным.
Удерживаемый лок на мьютексе также не даёт потоку expire удалить забаненный объект. Сам воркер(поток, обрабатывающий клиентский запрос) не удаляет объект напрямую — он отправляет уведомление потоку expire, который и отвечает за удаление объектов (в том числе с истёкшим TTL).
Это объясняет внезапную остановку обновления счётчика n_expired, которую я показал в самом начале статьи.
Как устранить проблему в версиях до 7.7
После того как стало понятно, что проблема зависания Varnish кроется в большом количестве вариантов и большом TTL hit-for-miss-объектов, стало ясно, что для ее решения надо уменьшить либо одно, либо другое, а еще лучше оба значения.
Заменить Vary: User-Agent на что-то менее вариативное было бы слишком серьёзным инфраструктурным изменением, поэтому я предложил уменьшить TTL для hit-for-miss с одного дня до двух минут. Такой радикализм DevOps не приняли, но согласились снизить TTL до двух часов.
После выкатки обновлённого конфига кэш Varnish стал наполняться в 5 раз меньшим количеством объектов. Это подтвердило моё предположение: hit-for-miss-объекты учитываются в кэше как обычные, и большая часть объектов в кэше были именно они.
Теперь, при запросе объекта из кэша, список вариантов, которые необходимо проверить на баны, стал значительно короче. Кроме того, меньшее количество объектов в кэше позволяло lurker'у (о нём в следующем абзаце) быстрее обходить бан-лист, проверяя каждый бан с меньшим числом объектов, что в итоге ускоряло сокращение бан-листа.
В результате проблема с зависаниями Varnish, которая мучила нас на протяжении нескольких месяцев, была окончательно решена.
Почему не помог lurker
Мы используем Lurker-friendly баны — баны, которые могут быть обработаны не только во время запроса объекта из кэша, но и асинхронно, в фоне, отдельным потоком — Lurker-ом. Это позволяет сократить время обработки клиентского запроса: объект, запрошенный клиентом, может быть уже заранее проверен на все активные баны самим Lurker-ом.
Но почему бан-лист вырастал до сотен тысяч банов? Просто Lurker не успевал за скоростью добавления новых банов, если количество объектов в кэше было достаточно велико.
Допустим, у нас в кэше находится 500 000 объектов. Lurker способен выполнять 100 миллионов проверок бан-объект в минуту. Делим:
100 000 000 / 500 000 = 200
Это значит, что Lurker способен проверять 200 банов в минуту для всех 500 000 объектов. То есть примерно 12 000 банов в час.
А мы в некоторые часы добавляем до 50 000 банов в час.
Поэтому число активных банов в бан-листе у нас вырастало до сотен тысяч.
Что изменилось в версии 7.7
Чтобы устранить такие зависания Varnish, я создал pull request: https://github.com/varnishcache/varnish-cache/pull/4236 в котором поменял местами проверку банов и проверку заголовков Vary — сначала выбирается вариант, на 100% удовлетворяющий параметрам запроса, и только потом он проверяется на баны. Такое изменение уменьшает сложность операции с n*m до n+m, где n и m — количество банов и количество вариантов.
В итоге моё предложение реализовали в другом PR, обернув его параметром для настройки старого и нового поведения: https://github.com/varnishcache/varnish-cache/pull/4253
Новый параметр ban_any_variant регулирует поведение банов — применять их ко всем вариантам, возможно даже не подходящим под запрос (старое поведение), либо только к тем, которые точно совпадают с запросом по заголовку Vary. В релизе 7.7 дефолтное значение — 10000. Это означает, что первые 10 тыс. вариантов будут проверяться сначала на баны, а потом на заголовок Vary — старое поведение. Свыше этого значения — на баны проверяются только те варианты, которые удовлетворяют заголовкам запроса по Vary — новое поведение. В версии 8.0 планируется установить значение по умолчанию ban_any_variant = 0 — это означает, что включится новое поведение, которое я предложил в своём PR: сначала выбирается вариант, соответствующий параметрам запроса, и только потом выполняется проверка на баны.
Как воспроизвести проблему
Для демонстрации описанной в этой статье проблемы с Varnish я подготовил репозиторий на GitHub. В нём также есть пример использования Varnish версии 7.7 с параметром ban_any_variant=0.
При наполнении Varnish 1000 вариантами одного объекта и 100 000 банами, время ответа сокращается с 25 секунд до 0,5 секунды, если ban_any_variant установлен в 0!
Автор: sobolevsv