docker

Многие интересуются технологией, которая обещает изменить подход к организации жизненного цикла приложений под названием Docker. Если начинать с определений, то оно будет звучать как-то вроде: “Docker - это технология контейнеризации приложений…” и далее по тексту. Но я хотел бы прерваться на этом месте и сделать важное отступление. Дело в том, что с технологической точки зрения Докер - далеко не прорывной проект, это один из представителей целого семества проектов, реализующих технологию контейнеризации. Более того, Докер - не первый среди них, и чтобы уж совсем развеять ореол исключительности - все они опираются на примерно один и тот же стек базовых технологий.

Так почему же Докер у всех на слуху, а про OpenVZ или LXC мало кто слышал? Почему Докер один из всего семейства схожих проектов выстрелил, а его родственники - в лучшем случае остались нишевыми инструментами? Все дело не в технологическом превосходстве, а в правильной идее и позиционировании. Команда Докера взялась решать проблему, которую до них не решали другие проекты контейнеризации.

Минутка истории

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

Зависимости

В современном мире ни одна программа, решающая конкретную бизнес-задачу, не пишется с нуля. Мы опираемся на библиотеки и фреймворки, они в свою очередь - на примитивы языка программирования и его стандартную библиотеку. Любая программа скорее всего будет запущена в окружении какой-то операционной системы - огромного количества програмного кода, решившего за нас все низкоуровневые проблемы.

С одной стороны это великолепно: резко сокращается время на разработку приложений, проект любого приложения завершен на 97% еще до написания первой строчки кода. С другой стороны - оставшиеся 3% кода, которые мы напишем сами, предъявляют большой набор требований к окружению, в котором они будут запускаться и работать:

  • нашему приложению нужна операционная система, обладающая определенным функционалом
  • в этой операционной системе должен быть установлен рантайм языка программирования
  • должны быть установлены все библиотеки/фреймворки/пакеты языка, которые использует наше приложение
  • для всех предыдущих пунктов крайне желательно зафиксировать определенные версии компонентов, иначе - никаких гарантий

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

Над его кодом будут работать его коллеги, затем этот код будет размещаться на боевых серверах - а значит нам нужна надежная и повторяемая процедура создания рабочего окружения.

В процессе разработки зависимости приложения могут меняться - а значит нам нужен способ заставить окружение следовать за этими изменчивыми требованиями.

Последним гвоздем в крышку нашего гроба может стать ситуация, когда мы захотим на одном сервере разместить несколько приложений со взаимоисключающими зависимостями: приложение А хочет рантайм языка версии 3, а приложение Б - версии 5, которые друг с другом не совместимы. Все? Приплыли?

Изоляция

Раз интернеты еще не развалились - то значит какое-то решение умные люди придумали, правда? Этим решением были различные технологии изоляции. Если у нас имеется “проблема коммуналки”, когда логически не связанные друг с другом приложения делят единое окружение, как чуждые друг другу жильцы коммуналки делят санузел и кухню, то надо эту коммуналку расселить.

Виртуализация

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

Это позволит нам установить на виртуальную машину операционную систему по вкусу, а за ней и весь комплект зависимостей нашего приложения. Нам не нужно ломать голову над тем, как тут будут уживаться несколько приложений, мы выделим каждому по своей виртуальной машине. Ура! Коммуналочки расселены! Победа? Или пока только одной проблемой меньше?

Подход “каждому приложению - по виртуалочке” крайне расточителен. Мы тратим много ресурсов, на то, чтобы эмулировать аппаратное обеспечение, на работу всех копий ОС в каждой ВМ и т.д. Нам придется либо закидывать проблему деньгами, закупая больше мощностей, либо искать компромисный подход, когда мы не целиком расселяем “коммуналочки”, а дробим их на более мелкие.

Контейнеризация

Не пожелав мириться с таким раскладом умные люди подумали-подумали и поняли, что почти никогда им на самом деле не нужно эмулировать оборудование и ставить отдельную ОС. Это лишь средство достижения цели, которая на самом деле звучит как “приложению должно казаться, что оно одно на этом компьютере”.

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

  • файловую систему
  • память с деревом процессов
  • сетевую активность

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

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

Ну и наконец, если у каждого приложения будет свой сетевой стек и они не смогут видеть пакеты друг друга - то мы отрезали последний канал коммуникаций между приложениями на данном компьютере.

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

Процессы внутри контейнеров являются самыми обычными процессами, запущенными на общем ядре одной единственной операционной системы. Соответственно получатся несравнимо малый объем накладных расходов и падения производительности по сравнению с виртуальными машинами.

Ну что? На этот раз победа? К сожалению, еще нет. Если мы остановимся здесь, то получим те самые известные только в узких кругах технологии контейнеризации, появившиеся до Докера. А все потому, что кроме “проблемы коммуналочек” существует еще как минимум одна проблема, которую необходимо решить.

Mutable Monster

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

Представим себе ситуацию, когда мы хотим установить программу X. В инструкции написано, что у нее есть зависимости A и B. Для наглядности пусть все будет слегка игрушечное: пусть X, A и B - просто файлы, которые должны оказаться в одной папочке, чтобы все заработало. Также у нас есть свой примитивный язык для скриптов установки:

COPY X
COPY A
COPY B

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

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

Файл A на самом деле не нужен, а чтоб все завелось - надо подкинуть файлы B и С.

Мы правим наш скрипт в соответствии с советом:

COPY X
COPY B
COPY C

Запускаем, и… работает! Отписываемся под советом, обещаем Васяну проставиться при случае и коммитим наш скрипт… Только чтобы утром услышать от коллег, что он не работает. После стандартных аргументов про кривые руки мы проверяем скрипт на случайном чистом сервере - и правда не работает! Как так?!

Дело в том, что в любой системе, в которую можно вносить изменения (Mutable) успешной является не последняя попытка, а сумма всех попыток. Если мы долго и так и этак пинали систему и в конечном итоге оно завелось, то это не значит, что последний вариант был правильный и надо тут же его документировать. Он просто нужным образом наслоился на все неправильные варианты, которые хотя бы частично продвигали нас в нужном направлении. Совет Васяна был отчасти верным, после долгих разборок мы выяснили, что программа X зависит от всех трех файлов: A, B и C. И правильный скрипт установки выглядит следующим образом:

COPY X
COPY A
COPY B
COPY C

Почему у нас все заработало и нам показалось, что скрипт верный? Мы добавили файл A во время первой, неудачной попытки. И “как-бы верного” второго варианта скрипта хватило, чтобы довести систему до ума.

На таком тривиальном примере, где у нас:

  • одно единственное приложение
  • все зависимости представлены в виде одного файлика
  • присутствует только операция добавления файла

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

  • приложение может состоять из многих компонентов
  • зависимости представляют собой сложные объекты, которыми манипулируют пакетные менеджеры
  • присутствуют операции удаления, изменения, версионирования, взаимные зависимости и конфликты

Таким образом ситуация многократно усложняется и выходит из под контроля. Вот тут-то и вылезает этот самый Mutable Monster - сущность, на которую от начала времен наложили такое количество изменений, что никто уже не может точно описать её текущее состояние. Даже если мы старательно документировали каждое изменение (в журнале строгого учета с подписями и печатью :)), то не факт, что среди этих накатов-откатов-перекатов мы сможем найти рациональное зерно и кратчайший путь от “чистой” системы до “рабочей”.

Житейский пример: думаю всем знаком ритуал переустановки Windows. Со временем система “замусоривается” и “замедляется”. Мы вынуждены прибегать к таким житейским терминам из-за невозможности точно описать причину возникающих проблем. Куча программ и установщиков могут вносить изменения в системную коммуналочку (совокуность двух проблем, которые я расписывал до этого момента). Даже если каждый такой установщик честно и точно зафикисирует все свои действия, то со временем распутать этот клубок человеческими усилиями становится неоправдано сложно, начать с чистого листа проще, чем заниматься микро-хирургией. Кто-то может сказать, что переустановками занимаются только лузеры и эникейщики, не способные разобраться в проблеме, но на эту ситуацию можно посмотреть и с другой стороны.

Почему принтер так хорош в печати одинаковых копий одной и той же страницы? “Ну это же машина! Арифметика и копирование - две задачи, с которыми компьютеры справляются лучше всего!” - скажите вы и будете правы. Но есть и еще один аспект успеха: принтер всегда начинает с чистого листа. Это настолько хороший подход, что даже само выражение выражение “начать с чистого листа” стало крылатым. Зная, что у нас всегда одинаковые начальные условия, мы можем здорово упростить механизм принтера, увеличив его производительность и надежность.

Представьте, что мы поставили себе задачу создать принтер, который сможет печатать на использованных листах. В принтер можно положить лист, на котором уже что-то напечатано, а он бы вносил в имеющийся текст правки, как учитель красной ручкой в тетрадке, но в намного меньшем масштабе. Устройство принтера многократно усложнится, а результат будет не уровня идеальных копий, а “как шмогла”.

А ведь это именно то, чем мы занимаемся в создании окружений для наших приложений. Мы пытаемся привести окружение из неизвестного состояния в желаемое. Эта стратегия обречена на поражение. Это не вопрос “насколько легко вы это делаете?” а “сколько вы так продержитесь?”.

Docker to the rescue!

В попытке решить сразу обе эти проблемы (изменяемые коммуналочки) и появился Докер. Для этого необходимо было переосмыслить определение и предназначение контейнера.

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

Докер же позиционирует контейнер, как единицу полезной функциональности: веб-сервер, бекенд-приложение, база данных и т.д. Соответственно контейнер должен содержать только то, что минимально необходимо для работы единственного приложения, ради которого он создавался. Если у нас имеется Python-приложение, то мы должны положить в контейнер:

  • рантайм языка
  • пакеты-зависимости приложения
  • код самого приложения

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

Контейнер должен рассматриваться как черный ящик, как большой исполняемый файл, а не как мини-ОС, в которую мы будем логиниться, обживаться и работать. Чтобы укрепить этот подход, в Докере различаются образы и контейнеры.

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

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

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

Такой подход позволяет нам закрыть обе наши проблемы. С одной стороны мы расселили все коммуналочки, в крайних случаях доходя до контейнерного дзена и контейнера в один файл. С другой - мы убили Mutable monster‘а, утвердив единственный возможный способ внесения изменений в запущенные контейнеры только через пересборку образов с нуля, удаление старых контейнеров и создание на их месте новых.

Brave new world

Имея перед глазами взгляд на проблемы жизненного цикла приложений, становятся понятны самые основные черты дизайна Докера. Но прежде чем давать более подробный технический обзор и практическое руководство к применению, я хотел бы еще не на долго задержаться на волне рассуждений.

Дело в том, что у большинства технологических прорывов есть две стороны медали:

  • решение старых проблем
  • открытие новых возможностей

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

Допустим мы фотографы начала 20 века, много фотографируем и долго и утомительно проявляем снимки в проявочных ваннах с красными лампами. Если достаточно долго совершенствовать и автоматизировать процесс проявки, то дойдя до скорости 24 фотографии в секунду мы не просто облегчим себе жизнь. При должной смекалке можно понять, что мы изобрели кино.

Начну с минорных улучшений и буду продвигаться в сторону революций и футуризма.

Локализация разработки

Иногда бывает удобно при локальной разработке посмотреть, как приложение ведет себя с реальными данными и в окружении, приближенном к боевому, со всякими очередями, кешами и балансировщиками.

Зачастую для этих целей на серверах компании создаются специальные сетапы для тестирования, а затем в конфигурации проекта создаются “окружения”, где указываются адреса и креды от всех этих тестовых баз, редисов и реббитов. Такой подход имеет несколько очевидных недостатков:

  • этот сетап кто-то должен создать (и задокументировать)
  • от стабильности этого сетапа зависит процесс разработки (а значит - надо мониторить)
  • если этим сетапом будут пользоваться много разработчиков, он может начать тормозить (а значит - придется масштабировать)
  • усложняется процесс конфигурирования: необходимость переключаться между дев- и прод-окружениями
  • ну и как же не упомянуть аргумент “не покодить в самолете” :)

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

Докер позволяет одной командой развернуть весь стек сервисов, необходимых для разработки и тестирования приложения в виде отдельных контейнеров. И так же одной командой - бесследно их удалить. Типичный случай: пустые инстансы redis, rabbitmq, mongodb, в которых не хранится ничего важного.

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

Continuous Deployment

Continuous Integration - не новость в мире разработки, эта практика появилась задолго до контейнеров. Но контейнеры привносят то, что любят называть Quality of Life - всякие плюшки, которые упрощают нам жизнь.

Унификация билд-серверов

Если в вашей компании разрабатываются приложения на различных языках и фреймворках, то на билд-сервере должны быть удовлетворены их зависимости. По сути мы возвращаемся к проблеме коммуналочек в миниатюре. Также артефакты собрки требуют различного отношения к себе: кого-то надо положить в репозиторий пакетного менеджера, кого-то положить на файлопоечку и так далее…

Даже если вы разрабатываете один проект, или все ваши проекты работают на абсолютно одинаковом стеке, то никуда не уходит чистота сборочных и тестовых окружений. Мы должны либо озаботиться их очисткой после каждой сборки (да так, чтобы повторное создание этого окружения не длилось мучительно долго), либо довольствоваться тем, что каждый из наших пайплайнов - это маленький Mutable Monster, который в определенный момент может выстрелить нам в ногу.

Если же каждый из наших сервисов - это докер-контейнер, то весь пайплайн унифицируется:

  • сборка через docker build
  • тестирование через docker run
  • управление артефактами через docker push
  • развертывание через docker service create|update

…ну вы поняли идею, никакого зоопарка систем сборок, хранилищ артефактов и систем развертывания. Все это в лучшем случае будет инкапсулировано в инструкциях сборки образа, как максимум - упразнено за ненадобностью.

Еще один слой уверенности

Благодаря неизменяемости докер-образов мы можем пойти на шаг дальше: вместо прогона сборки и тестов на каждый коммит, мы можем сразу же после успешных тестов выкатывать эти изменения на прод. Это и будет переход от Continuous Integration к Continuous Deployment.

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

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

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

Лечение и адаптация инфраструктуры

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

Подробнее про встроенный планировщик Swarm в этой статье.

Вместо послесловия

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