Отладка высоконагруженных Golang-приложений или как мы искали проблему в Kubernetes, которой не было

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

    image

    Меня зовут Виктор Ягофаров, я занимаюсь развитием Kubernetes-облака в компании ДомКлик, и сегодня хочу рассказать о том как мы решили проблему с одним из ключевых компонентов нашего production k8s (Kubernetes) кластера.

    В нашем боевом кластере (на момент написания статьи):

    • запущено 1890 pod'ов и 577 сервисов (количество реальных микросервисов тоже в районе этой цифры)
    • Ingress-контроллеры обслуживают около 6k RPS и примерно столько же идёт мимо Ingress сразу в hostPort.


    Проблема


    Несколько месяцев назад наши pod'ы начали испытывать проблему с разрешением DNS-имён. Дело в том, что DNS работает, в основном, по UDP, а в ядре Linux есть некоторые проблемы с conntrack и UDP. DNAT при обращении на сервисные адреса k8s Service только усугубляет проблему с conntrack races. Стоит добавить, что в нашем кластере на момент проблемы было около 40k RPS в сторону DNS-серверов, CoreDNS.

    image

    Было принято решение использовать специально созданный сообществом локальный кэширующий DNS-сервер NodeLocal DNS (nodelocaldns) на каждой worker-ноде кластера, который всё еще находится в beta и призван решить все проблемы. Если вкратце: избавляемся от UDP при коннекте к кластерному DNS, убираем NAT, добавляем дополнительный слой кэширования.

    В первую итерацию внедрения nodelocaldns мы использовали версию 1.15.4 (не путать с версией куба), которая шла в комплекте с «kubernetes-инсталлятором» Kubespray – речь идёт о нашем форке форка от компании Southbridge.

    Почти сразу же после внедрения начались проблемы: текла память, и происходил рестарт подов по memory limits (OOM-Kill). На момент рестарта такого пода терялся весь трафик на хосте, так как во всех подах /etc/resolv.conf указывал именно на IP-адрес nodelocaldns.

    Эта ситуация решительно всех не устраивала, и наша команда OPS предприняла ряд мер, чтобы ее устранить.

    Так как я сам являюсь новичком в Golang, мне было очень интересно пройти весь этот путь и познакомиться с отладкой приложений на этом замечательном языке программирования.

    Ищем решение


    Итак, поехали!

    На dev кластер была выкачена версия 1.15.7, которая уже считается beta, а не alpha как 1.15.4, но на деве нет такого трафика в DNS (40k RPS). Печально.

    По ходу дела мы отвязали nodelocaldns от Kubespray и написали специальный Helm chart для более удобной выкатки. Заодно написали playbook для Kubespray, который позволяет менять настройки kubelet, не переваривая весь стейт кластера по часу; причем, делать это можно точечно (проверяя сначала на небольшом количестве нод).

    Далее, мы выкатили версию nodelocaldns 1.15.7 на прод. Ситуация, увы, повторилась. Память текла.

    В официальном репозитории nodelocaldns была версия с тэгом 1.15.8, но я почему-то не смог сделать docker pull на эту версию и посчитал, что раз еще не собрали официальный Docker-образ – значит версию эту использовать не стоит. Это важный момент, и мы к нему еще вернемся.

    Отладка: этап 1


    Я долго не мог понять, как вообще собрать свою версию nodelocaldns в принципе, так как Makefile из репы валился с непонятными ошибками изнутри докер-образа, а я не очень понимал, как хитро собрать Go-проект с govendor, разложенным странным образом по директориям сразу для нескольких разных вариантов DNS-серверов. Всё дело в том, что Go я начинал изучать, когда уже появилось нормальное версионирование зависимостей «из коробки».

    С проблемой мне очень помог справиться Павел Селиванов pauljamm, за что ему огромное спасибо. Удалось собрать свою версию.

    Далее мы прикрутили профайлер pprof, протестировали сборку на деве и выкатили в прод.

    Коллега из команды Chat очень помог разобраться с профилированием так, чтобы можно было удобно цепляться через URL CLI утилитой pprof и изучать память и треды процесса с помощью интерактивных меню в браузере, за что ему тоже огромное спасибо.

    На первый взгляд, исходя из вывода профайлера, у процесса было всё хорошо — бОльшая часть памяти выделялась на стеке и, вроде бы, использовалась Go-рутинами постоянно.

    Но в какой-то момент стало понятно, что у «плохих» подов nodelocaldns было активно слишком много тредов по сравнению со «здоровыми» экземплярами. И треды никуда не девались, а продолжали висеть в памяти. В этот момент подтвердилась догадка Павла Селиванова о том, что «текут треды».

    image

    Отладка: этап 2


    Стало интересно, почему это происходит (текут треды), и начался следующий этап изучения процесса nodelocaldns.

    Было решено сделать coredump «плохого» процесса с помощью утилиты gcore и посмотреть что там внутри.

    Потыкав в coredump с помощью gdb-подобного инструмента dlv я осознал его мощь, но понял, что причину искать таким образом буду очень долго. Поэтому, я загрузил coredump в IDE Goland и проанализировал состояние памяти процесса.

    Отладка: этап 3


    Было очень интересно изучать структуры программы, видя код, который их создаёт. Минут за 10 стало понятно, что множество go-рутин создают некую структуру для TCP- соединений, помечает их как false и никогда не удаляет (помним про 40k RPS?).

    image

    image

    На скриншотах виден и проблемный участок кода и структура, которая не очищалась при закрытии TCP-сессии.

    Также, из coredump по IP-адресам в этих структурах стал известен виновник такого количества RPS (спасибо, что помогли найти узкое место в нашем кластере :).

    Решение


    Во время борьбы с этой проблемой я обнаружил с помощью коллег из сообщества Kubernetes, что официальный Docker-образ nodelocaldns 1.15.8 всё же существует (а у меня на самом деле кривые руки и я как-то неправильно сделал docker pull, либо WIFI шалил в момент pull'а).

    В данной версии сильно «апнуты» версии библиотек, которые он использует: конкретно «виновница» «апнулась» примерно аж на 20 версий вверх!

    Мало того, в новой версии уже есть поддержка профилирования через pprof и включается через Configmap, ничего пересобирать не нужно.

    Была выкачена новая версия сначала в dev, а потом в прод.
    Ииии… Победа!
    Процесс стал возвращать свою память в систему и проблемы прекратились.

    На графике ниже можно видеть картину: «DNS курильщика vs. DNS здорового человека».

    image

    Выводы


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

    Полезные ссылки:

    1. www.freecodecamp.org/news/how-i-investigated-memory-leaks-in-go-using-pprof-on-a-large-codebase-4bec4325e192
    2. habr.com/ru/company/roistat/blog/413175
    3. rakyll.org
    ДомКлик
    Место силы

    Комментарии 18

      +4
      Подпись под КДПВ: «Учись, сынок, а то всю жизнь ключи подавать будешь».
      Спасибо за пример благополучного разрешения проблемы за счёт настойчивости и глубокого погружения.
        +1
        структура, которая не очищалась при закрытии UDP-сессии.


        Не уловил, что за структура, и как ее надо правильно очищать. Можете пояснить?
        +4
        резюме статьи:
        Данная статья, возможно, поможет новичку в мире Golang и Kubernetes понять некоторые способы отладки своего и чужого софта


        Я долго не мог понять, как вообще собрать свою версию nodelocaldns
        решение —
        С проблемой мне очень помог справиться Павел Селиванов
        А где кровавые подробности и пути решения, которые так необходимы новичкам? Или автор предлагает всем обращаться к Павлу?

        решение всех проблем —
        официальный Docker-образ nodelocaldns 1.15.8


        Может есть смысл сделать видео с комментариями.
        начало — есть проблема см. график, скачали source прогнали анализаторы увидели узкие места, сняли дамп, подтвердили опасения, сбилдили новую сборку и вот она в докере. Проверили и счастье нам всем.

        Для новичков самое оно будет отойти от заданий «hello word» и посмотреть на жизнь
          0
          А где кровавые подробности и пути решения, которые так необходимы новичкам? Или автор предлагает всем обращаться к Павлу?


          Паша помог мне с командой:
          cd ~/go/src/k8s.io/dns && \
          GO111MODULE=off GOOS=linux go install ./... ./vendor/...

          Дальше сам :)

          Может есть смысл сделать видео с комментариями.


          Спасибо за конструктив. Была идея сделать доклад на эту тему, но сами знаете что остановило :(
            +2
            Доклад будет не то, для новичков, нельзя отмотать и посмотреть что на экране было и если что то не понял в начале, то дальше слушать бессмысленно, нельзя параллельно у себя на ПК делать.
            А Стрим (видео) легче делается, записал экран а потом сиди звук пиши сколько сочтешь нужным в любой одежде из любого места. И изоляция не помеха.

            В общем похвастаться — лучше докад
            улучшить мир для новичнов — видео

            и не болеть :) :)
          0
          > На dev кластер была выкачена версия 1.15.7, которая уже считается beta, а не alpha как 1.15.4, но на деве нет такого трафика в DNS (40k RPS). Печально.

          Суровые парни ставят альфу в дев и ловял лулзы. Мощно.
            0
            В момент внедрения nodelocaldns у нас, оно существовало лишь в альфе, а эксплуатировало в проде этот софт очень мало народу. Этот дивный новый мир.
            0
            который всё еще находится в beta
            С 1.18 уже stable. Поправьте пожалуйста.
            0
            А чем ДомКлик пользуется для гео-запросов? Сколько записей в БД?
              0

              Могу ответить, но это не точно: postgis, не больше 800GiB

              0
              На это место у меня ругался staticcheck

              не вижу ругани на github.com/miekg/dns/blob/v1.0.14/server.go#L260
              проблемная версия
              git checkout v1.0.14
              staticcheck | grep server.go
              server.go:543:21: argument should be pointer-like to avoid allocations (SA6002)
              server.go:626:19: argument should be pointer-like to avoid allocations (SA6002)
              server.go:722:19: argument should be pointer-like to avoid allocations (SA6002)

              текущая версия из master
              git checkout v1.1.29
              staticcheck | grep server.go
              server.go:474:21: argument should be pointer-like to avoid allocations (SA6002)
              server.go:586:20: argument should be pointer-like to avoid allocations (SA6002)
              server.go:606:19: argument should be pointer-like to avoid allocations (SA6002)
              server.go:647:19: argument should be pointer-like to avoid allocations (SA6002)

              множество go-рутин создают некую структуру для TCP- соединений, помечает их как false и никогда не удаляет

              По коду v1.0.14 не вижу, почему не удаляются, есть строка github.com/miekg/dns/blob/v1.0.14/server.go#L581. И причем здесь false, если создается map[interface{}]struct{}?
              На скриншотах виден и проблемный участок кода и структура, которая не очищалась при закрытии UDP-сессии.

              Причем тут UDP, если мы говорим про TCP, причем для UDP объект conns не используется.
              А на скриншоте мы видим, что len(conns)=1.152.090 (то есть открытых TCP соединений) — это нормально вообще?

              И вообще — почему версии не обновлялись вовремя? Обновили бы, может и не увидели проблему вообще. А так начали глубоко копать, а по факту в чем конкретно проблема и как ее пофиксить не разобрались до конца.
                0
                Прошу прощения, хотел тогда детально разобрать ваш столь интересный комментарий, но что-то меня отвлекло и я просто забыл. А жаль — было бы очень интересно подискутировать. :(

                Staticcheck в последние разы у меня тоже не ругался и я сейчас не могу вспомнить с чего я вообще взял что он ругался. Возможно я что-то делал не так и это была вообще другая ветка. Пожалуй, удалю инфу про staticcheck из статьи.

                К сожалению у меня обновился GoLand на MacOS и в нем перестал открываться мой старый coredump (как и любой coredump, снятый в Linux). Теперь не могу вернуть всё как было и посмотреть заново. Поэтому, я сейчас не смогу пальцем тыкнуть в точное место в коде, в котором не очищается структура с TCP-соединениями.
                Мало того, сейчас я наверное и не в состоянии был бы это сделать, так как уже месяца 3 не писал ничего на Golang.

                Про UDP — оговорка вышла. nodelocaldns с «апстримами» соединяется по TCP.

                И вообще — почему версии не обновлялись вовремя?


                Потому что у нас исторически случился Kubespray, в который был «вшит» nodelocaldns. Обновляем сам Kubespray мы раз в 3-6 месяцев (вместе с обновлением Kubernetes). Пришлось выпилить из Kubespray nodelocaldns и перейти на собственный helm-чарт.
                Обновление Kubespray — сомнительное удовольствие и каждую неделю обновлять его ну никак не получится. По сути — это задача сложнее чем обновить сам Kubernetes.
                  +1
                  Ок, я просто сам контрибьютор в github.com/miekg/dns. И там реально могли быть проблемы типа утечки тредов, например. Дело в том, что там используется антипаттерн «пришел запрос — спавним рутину» для обработки (в стандартном http go тоже он используется, откуда и распространился по миру). Это все чревато тем, что под большой нагрузкой сервер получит OOM (в какой то момент спавн рутин начинает занимать намного больше времени, чем тратиться на обработку). Я пытался продвинуть идею пула воркеров (сначала фиксированное количество), но ее не приняли (потому что количество нужно было подбирать — а оно зависит от железа и ожидаемой нагрузки). Потом была попытка продвинуть идею динамических воркеров (прирост перфоманса был ~ 20% и меньшее потребление памяти), но в процессе codereview вся идея была испохаблена путем добавления логики до 10.000 рутин спавним воркеры, после этого лимита спавним рутины. Эта версия даже была некоторое время в репе (в v1.0.14 в частности), потом у них возникли проблемы с пиками потребления памяти, и все мои правки откатили (это opensource, детка) без попытки какого-либо анализа, с комментами типа: «у нас это работало 8 лет» и «мы потом разберемся» (уже год прошел) — это все, что нужно знать об opensource. У нас на проекте сейчас есть кастомный код с воркерами на базе github.com/panjf2000/ants, но лимит так и приходится подбирать, так что режим выключен по умолчанию (ждем OOM на проде, хе-хе).
                    0
                    Здорово, очень круто! ) У меня такой патч был для pgbouncer (пулер соединений для Postgres) — пул-реквесту уже 5 лет :)

                    А в куб что-либо контрибьютить вообще нет желания, глядя на срачи других контрибьютеров с «кураторами». Проще накатать фича-реквест, а для себя просто поправить несчастные 2 строчки кода и закрыть рабочую задачку.
                0
                Отличная обратная связь
                  +1
                  Каюсь, забыл ответить вовремя. Ответил выше на ваш комментарий.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое