Docker: трюки и хитрости

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

Начинающие специалисты очень часто забывают об этом простом и удобном способе уменьшить размер конечного образа. По своему опыту могу сказать, что обычно мои .dockerignore отличаются от .gitignore только тем что в .dockerignore имеется строка вида /.git/, то есть сказано чтобы директория с Git-индексом не попала в контейнер.

Пример простого .dockerignore:

.git
node_modules
*.log

Docker позволяет использовать хелсчеки (health checks) для проверки состояния контейнера. Если контейнер работает некорректно, Docker может автоматически перезапустить его.

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

docker run -d \
--name autoheal \
--restart=always \
-e AUTOHEAL_CONTAINER_LABEL=autoheal \
-v /var/run/docker.sock:/var/run/docker.sock \
willfarrell/autoheal

Добавим пример использования хелсчеков и контейнера autoheal в файл docker-compose.yml. Для этого мы определим сервисы для нашего приложения и контейнера autoheal.

Пример docker-compose.yml: https://gist.github.com/EvilFreelancer/79841eccc471fa1c268705a6126d5af6

В этом примере мы определили сервис app с нашим приложением и добавили хелсчек для проверки его состояния. Обратите внимание на параметры healthcheck, которые указывают Docker проверять состояние контейнера с помощью curl каждую минуту. Если проверка не пройдет успешно три раза подряд, контейнер будет считаться не работающим.

Мы также добавили метку autoheal=true к сервису app, чтобы контейнер autoheal мог обнаружить его и следить за его состоянием. Сервис autoheal использует образ willfarrell/autoheal и монтирует Docker сокет для доступа к API Docker. Он периодически проверяет состояние всех контейнеров с меткой autoheal=true и перезапускает их, если они находятся в состоянии «unhealthy», то есть нездоров.

Теперь, запустив приложение с помощью docker-compose up -d, вы будете иметь автоматическое восстановление для вашего приложения, если оно станет недоступным или перестанет функционировать корректно.

Кстати, можно встроить команду хелсчека в Dockerfile, сделать это можно при помощи директивы HEALTHCHECK, например так:

FROM node:14

WORKDIR /app
COPY . .
RUN npm install
CMD [ "npm", "start" ]

HEALTHCHECK --interval=1m --timeout=3s CMD curl -f http://localhost:3000/ || exit 1

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

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

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

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

Вот так можно смонтировать папку хоста:

docker run -d -p 8080:80 -v /host/data:/container/data my-image

А вот так том:

docker volume create my-volume
docker run -d -p 8080:80 -v my-volume:/container/data my-image

Ещё одной любопытной особенность Docker Volumes является возможность монтировать файлы, к примеру у вас есть файл конфигурации config.ini и вы хотите положить его в папку /app.

docker run -d -p 8080:80 -v ./config.ini:/app/config.ini my-image

Но если файла ./config.ini нет, то Docker Engine создаст на его месте пустую директорию, так что будьте внимательны.

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

Docker Compose поддерживает использование переменных окружения и шаблонов в файлах docker-compose.yml. Это позволяет создавать универсальные конфигурации, которые можно настраивать с помощью переменных окружения.

Пример docker-compose.yml файла с использованием переменных окружения, которые находятся в файле .env (который находится на одном уровне с docker-compose.yml) или экспортированы в оболочку: https://gist.github.com/EvilFreelancer/45510caafdf8781c7eb50ea1788f0cf7

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

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

Пример docker-compose.yml с использованием YML-шаблонов: https://gist.github.com/EvilFreelancer/5af683e17dc7111ebccd92603254014d

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

Docker Compose создает изолированную сеть для приложений, описанных в docker-compose.yml. Все сервисы, определенные в этом файле, работают в рамках этой сети и имеют доступ друг к другу по имени сервиса. Это облегчает настройку взаимодействия между сервисами без необходимости указывать IP-адреса и порты.

Для определения пользовательских сетей и более точной настройки взаимодействия между сервисами, можно использовать ключ networks в файле docker-compose.yml.

В примере ниже создается приложение с тремя сервисами: frontend, backend, и database. Мы создадим две сети: backend-network и frontend-network. Сервисы backend и database будут подключены к backend-network, а сервис frontend - к frontend-network. Таким образом, доступ к базе данных будет закрыт со стороны frontend.

Файл docker-compose.yml: https://gist.github.com/EvilFreelancer/18fca5fed1b0cd4c384b1235b4af6e82

В этом примере, сервис frontend не сможет напрямую подключиться к сервису database, так как они находятся в разных сетях. Однако сервис backend будет иметь доступ к обоим сетям и сможет общаться с обоими сервисами - frontend и database.

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

Docker позволяет ограничивать использование процессорного времени и памяти контейнерами. Чтобы ограничить использование ресурсов, используйте параметры –cpus, –cpu-shares, –memory и –memory-swap при запуске контейнера.

Пример ограничения ресурсов:

docker run -d --cpus=2 --memory=1g --memory-swap=2g my-image

Аналогичные настройки можно использовать и в docker-compose.yml, но есть одна небольшая проблема, начиная со схемы Docker Compose версии 2.4 данные опции можно применять только на Docker Swarm, однако, поскольку Swarm помер так и не успев родиться (Kubernetes передаёт привет) использовать его не имеет никакого смысла.

Поэтому вот пример docker-compose.yml со схемой «2.4»: https://gist.github.com/EvilFreelancer/9a2077b8613216b86e25cabe1756d137

В этом примере:

  • cpus: 0.5 ограничивает сервис web на использование 50% от одного процессорного ядра.
  • cpu_shares: 73 задает вес сервиса web для распределения процессорного времени относительно других контейнеров. Чем выше значение, тем больше процессорного времени будет выделено.
  • cpu_quota: 50000 ограничивает сервис web на использование 50 000 микросекунд процессорного времени каждый cpu_period.
  • cpu_period: 20000 задает период времени в микросекундах, в течение которого сервис web будет ограничен использованием процессорного времени в соответствии с cpu_quota.
  • cpuset: «0,1» указывает, что сервис web может использовать только ядра 0 и 1.
  • mem_limit: 1024m ограничивает использование памяти сервисом web до 1024 мегабайт.
  • memswap_limit: 2048m ограничивает общее использование памяти, включая swap, сервисом web до 2048 мегабайт.
  • mem_reservation: 512m задает минимальное количество памяти, которое должно быть доступно для сервиса web.
  • cpu_count: 2 указывает, что сервис database может использовать до 2 ядер процессора.
  • cpu_percent: 50 ограничивает сервис database на использование 50% от двух процессорных ядер.

А вот тут можете почитать подробности.

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

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

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

Так же стоит команды копирования в одну и ту же директорию собирать в один шаг.

Пример Dockerfile без оптимизации количества слоев:

FROM node:14

# Создание каталога приложения
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# Установка зависимостей
COPY package.json /usr/src/app/
COPY package-lock.json /usr/src/app/
RUN npm install
RUN npm cache clean --force

# Копирование исходного кода приложения
COPY . /usr/src/app

EXPOSE 3000
CMD [ "npm", "start" ]

Пример Dockerfile с оптимизацией количества слоев:

FROM node:14
# Создание каталога приложения и установка зависимостей
WORKDIR /usr/src/app
COPY package*.json ./
# Или например вот так
# COPY ["./package.json", "./package-lock.json", "./"]
RUN npm install && npm cache clean --force

# Копирование исходного кода приложения
COPY . .

EXPOSE 3000
CMD [ "npm", "start" ]

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

Пример Dockerfile с многоступенчатой сборкой для приложения на Node.js:

# Стадия - сборка
FROM node:14 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Стадия - финальный образ
FROM node:14-alpine
WORKDIR /app
COPY --from=build /app/package*.json ./
RUN npm install --production
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD [ "npm", "start" ]

А вот ещё один пример для приложения на PHP:

# Стадия сборки
FROM composer:latest AS composer
COPY . /app
WORKDIR /app
RUN composer install

# Стадия запуска
FROM php:8.2-apache
COPY --from=composer /app /var/www/html
COPY ./apache-config.conf /etc/apache2/sites-enabled/000-default.conf
EXPOSE 8080
RUN a2enmod rewrite

Подробнее про multistage build читайте в официальной документации Docker.

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

Обычно данную процедуру настраивают на CI/CD системах, чтобы после завершения сборки происходило «обрезание», ну или шринк.

Приложения, написанные на языках Go и C(++), можно собирать в самодостаточные бинарные файлы, которые не требуют внешних зависимостей или среды выполнения. В таких случаях можно использовать базовый образ FROM scratch для создания минимального и небольшого образа Docker.

Пример Dockerfile для приложения на Go:

# Стадия сборки
FROM golang:1.16 AS build

WORKDIR /src
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o /app

# Стадия продакшена с использованием базового образа scratch
FROM scratch

COPY --from=build /app /app

ENTRYPOINT [ "/app" ]

Docker-in-Docker (DinD) - это концепция запуска одного или нескольких Docker контейнеров внутри другого Docker контейнера. Это может быть полезно в определенных сценариях, таких как тестирование и настройка CI/CD пайплайнов.

DinD может быть полезен в следующих ситуациях:

  • Тестирование Docker плагинов и интеграций с другими системами.
  • Изоляция Docker демона для снижения риска и избежания воздействия на хост-систему.
  • Создание CI/CD пайплайнов, которые требуют работы с Docker образами и контейнерами.

Чтобы запустить контейнер Docker внутри другого контейнера, выполните следующие шаги:

Запустите основной контейнер с привилегированными правами и смонтируйте Docker сокет:

docker run -it --privileged -v /var/run/docker.sock:/var/run/docker.sock docker:dind

Внутри контейнера установите Docker CLI и выполните команды Docker:

apk add docker-cli
docker ps

Если вы видете списко контейнеров запущенных в хостовой системе, значит всё настроено верно.

Docker-in-Docker может иметь некоторые недостатки, такие как проблемы с безопасностью и сложность управления ресурсами. Вместо использования DinD, вы можете рассмотреть следующие альтернативы:

  • Docker-outside-of-Docker** (DooD) - при использованни данного подхода вы смонтируете Docker сокет из хост-системы в контейнер. Это позволит контейнеру выполнять команды Docker, управляя контейнерами и образами на хост-системе.
  • Kaniko** - это инструмент от Google, который позволяет собирать Docker образы без использования Docker демона. Это может быть полезно для CI/CD пайплайнов, где требуется только сборка образов.
  • BuildKit**- это инструмент для сборки образов, разработанный Docker. Он предоставляет расширяемый и эффективный фреймворк для сборки образов без необходимости запускать Docker демон.

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

Docker может быть полезен для автоматизации процессов непрерывной интеграции (CI) и непрерывной доставки (CD). Вместе с инструментами, такими как Jenkins, GitLab CI, и GitHub Actions, вы можете автоматически собирать, тестировать и развертывать приложения на разных платформах и средах.

В процессе работы с Docker на вашей машине могут накапливаться старые образы, контейнеры и тома. Чтобы освободить дисковое пространство и улучшить производительность, регулярно удаляйте неиспользуемые ресурсы с помощью команд docker system prune, docker image prune, docker container prune и docker volume prune.

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

Docker предоставляет встроенные средства для просмотра логов контейнеров. Вы можете использовать команду docker logs [container-id] для просмотра логов контейнера. Чтобы следить за логами в реальном времени, используйте флаг -f или –follow:

docker logs -f [container-id]

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

  • json-file (по умолчанию)
  • syslog
  • journald
  • gelf (Graylog Extended Log Format)
  • fluentd
  • awslogs (Amazon CloudWatch Logs)
  • splunk

Для выбора драйвера логирования используйте опцию –log-driver при запуске контейнера или укажите его в файле docker-compose.yml: https://gist.github.com/EvilFreelancer/be52bbd3187558105753d155ddc66638

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

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

Популярные решения для централизованного логирования включают:

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

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

Например, чтобы настроить ротацию логов для контейнера с ограничением на максимальный размер лог-файла в 10 МБ и максимальное количество лог-файлов в 3, используйте следующие опции при запуске контейнера:

docker run --log-opt max-size=10m --log-opt max-file=3 my-image

В случае использования docker-compose, укажите параметры ротации логов в файле docker-compose.yml: https://gist.github.com/EvilFreelancer/f0178d47ea3b8d64446bd92a89bedb8a

Подробности тут.

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

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

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

Переменные окружения можно передавать контейнерам во время их запуска, используя опцию -e с командой docker run:

docker run -e VARIABLE_NAME=value my-image

Для docker-compose вы можете определить переменные окружения в файле docker-compose.yml: https://gist.github.com/EvilFreelancer/ec50d3156cbbcad8516798f12e33db72

Также можно использовать файл .env для хранения переменных окружения и ссылаться на него в docker-compose.yml: https://gist.github.com/EvilFreelancer/30f7e15d39dd7f18c369e28d955cddd5

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

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