# Клон учебного репозитория 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)
# Интерактивно зайти внутрь контейнера (-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)
# Скачать образ из реестра по умолчанию (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 - это скрипт для запаковки приложения в контейнер.
# Базовый образ 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
Внутри докерфайла можно запускать команды. Команды выполняются во время сборки, и изменения файловой системы в результате их выполнения сохраняются как слои. Это делает докерфайлы невероятно гибким форматом упаковки - можно распаковывать zip-файлы, запускать установщики Windows и т. д. Рассмотрим вариант упаковки приложений из исходного кода. Очень удобно написать докерфайл, который ставит все утилиты/зависимости/инструменты и встраивает их в образ. Затем этот образ используется для компиляции приложения.
Это «поэтапный» докерфайл (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-го этапа и выводит его содержимое.
Каждый этап изолирован. Можно использовать разные базовые образы с разным набором инструментов и запускать какие угодно команды. Если на каком-то этапе возникает ошибка, вся сборка терпит неудачу.
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, они все остались на первом этапе.
Это другой подход к сборке, подобный 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 компилируется в исполняемый бинарник, не требующий для запуска установленной среды выполнения (.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 или какой-то облачный - везде это будет работать одинаково.
Итак, ключевые особенности такого подхода:
От сборки и запуска образов переходим к выкладыванию их в общий доступ. По умолчанию в Докере прописан реестр 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) реестрам запрещено.
{ "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://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
Каждый контейнер имеет свою изолированную файловую систему, которая при остановке контейнера автоматически не удаляется. Вот так можно вытащить файлы из контейнера, даже остановленного, на локальную машину:
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
В общем, монтировать можно всё, к чему имеет доступ хост - это могут быть отказоустойчивые или распределённые хранилища, тем не менее, есть ограничения использования привязок.
docker container run --mount type=bind,source="$(pwd)/new",target=/init diamol/ch06-bind-mount
docker container run --mount type=bind,source="$(pwd)/new/123.txt",target=/init/123.txt diamol/ch06-bind-mount
Каждый контейнер содержит виртуальный диск, собранный из нескольких источников, это т. н. единая файловая система (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
Возможность указать желаемую конфигурацию в едином файле.
Пример секции 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
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.yml фактически является описанием конфигурации системы, который довольно легко читается. Он всегда актуален и нет необходимости создавать текстовые файлы с описанием.
Позволяет создавать мультиконтейнерные сервисы в рамках одного хоста. Тем не менее, он не следит за состоянием контейнеров, как оркестраторы - если контейнер упал или был удалён, то docker-compose ничего сделать не сможет, надо руками перезапускать конфигурацию. Тем не менее, это не значит, что он не подходит для пром. использования - он хорош для начала, когда идёт миграция с ВМ или железа. Докер-композ не даёт HA и NLB, но этого не было и на ВМ и железке.
В продуктивной среде используется 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 у 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%, но, во всяком случае, оно уже будет работать. Идея самовосстанавливающихся приложений - это переложить работу с временными ошибками на плечи платформы. Например, если приложение кривое и вызывает переполнение памяти, то платформа заменит контейнер новым. Это не исправит причину, но приложение хотя бы будет работать.
С проверками не надо переусердствовать.
Проверки здоровья запускаются периодически, и они не должны выполнять слишком много работы. Лучше всего, если проверяться будут ключевые компоненты приложения, без того, чтобы занимать этим много времени и вычислительных ресурсов.
Проверки зависимостей запускаются только во время старта контейнера и экономия вычислительных ресурсов здесь неактуальна, но необходимо быть внимательным к тому, что проверяется. Некоторые зависимости находятся вне нашего контроля, и если платформа не умеет исправлять проблемы, то она не поможет, если контейнер упадёт.
Контроль и возможность обзора работы приложений очень важен, без него нельзя переводить приложение в продуктивную среду. Ниже будут рассмотрены 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. Это мощный язык запросов, но необязательно сильно погружаться в него, чтобы настроить хорошо структурированную панель мониторинга, чаще всего достаточно простых запросов и фильтрации ярлыков.
Интерфейс Прометея хорош для проверки конфигурации, доступности всех объектов мониторинга и отработки запросов, но это не графическая панель.
Гугл в книжке Site Reliability Engineering описывает т. н. «золотые сигналы» для мониторинга сайтов - это задержка (latency), трафик (traffic), ошибки (errors) и насыщенность (saturation).
// Можно вместо 200 поставить 500, тогда будет показывать кол-во сбойных запросов sum(image_gallery_requests_total{code="200"}) without(instance)
// это по всем контейнерам sum(image_gallery_in_flight_requests) without(instance)
go_memstats_stack_inuse_bytes{job="image-gallery"}
sum(go_goroutines{job=\"image-gallery\"}) without(instance)
Остальные показатели используют примерно такие же несложные запросы. Не нужно сильно заморачиваться с 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) - важнейшее требования для продуктивной системы. Необходимо несколько панелей наблюдения - инфраструктурная (загрузка серверов - проц, память, диск и т. д.), по каждому компоненту, и суммарная панель, которая даёт общую картину и позволяет оперативно отреагировать, если что-то начинает идти не так.
Надо написать конфиг для Прометея и собрать образ:
global: scrape_interval: 10s scrape_configs: - job_name: 'todo' metrics_path: /metrics static_configs: - targets: ['todo']
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
, собирается образ
FROM diamol/ch09-grafana COPY dashboard.json /var/lib/grafana/dashboards/
# Сборка docker image build -t my-grafana -f grafana/Dockerfile grafana/
Потом в 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
Нужно, когда одна версия продуктивная, другая тестовая, третья в разработке и т. д. Просто так запустить два раза приложение не выйдет, потому что Докер-композ думает, что вы запускаете приложение, которое уже и так запущено:
$ 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
Чтобы сделать ситуацию более управляемой, можно, конечно, дублировать докер-композ-файлы и вносить туда изменения, но имеется более удобный способ.
При простом дублировании и редактировании файлов их содержимое будет практически одинаковым, что неудобно для редактирования и создаёт путаницу и рассинхронизацию конфигураций.
Докер-композ позволяет создавать перекрывающие файлы, где будут отражены только изменения базовой конфигурации. К примеру, в основном файле настроено только то, что используется везде, для среды разработки добавляется сеть и доп. сервисы, а для теста - другие сервисы, сеть и добавляются тома.
В этом случае для изменения какой-то среды нужно редактировать только один файл, а если нужно изменить что-то для всех окружений - отредактировать основной файл.
# 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
Основной файл: описывает только сервисы без портов и сетей.
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:
Среда разработки: порты и сеть, без проверок.
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 остаётся внутренним и не публикуется.
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, авторестарт сервисов, более строгая проверка.
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 параметра:
Конфигурация приложения в виде секрета.
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
и т. п.)
Приоритет переменных в зависимости от места их вызова
Поле расширения - это блок файла 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).
Очень хорошо иметь всю конфигурацию для деплоя в гите - это позволяет развернуть любую версию приложения, взяв исходники и запустив скрипты развёртывания. Это также позволяет разработчикам быстро приступить к работе над исправлениями, запустив приложение локально и воспроизведя ошибку у себя.
Между окружениями всегда существуют различия, и Докер-композ даёт возможность их настраивать. Эти различия сосредоточены в трёх ключевых областях:
Важно, что для всех окружений используется одни и те же образы, которые собираются, проходят автоматические тесты и им проставляется версия. По мере дальнейшего тестирования они продвигаются по окружениям, и в конечном итоге то, что попадает в прод, является теми же образами, прошедшими все тесты.
Создать два окружения:
version: '3.7' services: todo-web: image: ${IMAGE_WEB} ports: - ${PORT_WEB}:80 networks: - network networks: network: name: ${NETWORK}
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
services: todo-web: depends_on: - todo-db todo-db: image: ${IMAGE_DB} networks: - network volumes: - todo-db:${PGDATA} volumes: todo-db:
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
Докер унифицирует 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
и дальше в конце будет прибавляться номер сборки.
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
Перекрывающий файл: где искать докерфайлы.
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, которая собрала его, а оттуда перейти на версию кода, который запустил задачу. Это путь аудита от контейнера к исходному коду.
Jenkins в репозитории устарел и сломан.
# Включить режим 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
Здесь видно один порт на несколько контейнеров, что невозможно в Композе.
В датацентре удобнее и проще Сворм, в облаке - Кубер. Сворм в целом проще, хотя не имеет настолько больших возможностей, как Кубер, но в большинстве случаев они и не нужны. У Сворма 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
До этого команды запуска были императивными, но основной подход - это декларации, т. е. применение YAML-файлов, использующих синтаксис Композа. Тем не менее, имеются некоторые различия в подходе к Сворм-режиму и к обычному Композу.
К примеру, имеется композ-файл:
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
Теперь приложение работает.
Ни конфиги, ни секреты нельзя обновить в Сворме, их можно только заменить. Делается так:
Кубер позволяет обновлять конфиги/секреты, но всё зависит от приложения - одно умеет обновлять конфиг на лету, другое - нет, так что во избежание проблем лучше их пересоздавать всегда.
В кластере контейнер может запуститься на разных нодах, поэтому локальный том имеет ограниченное применение. Самый простой случай, когда не учитывается возможность сбоя ноды и не нужно запускать несколько экземпляров контейнера - это присвоение ярлыка, который привязывает контейнер к ноде.
# Вывести идентификатор узла и прописать его как ярлык 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:
Это самый простой пример, но если требуется общее хранилище для всех нод, тут сложнее. У Докера есть плагины для разных систем хранения, и Сворм может быть настроен соответствующим образом. Настройка может быть различной для разных типов томов, но в любом случае тома подключаются к сервисам.
Стэк - это группа ресурсов в кластере, которыми он управляет. Подход к управлению различается в зависимости от типа ресурса.
Между сервисами нельзя создать зависимость, т. е., чтобы один запускался только после другого, потому что это бы сильно замедлило бы развёртывание. Вместо этого нужно делать проверки здоровья и зависимостей в образах, тогда кластер сможет сам перезапускать или заменять контейнеры, делая приложение самовосстанавливающимся.
Докер не поддерживает развёртывание стэка из нескольких (перекрывающих) композ-файлов, их надо сначала слепить.
# Слепить файлы, заодно проверить конфиг 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:
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
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
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
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 способа для ноды выйти из кластера:
node rm
swarm leave
(на менеджере нода останется в списке узлов и будет выглядеть как выключенная)
Понизить менеджера до рабочего: node demote
.
Рассмотрим некоторые ситуации:
swarm init –force-new-cluster
, сохраняя всю информацию кластера и работающие задания. Затем нужно добавить менеджеров для восстановления отказоустойчивости.service update –force
, не меняя никаких других параметров.Есть несколько слоёв внедрения приложения с точки зрения отказоустойчивости, которые были рассмотрены:
Остался вопрос отказоустойчивости самого датацентра. Вариант разнесения датацентров кластера по разным геолокациям хорош с точки зрения простоты управления, но имеет проблемы, связанную с сетевой задержкой. Узлы кластера довольно много обмениваются информацией, поэтому задержки могут приводить к тому, что кластер подумает, что какие-то узлы выключились и начать перераспределять нагрузку на другие узлы. Также можно получить split-brain, когда группы менеджеров в разных локациях выберут своих лидеров.
Лучше иметь несколько кластеров, что повлечёт увеличение сложности управления инфраструктурой, но зато не будет проблем с сетевой задержкой. Кластеры будут идентичными по конфигурации, а пользователи будут попадать на сервис с помощью внешнего DNS, направляющего запрос на ближайший расположенный к ним кластер.
Стандартно Docker API открыт только для локальной машины, но можно открыть доступ извне.
Незащищённый доступ использовать в реальной жизни крайне не рекомендуется!
{ "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, когда к кластеру никто не подключается, но он сам следит за появлением новых образов в реестре и развёртывает их.
✔ Докер не удаляет старые слои после скачивания новых - это нужно делать руками. Хорошей практикой является регулярная очистка.
# Оценка занимаемого места 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), а самые часто меняющиеся - в конец (бинарники, конфиги).
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"}}}
Чтобы заставить старое приложение, которое не умеет читать перекрывающие файлы и не учитывает переменные окружения, вести себя как новое, нужно предпринять дополнительные усилия.
# Настройки по умолчанию 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 .