null

Продолжаем изучать Docker

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

В этой статье рассмотрим рассмотрим его основные концепции и покажем пример полезного использования.

Статьи цикла:
1. Введение в Docker
2. Продолжаем изучать Docker

Основные концепции Docker'а

- Образ (image) - Набор изменений файловой системы, который в сумме даёт финальное состояние будущего контейнера. Также образ содержит некоторую мета-информация о нём (манифест).
- Контейнер (container) - Непосредственный экземпляр образа, который способен выполнять полезную работу.
- Том (volume) - Абстрактное хранилище для постоянных данных.
- Сеть (network) - Набор виртуальных сетевых интерфейсов, обеспечивающий изоляцию и сетевую связность контейнеров.

Эти концепции нужны для того, чтобы разграничить зону ответственности компонентов системы внутри docker. Благодаря этому некоторые компоненты можно заменять на другие. Так можно заменить `containerd` на какой-нибудь другой runtime (например, на katacontainers или sysbox), можно добавить новые "драйвера" для хранения данных или добавить сеть, которую можно использовать на нескольких хостах (например, flannel)

Образы

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

Например есть слои со следующими файлами:
- Слой #1: dir1/other-file, file1
- Слой #2: dir2/yet-another-file, file2, file3
- Слой #3: dir2/yet-another-file, file1

Наложив их друг на друга по-порядку получаем следующую ФС (в скобках указан номер слоя, из которого был взят файл):
dir1/other-file(#1), dir2/yet-another-file(#3), file1(#3), file2(#2), file3(#2)

В linux такого эффекта можно добиться с помощью aufs и overlayfs. Создавая контейнер, docker создаёт ФС, которая накладывает эти слои друг на друга, и добавляет ещё один слой, который будет содержать изменения, которые произошли в контейнере.

Запускаем базу данных

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

Запустим сначала временный сервер БД:

docker run -d --name app_db -e POSTGRES_PASSWORD=pass postgres:13

Выполнив эту команду, мы в фоне (-d) запустили сервер PostreSQL версии 13.* . Таким образом БД будет иметь последний релиз мажорной версии и не важно, есть ли в вашем дистрибутиве пакет такой версии. Если нужна конкретная версия, то доступные  минорные релизы можно найти на Docker Hub.
С помощью ключа -e была задана пременная окружения POSTGRES_PASSWORD, которая в данном случае указывает пароль дефолтного пользователя postgres.
Также стоит отметить, по-умолчанию docker не "публикует" порты приложения, что позволяет избежать проблему "уже используемого порта".

Работающий сервер это хорошо, но как же с ним взаимодействовать?
Запустим клиент внутри контейнера:

$ docker exec -it app_db psql -U postgres
psql (13.4 (Debian 13.4-1.pgdg100+1))
Type "help" for help.

postgres=#

Таким образом мы можем проверить с помощью `\l` и `\d`, что БД действительно  пуста. Можем создать новую таблицу ( CREATE TABLE new_table (id bigint PRIMARY KEY, name varchar(255)); ), добавить в неё данные и т.д.

Выйти из клиента можно с помощью \q или Ctrl+D.

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

docker inspect app_db

На выходе получаем подробный JSON с информацией о контейнере, но нас будет интересовать секция `.NetworkSettings.Networks`, которая содержит информацию о сетевых интерфейсах:

"Networks": {
  "bridge": {
    "IPAMConfig": null,
    "Links": null,
    "Aliases": null,
    "NetworkID": "...",
    "EndpointID": "...",
    "Gateway": "172.17.0.1",
    "IPAddress": "172.17.0.2",
    "IPPrefixLen": 16,
    "IPv6Gateway": "",
    "GlobalIPv6Address": "",
    "GlobalIPv6PrefixLen": 0,
    "MacAddress": "02:42:ac:11:00:02",
    "DriverOpts": null
  }
}

Gateway как правило указывает на хост, IPAddress является IP адресом контейнера, к которому можно обратиться с хоста напрямую. (IP-адреса контейнера можно получить сразу использовав команду: docker inspect --format "{{ range .NetworkSettings.Networks }}{{ .IPAddress }}, {{ end }}- {{ .Name }}" app_db​​​​​​​​​​​​​​ )
Если у вас установлен psql, то можете подключиться к серверу с помощью него, но также это можно сделать с помощью Docker:

docker run --rm -it postgres:13 psql -h 172.17.0.2 -U postgres
Password for user postgres:
psql (13.4 (Debian 13.4-1.pgdg100+1))
Type "help" for help.

postgres=#

Таким образом мы переиспользовали psql из образа с сервером PostgreSQL, но при этом стоит заметить, что второй сервер не был запущен т.к. мы перезаписали команду запуска. Но к сожалению, некоторые создатели контейнеров помещают запуск основного приложения в entrypoint, что делает использование других команд неудобным (приходётся добавлять --entrypoint /bin/sh или т.п.).

На данном этапе сервер работает, но стоит учитывать, что docker-контейнеры эфемерны, из-за чего предполагается, что изменения в контейнере могут быть безболезненно потеряны. Давайте это исправим!

Остановим контейнер и скопируем данные БД из контейнера на хост:

docker stop app_db
docker cp app_db:/var/lib/postgresql/data data

Удалим контейнер и запустим его с новой конфигурацией:

docker rm app_db
docker run -d --name app_db -v $(pwd)/data:/var/lib/postgresql/data -e POSTGRES_PASSWORD=pass postgres:13

После этого можно удостовериться что все данные на месте. Стоит заметить, что docker требует полные пути для того, чтобы примонтировать директорию внутрь контейнера.
Также стоит заметить, что при пересоздании контейнера (или при перезагрузке компьютера) у него может меняться IP-адрес, что может быть неудобно.

Тома

Тома в docker в основном делятся на два типа: bind-mount и docker-том.
Bind-mount является аналогом mount --bind, он позволяет "пробросить" директорию с хоста вовнутрь контейнера.
Docker-том позволяет отдать управление хранением данных docker'у, либо задав опции можно монтировать ФС и использовать другие драйвера. По-умолчанию docker сам создаст каталог (внутри /var/lib/docker/volumes), в котором будут храниться данные приложения. Подключать тома можно по имени вместо путей.

Основная проблема с bind-mount'ами в том, что пользователь на хосте часто не совпадает с пользователем внутри контейнера, из-за чего становится неудобно редактировать файлы, если нужно обновить часть файлов вручную, а другую сгенерировать. С другой стороны тома лишают удобства использования ¯\_(ツ)_/¯.

Сети

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

Собираем всё вместе

Создадим новую бд, подключим к ней том и выдадим ей статический IP-адрес:

docker network create --subnet "172.0.123.0/24" app-net
docker volume create app-db-volume
docker run -d --name app_db --network app-net --ip 172.0.123.5 -v app-db-volume:/var/lib/postgresql/data -e POSTGRES_PASSWORD=pass postgres:13

В следующей статье рассмотрим как избежать большинства длинных команд.

Вперед