Docker

Приветствуйте, убийца классических системных администраторов и причина экспоненциального роста отвратительных архитектурных решений. 1)

Итак, что же такое Docker - в девичестве написанный на python, но в какой-то момент разработчики осознали, что движутся не туда и свернули в другом направлении, переписав на GO. Лучше не стало, но и хуже тоже, но я отвлёкся. Итак, Docker это огромная обвязка вокруг cgroups, network namespaces, iptables и некоторых других запчастей системы, которая позволяет запускать контейнеры в два клика не задумываясь о том, что в них завёрнуто2)

Собственно состоит Docker из демона на сервере и клиента на клиенте, соответственно с помощью одного клиента можно управлять парком серверов особо не напрягаясь. В прицнипе о том как завести Docker в gentoo написана хорошая статья на вики, поэтому не вижу смысла её копировать. Мы же будем разбираться с Docker на Debian хосте.

Установка

Проста и незамысловата, что даже самый отсталый web-dev справится:

# apt-get install docker.io

И на этом всё, господа и дамы, оно само запустится, само добавится в автозагрузку и начнёт работать, однако поскольку у нас Docker в виртуалке, нам надо ему сказать, что слушай внешний интерфейс, чтобы рулить им с локальной тачки. Для этого нам понадобится в /etc/default/docker привести переменную DOCKER_OPTS к следующему виду:

DOCKER_OPTS="-H <binding ip address>:<port>"

где <binding ip address> нужно заменить на тот ip, на котором должен слушать демон, а <port>, соответственно, на порт.

Кстати маленькое замечание - открывая таким образом порт наружу вы даёте полный доступ к управлению докером без какой-либо авторизации, так что делать это рекомендуется только если вы доверяете сети, в которую открываете (ну или если вы прочитаете, а я напишу, дальше как его засекурить, но это не точно)

Всё, установлено, настроено, делаем

# systemctl restart docker

и voila, у нас готов Docker сервер, к которому имеется удалённый доступ.

Использование

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

  • Несколько серверов/виртуалок с докером
  • Докер клиент на локальной тачке
  • export DOCKER_HOST='tcp://<hostname>:<port>'

На этом, пожалуй и всё. Дальше выполняем

$ docker info

и если всё правильно прописано - получаем длинную портянку о прописаном в экспорте хосте. На этом настройка заканчивается и начинается магия.

Запуск первого контейнера

Поскольку инфраструктура докера подразумевает использование чёрных ящиков (готовых образов систем), то и рассказывать тут особо нечего, но я всё же попытаюсь.

Итак, внутри докера с целью экономии занятого пространства3) используется overlayfs, которая работает по принципу Copy-On-Wrtie. Соответственно строительными блоками, из которых впоследствии рождаются всевозможные сервисы, является образ (image), будь-то скаченный с докерхаба или созданный самостоятельно. Образа накладываются друг на друга как матрёшки, в конечном итоге из которых получается корневая файловая система запущенного контейнера. Для конечного пользователя это всё выглядит прозрачно и непорочно - вы просто говорите - хочу в таком-то образе выполнить такую-то команду и полетели. Не будем далеко отходить от официального курса по Docker и запустим простейшую Ubuntu с bash:

$ docker run -i -t ubuntu /bin/bash

После того, как образы скачаются будет запущен первый контейнер, у которого в роли PID 1 будет выступать наша команда /bin/bash, а мы получим интерактивный шэлл внутри контейнера. Что же значат аргументы

  • run - запускаем новый контейнер (можно писать container run)
  • -i - interactive - проще говоря перенаправляем stdin
  • -t - заставляем аллоцировать псевдо-tty
  • ubuntu - название образа
  • /bin/bash - команда, которая выступит в роли PID 1

Собственно что происходит с системой, которая лишается PID 1? Правильно, ядро выпадает в панику и шоу останавливается, но поскольку для контейнера ядро лишь даётся во временное пользование - при смерти PID 1 контейнер просто умирает.

Базовый образ Ubuntu несёт в себе чуть более, чем ничего, поэтому он нам особо не интересен для опытов, однако чтобы понять какие контейнеры и в каком состоянии находятся в данный момент мы запустим ещё один, но на этот раз с немного другими аргументами и в демонизированном состоянии. Итак, прикрутим, например, nginx:

$ docker run -d -p 8080:80 --name nginx1 nginx 

Аргументы претерпели изменения:

  • run - ничего новго
  • -d - daemonize
  • -p - задаём трансляцию портов с внешнего интерфейса сервера на контейнер (эдакий DNAT), первое число порт на внешнем интерфейсе, второе - порт, на который следует направить проброс.
  • –name - контейнерам можно задачать ЧЕЛОВЕКОЧИТАЕМЫЕ имена
  • nginx - название образа

Теперь можно проверить какие контейнеры и в каком состоянии у нас имеются

$ docker ps -a

Ключ -a добавляет в вывод так же и остановленные контейнеры.

Чтобы приаттачиться к уже запущенному контейнеру можно использовать команду exec

$ docker exec -it nginx1 /bin/bash

После её выполнения мы попадём в интерактивный шэлл внутри контейнера. Так же можно посмотреть извне процессы, запущенные внутри

$ docker top nginx1

Однако PID будут отображаться относительно материнской системы.

Удаление контейнеров

Ну здесь в принципе ничего сложного, как и linux есть команда rm:

$ docker rm <name/id/first 3+ letters from id>

Если контейнер запущен, то его потребуется либо остановить, либо добавить к команде ключик -f. Например в интернете можно найти замечательный способ очистить весь сервер от скверны4)5)

$ docker rm -f $(docker ps -aq)

Образы

Как было вышеозвучено строительными блоками для контейнеров служат образы. Docker позволяет нам использовать как готовые образы, так и собирать свои. Наверняка есть возможность создать свой образ с нуля, но все источники в один голос твердят - бери готовый базовый (причем под базовым подразумевается чаще всего не голая система, а готовый сервис - nginx, postgres, apache, etc) и докручивай в соответствии со своими реалиями, после этого можно сделать commit, повесить тэг и выпушить как во внешний регистри, так и в локальный.

Поскольку я у мамы криптоконспиролог, который пишет свои мысли на весь интернет - мы будем разбираться только с локальным регистри. Работа с докерхабом аналогична, но в дополнение к обычным описанным командам нужно ещё зарегистрироваться на hub.docker.com и в клиенте использовать команду login, после чего можно делать push своих образов на суд общественности.

Вручную

Начнём с метода, который не приветствуется господами разработчиками, но вполне возможен. Это commit. Как пишут в документации - этот метод похож на работу с git или любой другой cvs. У нас есть базый образ (репозиторий), мы вносим в него изменения, коммитим их и по сути у нас сохраняется только diff между базовым образом и нашими изменениями. Пользоваться данной командой достаточно просто

$ docker commit <id/name> <name for image>

Причём <name for image> принято использовать - свой ник/название образа. А теперь подробнее о происходящем. Возьмём например образ ubuntu:latest и поставим туда nginx

$ docker run -it --name webserver1 ubuntu /bin/bash
(webserver1)# apt-get update
(webserver1)# apt-get install -y nginx
(webserver1)# echo "<html><body><h1>My first page in Docker</h1><p>It's awful, but real</p></body></html>" > /var/www/html/index.html
(webserver1)# exit

После этого наш контейнер умрёт, а мы будем иметь diff для создания нашего собственного образа. Дальше просто фиксируем имя за нашим diff'ом

$ docker commit -a "Digital Owl" -m "It's simple webserver container" webserver1 owlbook/webserver

И получаем в списке образов свой собственный новый образ. Ключ -a позволяет задать автора для коммита, а -m комментарий, webserver1 - имя контейнера с изменениями, owlbook/webserver - название создаваемого образа. В используемой литературе эта команда почему-то используется с sudo, но может я читал не внимательно и там всё так работает, можно просто добавиться в группу docker. Теперь можно запустить наш образ с nginx в роли PID 1

$ docker run -d --name webserver2 -p 8081:80 owlbook/webserver nginx -g "daemon off;"

После чего идём в браузере по адресу нашего Docker сервера и порту 8081 и получаем наши художества. Для полного понимания наверное следует пояснить, что ключ -g и “daemon off;” относятся непосредственно к команде nginx, проще говоря все аргументы, которые написаны после названия образа, передаются в контейнер as-is. В нашем же случае отключение демонизации у nginx нужно для того, чтобы PID 1 был постоянно запущен в интерактивном режиме иначе nginx отфрокается от PID 1 и последний умрёт, что приведёт к смерти контейнера.6)

Dockerfile

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

FROM ubuntu:16.04
MAINTAINER Digital Owl "fuck@yourself.spammers"
RUN apt-get update; apt-get install -y nginx
RUN echo "<html><body><h1>My first page in Docker</h1><p>It's awful, but real</p></body></html>" > /var/www/html/index.html
EXPOSE 80

И на этом пожалуй всё. Как можно догадаться из контекста FROM - указывает на базовый образ, MAINTAINER - просто метаинформация и создателе образа, RUN - запускает команду внутри контейнера, EXPOSE - указывает какие порты открывает контейрнер изнутри. По умолчанию все порты для всех контейнеров закрыты.8)

Кстати интересный факт - все блоки RUN docker запускает используя '/bin/sh -c', если в базовом образе отсутствует шэлл, можно написать

RUN ['apt-get','install','-y','nginx']

и shell использоваться не будет9)

Ну и самое вкусное - сохраняем наш Dockerfile в файл с именем Dockerfile и выполняем следующую команду

$ docker build -t owlbook/webserver_df .

Ещё один забавынй факт - Dockerfile лучше класть в отдельную пустую директорию, поскольку при сборке docker клиент засасывает весь контекст из текущей директории.

После непродолжительной сборки в docker images появится наш образ. Остаётся всего ничего - запустить из него контейнер и проверить, что всё работает как задумывалось

$ docker run -d --name webserver3 -p 8082:80 owlbook/webserver_df nginx -g "daemon off;"

Если в браузере по порту 8082 открылась наша чудо-страничка - поздравляю, мы молодцы. Кстати вмест второго RUN можно использовать COPY, который позволяет схватить файл с текущей тачки и бросить его в контейнер по определённому пути. Ещё есть CMD, который позволяет не указывать при запуске нового контейнера команду для PID 1. Преобразим наш Dockerfile в соответствии с новыми знаниями

Dockerfile
FROM ubuntu:16.04
MAINTAINER Digital Owl "fuck@yourself.spammers"
RUN apt-get update; apt-get install -y nginx
COPY . /var/www/html/
CMD nginx -g "daemon off;"
EXPOSE 80

Сохраним его в директории webserver_sh10) и положим рядом файлики

index.html
<html>
  <body>
    <h1>It's main page</h1>
    <p>You can read <a href="/about.html">about</a> this site</p>
  </body>
</html>
about.html
<html>
  <body>
    <h1>It's about page</h1>
    <p>Nothing to do here. you can go <a href="/">back</a></p>
  </body>
</html>

Переходим в директорию и запускаем билд

$ docker build -t owlbook/webserver_sh .

После успешной сборки запускаем контейнер из нашего образа и voila - готов простенький сайтик с нашими статическими страничками на порту 8083

$ docker run -d --name webserver4 -p 8083:80 owlbook/webserver_sh

Кстати как я и говорил выше для CMD действуют те же правила передачи аргументов, что и для RUN. Если взглянуть на текущий вывод docker ps - мы увидим, что в столбце COMMAND для webserver4 светится /bin/sh -c 'nginx… Можно конечно в нашем Dockerfile поменять строку на массив, но пусть это останется домашним заданием для самостоятельной работы, которую никто делать не будет.

Есть ещё интересный момент - в docker к именам образов можно прикреплять так называемые тэги, делается это достаточно просто - к имени будущего образа добавляется двоеточие и пишется тэг. Таким образом мы получаем некое подобие версионирования. Например как можно увидеть из докерфайла выше мы используем образ ubuntu с тэгом 16.04. В принципе наши образы можно было назвать webserver:v1 и webserver:v2, поскольку они оба делают одно и то же и отличаются лишь версиями докерфайла.

Внешнее хранение образов

Одним из достоинств, которые неугомонное сообществе адептов docker любит выпячивать, является так называемый регистри, в который любой желающий может залить свою поделку и любой желающий её получить беспрепятственно получит. Это моя самая любимая часть, в которой фигурирует эффект свидетеля11)

Тем не менее не будем засорять столь возвышенный сервис как dockerhub, а поднимем свой регистри и будем пушить наши поделки туда. Поднимается он в принципе так же просто как и сам докер - установка пакета и полетели. В дебиане это выглядит вот так

# apt-get install docker-registry

Поскольку я играю с системой удалённо - пришлось ещё адрес, на котором слушает регистри подкрутить. Но и это ещё не всё. По умолчанию (которое нельзя оверрайдить через клиент) - dockerd работает с registry исключительно по https, что доставляет свои пять копеек в работу со всей системой. Сам же процесс загрузки образа в регистри выглядит следующим образом:

  • написание Dockerfile и сборка или создание образа с помощью commit (средствами docker client, но все изменения происходят на сервере с dockerd)
  • создание специально сформированного названия образа, в котором указан адрес целевого регистри, название и тэг (последний по желанию, по умолчанию latest)
  • отправка образа в регистри

На основании вышеизложенного можно сделать вывод, что все взаимодействия с внешним миром происходят исключительно от лица dockerd. Продолжая рассуждать мы приходим к выводу, что отключение ssl по умолчанию для взаимодействия с регистри где-то в недрах конфигурации dockerd и действительно в файле /etc/docker/daemon.json есть крутилка, которая позволяет указать доверенные регистри. Крутилка выглядит просто

{
        "insecure-registries": ["<registry addr>:<registry port>"]
}

Вкручиваем, рестартуем dockerd и получаем возможность работать с локальным12) registry без ssl.

Переходим к передаче нашего образа в регистри. В него поедет наш webserver_sh

$ docker tag owlbook/webserver_sh <registry host>:<registry port>/owlbook/webserver_sh

После чего просто делаем пуш в регистри

$ docker push <registry host>:<registry port>/owlbook/webserver_sh

Через некоторое время оно залётся и в любой момент времени можно будет получить к нему доступ.

Честно говоря не нашёл в интернете вменяемого ответа как посмотреть через docker клиент13) все образы в регистри или поискать нужный, но ребята реализовали этот функционал в API и за него можно подёргать

$ curl http://<registry addr>:<registry port>/v2/_catalog

так же можно получить все теги по интересующему образу

$ curl http://<registry addr>:<registry port>/v2/<image name>/tags/list

Работа с сетью

Проброс портов в контейнер

Как мы раньше узнали Docker по умолчанию закрывает все входящие порты для контейнера, но при создании последнего (а так же в Dockerfile) можно указать какие порты открыть, а так же что куда пробрасывать. Разберёмся поподробнее с ключём -p для docker run.

Первый кейс - просто ключ -p с указанием номера порта - указывает открыть рандомный порт в промежутке с 32768 по 61000 на хост системе и пробросить его на указанный аргументом порт в контейнере

$ docker run -d -p 80 --name webserver5 owlbook/webserver_sh

Как видно из docker ps - на хост машине для контейнера webserver5 открылся порт 32768

0.0.0.0:32768->80/tcp   webserver5

Чтобы не гадать при создании контейнера какой порт будет открыт - можно указать аргументом для ключа -p два числа, разделённые двоеточием <host port>:<target guest port>14)

$ docker run -d -p 8085:80 --name webserver6 owlbook/webserver_sh

В ps в итоге получаем

0.0.0.0:8085->80/tcp    webserver6

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

$ docker run -d -p 127.0.0.1:8086:80 --name webserver7 owlbook/webserver_sh

В итоге порт 8086 будет открыт только на loopback.

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

Внутренняя кухня

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

-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8081 -j DNAT --to-destination 172.17.0.2:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8082 -j DNAT --to-destination 172.17.0.3:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8083 -j DNAT --to-destination 172.17.0.4:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 32768 -j DNAT --to-destination 172.17.0.5:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8085 -j DNAT --to-destination 172.17.0.6:80
-A DOCKER -d 127.0.0.1/32 ! -i docker0 -p tcp -m tcp --dport 8086 -j DNAT --to-destination 172.17.0.7:80

Внутри одного сервера по умолчанию контейнеры находятся в одном l2 сегменте. Интерфейс docker0 является виртуальным ethernet бриджом, в который впоследствии втыкаются интерфейсы контейнеров. По умолчанию Docker использует подсети 172.16-172.30. На docker0 висит адрес гейта для сети, а для выхода в интернет используется NAT из iptables хост системы.

Изоляция между бриджами в docker происходит так же через iptables, причём в два этапа - сначала проверяется, что пакет пришёл с интерфейса и не ушёл в тот же интерфейс, а потом проверяется, что пакет не уходит ни в один из бриджей. Таким образом наружу пакеты успешно выходят, а между бриджами не перемещаются.

....
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
...
-A DOCKER-ISOLATION-STAGE-1 -i br-b693aeb84cee ! -o br-b693aeb84cee -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-USER -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o br-b693aeb84cee -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
Пользовательские сети

В свежем, только из печи, Docker есть возможность создавать свои собственные сети/подсети и раздавать их контейнерам.

$ docker network create private

После выполнения данной команды у нас создаётся новый bridge на хост системе и ему назначается подсеть из пула подсетей (в моём случае приехала подсеть 172.18.0.0/1615)) Посмотреть информацию у свежесозданном интерфейсе можно с помощью команды inspect

$ docker network inspect private

Дальше всё достаточно просто и автоматически. Можно указать имя сети при запуске контейнера через аргумент –net

$ docker run -d -P --net private --name webserver9 owlbook/webserver_sh

И в inpect по этому контейнеру мы уже видим другую подсеть

$ dcoker inspect webserver9
[
    {
        ...
        "NetworkSettings": {
            ...
            "Networks": {
                "private": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": [
                        "5a61e8317e90"
                    ],
                    "NetworkID": "b693aeb84cee61f05c959b426dfc265f1ac9fa298f9dc48e157bb0bf7be64914",
                    "EndpointID": "55f22d24be57a029a91abe38868046348e5a24a69678f62a4bc5be122387221f",
                    "Gateway": "172.18.0.1",
                    "IPAddress": "172.18.0.2",
                    "IPPrefixLen": 16,
                    ...
                },
                ...
            },
            ...
        },
        ...
    }
]

Подключить уже существующую сеть к контейнеру можно командой connect

$ docker network connect private webserver8

Отключить, соответственно, disconnect

$ docker network disconnect bridge webserver7
1)
Хотя если быть совсем честным сам докер слабоват, чтобы этого добиться, но вместе с Kubernates или другим оркестратором вполне
2)
добро пожаловать в мир ботнетов и майнеров
3)
а может и ещё какой
4)
не повторяйте это на продакшене
5)
с другой стороны будет повод отказаться от докера
6)
путанно, странно, но должно быть понятно
7)
ну конечно, это же инновационная разработка, вместо уже готовых хороших решений мы возьмём своё
8)
однако выход в интернет открыт для всех и вся, можно спокойно связываться с мастернодой ботнета или запускать выходную tor ноду
9)
а что будет никто не сказал, но я думаю какой-нить system внутри билдера docker
10)
sh в конце это selfhosted, если что
11)
это когда все считают, что кто-то другой обязательно что-то сделает - в нашем случае проверит образ на отсутствие инородных сервисов, ботнетов и прочих майнеров
12)
в рамках нашей инфраструктуры, а не установленный на клиентской тачке, хотя последнее не воспрещается
13)
на самом деле нашёл https://github.com/docker/distribution/issues/206 Work in progess. Since 26 Feb 2015
14)
порт, открываемый на хост системе, может быть любым, но не занятым
15)
а ребята не парятся, берут большими кусками