Итак, допустим у вас есть представление, о том, что такое докер, зачем он нужен и какие проблемы решает. Если нет - милости прошу в предыдущую статью. Следующий логичный вопрос: “Хочу попробовать - куда нажимать?”. За процессом установки направлю в официальную документацию, с одним лишь замечанием, что для Linux-дистрибутивов надо смотреть “серверные” инструкции.

По традиции, попробуем запустить “Hello, world!”:

$ docker run --rm hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
9bb5a5d4561a: Pull complete
Digest: sha256:f5233545e43561214ca4891fd1157e1c3c563316ed8e237750d59bde73361e77
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/engine/userguide/

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

Действующие лица

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

Основные сущности в докере - это образы и контейнеры.

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

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

Реестры образов

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

Готовые образы берутся из реестров, официальный, дефолтный и самый большой реестр: DockerHub. Здесь можно найти официальные образы для огромного количества популярных приложений:

  • базовые образы ОС: ubuntu, centos, alpine, windowsservercore
  • образы с языками програмирования: java, go, python, node
  • субд: mysql, mongo, redis
  • приложения: nginx, wordpress, jenknis, grafana

Это далеко не полный список, официальных контейнеров тысячи. Именно с ДокерХаба был взят образ, который мы недавно запускали:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
9bb5a5d4561a: Pull complete
Digest: sha256:f5233545e43561214ca4891fd1157e1c3c563316ed8e237750d59bde73361e77
Status: Downloaded newer image for hello-world:latest

Если присмотреться, то этот выхлоп дает нам несколько инсайтов во внутреннюю кухню докера и ДокерХаба.

Во-первых, почему-то мы искали образ hello-world:latest, что за приписочка через двоеточие? Образы поддерживают версионирование, а точнее - тегирование. Мы можем указать конкретную версию или вариант образа нужного нам приложения. Официальные образы тегируются по семверу с добавлением вариантов, например вот список тегов для редиса.

Специальное соглашение: если в любой операции с образами явно не указан тег, используется тег latest. Надо отметить, что название обманчиво. Это не какой-то волшебный тег, который динамически отслеживает последний залитый образ. Если мы соберем образ, протегируем его как 1.0 и latest, зальем, а затем будем выпускать релизы 1.1, 1.2 и т.д., то latest продолжит указывать на 1.0. В работе с официальными образами об этом можно не беспокоиться, у матерых ребят все схвачено, но при игре с собственными образами может случиться конфуз.

Во-вторых latest: Pulling from library/hello-world. Ок, мы тянем тег latest, но кто такой library? Это еще одно соглашение, на этот раз не в самом докере, а на ДокерХабе. Это служебный юзер, под которым размещаются официальные образы, он используется, когда юзер не указан явно. Кроме официальных образов на ДокерХабе можно размещать собственные образы. Тогда они будут именоваться как john/cool-app. Многие публичные опенсорс проекты размещают свои образы именно там.

Естественно, коммерческие разработки хочется хранить подальше от посторонних глаз. Docker Inc. не жмется и раздает standalone-версию реестра, естественно в виде контейнера. Приватный реестр - неотъемлемый кирпичик частной инфраструктуры наравне с репозиториями и CI-системой. Имя образа в приватном реестре будет еще более полным, на этот раз добавляется адрес реестра: acme.com/foo/cool-app. Еще одно умолчание: дефолтный адрес реестра указывает на ДокерХаб.

Собственные образы

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

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

1) на вход принимается Dockerfile с инструкциями 2) запускается контейнер с неким начальным образом 3) выполняется инструкция, вносящая какие-то изменения 4) состояние ФС контейнера фиксируется (аналогично git commit) 5) это состояние становится новым начальным образом 6) переходим на пункт 2 и выполняем все инструкции до упора

В сборке образа нет никакой магии, это просто цикл запуска контейнера, выполнения внутри него команды и фиксации состояния. Звучит немного путано, сейчас будет пример и все станет понятно.

Допустим у нас есть бекенд-приложение на питоне. Его репозиторий будет представлять из себя набор исходников и манифест с зависимостями requirements.txt. Важно заранее отметить, что тут нет никакой заязки на конкретный язык, это может быть package.json для ноды, Gopkg.toml для голанга и так далее, паттерн более-менее похож во всех распространенных языках.

Мы решили контейнеризировать наше приложение и создали в репозитории Dockerfile со следующим содержимым:

FROM python:3.6.5

WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py","--serve", "0.0.0.0:80"]

и запустили сборку через docker build . -t cool-app. Эта команда соберет из файлов текущей директории образ cool-app, или, если быть точным, cool-app:latest.

Разберем по порядку, что тут происходит. Сперва мы указываем базовый образ через директиву FROM - с него начинается наша история. Мы явно указали версию питона, всегда делайте так, это повышает ясность докерфайла и повторяемость его сборки.

Командой WORKDIR мы указали директорию внутри контейнера, где будет проходить вся движуха. Положить приложение в /app - распространенная практика, помогающая визуально отделить стандартные директории окружения от явно нестандартной дирекории с приложением.

COPY копирует файлы с хоста в файловую систему контейнера. Две точечки означают текущие директории хоста и контейнера соответственно. Для хоста это директория с исходниками проекта, для контейнера - установленная предыдущей командой /app.

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

Наконец CMD задает команду, которая будет выполняться при запуске нашего контейнера. Это просто метаданные, во время сборки эта команда выполняться НЕ будет.

Итого:

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

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

В итоге, относительная единообразность сборки образов сильно упрощает ознакомление с новым микросервисом. Докерфайл становится точкой входа в репозиторий сразу после README.md. Даже больше, в Докерфайл переносятся технические детали процесса “установки” приложения, а Ридми фокусируется на описании его функций и назначения.

Процесс разработки и жизненный цикл контейнера с приложением

Локальная разработка

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

Все начинается с чтения документации проекта на тему настройки рабочего окружения. Помимо IDE нам нужен рантайм языка, желательно “правильной” версии. Приложению нужны библиотеки/пакеты и прочие зависимости (хорошо, если они не общесистемные, а устанавливаются в локальную для проекта папочку). В процессе разработки могут понадобиться дополнительные сервисы вроде СУБД, кеша, очереди, веб-сервера и прочих… их тоже придется установить и настроить. Или еще хуже - все эти сервисы будут запущены на дев-серверах компании. Тогда для них будут выработаны витиеватые регламенты совместного пользования, а от их бесперебойной работы будет зависеть продуктивность всех причастных разработчиков.

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

Несмотря на то, что установка дополнительного сервиса усохла из мануала размером на страницу до одной команды запуска контейнера, очевидная проблема настигнет нас довольно быстро - это количество контейнеров, которыми надо управлять, чтобы заставить работать один проект. Типичный стек python + mongo + redis + rabbitmq - это уже 4 контейнера, ситуация усугубится, когда захочется вести разработку сразу нескольких микросервисов - проблема сразу же умножится “на несколько”.

Лень - двигатель прогресса, поэтому ленивые люди придумали Docker Compose - утилиту, позволяющую описать и управлять комплектом контейнеров как единым проектом. Описание проекта делается в файле docker-compose.yml в YAML-формате. Он, наравне с Докерфайлом, ложится в репозиторий проекта. Рассмотрим пример такого файла:

version: '2'
services:

  api:
    build: .
    entrypoint: nodemon -L /app/app.js start --app api
    ports:
      - 8080:80
    volumes:
      - ./classes:/app/classes
      - ./config:/app/config
      - ./controllers:/app/controllers
    depends_on:
      - mongo

  mongo:
    image: mongo:3.6.3-jessie
    ports:
      - 27017:27017
    volumes:
      - mongo:/data/db

volumes:
  mongo:

В данном примере мы разрабатываем API на node.js, которое использует в своей работе MongoDB. Описание всех сервисов один к одному отображается в докер-команды, например база будет запускаться через docker run --name mongo -p 27017:27017 -v mongo:/data/db mongo:3.6.3-jessie. У контейнера API описание чуть обширнее, три хитрых момента следующие:

1) Вместо image у нас build, что говорит сперва сделать docker build, а затем уже docker run из получившегося образа. 2) У нас переопределен entrypoint - команда, выполняемая при запуске контейнера. В докерфайле мы просто запускаем наше приложение через node /app/app.js ..., но на время разработки мы используем nodemon - утилиту, которая смотрит за изменением исходников и перезапускает сервер за нас. Не стоит детально вдаваться этот трюк. Важно только то, что это частный слушай решения проблемы автоматической пересборки приложения при внесении изменений, в различных языках и фреймворках эту проблему решают по-разному. 3) В качестве вольюмов мы указали каталоги с исходниками, так чтобы изменяя их на хосте они изменялись внутри контейнера, а эти измения подхватывались бы за счет предыдущего трюка.

Чтобы запустить весь проект нам достаточно команды docker-compose up:

$ docker-compose up
Creating network "api_default" with the default driver
Creating volume "api_mongo" with default driver
Building api
Step 1/9 : FROM node:8.10.0-alpine AS build
8.10.0-alpine: Pulling from library/node
...

Для проекта была создана отдельная сеть, чтобы не мешать другим, под данные монги был создан вольюм, а дальше начался процесс сборки образа API. Через некоторое время процесс сборки будет завершен, контейнеры запустятся и мы сможем достучатся до нашего приложения на http://localhost:8080/ и mongo://localhost:27017/, потому, что именно эти порты мы попросили прокинуть наружу из контейнеров в нашем компоуз-файле. После логов сборки мы увидим логи всех контейнеров.

Таким нехитрым образом весь процесс подготовки рабочего окружения свелся к двум командам: git clone и docker-compose up. Этот процесс универсален и практически не зависит как от языка и фреймворка, на котором написан микросервис, так и от набора дополнительных сервисов, необходимых для его работы. Если чтение README.md и Dockerfile в корне репозитория позволяет нам быстро ознакомиться с сутью проекта, то docker-compose up позволяет за пару минут поднять его локально и поиграться с ним.

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

Допустим у нас еще рядом есть контейнер с редисом, для которого нам достаточно стандартного redis-cli, что тогда? Большинство официальных контейнеров с какими-либо сервисами комплектуются соответствующими клиентами. Значит мы можем запустить клиент внутри контейнера и подключиться к сервису, например: docker-compose exec redis redis-cli, где redis - это имя контейнера, а redis-cli - программа, которую мы запускам внутри него. В общем случае мы можем “завалиться в контейнер” через docker-compose exec <container> bash (или sh для alpine-версий), но не стоит слишком увлекаться.

“Ладно-ладно, я-то завалюсь в контейнер, а как они друг с другом будут общаться без проброшенных портов?” Все просто, вспомним самую первую строчку при запуске проекта: Creating network "api_default" with the default driver. Внутри докер-сети контейнеры могут общаться друг с другом по именам, не нужны никакие IP-адреса и прочие детали из “реального” сетевого мира. Например, API будет ходить в монгу через mongo://mongo, а в обратную сторону, если вдруг потребуется, через http://api. Таким образом мы можем собрать полностью работающий проект без единого проброшенного порта… правда наш доступ к нему будет затруднен :).

Жизнь после коммита

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

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

Дальше пойдет в основном вольный пересказ подхода 12 factor app, крайне рекомендую с ней ознакомиться в полном объеме.

Конфигурационные файлы

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

Конфиг должен быть доступен приложению внутри контейнера, для этого есть несколько вариантов:

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

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

  • завалиться внутрь контейнера и выполнить reload-команду
  • убить контейнер, в расчете на то, что он перезапустится и заново прочитает конфиг Оба варианта сильно костыльны и требуют много ручной работы

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

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

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

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

Конечно переменные окружения не лишены недостатков. Они созданы для кастомизации простых параметров, вроде docker run -e DB_HOST=acme.com DB_TABLE=clients cool-app, и ими довольно проблематично заменить большие структурированные конфиг-файлы в форматах JSON, XML или прочих кастомных конфиг-форматах. Это побуждает делать наши сервисы максимально простыми, чтобы им не требовались сложные конфиги, но множество традиционных приложений при портировании в контейнеры не отказываются от своих методов конфигурирования. Для таких случаев есть решения вроде Config в Docker Swarm или ConfigMap в Kubernetes. Но они должны оставаться последним вариантом, когда по-другому никак.

Логи

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

Файл с логами, как и любой файл пишется в эфемерный слой контейнера, своеобразный /tmp, созданный на время работы контейнера и удаляемый при его завершении. Неконтролируемое разрастание этих слоев может привести к забиванию диска ноды, на которой крутятся контейнеры, возможному падению текущих контейнеров и невозможности запустить новые.

Контейнеры придерживаются подхода “один контейнер - один процесс”. Ротаторы логов и прочие сервисные утилиты внутри контейнера мешают реализации этого подхода.

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

Контейнеры - эфемерны

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

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

В свете таких ожиданий, разработчик, проектируя свое приложение, должен держать в уме несколько принципов:

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

Заключение

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