Содержание

Docker in a month

# Клон учебного репозитория
git clone https://github.com/sixeyed/diamol.git
# грохнуть все контейнеры, в т. ч., запущенные
docker container rm -f $(docker container ls -aq)
# грохнуть все образы diamol
docker image rm -f $(docker image ls -f reference='diamol/*' -q)

2. Начало

# Интерактивно зайти внутрь контейнера (-it - interactive, tty)
docker container run -it diamol/base
 
# Список запущенных контейнеров. ID контейнера = его hostname
docker ps
CONTAINER ID   IMAGE         COMMAND     CREATED              STATUS              PORTS     NAMES
aa9454c9f05e   diamol/base   "/bin/sh"   About a minute ago   Up About a minute             modest_fermi
 
# Список процессов внутри контейнера (aa - начало ID контейнера)
docker container top aa
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                3138                3112                0                   11:22               pts/0               00:00:00            /bin/sh
 
# Логи контейнера - здесь то, что было введено в командной строке интерактивно
docker logs 9c
/ # hostname
9cfdb08bc3c8
/ # date
Mon Apr 10 11:34:58 UTC 2023
 
# Подробная информация о контейнере
docker container inspect 9c
[
    {
        "Id": "9cfdb08bc3c8184aec9a5837d04386ad650f4a1f107b20133d8f90671e1ffd77",
        "Created": "2023-04-10T11:34:52.389807692Z",
        ...
 
# Список работающих контейнеров
docker ps # или
docker container ls
# Список всех контейнеров
docker ps -a # или
docker container ls -a
 
# Контейнеры не исчезают после того, как они выполнили задачу и остановились. Можно снова их запустить, посмотреть в логи, скопировать файлы в/из них.
# Докер не удаляет контейнеры, пока не поступит команда на это.
# Запуск контейнера с постоянно работающим процессом в фоне (''%%--detach%%'') и на порту 8088 (''%%--publish%%''), на внутренний 80 порт контейнера:
docker run -d -p 8088:80 diamol/ch02-hello-diamol-web
 
# Потребление контейнером CPU/Mem/Net/disk
docker container stats 9c
 
# Удалить контейнер (--force, -f для запущенных контейнеров)
docker container rm -f 9c
# Удалить все контейнеры
docker container rm --force $(docker container ls --all --quiet)

3. Создание собственных образов

# Скачать образ из реестра по умолчанию (default registry)
docker image pull diamol/ch03-web-ping
Using default tag: latest
latest: Pulling from diamol/ch03-web-ping
e7c96db7181b: Pull complete # слой
bbec46749066: Pull complete # слой
89e5cf82282d: Pull complete # слой
5de6895db72f: Pull complete # слой
f5cca017994f: Pull complete # слой
78b9b6c949f8: Pull complete # слой
Digest: sha256:2f2dce710a7f287afc2d7bbd0d68d024bab5ee37a1f658cef46c64b1a69affd2
Status: Downloaded newer image for diamol/ch03-web-ping:latest
docker.io/diamol/ch03-web-ping:latest
 
# Запустить, задав имя (это проверялка доступности сайта)
docker run -d --name web-ping diamol/ch03-web-ping
 
# В логах:
docker container logs web-ping |less
** web-ping ** Pinging: blog.sixeyed.com; method: HEAD; 3000ms intervals
Making request number: 1; at 1681130396655
Got response status: 200 at 1681130397047; duration: 392ms
Making request number: 2; at 1681130399657
Got response status: 200 at 1681130400349; duration: 692ms

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

# Запуск с переменной окружения, чтобы изменить сайт для проверки
docker run --name web-ping --env TARGET=bva.dyndns.info diamol/ch03-web-ping
** web-ping ** Pinging: bva.dyndns.info; method: HEAD; 3000ms intervals
Making request number: 1; at 1681131611538
Got response status: 200 at 1681131612215; duration: 677ms
Making request number: 2; at 1681131614540
Got response status: 200 at 1681131615097; duration: 557ms

Dockerfile

Dockerfile - это скрипт для запаковки приложения в контейнер.

# Базовый образ
FROM diamol/node
 
# Значения по умолчанию для переменных окружения
ENV TARGET="blog.sixeyed.com"
ENV METHOD="HEAD"
ENV INTERVAL="3000"
 
# Рабочий каталог - если его нет, то он создаётся
WORKDIR /web-ping
# Копирование файлов с хоста внутрь образа, здесь один файл app.js
COPY app.js .
 
# Команда, которую запускает контейнер после старта
CMD ["node", "/web-ping/app.js"]

Сборка и использование образа

# --tag - имя образа, . - из текущей папки.
docker image build --tag web-ping .
 
# вывести список образов на w
docker image ls 'w*'
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
web-ping     latest    bebf5120fd51   15 minutes ago   75.3MB
 
# Запустить собранный образ
docker container run -e TARGET=bva.dyndns.info -e INTERVAL=5000 web-ping
** web-ping ** Pinging: bva.dyndns.info; method: HEAD; 5000ms intervals
Making request number: 1; at 1681137797563
Got response status: 200 at 1681137798217; duration: 654ms
Making request number: 2; at 1681137802567
Got response status: 200 at 1681137803136; duration: 569ms

Слои

# История создания образа послойно
docker image history web-ping
# В списке образов можно увидеть их размер
docker image ls

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

Размер в списке образов - логический, т. е., тот, который был бы реально, если нет общих слоёв. Если они есть, то реальный размер получается меньше.

# Сколько всего места занимает Докер (реальный размер)
docker system df
TYPE                TOTAL               ACTIVE              SIZE                RECLAIMABLE
Images              27                  4                   4.249GB             4.142GB (97%)
Containers          9                   2                   73B                 0B (0%)
Local Volumes       8                   0                   311MB               311MB (100%)
Build Cache         0                   0                   0B                  0B

Если слой используется в нескольких образах/контейнерах, то они становятся нередактируемыми (read-only) - иначе бы изменения в одном образе приводили бы к перестроению всех остальных.

Оптимизация Докерфайлов через использование кэша слоёв

В примере с web-ping есть файл приложения. Если его изменить и пересоздать образ, то получится новый слой. Так как слои идут последовательно, накладываясь один на другой, то изменение слоя влечёт за собой изменение всех последующих. Если из примера выше изменить файл app.js и создать образ, то будет видно, что из 7 слоёв будут заново сделаны только последние 2, остальные будут взяты из кэша, т. к. там ничего не менялось.

docker image build -t web-ping:v2 .

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

В примере выше можно оптимизировать Докерфайл, перенеся выше строку команд и слепив строки переменных в одну, что при сборке образа даст 5 слоёв вместо 7, а при изменении app.js и пересборке образа пересоздаваться будет только последний слой.

FROM diamol/node
 
CMD ["node", "/web-ping/app.js"]
 
ENV TARGET="yandex.ru" \
METHOD="HEAD" \
INTERVAL="3000"
 
WORKDIR /web-ping
 
COPY app.js .

Создание образа без использования Dockerfile, пример.

# Запуск контейнера с именем ch03-lab из образа diamol/ch03-lab
docker container run -it --name ch03-lab diamol/ch03-lab
# Изменение файла ch03.txt
echo "Vasya" >> ch03.txt
# Выход из контейнера, он завершает работу (в данном случае)
exit
# Сделать образ ch03-lab-image-new из контейнера ch03-lab
docker container commit ch03-lab ch03-lab-image-new
# Создать контейнер из образа ch03-lab-image-new и вывести содержимое файла ch03.txt
docker container run ch03-lab-image-new cat ch03.txt

4. Из исходного кода - в образ

Внутри докерфайла можно запускать команды. Команды выполняются во время сборки, и изменения файловой системы в результате их выполнения сохраняются как слои. Это делает докерфайлы невероятно гибким форматом упаковки - можно распаковывать zip-файлы, запускать установщики Windows и т. д. Рассмотрим вариант упаковки приложений из исходного кода. Очень удобно написать докерфайл, который ставит все утилиты/зависимости/инструменты и встраивает их в образ. Затем этот образ используется для компиляции приложения.

multi-stage

Это «поэтапный» докерфайл (multi-stage Dockerfile), потому что в сборке несколько этапов - несколько строк FROM. Тем не менее, на выходе будет один образ с содержимым последнего этапа. Этапы запускаются независимо, но можно копировать файлы и папки из прошлых этапов. Смысл поэтапного докерфайла - избавление от необходимости настройки зависимостей, рабочих мест разработчиков, унификация всего процесса. Необходимо только поставить Докер, дальше всё будет происходить по написанному сценарию.

# AS - название этапа (необязательно)
FROM diamol/base AS build-stage
RUN echo 'Building...' > /build.txt
 
FROM diamol/base AS test-stage
# COPY с аргументом --from предписывает копировать файлы с предыдущего этапа, а не с хоста
COPY --from=build-stage /build.txt /build.txt
# Запись файла, вывод сохраняется как слой. Вызываемые команды должны присутствовать в образе из FROM
RUN echo 'Testing...' >> /build.txt
 
FROM diamol/base
COPY --from=test-stage /build.txt /build.txt
CMD cat /build.txt

Что тут происходит: на 1 этапе создаётся файл, на втором он копируется из 1-го и в него добавляется текст, а третий копирует файл из 2-го этапа и выводит его содержимое.

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

Java app with Maven

FROM diamol/maven AS builder
 
WORKDIR /usr/src/iotd
COPY pom.xml .
RUN mvn -B dependency:go-offline
 
COPY . .
RUN mvn package
 
# app
FROM diamol/openjdk
 
WORKDIR /app
COPY --from=builder /usr/src/iotd/target/iotd-service-0.1.0.jar .
 
EXPOSE 80
ENTRYPOINT ["java", "-jar", "/app/iotd-service-0.1.0.jar"]

Здесь первый этап (builder) использует образ diamol/maven, где установлен OpenJDK Java development kit и Maven build tool, копирует файл pom.xml (конфиг Maven) в рабочую папку и запускает установку зависимостей. Затем идёт копирование всех файлов в текущем каталоге в текущий каталог контейнера, и в конце запускается компиляция приложения и упаковка в файл jar. На втором этапе используется образ diamol/openjdk с Java 11, но уже без Maven. Создаётся рабочий каталог и туда копируется файл jar, созданный на предыдущем этапе, куда Maven уже положил все необходимые зависимости. Дальше публикуется порт 80 и приложение запускается (ENTRYPOINT - это аналог CMD).

Во время сборки будет обширный вывод, и одна из строк будет

Step 9/11 : COPY --from=builder /usr/src/iotd/target/iotd-service-0.1.0.jar .

, что свидетельствует о копировании готового файла из этапа сборки.

Контейнеры получают доступ друг к другу через виртуальную сеть по виртуальным IP-адресам, которые им присваивает Докер.

# Создать сеть
docker network create nat

Теперь можно при запуске контейнера указывать, что он подключен к сети nat.

docker container run --name iotd -d -p 800:80 --network nat image-of-the-day

Ещё раз - при таком подходе необходим Докер, не нужно заморачиваться со всем остальным. Ещё нужно обратить внимание, что в финальном образе нет утилит сборки, нет команд mvn, они все остались на первом этапе.

node.js

Это другой подход к сборке, подобный Python, PHP и Ruby. Если для Java заливается исходный код и он компилируется в файл JAR, а затем файл JAR передаётся в финальный образ (то же и с .NET и файлами DLL), то здесь нет компиляции, а нужен Javascript. Соответственно, для обоих этапов используется один и тот же образ с node.js и npm. Этапы сборки здесь нужны для того, чтобы не тащить с собой зависимости (npm install, pip для Python, Gems для Ruby) в финальный образ.

FROM diamol/node AS builder
 
WORKDIR /src
COPY src/package.json .
 
RUN npm install
 
# app
FROM diamol/node
 
EXPOSE 80
CMD ["node", "server.js"]
 
WORKDIR /app
COPY --from=builder /src/node_modules/ /app/node_modules/
COPY src/ .

Выполнить:

cd ch04/exercises/access-log
docker image build -t access-log .
docker container run --name accesslog -d -p 801:80 --network nat access-log

Go

Go компилируется в исполняемый бинарник, не требующий для запуска установленной среды выполнения (.NET, Node.js и т. п.). Поэтому образы получаются очень маленькими. Так же работают Rust и Swift, но Go популярнее. Образы Докера для Go делаются по аналогии с Java, но с некоторыми отличиями. Билдер использует образ с Go tools, а финальный этап использует базовый. На финальном этапе копируется индексный файл HTML, и так как Go - это бинарник, надо его сделать исполняемым.

FROM diamol/golang AS builder
 
COPY main.go .
RUN go build -o /server
RUN chmod +x /server
 
# app
FROM diamol/base
 
EXPOSE 80
ENV IMAGE_API_URL="http://iotd/image" \
    ACCESS_API_URL="http://accesslog/access-log"
CMD ["/web/server"]
 
WORKDIR web
COPY --from=builder /server .
COPY index.html .
cd ch04/exercises/image-gallery
docker image build -t image-gallery .
docker container run -d -p 802:80 --network nat image-gallery

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

docker image ls -f reference=diamol/golang -f reference=image-gallery
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
image-gallery       latest              5880a505b363        3 minutes ago       25.5MB
diamol/golang       latest              119cb20c3f56        6 weeks ago         803MB

Поэтапный докерфайл делает проект полностью портативным. Вне зависимости от того, какой CI-сервис используется - Jenkins или какой-то облачный - везде это будет работать одинаково.

Итак, ключевые особенности такого подхода:

  1. Стандартизация, унификация инструментов, простота применения
  2. Производительность: каждый этап имеет свой собственный кэш и использует его по возможности
  3. Управляемость: всю предварительную работу - зависимости, сборщики и прочие инструменты - можно выносить в ранние стадии, и они не войдут в конечный образ.

5. Доступ к образам: Dockerhub и прочие

От сборки и запуска образов переходим к выкладыванию их в общий доступ. По умолчанию в Докере прописан реестр Dockerhub. Формат пути к образу:

# docker.io - реестр
# diamol - аккаунт в реестре
# golang - репозиторий (имя образа)
# v2 - Тэг (по умолчанию - latest)
docker.io/diamol/golang:v2

Тэг - самая важная часть, которая используется для различения версий/вариантов образа, например,

openjdk:13 # свежий релиз
openjdk:8u212-jdk # конкретный релиз 8 версии
openjdl:for-Vasya-Windows # вариант для Васи под Windows

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

Загрузка образа в реестр

dockerId="vasya"
# вход в реестр
docker login --username $dockerId 
# Установить метку (reference) на образ
# Если потом вывести список образов, то будет два одинаковых образа по размеру и ID, но с разными метками - старой и новой.
docker image tag image-gallery $dockerId/image-gallery:v1
# Загрузить образ в реестр
docker image push $dockerId/image-gallery:v1

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

Собственный реестр

У Докера есть базовый сервер реестра, который позволяет скачивать и загружать образы с использованием слоёв, но у него нет веб-интерфейса.

# --restart=always для того, чтобы контейнер запускался после перезагрузки самого Докера, REGISTRY_STORAGE_DELETE_ENABLED - возможность удалять образы
docker run -d -p 5000:5000 --restart=always -e REGISTRY_STORAGE_DELETE_ENABLED=true --name registry registry:2
 
# Прописать псевдоним registry.logal для наглядности
echo $'\n127.0.0.1 registry.local' | sudo tee -a /etc/hosts
 
# Пометить образы
docker image tag lab4:v2 registry.local:5000/gallery/lab4:v1
docker image tag image-gallery registry.local:5000/gallery/api:v1
docker image tag access-log registry.local:5000/gallery/logs:v1
docker image tag image-of-the-day:v1 registry.local:5000/gallery/ui:v1
# Загрузить образы
docker image push registry.local:5000/gallery/lab4:v1
docker image push registry.local:5000/gallery/api:v1
docker image push registry.local:5000/gallery/logs:v1
docker image push registry.local:5000/gallery/ui:v1
 
# Если тэгов у образа несколько, можно загрузить их все одной командой (-a или --all-tags)
docker image push -a registry.local:5000/gallery/ui

https://docs.docker.com/engine/reference/commandline/push/#all-tags

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

/etc/docker/daemon.json
{
  "insecure-registries": [
    "registry.local:5000"
  ]
}

Проверить - docker info, параграф Insecure registries:. Стандартно там прописана сеть 127.0.0.0/8.
Список образов: http://registry.local:5000/v2/_catalog

Эффективный выбор тэгов

Основная идея - использовать [major].[minor].[patch], где

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

registry.local:5000/gallery/ui # всегда брать latest
registry.local:5000/gallery/ui:2 # все релизы в рамках конкретной мажорной версии
registry.local:5000/gallery/ui:2.3 # все патчи в рамках минорной
registry.local:5000/gallery/ui:2.3.65 # конкретный выпуск

"Золотые" образы

Проблема доверия к образам, выложенном на Докерхабе, решается с помощью

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

FROM mcr.microsoft.com/dotnet/core/sdk:3.0.100
 
LABEL framework="dotnet"
LABEL version="3.0"
LABEL description=".NET Core 3.0 SDK"
LABEL owner="golden-images@sixeyed.com"
 
WORKDIR src
COPY global.json .

Сборка золотых образов:

docker image build -t golden/dontencore-sdk:3.0 dotnet-sdk/
docker image build -t golden/aspnet-core:3.0 aspnet-runtime/

Пример сборки с использованием золотого образа:

FROM golden/dotnetcore-sdk:3.0 AS builder
COPY . .
RUN dotnet publish -o /out/app app.csproj
 
FROM golden/aspnet-core:3.0
COPY --from=builder /out /app
CMD ["dotnet", "/app/app.dll"]

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

HTTP API v2

# Список репозиториев
http://registry.local:5000/v2/_catalog
# Вывести тэги репозитория gallery/ui
http://registry.local:5000/v2/gallery/ui/tags/list
# Манифест для gallery/ui:v1
http://registry.local:5000/v2/gallery/ui/manifests/v1
# Дайджест для gallery/ui:v1
curl -s --head http://registry.local:5000/v2/gallery/ui/manifests/v1 -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' |grep -i content-digest |cut -d ' ' -f 2 |tr -d '\r'
# Удалить
registry='http://registry.local:5000/v2' 
repo='gallery/ui'
tag='v1'
digest=`curl -s --head $registry/$repo/manifests/$tag -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' |grep -i content-digest |cut -d ' ' -f 2 |tr -d '\r'`
curl -X DELETE $registry/$repo/manifests/$digest

https://docs.docker.com/registry/spec/api

curl -X DELETE $registry/$repo/manifests/$digest
curl: (3) URL using bad/illegal format or missing URL

You get a \r (carriage return). You can remove it with tr -d '\r'
https://stackoverflow.com/questions/67214881/curl-throws-error-3-with-variable-but-not-with-manually-written-url

6. Постоянное хранение данных - тома (volumes) и точки монтирования (mounts)

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

docker container cp <containername>:/path/to/file.txt file.txt

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

Если требуется сохранять данные и после удаления контейнера, в этом случае используются тома (volumes) или точки монтирования (mounts).

Тома

Можно вручную создавать тома и подключать их к контейнерам, а можно использовать докерфайл с командой VOLUME.

FROM diamol/dotnet-aspnet
WORKDIR /app
ENTRYPOINT ["dotnet", "ToDoList.dll"]
 
VOLUME /data
COPY --from=builder /out/ .

Приложение to-do, которое будет хранить свои данные на томе

docker container run --name todo1 -d -p 8010:80 diamol/ch06-todo-list
# Показать ID тома, путь на хосте и путь в контейнере 
docker container inspect --format '{{.Mounts}}' todo1
[{volume 48d9319e1601b951e8f578b77bf03b38f414119bd0509d16fa7b31eab8565433 /var/lib/docker/volumes/48d9319e1601b951e8f578b77bf03b38f414119bd0509d16fa7b31eab8565433/_data /data local  true }]
# список томов
docker volume ls

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

# подключить контейнеру app2 том app1
docker container run -d --name t3 --volumes-from todo1 diamol/ch06-todo-list
# проверить каталог, куда подключается том в контейнере t3 (например, /data), есть ли там данные тома todo1
docker exec t3 ls /data

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

target='/data'
docker volume create todo-list
docker container run -d -p 8011:80 -v todo-list:$target --name todo-v1 diamol/ch06-todo-list
# ... добавляется какая-то информация в http://host:8011 ...
# удаление контейнера
docker container rm -f todo-v1
# создание нового с прикручиванием старого тома
docker container run -d -p 8011:80 -v todo-list:$target --name todo-v2 diamol/ch06-todo-list:v2

Команда VOLUME в докерфайле и -v при запуске контейнера - это разные вещи. Докерфайл создаёт образ, и все контейнеры, которые будут созданы на основе этого образа, будут в любом случае создавать том, даже если это не указано в команде docker run. Этот том будет иметь случайный ID, и данные с этого тома, конечно, можно использовать потом, но если сможешь его найти.

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

Точки монтирования

Bind mounts - прямое использование файловой системы хоста: на нём создаётся каталог, который используется внутри контейнера. Путь может вести на быстрый диск, RAID-массив, хранилище и т. д. По сути, то же самое (The biggest difference is that the -v syntax combines all the options together in one field, while the --mount syntax separates them.)

source="$(pwd)/databases" && target='/data'
mkdir $source
docker container run --mount type=bind,source=$source,target=$target -d -p 8012:80 diamol/ch06-todo-list
curl http://localhost:8012 # создаёт todo-list.db
ls $source # проверить наличие файла

Bind mount двунаправленный - можно создавать файлы в контейнере и редактировать их на хосте и наоборот. Так как контейнеры должны запускаться от непривилегированной учётки, чтобы свести к минимуму риск при атаке на систему, для того чтобы читать и писать файлы на хосте, требуется указывать в докерфайле инструкцию USER, имеющую соответствующие права на хосте.
Если писать файлы не нужно, можно смонтировать каталог на хосте только для чтения контейнером - это один из вариантов брать конфигурацию для контейнера с хоста без переделывания образа.

docker run --name todo-configured --mount type=bind,source='/home/user/config',target='/app/config',readonly -d -p 8013:80 diamol/ch06-todo-list

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

  1. Что, если целевой каталог контейнера (target) уже существует и там уже есть файлы? При монтировании source полностью заменяет target - файлы, которые там были, будут недоступны.
    docker container run --mount type=bind,source="$(pwd)/new",target=/init diamol/ch06-bind-mount
  2. Что если монтируется один файл в существующий каталог в контейнере? Внутри контейнера каталог, где содержится файл, будет взят из образа, а сам файл будет заменён (в Windows-контейнерах монтирование одного файла не поддерживается).
    docker container run --mount type=bind,source="$(pwd)/new/123.txt",target=/init/123.txt diamol/ch06-bind-mount
  3. При использовании распределённых хранилищ (SMB, Azure, S3 и т. д.) надо быть готовым к тому, что система может не заработать, так как распределённое хранилище может не поддерживать операций, которые требует контейнер для работы с привязкой, например, поддержку жёстких ссылок.

Строение файловой системы контейнера

Каждый контейнер содержит виртуальный диск, собранный из нескольких источников, это т. н. единая файловая система (union filesystem). Источники - это слои образов, точки монтирования томов и привязок, и сверху находится слой для записи. Контейнер работает с этой ФС как с одним разделом. Тем не менее, разные каталоги на этом диске могут быть смонтированы в разные места.

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

docker volume create todo6
docker run --name todo6 -dp 8003:80 -v todo6:/new-data \
--mount type=bind,source='/home/user/docker/todo6-conf',target='/app/config',readonly \
diamol/ch06-lab

7. Multi-container apps (Docker Compose)

Возможность указать желаемую конфигурацию в едином файле.

Пример секции services в docker-compose.yml:

accesslog:
  image: diamol/ch04-access-log

iotd:
  image: diamol/ch04-image-of-the-day
  ports:
    - "80"

image-gallery:
  image: diamol/ch04-image-gallery
  ports:
    - "8010:80"
  depends_on:
    - accesslog
    - iotd

iotd - публикуется порт 80 контейнера на любой порт хоста, а image-gallery запустится только после accesslog и iotd.

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

docker-compose up -d --scale iotd=3
# browse to http://localhost:8010 and refresh
docker-compose logs --tail=1 iotd # показать последнюю запись из каждого экземпляра iotd

Остановить, стартовать, посмотреть статус.

docker-compose stop # Показываются контейнеры
Stopping image-of-the-day_iotd_3          ... done
Stopping image-of-the-day_iotd_2          ... done
Stopping image-of-the-day_image-gallery_1 ... done
Stopping image-of-the-day_accesslog_1     ... done
Stopping image-of-the-day_iotd_1          ... done
docker-compose start # Показываются сервисы (запускаются в заданном порядке)
Starting accesslog     ... done
Starting iotd          ... done
Starting image-gallery ... done
docker container ls # Если остановленные контейнеры существуют, то они и запускаются (см. CREATED и STATUS)
CONTAINER ID   IMAGE                          COMMAND                  CREATED          STATUS          PORTS                                     NAMES
732d36e8af7d   diamol/ch04-image-of-the-day   "java -jar /app/iotd…"   6 minutes ago    Up 10 seconds   0.0.0.0:49156->80/tcp, :::49156->80/tcp   image-of-the-day_iotd_3
81b762117f5a   diamol/ch04-image-of-the-day   "java -jar /app/iotd…"   6 minutes ago    Up 10 seconds   0.0.0.0:49157->80/tcp, :::49157->80/tcp   image-of-the-day_iotd_2
abce83ac3a53   diamol/ch04-image-gallery      "/web/server"            14 minutes ago   Up 8 seconds    0.0.0.0:8010->80/tcp, :::8010->80/tcp     image-of-the-day_image-gallery_1
01cff95179c8   diamol/ch04-access-log         "docker-entrypoint.s…"   14 minutes ago   Up 10 seconds   80/tcp                                    image-of-the-day_accesslog_1
c523d48d79de   diamol/ch04-image-of-the-day   "java -jar /app/iotd…"   14 minutes ago   Up 9 seconds    0.0.0.0:49158->80/tcp, :::49158->80/tcp   image-of-the-day_iotd_1

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

# down - удалить приложение, контейнеры, сети и тома, упомянутые в docker-compose.yml (если они не помечены как external)
docker-compose down
docker-compose up -d
docker container ls

Как контейнеры взаимодействуют

В докере есть свой DNS, и контейнеры в пределах виртуальной сети видят друг друга по имени. Если контейнер запрашивает имя, которого не существует в виртуальной сети, Докер запрашивает внешний DNS. Это можно проверить, зайдя внутрь контейнера и запрашивая имена через nslookup. Если одинаковых контейнеров несколько, то внутренний DNS Докера будет выдавать все экземпляры, но раз от раза тасуя их очерёдность, что обеспечивает простую балансировку нагрузки типа round-robin.

Если грохнуть контейнер через Docker CLI мимо docker-compose, то при повторной команде docker-compose up недостающие контейнеры будут запущены снова.

docker rm -f image-of-the-day_accesslog_1
image-of-the-day_accesslog_1
 
docker-compose up -d --scale iotd=3
image-of-the-day_iotd_1 is up-to-date                                                                                            
image-of-the-day_iotd_2 is up-to-date                                                                                            
image-of-the-day_iotd_3 is up-to-date                                                                                            
Creating image-of-the-day_accesslog_1 ... done
image-of-the-day_image-gallery_1 is up-to-date         

Пример конфигурации docker-compose

services:

  todo-db:
    image: diamol/postgres:11.5
    ports:
      - "5433:5432"
    networks:
      - app-net

  todo-web:
    image: diamol/ch06-todo-list
    ports:
      - "8020:80"
    environment:
      - Database:Provider=Postgres
    depends_on:
      - todo-db
    networks:
      - app-net
    secrets:
      - source: postgres-connection
        target: /app/config/secrets.json

environment - переменные окружения, создаваемые внутри контейнера, secrets читаются Докером и предоставляются как файлы внутри контейнера. В данном случае, в распоряжении приложения будет файл /app/config/secrets.json с содержащимися там учётными данными под названием postgres-connection.

Secrets обычно нужны при кластеризации типа Docker Swarm или K8s. Они хранятся в БД кластера и могут быть зашифрованы, что даёт возможность более-менее безопасно хранить всякие учётные данные типа сертификатов, ключей API и т. д. На одиночном хосте нет кластерной БД для секретов, поэтому можно читать секреты из файлов. Вот соответствующая секция в docker-compose.yml:

secrets:
  postgres-connection:
    file: ./config/secrets.json

В какой-то степени чтение секретов из файлов напоминает bind mounts, потому что файлы хоста используются в контейнере. Тем не менее, использование опции секрета позволяет впоследствии легче мигрировать с кластер.

Файлы docker-compose.yml делают проще настройку для нескольких окружений, например, теста и разработки - можно указать разные порты и другие настройки. В примере выше todo-app используется вместе с БД Postgres - работает всё так же, но данные теперь пишутся в БД, которая может управляться отдельно.

# Показать только контейнеры, относящиеся к текущей конфигурации docker-compose
docker-compose ps

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

Какие проблемы решает docker-compose

Исчезает разница между документацией и конфигурацией. Файл docker-compose.yml фактически является описанием конфигурации системы, который довольно легко читается. Он всегда актуален и нет необходимости создавать текстовые файлы с описанием.

Позволяет создавать мультиконтейнерные сервисы в рамках одного хоста. Тем не менее, он не следит за состоянием контейнеров, как оркестраторы - если контейнер упал или был удалён, то docker-compose ничего сделать не сможет, надо руками перезапускать конфигурацию. Тем не менее, это не значит, что он не подходит для пром. использования - он хорош для начала, когда идёт миграция с ВМ или железа. Докер-композ не даёт HA и NLB, но этого не было и на ВМ и железке.

8. Проверка здоровья и зависимостей

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

Проверки здоровья в образах

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

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

# --fail - код возврата, запускать каждые 10 сек
HEALTHCHECK --interval=10s \
CMD curl --fail http://localhost/health # /health - специально предусмотренный путь для проверки (в этом приложении), который должен выдавать код возврата 200, если всё нормально.

--fail - выдаёт 0, если всё нормально. Если возврат будет не 0, то проверка считается непройденной.

Если вывести простыню о состоянии контейнера, то там в разделе State появится раздел Health.

docker container inspect $(docker container ls --last 1 --format '{{.ID}}')

Теперь управляющий софт будет в курсе проблемы и сможет предпринять соответствующие действия. На одиночном сервере, даже если контейнер помечен как неисправный, Докер не делает ничего, чтобы не было простоя и потерялись данные, и контейнер продолжает работать, а проверки продолжаются, и если проверка будет успешной, контейнер снова вернётся в состояние healthy.

Запуск с проверкой зависимостей

В отличие от docker-compose, в кластере нельзя гарантировать, что контейнеры запустятся в нужной последовательности, например, веб-морда может запуститься раньше API и приложение обломится, хотя контейнеры будут в порядке, поэтому нужно настраивать зависимости. Проверка зависимостей отличается от проверки здоровья - она запускается до запуска приложения и проверяет, всё ли готово для его запуска. Если что-то не готово, контейнер не запускается. У Докера нет встроенной функции проверки зависимостей типа HEALTHCHECK, так что нужно добавлять это в команду запуска.

В Докерфайле:

# Если API отвечает по ссылке, то запустить приложение
CMD curl --fail http://numbers-api/rng && \
dotnet Numbers.Web.dll

Написание собственных инструментов для проверки

Curl - хорошая вещь для проверок, лучше написать свой механизм проверки на том же языке, на котором написано само приложение - не нужно ставить дополнительных компонентов, можно задействовать более сложную логику проверок, использовать не только URL и т .д. Например, в поэтапном докерфайле сначала собирается приложение, на втором этапе - проверяльщик, а на третьем всё это копируется в основной образ, и HEALTHCHECK задействует уже не curl, а самописный проверяльщик. Проверяльщик, конечно, может быть задействован и в строке проверки зависимостей.

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

Проверки здоровья и зависимостей в Docker Compose

В Docker Compose у healthcheck есть тонкие настройки (в данном случае используется проверка, встроенная в образ):

numbers-api:
  image: diamol/ch08-numbers-api:v3
  ports:
    - "8087:80"
  healthcheck:
    interval: 5s     # интервал между проверками
    timeout: 1s      # время ожидания выполнения проверки (после истечения проверка считается неуспешной)
    retries: 2       # кол-во неуспешных проверок для признания контейнера нездоровым
    start_period: 5s # через какое время после запуска начинать проверки (чтобы дать приложению запуститься)
  networks:
    - app-net

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

Если проверка не строена в образ, можно задать её в docker-compose.yml:

  healthcheck:
    test: ["CMD", "dotnet", "Utilities.HttpCheck.dll", "-t", "150"]
    interval: 5s
    timeout: 1s
    retries: 2
    start_period: 10s
  # тут по желанию можно добавить автоперезапуск
  restart: on-failure
  # и зависимости
  depends_on: [serviceName]

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

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

От проверок к самовосстановлению

Запуск приложения как распределённой системы из мелких компонентов повышает гибкость и быстродействие, но усложняет управление. Если компонентов много и между ними куча зависимостей, то прописывать их все тяжело, но заниматься этим - не лучшая идея. Если на одном хосте можно указать, что веб-морда зависит от бекенда, то если у тебя K8s на десятке серверов и тебе нужно 20 бекендов и 50 веб-контейнеров, ты прописываешь веб-морде запуск после бекенда, запускается 19 бекендов и один не запускается или запускается очень долго - в результате приложение не работает вообще, потому что веб-контейнеры не запустились все, ожидая, пока все контейнеры бекенда поднимутся, хотя если бы не была прописана зависимость, то даже при одном запущенном бекенде и 50 веб-мордах приложение бы работало.

Здесь как раз могут помочь проверки здоровья и зависимостей. Лучше дать платформе запускать контейнеры как ей нужно - максимум, на всех серверах и как можно быстрее, но если какие-то контейнеры не видят зависимостей, они просто перезапускаются или пересоздаются до тех пор, пока их зависимости не запустятся и не станут для них доступными. Пусть в момент запуска приложение не будет развёрнуто на 100%, но, во всяком случае, оно уже будет работать. Идея самовосстанавливающихся приложений - это переложить работу с временными ошибками на плечи платформы. Например, если приложение кривое и вызывает переполнение памяти, то платформа заменит контейнер новым. Это не исправит причину, но приложение хотя бы будет работать.

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

9. Мониторинг

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

Структура мониторинга для контейнерных приложений

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

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

sudo nano /etc/docker/daemon.json
# добавить перед последней закрывающей }
# если файла нет, то нужно его создать
{
  "metrics-addr": "0.0.0.0:9323",
  "experimental": true
}
# Потом нужно дать права группе docker на этот файл, если он был создан
sudo chown user:docker /etc/docker/daemon.json
# перезапустить Докер
sudo systemctl restart docker

После этого метрики будут доступны по адресу http://docker:9323/metrics

После этого можно ставить самого Прометея

# так в книжке, базовая процедура инсталляции - ссылка ниже.
# здесь переменная указывает на хост, чтобы брать с него метрики
docker run -e DOCKER_HOST=192.168.1.10 -dp 9090:9090 diamol/prometheus:2.13.1

https://prometheus.io/docs/prometheus/latest/installation/#using-docker

После выполнения запуска Прометея веб-интерфейс доступен по адресу http://docker:9090, там можно посмотреть на доступные метрики Докера, и что в разделе targets он отображается как нормально работающий.

Метрики Докера Статус

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

Метрики приложения

У Прометея есть клиентские библиотеки для всех основных языков программирования, например, promhttp для Go, micrometer для Java REST API, prom-client для Node.js. Клиент собирает информацию, что делается в контейнере и какая на него нагрузка применительно к среде, в которой он выполняется, т. е., клиент будет выдавать специфические для своей среды метрики.

Помимо метрик самого Докера и метрик окружения (runtime), должны быть ещё и метрики самого приложения, чтобы была полная картина. Метрики приложения могут быть сконцентрированы на операциях, например, показывать среднее время выполнения запроса, а могут на бизнес-аналитике - кол-во пользователей в системе или регистрации на новом сервисе. Нестандартные метрики нужно создавать самому.

У Прометея есть несколько типов метрик, самые простые - это счётчики (counters) и датчики (gauges). Значение счётчика может либо оставаться стабильным, либо расти, датчик может менять значения в любую сторону.

Полезно мониторить в метриках:

Запуск Прометея в контейнере для сбора метрик

Прометей использует pull-сбор, т. е., сам лезет за информацией, а не ждёт её. Это называется скоблением (scraping), и когда Прометей устанавливается, нужно настроить конечные точки, которые он будет скоблить. Также, можно настроить Прометея так, чтобы он обнаруживал все контейнеры в кластере. В докер-композе на одном сервере используется простой список служебных имён, и Прометей находит контейнеры через Докер-DNS.

Вот пример конфигурации, где скоблится два компонента приложения:

# Общая настройка - 10 сек между проверками
global:
  scrape_interval: 10s
 
# job для каждого компонента
scrape_configs:
  - job_name: "image-gallery"
    metrics_path: /metrics
    static_configs:
      - targets: ["image-gallery"]
  
  - job_name: "iotd-api"
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ["iotd"]

  - job_name: "access-log"
    metrics_path: /metrics
    # использовать Докер-DNS для автопоиска
    dns_sd_configs:
      - names:
        - accesslog
        type: A
        port: 80

Здесь контейнеры будут обнаруживаться автоматически, но для image-gallery и iotd подразумевается наличие только одного контейнера (static_configs) - если их размножить, то из-за NLB метрики будут сниматься каждый раз с разных контейнеров, так как Прометей в этом случае берёт первый IP-адрес для доменного имени. Access-log же настроен на поддержку множества IP (dns_sd_configs).

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

access_log_total{hostname="3c57931b20b4",instance="192.168.48.6:80",job="access-log"}	167
access_log_total{hostname="4245de46cccb",instance="192.168.48.7:80",job="access-log"}	171
access_log_total{hostname="731f99d38048",instance="192.168.48.3:80",job="access-log"}	152

Но можно получить общее число, выполнив суммирование и фильтр ярлыков (labels):

sum(access_log_total) without(hostname, instance)
 
{job="access-log"}	490
# На соседней вкладке graph график тоже будет суммированным

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

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

Визуализация метрик с помощью Grafana

Гугл в книжке Site Reliability Engineering описывает т. н. «золотые сигналы» для мониторинга сайтов - это задержка (latency), трафик (traffic), ошибки (errors) и насыщенность (saturation).

Пример настройки панели:

Остальные показатели используют примерно такие же несложные запросы. Не нужно сильно заморачиваться с PromQL - достаточно выбирать правильные метрики и подходящее их отображение.

В мониторинге актуальные значения менее ценны, чем отображение тенденций (трендов), так как знание того, что приложение занимает 200 МБ памяти, не так нужно, как знание того, что произошёл неожиданный скачок её потребления. Это даёт возможность принять какое-то решение, например, увеличить кол-во контейнеров.

Добавить панель в Графану можно с помощью кнопки с плюсиком вверху. В том же ряду есть кнопка Share dashboard, и там есть складка Export, где выгружается json, с помощью которого можно сделать свой образ, в котором сразу будет всё настроено, например

FROM diamol/grafana:6.4.3
 
COPY datasource-prometheus.yaml ${GF_PATHS_PROVISIONING}/datasources/
COPY dashboard-provider.yaml ${GF_PATHS_PROVISIONING}/dashboards/
COPY dashboard.json /var/lib/grafana/dashboards/

Таким же образом можно добавлять и пользователей, например, пользователя только на чтение всех панелей. У Графаны есть «список воспроизведения» (playlist), и если добавить этого пользователя и все панели в этот список, то на экране панели под этим пользователем будут циклически переключаться.

Уровни наблюдения

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

Лабораторная

Надо написать конфиг для Прометея и собрать образ:

prometheus.yml
global:
  scrape_interval: 10s

scrape_configs:
  - job_name: 'todo'
    metrics_path: /metrics
    static_configs:
      - targets: ['todo']
Dockerfile
FROM diamol/ch09-prometheus
COPY prometheus.yml /etc/prometheus/
# Сборка
docker image build -t my-prometheus -f prometheus/Dockerfile prometheus/

Потом пишется docker-compose.yml с образом diamol/ch09-grafana и запускается, там создаётся панель с нужными параметрами, выгружается JSON в grafana/dashboard.json, собирается образ

Dockerfile
FROM diamol/ch09-grafana
COPY dashboard.json /var/lib/grafana/dashboards/
# Сборка
docker image build -t my-grafana -f grafana/Dockerfile grafana/

Потом в docker-compose.yml образ Графаны меняется на свой и запускается:

docker-compose.yml
version: '3.7'
services:
  todo:
    image: diamol/ch09-todo-list
    ports:
      - 8080:80
  prometheus:
    image: my-prometheus
    ports:
      - 9090:9090
  grafana:
    image: my-grafana
    ports:
      - 3000:3000

10. Запуск нескольких окружений с помощью docker-compose

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

$ docker-compose -f numbers/docker-compose.yml up -d
Creating network "numbers_app-net" with the default driver
Creating numbers_numbers-api_1 ... done
Creating numbers_numbers-web_1 ... done
$ docker-compose -f todo-list/docker-compose.yml up -d
Creating network "todo-list_app-net" with the default driver
Creating todo-list_todo-web_1 ... done
$ docker-compose -f todo-list/docker-compose.yml up -d
todo-list_todo-web_1 is up-to-date

Докер-композ использует понятие проект (project) для определения разных ресурсов, принадлежащих одному приложению, и он использует имя папки, где лежит docker-compose.yml как имя проекта по умолчанию. У создаваемых ресурсов имя проекта идёт как префикс, и для контейнеров ещё добавляется числовой счётчик как суффикс. Например, в приложении app1, где есть сервис web и том disk, при создании ресурсов контейнеры будут называться app1_web_1, app1_web_2 и т. д., а volume - app1_disk.

Но имя проекта можно переопределить, тогда можно будет запускать параллельно множество экземпляров приложения на одном Докер-движке, так как для движка имена новых ресурсов не совпадают со старыми, соответственно, они создаются:

$ docker-compose -f todo-list/docker-compose.yml -p todo-test up -d
Creating network "todo-test_app-net" with the default driver
Creating todo-test_todo-web_1 ... done

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

docker-compose -f ./todo-list/docker-compose.yml -p todo-test up -d
docker ps
docker container port todo-test_todo-web_1 80 # Выяснить порт
0.0.0.0:49157
:::49157

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

Docker Compose override files

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

В этом случае для изменения какой-то среды нужно редактировать только один файл, а если нужно изменить что-то для всех окружений - отредактировать основной файл.

# from docker-compose.yml - the core app specification:
services:
  todo-web:
    image: diamol/ch06-todo-list
    ports:
      - 80
    environment:
      - Database:Provider=Sqlite
    networks:
      - app-net
# and from docker-compose-v2.yml - the version override file:
services:
  todo-web:
    image: diamol/ch06-todo-list:v2

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

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

# config в конце - проверка конфигурации без реального запуска, вывод результата
# config сортирует вывод по алфавиту, так что результат будет выглядеть непривычно
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml config

Основной файл: описывает только сервисы без портов и сетей.

numbers/docker-compose.yml
version: "3.7"

services:
  numbers-api:
    image: diamol/ch08-numbers-api:v3
    networks:
      - app-net

  numbers-web:
    image: diamol/ch08-numbers-web:v3
    environment:
      - RngApi__Url=http://numbers-api/rng
    networks:
      - app-net

networks:
  app-net:

Среда разработки: порты и сеть, без проверок.

numbers/docker-compose-dev.yml
version: "3.7"

services:
  numbers-api:
    ports:
      - "8087:80"
    healthcheck:
      disable: true

  numbers-web:
    entrypoint:
      - dotnet
      - Numbers.Web.dll
    ports:
      - "8088:80"

networks:
  app-net:
    name: numbers-dev

Тест: сеть, проверки. Сервис API остаётся внутренним и не публикуется.

numbers/docker-compose-test.yml
version: "3.7"

services:
  numbers-api:
    healthcheck:
      interval: 20s
      start_period: 15s
      retries: 4

  numbers-web:
    ports:
      - "8080:80"
    restart: on-failure
    healthcheck:
      test: ["CMD", "dotnet", "Utilities.HttpCheck.dll", "-t", "250"]
      interval: 20s
      timeout: 10s
      retries: 4
      start_period: 10s

networks:
  app-net:
      name: numbers-test

Тест на юзерах: сеть, стандартный порт 80, авторестарт сервисов, более строгая проверка.

numbers/docker-compose-uat.yml
version: "3.7"

services:
  numbers-api:
    healthcheck:
      interval: 10s
      retries: 2
    restart: always
    ports:
      - "8090:80"

  numbers-web:
    restart: always
    ports:
      - "80:80"
    healthcheck:
      interval: 10s
      retries: 2

networks:
  app-net:
    name: numbers-uat
# запуск трёх сред - разработка, тест и User acceptance test
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-dev.yml -p app-dev -d
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml -p app-test up -d
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-uat.yml -p app-uat up -d
 
docker ps
CONTAINER ID   IMAGE                        COMMAND                  CREATED              STATUS                        PORTS                                   NAMES
b4f10fb1eeac   diamol/ch08-numbers-web:v3   "/bin/sh -c 'dotnet …"   About a minute ago   Up About a minute             0.0.0.0:80->80/tcp, :::80->80/tcp       numbers-uat_numbers-web_1
7e2e8739eb06   diamol/ch08-numbers-api:v3   "dotnet Numbers.Api.…"   About a minute ago   Up About a minute (healthy)   0.0.0.0:8090->80/tcp, :::8090->80/tcp   numbers-uat_numbers-api_1
daef59fda817   diamol/ch08-numbers-api:v3   "dotnet Numbers.Api.…"   About a minute ago   Up About a minute (healthy)   80/tcp                                  numbers-test_numbers-api_1
d19477e16c43   diamol/ch08-numbers-web:v3   "/bin/sh -c 'dotnet …"   About a minute ago   Up About a minute (healthy)   0.0.0.0:8080->80/tcp, :::8080->80/tcp   numbers-test_numbers-web_1
f86e7079c986   diamol/ch08-numbers-web:v3   "dotnet Numbers.Web.…"   About a minute ago   Up About a minute             0.0.0.0:8088->80/tcp, :::8088->80/tcp   numbers-dev_numbers-web_1
a16bb1a08e64   diamol/ch08-numbers-api:v3   "dotnet Numbers.Api.…"   About a minute ago   Up About a minute             0.0.0.0:8087->80/tcp, :::8087->80/tcp   numbers-dev_numbers-api_1

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

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

# Не удалит ничего
docker-compose down
 
# Это работает, если при запуске не было указано имя проекта.
# Если имя проекта было указано, в этом примере попытается удалить сеть, но не сможет,
# т. к. контейнеры с префиксом по умолчанию он не найдёт (префикс другой - app-test),
# а сеть не может быть удалена, если в ней есть контейнеры.
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml down
Removing network numbers-test
ERROR: error while removing network: network numbers-test id 8193d858fd0f2c9716d750478c253d032251571586d12f61648987baadc75315 has active endpoints
# Если имя проекта было указано, то его необходимо указывать вместе с файлами
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml -p numbers-test down
Stopping numbers-test_numbers-api_1 ... done
Stopping numbers-test_numbers-web_1 ... done
Removing numbers-test_numbers-api_1 ... done
Removing numbers-test_numbers-web_1 ... done
Removing network numbers-test

Добавление в конфигурацию переменных и секретов

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

Возьмём приложение to-do, где нужно настраивать 3 параметра:

  1. Уровень логирования: высокий для разработчиков и менее подробный для теста и прода.
  2. Тип базы данных: простой файл или отдельная БД.
  3. Строка подключения к БД: учётные данные для подключения, если БД не файл.

Конфигурация приложения в виде секрета.

services:
  todo-web:
    image: diamol/ch06-todo-list
    secrets:
      # Название секрета, откуда брать данные (должен быть задан в докер-композ файле)
      - source: todo-db-connection
        # Целевой файл внутри контейнера
        target: /app/config/secrets.json

Базовый конфиг выше неполноценен, т. к. в нём нет секции secrets. Но вот, например, перекрывающий (override) файл, где эта секция задана:

services:
  todo-web:
    ports:
      - 8089:80
    # Environment добавляет переменную внутрь контейнера, которая указывает приложению использовать SQLite.
    # Это самый простой путь задавать параметры, и понятно, что именно настраивается.
    environment:
      - Database:Provider=Sqlite
    # env_file - путь к текстовому файлу, где заданы переменные в формате name=value
    # Удобно для задания переменных для нескольких компонентов сразу, чтобы не дублировать их в конфиге
    env_file:
      - ./config/logging.debug.env
 
# Secrets - секция верхнего уровня, как services.
# Указывает, где на хосте лежит файл для todo-db-connection в базовом конфиге.
secrets:
  todo-db-connection:
    file: ./config/empty.json

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

todo-web:
  ports:
    - "${TODO_WEB_PORT}:80"
  environment:
    - Database:Provider=Postgres
  env_file:
    - ./config/logging.information.env
  networks:
    - app-net

Если Докер-композ обнаруживает файл .env в текущем каталоге, он берёт его за основу как env-файл. В этом файле могут быть заданы основной и перекрывающий файл, имя проекта и т. д., избавляющее от необходимости каждый раз добавлять это в командную строку, поэтому достаточно в папке выполнить docker-compose up -d. Пример файла .env:

# Порты
TODO_WEB_PORT=8877
TODO_DB_PORT=5432
# Файлы и имя проекта:
COMPOSE_PATH_SEPARATOR=;
COMPOSE_FILE=docker-compose.yml;docker-compose-test.yml
COMPOSE_PROJECT_NAME=todo_ch10

Собственно говоря, файл .env является настройками docker-compose по умолчанию, избавляя от необходимости указывать перекрывающий (override) файл. Этот файл помогает ориентироваться, какие файлы относятся к какому проекту. Нужно учитывать, что докер-композ смотрит только на файл .env, ему нельзя задать другое имя.

Резюме:

Change pre-defined environment variables (насчёт COMPOSE_PROJECT_NAME и т. п.)
Приоритет переменных в зависимости от места их вызова

Поля расширения (extension fields) - устранение дублирования текста конфигурации

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

x-labels: &logging
  logging:
    options:
      max-size: '100m'
      max-file: '10'
      
x-labels: &labels
  app-name: image-gallery

Здесь в случае блока logging задаются настройки логирования, так что этот блок может использоваться непосредственно внутри блока service. Labels содержит ключ/значение ярлыка и может быть использован только внутри существующего блока labels.
Вызываются поля расширения с помощью конструкции <<: *имяБлока

services:
  iotd:
    ports:
      - 8080:80
    <<: *logging
    labels:
      <<: *labels
      public: api
# config покажет результат вместе с подстановкой полей расширений
docker-compose -f docker-compose.yml -f docker-compose-prod.yml config

Фрагмент вывода:

  image-gallery:
    image: diamol/ch09-image-gallery
    labels:
      app-name: image-gallery
      public: web
    logging:
      options:
        max-file: '10'
        max-size: 100m
    networks:
      app-net: {}
    ports:
    - published: 80
      target: 80

Поля расширения помогают стандартизировать сервисы. Тем не менее, есть ограничение - поля расширения не применяются к нескольким файлам, т. е. нельзя задать поля в основном файле и потом использовать их в перекрывающем (это ограничение формата YAML).

Процесс конфигурации с помощью Докера

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

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

  1. Композиция приложения: не нужно запускать все компоненты приложения в каждом окружении. Положим, для разработчиков запускать панель мониторинга не нужно, в тестовом окружении БД работает в контейнере, а в проде БД лежит в облаке. Override-файлы хорошо помогают в настройке таких различий.
  2. Конфигурация контейнеров: чтобы соответствовать требованиям и возможностям различных окружений, нужно настраивать свойства контейнеров. Публикуемые порты должны быть уникальными и непересекающимися, а пути томов могут вести на локальный диск в тестовом окружении и на общий каталог в проде. Override-файлы также позволяют это делать, создавая изолированные сети для каждого приложения, тем самым позволяя запускать несколько экземпляров приложения на одном сервере.
  3. Конфигурация приложения: поведение приложения внутри контейнеров меняется от окружения к окружению. Уровень логирования (например, debug для разрабов, info для теста), размер кэша или включение/отключение каких-то функций. Здесь помогут переменные окружения, секреты или env-файлы и те же оverride-файлы.

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

Лабораторная

Создать два окружения:

  1. dev: локальная база (файл), публикация на порту 8089, образ v2. Запуск должен быть по команде без лишних ключей, настройки по умолчанию.
  2. test: БД в отдельном контейнере и с томом для хранения данных, публикация на порту 8080, новейший образ.
docker-compose.yml
version: '3.7'

services:
  todo-web:
    image: ${IMAGE_WEB}
    ports:
      - ${PORT_WEB}:80
    networks:
      - network

networks:
  network:
    name: ${NETWORK}
.env
IMAGE_WEB='diamol/ch06-todo-list:v2'
PORT_WEB=8089
COMPOSE_PROJECT_NAME=todo-dev
NETWORK=todo-dev
Database:Provider=Sqlite
# Запуск
docker-compose up -d
Creating network "todo-dev" with the default driver
Pulling todo-web (diamol/ch06-todo-list:v2)...
v2: Pulling from diamol/ch06-todo-list
68ced04f60ab: Already exists
e936bd534ffb: Already exists
caf64655bcbb: Already exists
d1927dbcbcab: Already exists
641667054481: Already exists
9d301c563cc9: Pull complete
92dc1ae7fce7: Pull complete
12c9a1dda02c: Pull complete
Digest: sha256:d2341450aaf2c48ed48e8607bd2271e5b89d38779487c746836d42ddafa5496c
Status: Downloaded newer image for diamol/ch06-todo-list:v2
Creating todo-dev_todo-web_1 ... done
docker-compose-test.yml
services:
  todo-web:
    depends_on:
      - todo-db
  todo-db:
    image: ${IMAGE_DB}
    networks:
      - network
    volumes:
      - todo-db:${PGDATA}
volumes:
  todo-db:
test.env
IMAGE_WEB='diamol/ch06-todo-list'
IMAGE_DB='diamol/postgres:11.5'
PORT_WEB=8080
COMPOSE_PROJECT_NAME=todo-test
NETWORK=todo-test
Database:Provider=Postgres
PGDATA=/var/lib/postgresql/data
# Запуск
docker-compose -f docker-compose.yml -f docker-compose-test.yml --env-file test.env up -d
Creating volume "todo-test_todo-db" with default driver
Creating todo-test_todo-db_1 ... done
Creating todo-test_todo-web_1 ... done

Имя тома можно хардкодить в композ-файле - при наличии имени проекта оно ставится спереди и получается todo-test_todo-db, поэтому в разных окружениях будут создаваться разные тома.

Решение автора

Результат

docker ps
CONTAINER ID   IMAGE                      COMMAND                  CREATED          STATUS          PORTS                                   NAMES
91946dd92dd4   diamol/ch06-todo-list      "dotnet ToDoList.dll"    28 minutes ago   Up 28 minutes   0.0.0.0:8080->80/tcp, :::8080->80/tcp   todo-test_todo-web_1
4d834b9ae0a9   diamol/postgres:11.5       "docker-entrypoint.s…"   28 minutes ago   Up 28 minutes   5432/tcp                                todo-test_todo-db_1
69af7592fc08   diamol/ch06-todo-list:v2   "dotnet ToDoList.dll"    2 hours ago      Up 2 hours      0.0.0.0:8089->80/tcp, :::8089->80/tcp   todo-dev_todo-web_1

11. Сборка и тестирование приложений

Докер унифицирует CI-процесс, т. к. не нужно ставить зависимости, SDK и прочее на хост. Тем не менее, некоторые компоненты нужны в любом случае: Git, реестр образов и сервер автоматизации сборки. Есть масса вариантов - от Github + AzureDevOps + DockerHub или Gitlab как решения «всё в одном» до собственной инфраструктуры, развёрнутой в контейнерах. Мало кто хочет париться с собственной инфраструктурой когда есть бесплатные внешние решения, но о запуске системы сборки в Докере знать небесполезно. Помимо независимости от внешних сервисов и скорости доступа, хорошо иметь локальный резерв инфраструктуры на случай недоступности каких-либо внешних компонентов системы сборки или интернета в целом. Три вышеперечисленных компонента легко развёртываются - здесь это будут Gogs, Docker registry и Jenkins.

Суть в том, что в контейнер с Дженкинсом ставится Docker CLI, который подключается к Docker API хоста для выполнения задач сборки.

services:
  jenkins:
    volumes:
      - type: bind
      - source: /var/run/docker.sock
      - target: /var/run/docker.sock

Основной файл. Тут применяется замена переменной, если значение отсутствует, т. е. при локальной сборке имя образа будет docker.io/diamol/ch11-numbers-api:v3-build-local, а при сборке в Дженкинсе, где переменные заданы, registry.local:5000/diamol/ch11-numbers-api:v3-build-2 и дальше в конце будет прибавляться номер сборки.

docker-compose.yaml
version: "3.7"

services:
  numbers-api:
    image: ${REGISTRY:-docker.io}/diamol/ch11-numbers-api:v3-build-${BUILD_NUMBER:-local}
    networks:
      - app-net

  numbers-web:
    image: ${REGISTRY:-docker.io}/diamol/ch11-numbers-web:v3-build-${BUILD_NUMBER:-local}
    environment:
      - RngApi__Url=http://numbers-api/rng
    depends_on:
     - numbers-api
    networks:
      - app-net

Перекрывающий файл: где искать докерфайлы.

docker-compose-build.yaml
x-args: &args
  args:
    BUILD_NUMBER: ${BUILD_NUMBER:-0}
    BUILD_TAG: ${BUILD_TAG:-local}

services:
  numbers-api:
    build:
      context: numbers
      dockerfile: numbers-api/Dockerfile.v4
      <<: *args
      
  numbers-web:
    build:
      context: numbers
      dockerfile: numbers-web/Dockerfile.v4
      <<: *args
      
networks:
  app-net:

Context - относительный путь к рабочему каталогу сборки, dockerfile - относительный к контексту докерфайл, args - аргументы сборки (здесь используются поля расширения).

# Сборка через докер-композ - соберутся все компоненты, перечисленные в файле
docker-compose -f docker-compose.yml -f docker-compose-build.yml build
# Проверка ярлыков образа api
docker image inspect -f '{{.Config.Labels}}' diamol/ch11-numbers-api:v3-build-local
map[build_number:0 build_tag:local version:3.0]

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

FROM diamol/dotnet-aspnet
 
ARG BUILD_NUMBER=0
ARG BUILD_TAG=local
 
LABEL version="3.0"
LABEL build_number=${BUILD_NUMBER}
LABEL build_tag=${BUILD_TAG}
 
ENTRYPOINT ["dotnet", "Numbers.Api.dll"]

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

Значения по умолчанию в нескольких местах здесь нужны, чтобы обеспечить корректную сборку и через CI, и локально. Сборка через обычный Докер:

# С указанием докерфайла и аргумента сборки (здесь --build-arg BUILD_TAG перекроет ARG в докерфайле)
docker image build -f numbers-api/Dockerfile.v4 --build-arg BUILD_TAG=ch11 -t numbers-api .
# Проверка ярлыков
docker image inspect -f '{{.Config.Labels}}' numbers-api
map[build_number:0 build_tag:ch11 version:3.0]

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

Создание задач CI без каких-либо зависимостей, кроме Докера

Jenkins в репозитории устарел и сломан.

12. Оркестраторы - Docker Swarm и k8s

# Включить режим Swarm
docker swarm init 
  Swarm initialized: current node (ti5tg544h7k6dsw0mnj6uepot) is now a manager.
  To add a worker to this swarm, run the following command:
      docker swarm join --token SWMTKN-1-5hlq0y8b4z34bsuuopr3uovtwliehdoiwhew 192.168.0.2:2377
  To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
 
# Показать ссылку для добавления рабочего
docker swarm join-token worker
 
# Показать ссылку для добавления менеджера
docker swarm join-token manager
 
# Список узлов кластера
docker node ls
ID                            HOSTNAME    STATUS    AVAILABILITY   MANAGER STATUS   ENGINE VERSION
ti5tg544h7k6dsw0mnj6uepot *   t-docker2   Ready     Active         Leader           20.10.21

В кластере вы не запускаете контейнеры - вы разворачиваете сервисы, а контейнерами уже занимается сам кластер.

# Развернуть сервис
$ docker service create --name timecheck --replicas 1 diamol/ch12-timecheck:1.0
v02pz59txdtirqyqzr18s4w71
overall progress: 1 out of 1 tasks
1/1: running   [==================================================>]
verify: Service converged
 
# Вывести список сервисов
$ docker service ls
ID             NAME        MODE         REPLICAS   IMAGE                       PORTS
v02pz59txdti   timecheck   replicated   1/1        diamol/ch12-timecheck:1.0
 
# Обновить образ у сервиса timecheck
docker service update --image diamol/ch12-timecheck:2.0 timecheck
 
# Вывести все реплики сервиса timecheck
docker service ps timecheck
 
# Откатиться к предыдущему состоянию
docker service update --rollback timecheck
 
# Логи всех реплик за последние 30 сек
docker service logs --since 30s timecheck
 
# Удалить сервис
docker service rm timecheck

Управление сетью в кластере

Чтобы контейнеры могли обмениваться трафиком, находясь на разных узлах, в кластере создаётся своя абстрактная сеть (overlay network). Сервисы могут обращаться друг к другу внутри этой сети по именам сервисов или по DNS-именам. Разница в подходе между Композом и Свормом заключается в том, что Композ, когда у него несколько реплик контейнера, выдаёт на запрос все адреса и уже клиенту нужно выбирать, куда слать трафик. Сворм создаёт только один виртуальный IP для сервиса, за которым может крутиться множество контейнеров, и система сама решает вопрос балансировки.

$ docker network create --driver overlay iotd-net
gpl2688gwl76xtmo67u1g4xvg
 
$ docker service create --detach --replicas 3 --network iotd-net --name iotd diamol/ch09-image-of-the-day
if4bv9e96evlqm0d3hj35jx7c
 
$ docker service create --detach --replicas 2 --network iotd-net --name accesslog diamol/ch09-access-log
d7pq960ki6gqj1da0f8e34byf
 
$ docker service ls
ID             NAME        MODE         REPLICAS   IMAGE                                 PORTS
d7pq960ki6gq   accesslog   replicated   2/2        diamol/ch09-access-log:latest
if4bv9e96evl   iotd        replicated   3/3        diamol/ch09-image-of-the-day:latest

Балансировка запросов между узлами кластера, на которых запущены контейнеры сервиса и все ноды слушают один и тот же порт при публикации сервиса - это называется ingress networking. Это работает стандартно, когда сервис публикует порты.

# Запуск веб-части image gallery
$ docker service create --detach --replicas 2 --network iotd-net --name image-gallery --publish 8010:80 diamol/ch09-image-gallery
l9oju3hy9qi5qx4hz1ubinp4f
 
$ docker service ls
ID             NAME            MODE         REPLICAS   IMAGE                                 PORTS
d7pq960ki6gq   accesslog       replicated   2/2        diamol/ch09-access-log:latest
l9oju3hy9qi5   image-gallery   replicated   2/2        diamol/ch09-image-gallery:latest      *:8010->80/tcp
if4bv9e96evl   iotd            replicated   3/3        diamol/ch09-image-of-the-day:latest

Здесь видно один порт на несколько контейнеров, что невозможно в Композе.

Выбор между Swarm и k8s

В датацентре удобнее и проще Сворм, в облаке - Кубер. Сворм в целом проще, хотя не имеет настолько больших возможностей, как Кубер, но в большинстве случаев они и не нужны. У Сворма yaml-файлы от 5 до 10 раз меньше по объёму.

Лабораторная

Создать в Сворме приложение Numbers

docker network create --driver overlay numbers-net
docker service create --detach --replicas 2 --network numbers-net --name numbers-api diamol/ch08-numbers-api:v3
docker service create --detach --replicas 2 --network numbers-net --name numbers-web --publish 8010:80 diamol/ch08-numbers-web:v3
 
docker service ls
ID             NAME          MODE         REPLICAS   IMAGE                        PORTS
vr1psfnkhxs8   numbers-api   replicated   2/2        diamol/ch08-numbers-api:v3   
wtruhsgcjrhb   numbers-web   replicated   2/2        diamol/ch08-numbers-web:v3   *:8010->80/tcp
 
docker service rm numbers*
docker network rm numbers-net

13. Развёртывание приложений в кластере Docker Swarm

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

Композ для развёртывания в проде

К примеру, имеется композ-файл:

v1.yml
version: "3.7"
services:
  todo-web:
    image: diamol/ch06-todo-list
    ports:
      - 8080:80

Если развернуть его с помощью Композа на одном сервере, то будет один контейнер, опубликованный на порту 8080. Если развернуть через Сворм, то это будет сервис с одной репликой, использующей ингресс-сеть для опубликованного порта. В Сворм-режиме приложения разворачиваются как стэк, т. е., сущность, объединяющая сервисы, сети, тома и прочие ресурсы.

# Развернуть стэк
docker stack deploy -c ./todo-list/v1.yml todo
Creating network todo_default
Creating service todo_todo-web
# Список стэков
docker stack ls
NAME      SERVICES   ORCHESTRATOR
todo      1          Swarm
# Список развёрнутых сервисов
docker service ls
ID             NAME            MODE         REPLICAS   IMAGE                          PORTS
pb9y6a0dyxbf   todo_todo-web   replicated   1/1        diamol/ch06-todo-list:latest   *:8080->80/tcp

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

Специфичные для Сворма настройки размещаются в разделе deploy сервиса. Здесь указаны 2 реплики и ограничение в пол-ядра и 100 МБ памяти для каждой. Лимиты применяются во время запуска контейнера, поэтому после применения лимитов контейнеры будут пересозданы.

services:
  todo-web:
    image: diamol/ch06-todo-list
    ports:
      - 8080:80
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "0.50"
          memory: 100M

Стэк - это организационная единица для управления приложением в кластере, используя команду stack в Docker CLI. Управлять ресурсами стэка можно без композ-файла, т. к. вся информация хранится у менеджеров кластера.

# список всех сервисов
docker stack services todo
# список всех реплик всех сервисов в стэке
docker stack ps todo
# удалить стэк
docker stack rm todo

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

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

# Создание конфига из локального json-файла
$ docker config create todo-list-config ./todo-list/configs/config.json
xyc2p4bngbacp4zib7a5qo3ah
# Список конфигов
$ docker config ls
ID                          NAME               CREATED         UPDATED
xyc2p4bngbacp4zib7a5qo3ah   todo-list-config   9 seconds ago   9 seconds ago

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

$ docker config inspect  --pretty todo-list-config
ID:                     xyc2p4bngbacp4zib7a5qo3ah
Name:                   todo-list-config
Created at:             2023-04-25 11:13:58.250292261 +0000 utc
Updated at:             2023-04-25 11:13:58.250292261 +0000 utc
Data:
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Database": {
    "Provider": "Postgres"
  }
}

Чтобы сервис использовал конфиг, нужно прописать это в композ-файле. External: true значит, что Композ не будет создавать конфиг в системе и подразумевается, что он будет использовать созданный до этого.

services:
  todo-web:
    image: diamol/ch06-todo-list
    ports:
      - 8080:80
    configs:
      - source: todo-list-config
        target: /app/config/config.json
 
#...

configs:
  todo-list-config:
    external: true

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

docker stack deploy -c ./todo-list/v3.yml todo

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

Управление конфиденциальной информацией с помощью секретов

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

$ docker secret create todo-list-secret ./todo-list/secrets/secrets.json
1hc6k5nt0hy8a6pru6qedonkk
 
$ docker secret inspect --pretty todo-list-secret
ID:              1hc6k5nt0hy8a6pru6qedonkk
Name:              todo-list-secret
Driver:
Created at:        2023-04-25 11:49:38.860226816 +0000 utc
Updated at:        2023-04-25 11:49:38.860226816 +0000 utc

Секрет в композ-файле:

  secrets:
    - source: todo-list-secret
      target: /app/config/secrets.json
 
#...

secrets:
  todo-list-secret:
    external: true

Обновить стэк:

docker stack deploy -c ./todo-list/v4.yml todo

Теперь приложение работает.

Ни конфиги, ни секреты нельзя обновить в Сворме, их можно только заменить. Делается так:

  1. Создаётся новый конфиг/секрет
  2. В композ-файле прописывается новый секрет
  3. Стэк обновляется, используя новый композ-файл

Кубер позволяет обновлять конфиги/секреты, но всё зависит от приложения - одно умеет обновлять конфиг на лету, другое - нет, так что во избежание проблем лучше их пересоздавать всегда.

Хранение постоянных данных в Сворме (volumes)

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

# Вывести идентификатор узла и прописать его как ярлык
docker node update --label-add storage=raid $(docker node ls -q)

Привязка в композ-файле:

services:
  todo-db:
    image: diamol/postgres:11.5
    volumes:
      - todo-db-data:/var/lib/postgresql/data
    deploy:
      placement:
        constraints:
          - node.labels.storage == raid
 
#...

volumes:
  todo-db-data:

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

Как кластер управляет стэками

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

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

14. Автоматизация релизов - обновление и откат

Докер не поддерживает развёртывание стэка из нескольких (перекрывающих) композ-файлов, их надо сначала слепить.

# Слепить файлы, заодно проверить конфиг
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml config > stack.yml
# Развернуть
docker stack deploy -c stack.yml numbers
# Список сервисов стэка
docker stack services numbers
ID             NAME                  MODE         REPLICAS   IMAGE                            PORTS
htqpdr0tfvjw   numbers_numbers-api   replicated   6/6        diamol/ch08-numbers-api:latest
w2veemvl0qxf   numbers_numbers-web   global       1/1        diamol/ch08-numbers-web:latest

Режим global значит, что на каждом узле Сворма запускается по одному экземпляру контейнера, т. е. кол-во экземпляров контейнера будет равно кол-ву узлов кластера. При добавлении узлов в кластер на нём будет автоматически запущен этот контейнер. Фрагмент с настройкой:

numbers-web:
  ports:
    - target: 80
      published: 80
      mode: host
  deploy:
    mode: global

mode: host привязывает 80-й порт непосредственно на узле и не использует ingress-сеть.

Обновим стэк, добавив проверки и заменив образ на v2:

docker-compose.yaml
version: "3.7"

services:
  numbers-api:
    image: diamol/ch08-numbers-api
    networks:
      - app-net

  numbers-web:
    image: diamol/ch08-numbers-web
    environment:
      - RngApi__Url=http://numbers-api/rng
    networks:
      - app-net
prod.yaml
services:
  numbers-api:
    deploy:
      replicas: 6
      resources:
        limits:
          cpus: "0.50"
          memory: 75M

  numbers-web:
    ports:
      - target: 80
        published: 80
        mode: host
    deploy:
      mode: global
      resources:
        limits:
          cpus: "0.75"
          memory: 150M

networks:
  app-net:
    name: numbers-prod
prod-healthcheck.yaml
services:
  numbers-api:
    healthcheck:
      test: ["CMD", "dotnet", "Utilities.HttpCheck.dll", "-u", "http://localhost/health", "-t", "500"]
      interval: 2s
      timeout: 3s
      retries: 2
      start_period: 5s

  numbers-web:
    healthcheck:
      interval: 20s
      timeout: 10s
      retries: 3
      start_period: 30s
v2.yaml
services:
  numbers-api:
    image: diamol/ch08-numbers-api:v2

  numbers-web:
    image: diamol/ch08-numbers-web:v2

Обновить стэк:

docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml -f ./numbers/prod-healthcheck.yml -f ./numbers/v2.yml --log-level ERROR config > stack.yml
docker stack deploy -c stack.yml numbers

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

services:
  numbers-api:
    deploy:
      update_config:
        parallelism: 3 # Кол-во одновременных замен
        monitor: 60s # Период слежения за новыми репликами перед продолжением обновления
        failure_action: rollback # Действие при неудаче после истечения периода monitor
        order: start-first # Порядок замены реплик, здесь новые стартуют сначала, потом удаляются старые (стандартно stop-first)

Есть команда, показывающая в удобном виде спецификацию сервиса, конфигурацию обновления и его последний статус. Здесь видно, что развёртывание, начатое 4 минуты назад, приостановлено, и названа причина.

# Сервис именуется {stack-name}_{service-name}
docker service inspect --pretty numbers_numbers-api
 
ID:             htqpdr0tfvjwey55srp5njmph
Name:           numbers_numbers-api
Labels:
 com.docker.stack.image=diamol/ch08-numbers-api:v2
 com.docker.stack.namespace=numbers
Service Mode:   Replicated
 Replicas:      6
UpdateStatus:
 State:         rollback_paused
 Started:       4 minutes ago
 Message:       update paused due to failure or early termination of task j6qfwyekjvhlto6sh32tdxc45
Placement:
UpdateConfig:
 Parallelism:   1
 On failure:    pause
 Monitoring Period: 5s
 Max failure ratio: 0
 Update order:      stop-first
RollbackConfig:
 Parallelism:   1
 On failure:    pause
 Monitoring Period: 5s
 Max failure ratio: 0
 Rollback order:    stop-first
ContainerSpec:
 Image:         diamol/ch08-numbers-api:v2@sha256:a14fa4b916118e7c7d3ee688193b28c0c00949a6bbc08353cf6e918bf47bb942
Resources:
 Limits:
  CPU:          0.5
  Memory:       75MiB
Networks: numbers-prod
Endpoint Mode:  vip
 Healthcheck:
  Interval = 2s
  Retries = 2
  StartPeriod = 5s
  Timeout =     3s
  Tests:
         Test = CMD
         Test = dotnet
         Test = Utilities.HttpCheck.dll
         Test = -u
         Test = http://localhost/health
         Test = -t
         Test = 500

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

Откат обновления

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

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

Агрессивная политика отката обновления

services:
  numbers-api:
    deploy:
      rollback_config:
        parallelism: 6 # Здесь: все реплики сразу
        monitor: 0s # Note: Setting to 0 will use the default 5s.
        failure_action: continue # Если откат не удался, всё равно продолжить
        order: start-first # Сначала запустить старую версию, потом гасить новую

https://docs.docker.com/compose/compose-file/compose-file-v3/#rollback_config

Обслуживание узлов кластера

Чтобы выгнать контейнеры с узла для его обновления или обслуживания, нужно ввести его в drain mode.

docker node update --availability drain node5

Управляющие узлы в дрейн-моде всё равно выполняют свои функции, т. е., убирается только рабочая нагрузка. Управляющие узлы работают в режиме active-passive, т. е., управляет кластером только один со статусом leader. Если лидер падает, среди оставшихся менеджеров проводятся выборы и лидером становится получивший наибольшее кол-во голосов. Поэтому в кластере нужно иметь нечётное число управляющих узлов - 3 для небольших и 5 для больших кластеров. Если управляющий узел сломался и больше доступен не будет, можно назначить управляющий узел из рабочих для восстановления нечётного числа этих управляющих узлов.

# Повысить рабочий узел до менеджера
docker node promote node6
# Список узлов
docker node ls

Есть 2 способа для ноды выйти из кластера:

  1. С менеджера командой node rm
  2. С самой ноды командой swarm leave (на менеджере нода останется в списке узлов и будет выглядеть как выключенная)

Понизить менеджера до рабочего: node demote.

Рассмотрим некоторые ситуации:

Отказоустойчивость в кластерах Сворма

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

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

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

15. Удалённый доступ и CI/CD

Стандартно Docker API открыт только для локальной машины, но можно открыть доступ извне.

Незащищённый доступ

FIXME Незащищённый доступ использовать в реальной жизни крайне не рекомендуется!

/etc/docker/daemon.json
{
  "hosts": [
  # enable remote access on port 2375:
  "tcp://0.0.0.0:2375",
  # and keep listening on the local channel - Windows pipe:
  "npipe://"
  # OR Linux socket:
  "fd://"
  ],
  "insecure-registries": [
  "registry.local:5000"
  ]
}

Это не работает, пробовал и пример из документации.

docker --host tcp://10.1.0.235:2375 container ls
Cannot connect to the Docker daemon at tcp://10.1.0.235:2375. Is the docker daemon running?

Защищённый доступ, контекст

2 способа: TLS и SSH. SSH проще, т. к. не надо вносить изменения в конфиг Докера и нет возни с сертификатами.

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

# создание
docker context create remote --docker "host=ssh://user@server"
# список контекстов
docker context ls
NAME        DESCRIPTION                               DOCKER ENDPOINT               KUBERNETES ENDPOINT   ORCHESTRATOR
remote                                                ssh://user@server
default *   Current DOCKER_HOST based configuration   unix:///var/run/docker.sock                         swarm

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

# Временно
export DOCKER_CONTEXT='remote'
# Постоянно
docker context use remote

:!: Переменная DOCKER_CONTEXT имеет приоритет перед docker context use, поэтому если переключиться на контекст с помощью переменной и попробовать отменить его командой docker context use, то в текущей сессии останется контекст, заданный переменной. Если переключение между контекстами происходит часто, лучше использовать переменную и не трогать системную настройку.

Модель доступа в Докере

Защита доступа состоит из 2-х частей: шифрование трафика между CLI и API и аутентификация. Авторизации нет: если клиент подключился к API, он может делать всё, что угодно. Docker Enterprise и k8s имеют ролевую модель, но есть подход GitOps, когда к кластеру никто не подключается, но он сам следит за появлением новых образов в реестре и развёртывает их.

16. Сборка образов для разных архитектур (Linux, Windows, Intel, Arm)

17. Оптимизация образов по размеру, скорости и безопасности

✔ Докер не удаляет старые слои после скачивания новых - это нужно делать руками. Хорошей практикой является регулярная очистка.

# Оценка занимаемого места
docker system df
# Очистка старых слоёв и кэша
docker system prune

✔ В образ нужно копировать только то, что необходимо для запуска приложения. Например, вместо COPY . . сделать COPY ./app ./app. Удалять скопированные файлы, например, RUN rm -rf docs в Dockerfile бесполезно, т. к. из-за слоистой структуры образа файлы просто будут скрыты и образ останется того же размера.

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

# Запуск без .dockerignore
docker image build -t diamol/ch17-build-context:v3 -f ./Dockerfile.v3 .
Sending build context to Docker daemon  2.104MB
# После добавления .dockerignore с содержимым docs/
docker image build -t diamol/ch17-build-context:v3 -f ./Dockerfile.v3 .
Sending build context to Docker daemon  4.608kB

✔ Надо выбирать базовый образ с минимальным набором необходимых функций, чтобы уменьшить размер образа и поверхность атаки. По возможности рассматривать варианты на базе Alpine/Debian Slim.

✔ Контроль совместно устанавливаемых «рекомендуемых» пакетов + сокращение кол-ва слоёв.

FROM debian:stretch-slim
RUN apt-get update
RUN apt-get install -y curl=7.52.1-5+deb9u9
RUN apt-get install -y socat=1.7.3.1-2+deb9u1
 
# -20 МБ
FROM debian:stretch-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl=7.52.1-5+deb9u9 \
socat=1.7.3.1-2+deb9u1 \
&& rm -rf /var/lib/apt/lists/*

✔ Распаковка только тех файлов из внешних архивов, которые действительно нужны.

Большой размер образа

FROM diamol/base
 
ARG DATASET_URL=https://archive.ics.uci.edu/ml/machine-learning-databases/url/url_svmlight.tar.gz
 
WORKDIR /dataset
 
RUN wget -O dataset.tar.gz ${DATASET_URL} && \
    tar xvzf dataset.tar.gz
 
WORKDIR /dataset/url_svmlight
RUN cp Day1.svm Day1.bak && \
    rm -f *.svm && \
    mv Day1.bak Day1.svm

Маленький размер образа

FROM diamol/base
 
ARG DATASET_URL=https://archive.ics.uci.edu/ml/machine-learning-databases/url/url_svmlight.tar.gz
 
WORKDIR /dataset
 
RUN wget -O dataset.tar.gz ${DATASET_URL} && \
    tar -xf dataset.tar.gz url_svmlight/Day1.svm && \
    rm -f dataset.tar.gz

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

FROM diamol/base AS download
ARG DATASET_URL=https://archive.ics.uci.edu/.../url_svmlight.tar.gz
RUN wget -O dataset.tar.gz ${DATASET_URL}
 
FROM diamol/base AS expand
COPY --from=download dataset.tar.gz .
RUN tar xvzf dataset.tar.gz
 
FROM diamol/base
WORKDIR /dataset/url_svmlight
COPY --from=expand url_svmlight/Day1.svm .

Поэтапный докерфайл можно останавливать на любом этапе:

docker image build -t diamol/ch17-ml-dataset:v3 -f Dockerfile.v3 . # ~24 MB
docker image build -t diamol/ch17-ml-dataset:v3-expand -f Dockerfile.v3 --target expand . # ~2.4 GB
docker image build -t diamol/ch17-ml-dataset:v3-download -f Dockerfile.v3 --target download . # ~250 MB
 
# Размеры образов
docker image ls -f reference=diamol/ch17-ml-dataset:v3*

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

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

✔ Сокращение времени сборки. В поэтапном докерфайле самые редко меняющиеся части надо помещать в начало (опубликованные порты, переменные окружения, ENTRYPOINT), а самые часто меняющиеся - в конец (бинарники, конфиги).

18. Управление конфигурацией приложения в контейнерах

Многоуровневый подход

3 типа конфигурации:

  1. По релизу: настройки одинаковые во всех окружениях
  2. По окружению: настройки разнятся в зависимости от окружения
  3. По функциям: изменение поведения приложения от релиза к релизу

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

# default config
docker container run -d -p 8080:80 diamol/ch18-access-log
# loading a local config file override
docker container run -d -p 8081:80 -v "$(pwd)/config/dev:/app/config-override" diamol/ch18-access-log
# override file + environment variable, которая должна быть в формате JSON (здесь: переменная не срабатывает)
docker container run -d -p 8082:80 -v "$(pwd)/config/dev:/app/config-override" -e NODE_CONFIG='{\"metrics\": {\"enabled\":\true\"}}' diamol/ch18-access-log
 
# check the config APIs in each container:
curl http://localhost:8080/config
{"release":"19.12","environment":"UNKNOWN","metricsEnabled":true}
curl http://localhost:8081/config
{"release":"19.12","environment":"DEV","metricsEnabled":false}
curl http://localhost:8082/config
{"release":"19.12","environment":"DEV","metricsEnabled":true} # в реальности metricsEnabled остаётся false

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

Помещение всех конфигураций в образ

Можно зашивать в образ конфиги cразу для всех окружений, а потом переменной извне задавать, какой использовать. Здесь пример для .NET.

# Стандартно appsettings.json сливается с appsettings.Development.json (проверка - http://server:8083/diagnostics)
docker container run -d -p 8083:80 diamol/ch18-todo-list
# А здесь appsettings.json сливается с appsettings.Test.json (проверка - http://server:8084/diagnostics)
docker container run -d -p 8084:80 -e DOTNET_ENVIRONMENT=Test diamol/ch18-todo-list
# А здесь appsettings.json сливается с appsettings.Production.json, а ещё с файлом prod-local/local.json (проверка - http://server:8085/diagnostics)
# local.json указывает использовать локальный файл БД вместо сервера БД для диагностики ошибок
docker container run -d -p 8085:80 -e DOTNET_ENVIRONMENT=Production -v "$(pwd)/config/prod-local:/app/config-override" diamol/ch18-todo-list
# + имя релиза (проверка - http://server:8086/diagnostics)
docker container run -d -p 8086:80 -e DOTNET_ENVIRONMENT=Production -e release=CUSTOM -v "$(pwd)/config/prod-local:/app/config-override" diamol/ch18-todo-list

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

Загрузка конфигурации из среды выполнения

Подобно .NET Core libraries или node-config, у Go есть модуль Viper, позволяющий управлять конфигурациями. Viper поддерживает JSON/YAML/TOML. TOML Наиболее удобен для конфигов.

TOML, зашитый в образ

release = "19.12"
environment = "UNKNOWN"
 
[metrics]
enabled = true
 
[apis]
 
  [apis.image]
  url = "http://iotd/image"
 
  [apis.access]
  url = "http://accesslog/access-log"

Монтируемый TOML

environment = "DEV"
 
[metrics]
enabled = false
# Значения по умолчанию, зашитые в образ (проверка - http://server:8086/config)
docker container run -d -p 8086:80 diamol/ch18-image-gallery
{"Release":"19.12","Environment":"UNKNOWN","Metrics":{"Enabled":true},"Apis":{"access":{"Url":"http://accesslog/access-log"},"image":{"Url":"http://iotd/image"}}}
# Монтирование доп. конфига (проверка - http://server:8087/config)
docker container run -d -p 8087:80 -v "$(pwd)/config/dev:/app/config-override" diamol/ch18-image-gallery
{"Release":"19.12","Environment":"DEV","Metrics":{"Enabled":false},"Apis":{"access":{"Url":"http://accesslog/access-log"},"image":{"Url":"http://iotd/image"}}}

Настройка старых приложений

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

  1. Прочесть из специального файла перекрывающие настройки
  2. Прочитать перекрывающие настройки из переменных окружения
  3. Слить полученные перекрывающие настройки воедино с приоритетом переменных
  4. Результат записать в файл в контейнере
# Настройки по умолчанию
docker container run -d -p 8089:80 diamol/ch18-image-of-the-day
{"release":"19.12","environment":"UNKNOWN","managementEndpoints":"health,info,prometheus","apodUrl":"https://api.nasa.gov/planetary/apod?api_key="}
# Монтируется файл настройки и переменной указывается его расположение в контейнере
docker container run -d -p 8090:80 -v "$(pwd)/config/dev:/config-override" -e CONFIG_SOURCE_PATH="/config-override/application.properties" diamol/ch18-image-of-the-day
{"release":"19.12","environment":"DEV","managementEndpoints":"health","apodUrl":"https://api.nasa.gov/planetary/apod?api_key="}

В это время в Докерфайле задаётся переменная с путём:

FROM diamol/maven AS builder
# ...
RUN mvn package
# config util
FROM diamol/maven as utility-builder
WORKDIR /usr/src/utilities
COPY ./src/utilities/ConfigLoader.java .
RUN javac ConfigLoader.java
 
# app
FROM diamol/openjdk
 
ENV CONFIG_SOURCE_PATH="" \
    CONFIG_TARGET_PATH="/app/config/application.properties"
 
CMD java ConfigLoader && \
    java -jar /app/iotd-service-0.1.0.jar
 
WORKDIR /app
COPY --from=utility-builder /usr/src/utilities/ConfigLoader.class .
COPY --from=builder /usr/src/iotd/target/iotd-service-0.1.0.jar .