Как да разрешите неразбираеми бъгове на контейнера на докер.

Docker е невероятен. Това е индустриален стандарт по някаква причина и радикално улесни прехода на машинното обучение от изследвания към инженерство. Освен управлението на версии на среда и зависимости, изисквани за доставка на традиционен софтуер, производственото машинно обучение разчита на управление на версии и проследяване на модели — което прави docker перфектния инструмент.

Цената на модулността е потенциална загуба на прозрачност при изпълнение на сложни задачи

Съвременните среди за разработка предлагат обширни вградени дебъгери за регистриране, етап и тестване на код. Това позволява на инженерите ефективно да локализират неочаквано поведение. Отстраняването на грешки в докер контейнерите обаче може да бъде значително по-сложно поради присъщия изолиран характер на системата.

Наскоро наследих голяма кодова база (10'000+ lines of code distributed across 80+ files), пакетирана като докер изображение. Приложението въвежда качени от потребителя сигнали, за да изпълни поредица от повиквания за извод на ETL & (наука за данни), за да изготви аналитичен отчет и да генерира набор от функции. Контейнеризацията е важна за нас, тъй като това е начинът, по който продуктът ще бъде сервиран в производството.

При изпълнението на набор от тестове за разработчици забелязах няколко проблема. Отстраняването на грешки в контейнери може да бъде трудно. Контейнерите са модулни, изолирани системи, които не могат да бъдат изпълнени в стандартна среда за разработчици.

Ето някои съвети за отстраняване на грешки в Docker изображения.

Съвети за отстраняване на грешки за Docker CLI

Преди да разгледаме нашите конкретни проблеми, ето някои докер CLI инструменти, полезни за отстраняване на грешки в контейнери.

Преглед на регистрационни файлове

Можете да видите регистрационните файлове на предишно инстанциране на контейнер, като изпълните:

docker container logs [COUNTAINER]

--tail n може да се добави като аргумент за ограничаване на печата до n реда отдолу.

Поточно предаване на регистрационни файлове в реално време

Можете да преглеждате регистрационните файлове, докато се изпълнява контейнер, като прикачите времето за изпълнение към вашата обвивка:

docker attach CONTAINER

Влезте в историята на изображенията

docker image history IMAGE

Стартиране на Bash вътре в контейнер

Можете да стартирате интерактивна bash обвивка в изображение, за да изпълните ръчно командите, като изпълните:

docker run --rm -it --name CONTAINER [IMAGE] bash

Преглед на подробности за контейнера

Може да искате да проверите CMD (или други подробности за конфигурацията), изпълнявани в контейнер. Можете да получите списък с вашите контейнери във формат json, като изпълните:

docker container ls --all --format ‘{{json . }} -no-trunc | jq -C

Прикачен | jq -C (jq може да се инсталира на Homebrew) или | python3 -m json.tools --json-lines за форматиране на JSON по начин, който може да се чете. Опцията --no-trunc премахва съкращенията на текста.

По-подробна разбивка на отделен контейнер може да бъде регистрирана чрез изпълнение на:

docker container inspect CONTAINER — format ‘{{ json . }}’ | jq -C

Това ще разкрие информация за Entrypoints, Networksettings и т.н.

Стартиране на Shell в работещ Docker контейнер

Можете да изпълнявате допълнителни команди в рамките на контейнер, без да указвате командите по време на изграждане. Можете да изпълнявате допълнителни команди в (работещ) докер контейнер с функцията exec. Това е особено полезно, тъй като ви позволява да стартирате shell/bash терминал:

docker exec it CONTAINER /bin/sh

други

Тези съвети са общи инструменти, които позволяват по-лесно отстраняване на грешки в контейнер. Те обаче не решават конкретния ми проблем.

Забележка: други често срещани проблеми включват Entrypoints, file access & docker build и др.

Проблемни ограничения

Въпреки че е полезно, решаването на конкретния ми проблем може да изисква нещо по-персонализирано. Контейнерът стартира ETL тръбопровод за машинно обучение, който обработва необработени (въведени от потребителя) високочестотни EEG и EKG ленти. Срещаме се със следните ограничения:

  • Зависими от контейнера: Скриптовете не могат да бъдат изолирани по време на изпълнение поради зависимост от софтуер на трета страна, извикан в контейнера — което допълнително прави изпълнението на скриптове извън контейнера неосъществимо.
  • Липса на модулност: въпреки че софтуерът е добре написан и следва OOP дизайн на добра практика, много модули/скриптове са взаимозависими. В резултат на това изпълнението на допълнителни команди в докер обвивка не е възможно.
  • Паралелизиран дизайн: Високочестотният характер на данните прави изчисленията скъпи за изпълнение. Следователно беше взето предишно дизайнерско решение за паралелизиране на много последователности в основната нишка за изпълнение — добавяне на сложност и уникални времеви зависимости към изчислителната графика.
  • Без тестове и очаквания: Писането на очаквания за данните на различни етапи е сложно поради високата честота и високата променливост на данните. В резултат на това не са приложени статистически очаквания, нито единични тестове за изолиране на грешката. Това ще бъде разгледано по време на отстраняването на грешки.
  • (Почти) работи: Кодът връща очакваното поведение в 99,9% от случаите, но наблюдаваме грешка в изхода на някои странични случаи. Ще трябва да се приложи уникална логика за обработка на конкретни сигнали.

Спецификация на компилация

За щастие, спецификацията на компилацията е доста ясна. Ние желаем да:

  • Коригиране на грешки: Коригира всички грешки в системата, така че всички въведени данни да произвеждат очакваното.
  • Писане на тестове: За да се гарантира, че бъдещите грешки могат да бъдат открити по-рано.
  • Извличане на междинни данни: Имате способността да извличате данни на различни етапи на трансформация/извод за по-нататъшно развитие на функции, анализ и отстраняване на грешки.

Внедряване

За да постигнем това, трябва да следваме набор от стъпки:

  1. Изчислителна графика:Разберете реда на изпълнение на изчислителната графика, за да изясните зависимостите и да създадете логически етапи.
  2. Етап: Определете етапите и очакваното поведение на всеки етап и приложете тестове и очаквания за наблюдение на желаното поведение.
  3. Извличане: Съхранявайте междинни резултати за анализ надолу по веригата и разработване на продукти (извличане и разработване на функции).
  4. Дневник: Регистрирайте ключова информация за наблюдение на обработката по време на изпълнение.

Стъпка 1: Монтирайте контейнера

Изображението съдържа голям брой файлове и приложения. Идентифицирахме поддиректория, която съдържа грешката в обработката. За да модифицираме и тестваме кода и въведеното от потребителя, можем да клонираме поддиректорията локално и mount контейнер, така че локалните файлове да бъдат заменени с файловете контейнери в същата директория.

/opt/X11/bin/xhost +;
docker run -it -e DISPLAY=host.docker.internal:0 -v /tmp/.X11-unix:/tmp/.X11-unix --mount type=bind,source=LOCALPATH,target=TARGETPATH IMAGE
  • /opt/X11/bin/xhost +; позволява на XQuartz да внедри GUI на приложението (изисква се за работа на macOS)
  • docker run -it стартира докер и предоставя достъп до интерактивна обвивка. Обяснено тук.
  • -e DISPLAY=HOST указва променлива на средата, заменяща конфигурацията във файла ENV.
  • --mount свързва файлов път към контейнера, като използва локалния файлов път (source=) на мястото на target= пътя в контейнера. Това е от съществено значение за тестване на промените.

Стъпка 2: Разберете изчислителната графика

Паралелният и OOP характер на кодовата база замъглява детайлите, създавайки объркващ поток от операции. Наложително е да разберете реда на операциите, за да знаете какво да очаквате (улавяне на грешки и нарушения).

Псевдокодът по-долу описва последователността от извиквания на функции в кодовата база (след премахване на някои извиквания и промяна на имената и т.н.). Състоянието се предава в цялото приложение (изисква се контролна точка).

class ProcessThread(threading.Thread):
 -> perform_calc_a():
  -> with thread:
   calc_a1()
   calc_a2()
   calc_a3()
    -> if self.check_a():
     if self.check_b():
      execute.executable_a()
     else:
      execute.executable_b()
     execute.executable_c()
     execute.executable_d()
    -> else:
     execute.executable_e()
     if self.chcek_b():
      execute.executable_a()
     else:
      return
     execute.executable_c()
     execute.executable_d()
   execute.executable_d()

Стъпка 3: Контролна точка

Напишете обект от клас, който регистрира информация според изискванията и избира междинни резултати. Това формира основата за iвнедряване на тестове, идентифициране на точки на прекъсване и проследяване неочаквано поведение.

Забележка: Единичният тест може да бъде по-сложен при машинно обучение. Поради математическото си естество дадена стойност може да премине традиционен тест за единица (проявявайки правилното поведение, тип данни и т.н.), но да върне стойност, която очевидно е извън допустимия диапазон.

Вграденият регистратор на Python предлага чудесна отправна точка:

import logging, coloredlogs

# configuration
formatter   = logging.Formatter("[%(asctime)s] [%(levelname)8s] --- %(message)s (%(filename)s:%(lineno)s)", "%Y-%m-%d %H:%M:%S")
logger      = logging.getLogger('My logger.')
handler     = logging.FileHandler('mylogs.log')
handler.setLevel(logging.DEBUG)
handler.setFormatter(formatter)
logger.addHandler(handler)
coloredlogs.install(level='DEBUG', logger=logger)

# examples
logger.debug("this is a DEBUG message")
logger.info("this is an INFO message")
logger.warning("this is a WARNING message")
logger.error("this is an ERROR message")
logger.critical("this is a CRITCAL message")

Освен това трябва да добавим някои персонализирани функции за съхраняване на обекти. Паралелният характер на контейнера затруднява интуицията на реда на изчисление. Освен това, някои извиквания на функции разчитат на външни функции на „черна кутия“, върху които нямаме контрол. Следователно трябва да симулираме функционални повиквания, като изследваме поведението на входа и изхода. Кодът по-долу ни позволява да:

  • Съхранявайте междинни резултати (двойки вход/изход) във времето.
  • Регистрирайте етапите и оценете очакваното поведение.
  • Структурирайте данните еднакво.
class debuger_console:
    def __init__(self, debug_mode=True, path=None) -> None:

        self.logger_count   = 0
        self.store_count    = 0
        self.store_count_2  = 0
        self.counter        = {}
        self.debug_mode     = debug_mode

        if path is None:
            now         = datetime.now()
            now         = now.strftime('%Y.%m.%d %H:%M') + ' -hrv_service'
            self.path   = f'interim_store/{now}'
        else:
            self.path = path
        if not os.path.exists(self.path):
            os.makedirs(self.path)

        if self.debug_mode:
            print(f'\n\n\n\n\n\n\n\n\nrunning debugger :). \npath={self.path}\n\n\n\n\n\n\n\n\n')


    def increment_counter(self, key='calculate_hrv_for_relative_period'):
        if key in self.counter.keys():
            self.counter[key] += 1
        else:
            self.counter[key] = 0


    def logger(self, name, object) -> None:
        s=''
        nname   = name + ' '
        nn      = 100-len('Logging: ')
        print(f'{s:-<{100}}')
        print(f'Logging: {nname:.<{nn}}')
        print(f'logger count: {self.logger_count}')
        try:
            print(f'{name}:', object.keys())
        except:
            print(f'{name}:', object)
        print(f'{s:.<{100}}')
        self.logger_count += 1
       
 
    def store_interim_results(self, name, object) -> pickle:
        s=''
        print(f'{s:.<{100}}')
        try:
            with open(f'{self.path}/{name}.pkl', 'wb') as f:
                pickle.dump(object, f)
            print(f'{s:.<{100}}>>')
            print('store count: ', self.store_count)
            print('STATUS==PASSED')
            print(f'file: {name}.pkl saved successfully.')
            print(f'{s:.<{100}}>>')
            self.store_count += 1
        except Exception as e:
            print('STATUS==FAILED')
            print(f'failed to save file: {name}.pkl.')
            print(f'Exceptions: {e}')
            print(f'{s:.<{100}}')
    

    def store_list(self, dict_name, names, objects, prints=True, log_output:bool=True):
        try:
            data = {}; s=''
            if prints:
                print(f'{s:-<{100}}')
                print(f'CREATING OBJECT: {dict_name}')
            for n,o in zip(names, objects):
                data[n] = o
                if prints:
                    print(f'     - Variable added to dict: {n}')
            if log_output: dc.logger(dict_name, data)
            dc.store_interim_results(dict_name, data)
            if prints:
                print(f'{s:-<{100}}')
        except Exception as e:
            print(f'Failed. Exception: {e}')


    def get_var_name(self, var):
        gs = globals()
        try:
            return [name for name in gs if gs[name] is var][0]
        except Exception as e:
            print('e:                                       ', e)
            print('var:                                     ', var)
            print('[name for name in gs if gs[name] is var]: ', [name for name in gs if gs[name] is var])
            print('---'*100)


    def store_breakpoint(self, inputs, outputs, inames, onames, dict_name, base_func) -> pickle:
        message = f"""
            ################################################################
            ##################### CREATING BREAKPOINT ######################
            # dict_name:    {dict_name}
            # base_func:    {base_func}
            ################################################################
            ################################################################
        """
        print(message)
        """
        Capture:
            - BREAKPOINT ID:    Id/count.
            - INPUTS:           Breakpoint arguments.
            - OUTPUTS:          Breakpoint returns.
            - BASE FUNCTION:    The method that called this operation.
            - DICT NAME:        Name picle object.
            - inames,onames:    Variable names.
        """
        meta = {}
        meta['ID']              = self.store_count_2
        meta['INPUTS']          = inames
        meta['OUTPUTS']         = onames
        meta['BASE-FUNC']       = base_func
        self.store_count_2     += 1

        # add meta data
        vars = [meta]
        nmes = ['meta']

        # add data
        vars.extend(inputs)
        vars.extend(outputs)
        nmes.extend(inames)
        nmes.extend(onames)

        # write to file
        self.store_list(dict_name=dict_name, names=nmes, objects=vars)


dc = debuger_console()

Стъпка 4: Резултат

Ето! Изпълнението на контейнера в режим на отстраняване на грешки създава директория и настройва вътрешното състояние при различни ключови операции. Това ни позволява бързо да разработим пакет за тестове (и очаквания) извън контейнера — локализираме неочакваното поведение и внедрим допълнителната логика.

Опростеността на решенията е удобна за потребителя и подлежи на разширение. Освен това, той настройва кодовата база за подобрения надолу по веригата. Поетапните точки на прекъсване насърчават приемането на тестване, очакване и проследяване на модела и предупреждения.