Как правило образы в Docker имеют одну инструкцию FROM, которая задает образ, на основе которого будет собран данный образ
Однако Docker также позволяет применять несколько инструкций FROM в одном Dockerfile - такая техника называется многоэтапной сборкой (multi-stage build). Каждая такая инструкция FROM создает отдельный этап (stage), который может выполнять свою задачу, например, установку зависимостей, компиляцию, тестирование или подготовку финального образа. Суть применения состоит в том, чтобы собирать приложение в тяжелом образе с инструментами, а затем запускать в легком, куда копируются только готовые артефакты
Пусть есть такое приложение main.c на языке C:
#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}
Тогда для компиляции и запуска его в контейнере можно воспользоваться многоэтапной сборкой:
# 1-ый этап - сборка с помощью GCC
FROM gcc:13 AS builder
# Здесь этому этапу назначено имя `builder`
WORKDIR /src
COPY main.c .
RUN gcc -O2 -static -o hello main.c
# Здесь используем флаг `-static`, чтобы итоговый
# исполняемый файл содержал в себе нужные библиотеки
# 2-ой этап - запуск на основе чистого образа
FROM scratch
# Копируем файл `hello` из этапа `builder`
COPY --from=builder /src/hello /hello
ENTRYPOINT ["/hello"]
Далее образ можно собрать с помощью команды docker build -t hello-image .
Без инструкций FROM scratch и COPY --from=builder /src/hello /hello образ имеет объем около 1.96 гигабайт в несжатом виде, тогда как с этими инструкциями образ занимает примерно 1.39 мегабайт, так как не содержит компилятор GCC, библиотеки и другое, которые нецелесообразно хранить в этом образе для запуска исполняемого файла
Другим применением многоэтапной сборки помимо компиляции бэкенд-приложения может быть сборка фронтенд-приложения
# 1-ый этап
FROM node:18-alpine AS build
WORKDIR /app
# Настройка пакетного менеджера NodeJS
RUN npm config set registry https://registry.npmmirror.com
# Копируем package.json и устанавливаем зависимости
COPY package*.json ./
RUN npm install --legacy-peer-deps
# Копируем исходный код
COPY . .
# Собираем приложение
RUN npm run build
# 2-ой этап - запуск Nginx
FROM nginx:alpine
# Копируем собранные статические файлы
COPY --from=build /app/build /usr/share/nginx/html
# Копируем конфигурацию Nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Запуск
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Здесь на первом этапе загружаются пакеты из пакетного менеджера NodeJS (NPM), с помощью которых собираются итоговые статические файлы фронтенда, а затем на втором этапе запускается сервер Nginx. Таким образом, итоговый образ не содержит тяжеловесных пакетов NPM. Заметим, что здесь используются образы с суффиксом alpine - такие образы используют легковесную библиотеку musl libc (вместо glibc), альтернативные утилиты из пакета BusyBox (вместо стандартных утилит GNU), пакетный менеджер APK, которые используются в дистрибутиве Alpine Linux, что позволяет еще уменьшить объем образа
По умолчанию при запуске контейнера Docker создает пару виртуальных сетевых интерфейсов:
eth0)veth...), который подключен к виртуальному мосту docker0Интерфейсу контейнера и самому мосту docker0 назначаются частные IP-адреса (чаще всего в диапазоне 172.17.0.0/16), причем шлюзом по умолчанию для контейнера является адрес моста docker0
Для доступа к интернету пакет из окружения контейнера передается через пару интерфейсов eth0-veth... на мост docker0, откуда передается на реальный сетевой интерфейс хоста и далее к маршрутизатору. Доступ наружу контейнера обеспечивается благодаря трансляции адресов (NAT), а извне - благодаря настроенному пробросу портов
Если архитектура проекта микросервисная (то есть проект представляет набор сервисов, каждый со своей ответственностью и в своем контейнере), то прямой доступ между ними возможен только по частным IP-адресам (которые могут меняться), так как сеть Docker по умолчанию не имеет своей службы DNS
Для этого в Docker можно создать свою выделенную сеть, участники-контейнеры в которой смогут общаться между собой по именам контейнеров с помощью DNS
Чтобы создать сеть, используют команду:
docker network create имя-сети
Чтобы контейнер был подключен, нужно задать аргументом --network сеть при запуске:
docker run -d --name имя-контейнера --network имя-сети имя-образа
Так можно поместить два контейнера в одну сеть, которые могут общаться по сети с помощью имен контейнеров, например, имя-контейнера:8000
Docker Compose - инструмент, который позволяет задать конфигурацию Docker-контейнеров в YAML-файле и запускать их, что упрощает управление контейнерами одного проекта
Раньше Docker Compose первой версии представлял инструмент для Docker, написанный на Python и запускаемый как docker-compose. Сейчас Docker Compose второй версии - это плагин, написанный на языке Go, который является частью экосистемы и поставляется со стандартной установкой Docker
Манифест на языке YAML (рекурсивный акроним YAML Ain’t Markup Language) для Docker Compose описывает контейнеры, их образы, файлы Dockerfile, которые описывают инструкции для сборки, и другие настройки, такие как сеть и проброс портов, в одном файле docker-compose.yml:
services:
backend:
image: образ-бэкенда:0.2.0
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
networks:
- имя-сети
restart: unless-stopped
frontend:
image: образ-фронтенда:0.1.0
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
depends_on:
- backend
networks:
- имя-сети
restart: unless-stopped
networks:
имя-сети:
driver: bridge
Затем при запуске командой в каталоге с файлом docker-compose.yml:
docker compose up
инструмент Docker Compose создает:
имя-сети c драйвером bridgebackend из образа образ-бэкенда на основе ./backend/Dockerfilefrontend из образа образ-фронтенда на основе ./frontend/Dockerfile, запуск которого зависит от контейнера backendДалее эти связанные контейнеры можно остановить с помощью команды:
docker compose down
Аналогично, используя Docker Compose, можно запускать контейнер с базой данных, например, PostgreSQL. Для базы данных требуется усложненная конфигурация, поэтому используется больше полей в файле docker-compose.yml:
services:
postgres:
# Используем готовый образ, который загрузится с Docker Hub
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
# Переменные среды окружения
environment:
POSTGRES_USER: postgres
# Можно указать в формате `${}`, тогда Docker Compose
# обратится к файлу `.env`, где указана эта переменная
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: mydb
ports:
- "5432:5432"
# Монтирование томов наружу
volumes:
# Вместо имени `postgres_data` можно указать точное
# местоположение на хосте
- postgres_data:/var/lib/postgresql/data
# Такой подход позволяет сохранить данные базы данных
# между перезагрузками контейнера
# Проверка здоровья контейнера
healthcheck:
# Здесь задана команда, которая проверяет, что приложение работает
# Если база данных работает, то `pg_isready` вернет код 0
test: ["CMD-SHELL", "pg_isready -U postgres -d mydb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Такая проверка может использоваться другими контейнерами
volumes:
postgres_data: