Docker
Пример "контейнеризованного" React/Vue/Express/Postgres приложения.
Введение, Docker CLI и Dockerfile
Что такое Docker?
Docker
- это открытая платформа для разработки, доставки и запуска приложений. Он позволяет отделить приложения от инфраструктуры и управлять инфраструктурой по аналогии с тем, как мы управляем приложениями.
Docker
предоставляет возможность упаковывать и запускать приложение в слабо изолированной среде - контейнере. Изоляция и безопасность позволяют одновременно запускать несколько контейнеров на одном хосте (хостом может быть наша локальная машина, дата це нтр, облачный провайдер или их микс). Контейнеры являются легковесными и содержат все необходимое для запуска приложения, что избавляет нас от необходимости полагаться на то, что установлено на хосте.
Для чего Docker
может использоваться?
Быстрая и согласованная доставка приложений
Docker
рационализирует жизненный цикл разработки, позволяя разработчикам работать в стандартизированной среде через локальные контейнеры, предоставляющие приложения и сервисы. Контейнеры отлично подходят для рабочих процессов непрерывной интеграции и непрерывной доставки (continuous integration/continuous delivery, CI/CD
).
Отзывчивая разработка и масштабирование
Платформа, основанная на контейнерах, позволяет легко портировать приложения. Контейнеры могут запускаться на локальной машине разработчика, в физических или виртуальных дата-центрах, облачных провайдерах или смешанных средах.
Запуск большего количества приложений на одной машине
Docker
является легковесным и быстрым. Он предоставляет работоспособную и экономичную альтернативу виртуальным машинам на осн ове гипервизора, что позволяет использовать больше вычислительных мощностей для решения аналогичных задач.
Архитектура Docker
Docker
использует клиент-серверную архитектуру. Клиент (Docker client
) обращается к демону (Docker daemon
), который поднимает (собирает), запускает и распределяет контейнеры. Клиент и демон могут быть запущены в одной системе или клиент может быть подключен к удаленному демону. Клиент и демон общаются через REST API
поверх UNIX-сокетов
или сетевого интерфейса. Другим клиентом является Docker Compose
, позволяющий работать с приложениями, состоящими из нескольких контейнеров.
![](https://habrastorage.org/webt/dr/x3/4f/drx34fhmu1wqwpccdjzxgqufjeg.png)
Демон
Демон (dockerd
) регистрирует (слушает) запросы, поступающие от Docker API
, и управляет такими объектами как образы, контейнеры, сети и тома. Демон может общаться с другими демонами для управления сервисами.
Клиент
Клиент (docker
) - основной способ коммуникации с Docker
. При выполнении такой команды, как docker run
, клиент отправляет эту команду демону, который, собственно, эту команду и выполняет. Команда docker
использует Docker API
. Клиент может общаться с несколькими демонами.
Docker Desktop
Docker Desktop
- это десктопное приложение для Mac
, Windows
и Linux
, позволяющее создавать и распределять контейнерные приложения и микросервисы. Docker Desktop
включает в себя демона, клиента, Docker Compose
, Docker Content Trust
, Kubernetes
и Credential Helper
.
Реестр
В реестре (registry) хранятся образы контейнеров. Docker Hub
- это публичный реестр, который (по умолчанию) используется Docker
для получения образов. Имеется возможность создания частных (закрытых) реестров.
При выполнении таких команд, как docker pull
или docker run
, необходимые образы загружаются из настроенного реестра. А при выполнении команды docker push
образ загружается в реестр.
Объекты
При использовании Docker
мы создаем и используем образы, контейнеры, сети, тома, плагины и другие объекты. Рассмотрим некоторые из них.
Образы (Images
)
Образ - это доступный только для чтения шаблон с инструкциями по созданию контейнера. Часто образ представляет собой модификацию другого образа.
Можно создавать свои образы или использовать образы, созданные другими и опубликованные в реестре. Для создания образа используется Dockerfile
, содержащий инструкции по созданию образа и его запуску (см. ниже). Ряд инструкций в Dockerfile
приводит к созданию в образе нового слоя (раньше новый слой создавался для каждой инструкции). При изменении Dockerfile
и повторной сборке образа пересобираются только модифицированные слои. Это делает образы легковесными, маленькими и быстрыми.
Контейнеры (Containers
)
Контейнер - это запускаемый экземпляр образа. Мы создаем, запускаем, перемещаем и удаляем контейнеры с помощью Docker API
или CLI
(command line interface, интерфейс командной строки). Мы можем подключать контейнеры к сетям, добавлять в них хранилища данных и даже создавать новые образы на основе текущего состояния.
По умолчанию контейнеры хорошо изолированы от других контейнеров и хоста. Однако мы можем управлять тем, насколько изолированы сеть, хранилище данных или другая подсистема контейнера.
Контейнер определяется образом и настройками, указанными при его создании и запуске. При удалении контейнера его состояние также удаляется. Этого можно избежать с помощью хранилища данных.
Пример команды docker run
Следующая команда запускает контейнер ubuntu
, интерактивно подключается к локальному сеансу командной строки и выполняет в ней команду /bin/bash
:
docker run -i -t ubuntu /bin/bash
При выполнении этой команды происходит следующее:
- Поскольку на нашей машине нет образа
ubuntu
,Docker
загружает его из реестра (то же самое делает командаdocker pull ubuntu
). Docker
создает новый контейнер (то же самое делает командаdocker container create
).- В качестве последнего слоя
Docker
выделяет контейнеру файловую систему для чтения и записи. Это позволяет запущенному контейнеру создавать и модифицировать файлы и директории в его локальной файловой системе. - Поскольку мы не указали сетевых настроек,
Docker
создает сетевой интерфейс для подключения контейнера к дефолтной сети. Это включает в себя присвоение контейнеруIP-адреса
. Контейнеры могут подключаться к внешним сетям через сетевое соединение хоста. Docker
запускает контейнер и выполняет/bin/bash
. Поскольку контейнер запущен в интерактивном режиме и подключен к терминалу (благод аря флагам-i
и-t
), мы можем вводить команды и получать результаты в терминале.- Выполнение команды
exit
приводит к прекращению выполнения/bin/bash
. Контейнер останавливается, но не удаляется. Мы можем запустить его снова или удалить.
Команды и флаги
docker run
Команда docker run
используется для запуска контейнера. Это основная и потому наиболее часто используемая команда.
# сигнатура
docker run [OPTIONS] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]
# основные настройки (флаги)
-d - запуск контейнера в качестве отдельного процесса
-p - публикация открытого порта в интерфейсе хоста (HOST:CONTAINER)
# например
-p 3000:3000
-t - выделение псевдотерминала
-i - оставить STDIN открытым без присоединения к терминалу
--name - название контейнера
--rm - очистка системы при остановке/удалении контейнера
--restart - политика перезапуска - no (default) | on-failure[:max-retries] | always | unless-stopped
-e - установка переменной среды окружения
-v - привязка распределенной файловой системы (name:/path/to/file)
# например
-v mydb:/etc/mydb
-w - установка рабочей директории
Следующая команда запускает контейнер postgres
:
# \ используется для разделения длинных команд на несколько строк
docker run --rm \
# название контейнера
--name postgres \
# пользователь
-e POSTGRES_USER=postgres \
# пароль
-e POSTGRES_PASSWORD=postgres \
# название базы данных
-e POSTGRES_DB=mydb \
# автономный режим и порт
-dp 5432:5432 \
# том для хранения данных
-v $HOME/docker/volumes/postgres:/var/lib/postgresql/data \
# образ
postgres
docker build
Команда docker build
используется для создания образа на основе файла Dockerfile
и контекста. Контекст - это набор файлов, находящихся в локации, определенной с помощью PATH
или URL
. PATH
- это директория в нашей локальной системе, а URL
- это удаленн ый репозиторий. Контекст сборки обрабатывается рекурсивно, поэтому PATH
включает как директорию, там и все ее поддиректории, а URL
- как репозиторий, так и все его субмодули. Для исключения файлов из сборки образа используется .dockerignore
(синтаксис этого файла похож на .gitignore
).
# сигнатура
docker build [OPTIONS] PATH | URL | -
Создание образа:
# в качестве контекста сборки используется текущая директория
docker build .
Использование репозитория в качестве контекста (предполагается, что Dockerfile
находится в корневой директории репозитория):
docker build github.com/creack/docker-firefox
docker build -f ctx/Dockerfile http://server/ctx.tar.gz
В данном случае http://server/ctx.tar.gz
отправляется демону, которые загружает и извлекает файлы. Параметр -f ctx/Dockerfile
определяет путь к Dockerfile
внутри ctx.tar.gz
.
Чтение Dockerfile
из STDIN
без контекста:
docker build - < Dockerfile
Добавление тега к образу:
docker build -t myname/my-image:latest .
Определение Dockerfile
:
docker build -f Dockerfile.debug .
Экспорт файлов сборки в директорию out
:
docker build -o out .
Экспорт файлов сборки в файл out.tar
:
docker build -o - . > out.tar
docker exec
Команда docker exec
используется для выполнения команды в запущенном контейнере.
# сигнатура
docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
# основные флаги
-d - выполнение команды в фоновом режиме
-e - установка переменной среды окружения
-i - оставить `STDIN` открытым
-t - выделение псевдотерминала
-w - определение рабочей директории внутри контейнера
Пример:
# -U - это пользователь, которым по умолчанию является root
docker exec -it postgres psql -U postgres
В данном случае в контейнере postgres
будет запущен интерактивный терминал psql
. Выполним парочку команд:
# получаем список баз данных
\l
# подключаемся к базе данных mydb
-d mydb
# получаем список таблиц, точнее, сообщение об отсутствии отношений (relations)
\dt
# выходим
\q
docker ps
Команда docker ps
используется для получения списка (по умолчанию только запущенных) контейнеров.
# сигнатура
docker ps [OPTIONS]
# основные флаги
-a - показать все контейнеры (как запущенные, так и остановленные)
-f - фильтрация вывода на основе условия (`id`, `name`, `status` и т.д.)
-n - показать n последних созданных контейнеров
-l - показать последний созданный контейнер
# пример получения списка приостановленных контейнеров
docker ps -f 'status=paused'
Для получения списка образов используется команда docker images
.
Команды управления
# запуск остановленного контейнера
docker start CONTAINER
# приостановление всех процессов, запущенных в контейнере
docker pause CONTAINER
# остановка контейнера
docker stop CONTAINER
# "убийство" контейнера
docker kill CONTAINER
# перезапуск контейнера
docker restart CONTAINER
# удаление остановленного контейнера
docker rm [OPTIONS] CONTAINER
# основные флаги
-f - принудительное удаление (остановка и удаление) запущенного контейнера
-v - удаление анонимных томов, связанных с контейнером
# пример удаления всех остановленных контейнеров
docker rm $(docker ps --filter status=exited -q)
# удаление образа
docker rmi IMAGE
###
# управление образами
docker image COMMAND
# управление контейнерами
docker container COMMAND
# управление томами
docker volume COMMAND
# управление сетями
docker network COMMAND
# управление docker
docker system COMMAND
Другие команды
Для получения логов запущенного контейнера используется команда docker logs
:
docker logs [OPTIONS] CONTAINER
# основные флаги
-f - следование за выводом
-n - n последних строк
Для удаления всех неиспользуемых данных (контейнеры, сети, образы и, опционально, тома) используется команда docker system prune
. Основные флаги:
-a - удаление всех неиспользуемых образов, а не только обособленных (dangling)
--volumes - удаление томов
Предостережение: применять эту команду следует с крайней осторожностью, поскольку удаленные данные не подлежат восстановлению.
Полный список команд и флагов.
Dockerfile
Dockerfile
- это документ (без расширения), содержащий инструкции, которые используются для создания образа при выполнении команды docker build
.
Предостережение: не используйте /
в качестве PATH
для контекста сборки. Это приведет к передаче демону всего содержимого жесткого диска вашей машины.
Инструкции выполняются по одной. Результаты наиболее важных инструкций фиксируются в виде отдельных слоев образа. Обратите внимание: каждая инструкция выполняется независимо от других. Это означает, что выполнение RUN cd /tmp
не будет иметь никакого эффекта для последующих инструкций.
Dockerfile
может содержать следующие инструкции:
# Комментарий
ИНСТРУКЦИЯ аргументы
# Основные
# FROM - родительский образ
FROM <image>[:<tag>] [AS <name>]
# пример
FROM node:12-alpine AS build
# WORKDIR - установка рабочей директории для инструкций RUN, CMD, ENTRYPOINT, COPY и ADD
WORKDIR /path/to/dir
# пример
WORKDIR /app
# COPY - копирование новых файлов или директорий из <src>
# и их добавление в файловую систему образа по адресу, указанному в <dest>
COPY <src> <dest>
COPY ["<src>", "<dest>"]
# пример
COPY package.* yarn.lock ./
# или
COPY . .
# ADD, в отличие от COPY, позволяет копировать удаленные файлы,
# а также автоматически распаковывает сжатые (identity, gzip, bzip2 или xz) локальные файлы
# ADD - копирование новых файлов, директорий или удаленного (!) файла из <src>
# и их добавление в файловую систему образа по адресу, указанному в <dest>
ADD <src> <dest>
ADD ["<src>", "<dest>"]
# пример
ADD some.txt some_dir/ # <WORKING_DIR>/some_dir/
# RUN - выполнение команды в новом слое на основе текущего образа и фиксация результата
RUN <command>
# или
RUN ["executable", "arg1", "arg2"] # Кавычки должны быть двойными
# пример
RUN npm install
# CMD - предоставление дефолтных значений исполняемому контейнеру
CMD ["executable", "arg1", "arg2"]
# или если данной инструкции предшествует инструкция ENTRYPOINT
CMD ["arg1", "arg2"]
# или
CMD command arg1 arg2
# пример
CMD [ "node", "/app/src/index.js" ]
# RUN выполняет команду и фиксирует результат,
# CMD ничего не выполняет во время сборки, а определяет команду для образа
# (!) выполняется только одна (последняя) инструкция CMD
# ENTRYPOINT - настройка исполняемого контейнера
ENTRYPOINT ["executable", "arg1", "arg2"]
ENTRYPOINT command arg1 arg2
# пример
ENTRYPOINT ["top", "-b"]
CMD ["-c"]
# docker run -it --rm --name test top -H
# top -b -H
# разница между ENTRYPOINT и CMD:
# https://docs.docker.com/engine/reference/builder/#understand-how-cmd-and-entrypoint-interact
# https://stackoverflow.com/questions/21553353/what-is-the-difference-between-cmd-and-entrypoint-in-a-dockerfile
# переменные
# ${var} или $var
# пример
FROM busybox
ENV FOO=/bar
WORKDIR ${FOO} # WORKDIR /bar
ADD . $FOO # ADD . /bar
COPY \$FOO /qux # COPY $FOO /qux
# Другие
# LABEL - добавление метаданных к образу
LABEL <key>=<value>
# пример
LABEL version="1.0"
# EXPOSE - информация о сетевом порте, прослушиваемом контейнером во время выполнения
EXPOSE <port> | <port>/<protocol>
# пример
EXPOSE 3000
# ENV - установка переменных среды окружения
ENV <key>=<value>
# пример
ENV MY_NAME="No Name"
# VOLUME - создание точки монтирования
VOLUME ["/var/log"]
VOLUME /var/log
# USER - установка пользователя для использования при запуске контейнера
# в любых инструкциях RUN, CMD и ENTRYPOINT
USER <user>[:<group>]
USER <UID>[:<GID>]
# ARG - определение переменной, которая может быть передана через командную строку при
# выполнении команды `docker build` с помощью флага `--build-arg <name>=<value>`
ARG <name>[=<default value>]
# ONBUILD - добавление в образ триггера, запускаемого при использовании
# данного образа в качестве основы для другой сборки
ONBUILD <INSTRUCTION>
Рекомендации по Dockerfile
Рассмотрим следующий Dockerfile
:
# syntax=docker/dockerfile:1
FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py
Выполнение каждой инструкции (кроме CMD
) из этого файла приводит к созданию нового сло я:
FROM
создает слой из образаubuntu:18.04
COPY
добавляет файлы из текущей директорииRUN
собирает приложение с помощьюmake
CMD
определяет команду для запуска приложения в контейнере
При запуске образа и генерации контейнера мы добавляем новый слой, доступный для записи, поверх остальных. Все изменения в запущенном контейнере, такие как создание новых файлов, их модификация или удаление записываются в этот слой.
Создание эфемерных контейнеров
Генерируемые контейнеры должны быть максимально эфемерными. Под эфемерностью понимается возможность остановки, уничтожения, повторной сборки и замены контейнеров без необходимости дополнительной настройки процесса их генерации.
Понимание контекста сборки
При выполнении команды docker build
контекстом сборки, как правило, является текущая директория. Предполагается, что Dockerfile
находится в этой директории. Путь к Dockerfile
, находящемуся в другом месте, можно указать с помощью флага -f
. Независимо от того, где находится Dockerfile
, все файлы и директории из текущей директории отправляются демону в качестве контекста сборки.
В следующем примере мы
- создаем (
mkdir
) директориюmyapp
, которая используется в качестве контекста сборки - переходим в нее (
cd
) - создаем файл
hello
с текстом"hello"
- создаем
Dockerfile
, читающий (cat
) содержимое файлаhello
- собираем образ с тегом
helloapp:v1
mkdir myapp && cd myapp
echo "hello" > hello
echo -e "FROM busybox\nCOPY /hello /\nRUN cat /hello" > Dockerfile
docker build -t helloapp:v1 .
Размещаем Dockerfile
и hello
в разных директориях и собираем вторую версию образа без использования кеша предыдущей сборки (-f
определяет путь к Dockerfile
):
# создаем директории
mkdir -p dockerfiles context
# перемещаем файлы
mv Dockerfile dockerfiles && mv hello context
# собираем образ
docker build --no-cache -t helloapp:v2 -f dockerfiles/Dockerfile context
.dockerignore
В файле .dockerignore
указываются файлы, не имеющие отношения к сборке и поэтому не включаемые в нее. Синтаксис .dockerignore
похож на синтаксис .gitignore
или .npmignore
.
Многоэтапная сборка
Многоэтапная сборка позволяет существенно уменьшить размер финального образа без необходимости изучения процесса сборки на предмет наличия промежуточных слоев и файлов, которые можно удалить.
Если процесс сборки состоит из нескольких слоев, мы можем упорядочить их от редко модифицируемых до часто модифицируемых:
- установка инструментов, необходимых для сборки приложения
- установка или обновление зависимостей
- генерация приложения
Пример Dockerfile
для Go-приложения
:
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine AS build
# устанавливаем инструменты
# выполняем `docker build --no-cache .` для обновления зависимостей
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep
# список зависимостей из `Gopkg.toml` и `Gopkg.lock`
# эти слои будут собираться повторно только при изменении файлов `Gopkg`
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# устанавливаем зависимости
RUN dep ensure -vendor-only
# копируем проект и собираем его
# этот слой будет собираться повторно только при изменении файлов из директории `project`
COPY . /go/src/project/
RUN go build -o /bin/project
# получаем образ, состоящий из одного слоя
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]
Лишние библиотеки
Для уменьшения сложности, количества зависимостей и времени сборки следует избегать установки дополнительных и ненужных библиотек "на всякий случай".
Разделение приложений
Каждый контейнер должен иметь одну ответственность (single responsibility). Разделение приложений на несколько контейнеров облегчает горизонтальное масштабирование и переиспользуемость контейнеров. Например, стек веб-приложения может состоять из 3 отдельных контейнеров, каждый со своим уникальным образом, для управления приложением, базы данных и сервера или распределенного кеша, хранящегося в памяти. Если контейнеры зависят друг от друга для обеспечения возможности их коммуникации следует использовать сети.
Минимизация количества слоев
В старых версиях Docker
каждая инструкция в Dockerfile
приводила к созданию нового слоя. Сейчас новые слои создаются только инструкциями RUN
,COPY
и ADD
. Другие инструкции создают временные промежуточные образы, которые не приводят к увеличению размера сборки.
Сортировка многострочных аргументов
Многострочные аргументы рекомендуется сортировать в алфавитном порядке. Также рекомендуется добавлять пробел перед обратным слэшем (\
). Пример:
RUN apt-get update && apt-get install -y \
bzr \
cvs \
git \
mercurial \
subversion \
&& rm -rf /var/lib/apt/lists/*
Использование кеша сборки
При сборке образа Docker
изучает все инструкции в порядке, определенном в Dockerfile
. После изучения инструкции Docker
обращается к своему кешу. Если в кеше имеется соответствующий образ, новый образ не создается. Для сборки образа без обращения к кешу используется настройка --no-cache=true
.
Рекомендации по инструкциям
FROM
В качестве основы для создания образа рекомендуется использовать официальные образы из DockerHub
версии alpine
.
LABEL
Подписи позволяют структурировать образы проекта, добавлять информацию о лицензиях, могут использоваться для автоматизации и т.д.
# одна подпись
LABEL com.example.version="0.0.1-beta"
# неско лько подписей
LABEL vendor=ACME\ Incorporated \
com.example.is-beta= \
com.example.is-production="" \
com.example.version="0.0.1-beta" \
com.example.release-date="2021-01-12"
RUN
Длинные и сложные инструкции RUN
рекомендуется разделять на несколько строк с помощью обратного слэша (\
). Это делает Dockerfile
более читаемым, облегчает его понимание и поддержку.
RUN apt-get update && apt-get install -y \
package-bar \
package-baz \
package-foo \
&& rm -rf /var/lib/apt/lists/*
CMD
Инструкция CMD
используется для запуска программ в контейнере вместе с аргументами. CMD
должна использоваться в форме CMD ["executable", "param1", "param2"]
. В большинстве случаев первым элементом должен быть интерактивный терминал, такой как bash
, python
или perl
. Например, CMD ["perl", "-de0"]
, CMD ["python"]
или CMD ["php", "-a"]
. При использовании ENTRYPOINT
следует убедиться, что пользователи поним ают, как работает эта инструкция.
EXPOSE
Инструкция EXPOSE
определяет порты, на которых контейнер регистрирует соединения. Рекомендуется использовать порты, которые являются традиционными для приложения. Например, образ, содержащий веб-сервер Apache
, должен использовать EXPOSE 80
, а образ, содержащий MongoDB
- EXPOSE 27017
.
ENV
Для облегчения запуска программы можно использовать ENV
для обновления переменной среды окружения PATH
для приложения, устанавливаемого контейнером. Например, ENV PATH=/usr/local/nginx/bin:$PATH
обеспечивает, что CMD ["nginx"]
просто работает.
Инструкция ENV
также может быть полезна для предоставления обязательных для сервиса переменных, таких как PGDATA
для Postgres
.
Наконец, ENV
может использоваться для установки номеров версий, что облегчает их обновление.
ADD или COPY
Хотя ADD
и COPY
имеют похожий функционал, в большинстве случаев следует использовать COPY
, поскольку эта инструкция является более прозрачной, чем ADD
. COPY
поддерживает копирование в контейнер только л окальных файлов, а ADD
также позволяет извлекать файлы из локальных архивов и получать файлы по URL
, но вместо последнего лучше использовать curl
или wget
: это позволяет удалять ненужные файлы после извлечения.
Если Dockerfile
состоит из нескольких этапов, на которых используются разные файлы из контекста, эти файлы рекомендуется копировать индивидуально. Это позволяет обеспечить инвалидацию кеша только для модифицированных файлов. Например:
COPY package.json /app
RUN npm i
# предполагается, что директория node_modules указана в .dockerignore
COPY . /app
ENTRYPOINT
ENTRYPOINT
определяет основную команду для образа, что позволяет запускать образ без этой команды.
Рассмотрим пример образа для инструмента командной строки s3cmd
:
ENTRYPOINT ["s3cmd"]
CMD ["--help"]
Данный образ может быт запущен следующим образом:
docker run s3cmd
Это приведет к выводу справки.
Либо мы может передать параметры для выполнения команды:
docker run s3cmd ls s3://mybucket
Это может быть полезным при совпадении названия образа со ссылкой на исполняемый файл.
VOLUME
Инструкция VOLUME
следует использовать для доступа к любой области хранения базы данных, хранилищу настроек или файлам/директориям, созданным контейнером. Крайне не рекомендуется использовать VOLUME
для мутабельных и/или пользовательских частей образа.
WORKDIR
Для ясности и согласованности для WORKDIR
всегда следует использовать абсолютные пути. Также WORKDIR
следует использовать вместо инструкций типа RUN cd ... && do-something
, которые трудно читать, отлаживать и поддерживать.
Управление данными
По умолчанию файлы, создаваемые в контейнере сохраняются в слое, доступном для записи (writable layer). Это означает следующее:
- данные существуют только на протяжении жизненного цикла контейнера, и их сложно извлекать из контейнера, когда в них нуждается другой процесс
- слой контейнера, доступный для записи, тесно связан с хостом, на котором запущен контейнер. Данные нельзя просто взять и переместить в другое место
- запись в такой слой требует наличия драйвера хранилища (storage driver) для управления файловой системой. Эта дополнительная абстракция снижает производительность по сравнению с томами данных (data volumes), которые пишут напрямую в файловую систему
Docker
предоставляет 2 возможности для постоянного хранения данных на хосте: тома (volumes) и bind mount
. Для пользователей Linux
также доступен tmpfs mount
, а для пользователей Windows
- named pipe
.
Выбор правильного типа монтирования
Независимо от выбранного типа монтирования, для контейнера данные выглядят одинаково. Они представляют собой директорию или файл в файловой системе контейнера.
Разница между томами, bind mount
и tmpfs mount
заключается в том, где хранятся данные на хосте.
![](https://habrastorage.org/webt/zw/uh/4h/zwuh4hf842ombg9qhug0rophhyk.png)
- тома хранятся в части файловой системы хоста, управляемой
Docker
(/var/lib/docker/volumes/
наLinux
). Процессы, не относящиеся кDocker
, не должны модифицировать эту часть. Тома - лучший способ хранения данных вDocker
bind mount
может храниться в любом месте системы хоста. Это могут быть даже важные системные файлы и директории. Модифицировать их могут любые процессыtmpfs mount
хранится в памяти и не записывается в файловую систему
Тома
Тома (Volumes) - предпочтительный способ хранения данных, генерируемых и используемых контейнерами. Они полностью управляются Docker
, в отличие от bind mount
, которые зависят от структуры директории и операционной системы хоста. Преимущества томов состоят в следующем:
- тома легче восстанавливать и мигрировать
- томами можно управлять с помощью
Docker CLI
иDocker API
- тома работают как в
Linux
, так и вWindows
контейнерах - тома могут более безопасно распределяться между контейнерами
- движки томов (volume drivers) позволяют хранить тома на удаленных хостах и облачных провайдерах, шифровать содержимое томов или добавлять в них новый функционал
- с одержимое новых томов может заполняться (population) контейнером
- тома имеют более высокую производительность
Тома также являются более предпочтительными перед хранением данных в слое контейнера, доступном для записи, поскольку тома не увеличивают размер контейнера. Содержимое контейнера существует за пределами жизненного цикла контейнера.
Флаги -v
и --mount
--mount
является более явным и многословным. Основное отличие состоит в том, что при использовании -v
все настройки комбинируются вместе, а при использовании --mount
они указываются раздельно.
Движок тома может быть определен только с помощью --mount
.
-v
или--volume
: состоит из 3 полей, разделенных двоеточием (:
). Поля должны указываться в правильном порядке. Значение каждого поля не является очевидным- в случае именованных томов первое поле - это название тома, которое является уникальным в пределах хоста. В случае анонимных томов это поле опускается
- второе поле - это путь файла или директории, монтируемой в контейнере
- третье поле является опциональным и представляет собой разделенный запятыми список настроек, таких как
ro
--mount
: состоит из нескольких пар ключ/значение, разделенных запятыми. Синтаксис--mount
является более многословным, зато порядок ключей не имеет значения, а значения ключей являются более очевиднымиtype
: тип монтирования, может иметь значениеbind
,volume
илиtmpfs
source
: источник монтирования. Для именованных томов - это название тома. Для анонимных может быть опущено. Может определяться какsource
илиsrc
destination
: путь монтируемого в контейнере файла или директории. Может определяться какdestination
,dst
илиtarget
readonly
: если указана данная настройка, том будет доступен только для чтения. Может определяться какreadonly
илиro
volume-opt
: данная настройка может определяться несколько раз, принимая пары название настройки/ее значение
-v
и --mount
принимают одинаковые настройки. При использовании томов с сервисами, доступен только флаг --mount
.
Создание и управление томами
В отличие от bind mount
, тома могут создаваться и управляться за пределами любого контейнера.
Создание тома
docker volume create my-vol
Список томов
docket volume ls
Анализ тома
docker volume inspect my-vol
Удаление тома
docker volume rm my-vol
Запуск контейнера с томом
При запуске контейнера с несуществующим томом, он создается автоматически. В следующем примере том myvol2
монтируется в директорию app
контейнера:
docker run -d \
--name devtest \
-v myvol2:/app \
nginx:latest
# или
docker run -d \
--name devtest \
--mount source=myvol2,target=/app \
nginx:latest
# далее будет использоваться только `-v`
В данном случае том будет доступен как для чтения, так и для записи.
Использование тома с Docker Compose
Единичный сервис (single service) Compose
с томом может выглядеть так:
version: "1.0"
services:
frontend:
image: node:lts
volumes:
- myapp:/home/node/app
volumes:
myapp:
Том будет создан при первом вызове docker compose up
. При последующих вызовах данный том будет использоваться повторно.
Том может быть создан отдельно с помощью docker volume create
. В этом случае в docker-compose.yml
может быть указана ссылка на внешний (external) том:
version: "1.0"
services:
frontend:
image: node:lts
volumes:
- myapp:/home/node/app
volumes:
myapp:
external: true
Популяция тома с помощью контейнера
При запуске нового контейнера, создающего том, когда контейнер содержит файлы и директории в монтируемой директории, содержимое этой директории копируется в том. После этого контейнер монтирует и использует том. Другие контейнеры, использующие том, будут иметь доступ к его предварительно заполненному (pre-populated) содержимому.
В следующем примере мы создаем контейнер nginxtest
и заполняем новый том nginx-vol
содержимым из директории /usr/share/nginx/html
, в которой хранится дефолтная разметка:
docker run -d \
--name=nginxtest \
-v nginx-vol:/usr/share/nginx/html \
nginx:latest
Пример монтирования тома, доступного только для чтения
# :ro
docker run -d \
--name=nginxtest \
-v nginx-vol:/usr/share/nginx/html:ro \
nginx:latest
Резервное копирование, восстановление и передача данных томов
Для создания контейнера, монтирующего определенный том, используется флаг --volumes-from
.
Резервное копирование тома
Создаем контейнер dbstore
:
docker run -v /dbdata --name dbstore ubuntu /bin/bash
Следующая команда
- запускает новый контейнер и монтирует в него том из контейнера
dbstore
- монтирует директорию из локального хоста как
/backup
- передает команду для архивации содержимого тома
dbdata
в файлbackup.tar
в директори и/backup
:
docker run --rm \
--volumes-from dbstore \
-v $(pwd):/backup \
ubuntu tar \
cvf /backup/backup.tar /dbdata
Восстановление данных тома
Создаем новый контейнер:
docker run -d \
-v /dbdata \
--name dbstore2 \
ubuntu /bin/bash
Распаковываем архив в том нового контейнера:
docker run --rm \
--volumes-from dbstore2 \
-v $(pwd):/backup \
ubuntu bash -c \
"cd /dbdata && tar xvf /backup/backup.tar --strip 1"
Удаление томов
Данные томов сохраняются после удаления контейнеров. Существует 2 типа томов:
- именованные: имеют определенный источник за пределами контейнера, например,
awesome:/foo
- анонимные: не имеют определенного источника, поэтому при удалении контейнера демону следует передавать инструкции по их удалению
Для удаления анонимного контейнера можно использовать флаг --rm
. Например, здесь мы создаем анонимный том /foo
и именованный том awesome:/bar
:
docker run --rm -v /foo -v awesome:/bar busybox top
После удаления этого контейнера будет удален только том /foo
.
Для удаления всех неиспользуемых томов используется команда docker volume prune
.
Более подробную информацию о томах можно получить здесь.
Сети
Сети (Networks) позволяют контейнерам общаться между собой. Сетевой интерфейс Docker
не зависит от платформы.
Основная функциональность сетей представлена следующими драйверами:
bridge
(мост): дефолтный сетевой драйвер. Такие сети, как правило, используются, когда приложение состоит из нескольких автономных контейнеров, которые должны иметь возможность общаться между собойhost
: этот драйвер также предназначен для автономных контейнеров, он позволяет использовать сети хоста напрямую, удаляя изоляцию между контейнером и хостомoverlay
(перекрытие): такие сети объединяют нескольких демонов вместе и позволяют групповым сервисам (swarm services) взаимодействовать друг с другом. Эти сети также могут использоваться для обеспечения коммуникации между групповым сервисом и отдельным контейнером или между двумя автономными контейнерами на разных демонахipvlan
: такая сеть предоставляет пользователю полный контроль над адресациейIPv4
иIPv6
macvlan
: этот драйвер позволяет присваивать контейнеруMAC-адрес
, в результате чего контейнер появляется в сети как физическое устройствоnone
: отключает сети для данного контейнера. Обычно, используется в дополнение к кастомному сетевому драйверу- плагины
Резюме
bridge
: для обеспечения взаимодействия контейнеров, находящихся на одном хостеhost
: когда сетевой стек не должен быть изолирован от хостаoverlay
: для обеспечения взаимодействия контейнеров, находящихся на разных хостах, или для приложений, работающих вместе через групповые сервисы
Использование дефолтной сети bridge
Сейчас мы запустим 2 контейнера alpine
и посмотрим, как они могут взаимодействовать между собой.
- Открываем терминал и выполняем следующую команду:
docker network ls
Получаем список сетей bridge
, host
и none
.
- Запускаем 2 контейнера
alpine
.ash
- это дефолтный терминалAlpine
(аналогbash
). Флаги-dit
означают запуск контейнера в фоновом режиме (-d), интерактивно (чтобы дает возможность вводить команды) (-i) и с псевдотерминалом (чтобы видеть ввод и вывод) (-t). Поскольку мы не указываем--network
, контейнеры подключаются к дефолтной сетиbridge
:
# контейнер номер раз
docker run -dit --name alpine1 alpine ash
# и номер два
docker run -dit --name alpine2 alpine ash
Проверяем, что оба контейнера запущены:
docker container ls
# или
docker ps
- Анализируем сеть
bridge
на предмет подключенных к ней контейнеров:
docker network inspect bridge
Видим созданные нами контейнеры в разделе "Containers"
вместе с их IP-адресами
(172.17.0.2
для alpine1
и 172.17.0.3
для alpine2
).
- Подключаемся к
alpine1
:
docker attach alpine1
Видим приглашение #
, свидетельствующее о том, что мы являемся пользователем root
внутри контейнера. Взглянем на сетевой интерфейс alpine1
:
id addr show
Первый интерфейс (lo
) нас не интересует. Нас интересует IP-адрес
второго интерфейса (172.17.0.2
).
- Проверяем подключение к Интернету:
ping -c 2 google.com
Флаг -c 2
означает 2 попытки ping
.
- Обратимся ко второму контейнеру. Сначала по
IP-адресу
:
ping -c 2 172.17.0.3
Работает. Теперь попробуем обратиться по названию контейнера:
ping -c 2 alpine2
Не работает.
- Останавливаем и удаляем контейнеры:
docker container stop alpine1 alpine2
docker container rm alpine1 alpine2
Обратите внимание: дефолтная сеть bridge
не рекомендуется для использования в продакшне.
Использование кастомной сети bridge
В следующем примере мы снова создаем 2 контейнера alpine
, но подключаем их к кастомной сети alpine-net
. Эти контейнеры не подключены к дефолтной сети bridge
. Затем мы запускаем третий alpine
, подключаемый к bridge
, но не к alpine-net
, и четвертый alpine
, подключаемый к обеим сетям.
- Создаем сеть
alpine-net
. Флаг--driver bridge
можно опустить, посколькуbridge
является сетью по умолчанию:
docker network create --driver bridge alpine-net
- Получаем список сетей:
docker network ls
Анализируем сеть alpine-net
:
docker network inspect alpine-net
Получаем IP-адрес
сети и пустой список подключенных контейнеров.
Обратите внимание на сетевой шлюз, который отличается от шлюза bridge
.
- Создаем 4 контейнера. Обратите внимание на флаг
--network
. При выполнении командыdocker run
мы можем подключить контейнер только к одной сети, поэтому подключениеalpine4
кalpine-net
выполняется отдельно:
docker run -dit --name alpine1 --network alpine-net alpine ash
docker run -dit --name alpine2 --network alpine-net alpine ash
docker run -dit --name alpine3 alpine ash
docker run -dit --name alpine4 alpine ash
# !
docker network connect alpine-net alpine4
Получаем список запущенных контейнеров:
docker ps
- Анализируем
bridge
иalpine-net
:
docker network inspect bridge
Видим, что к данной сети подключены контейнеры apline3
и alpine4
.
docker network inspect alpine-net
А к этой сети подключены контейнеры alpine1
, alpine2
и alpine4
.
- В кастомных сетях контейнеры могут обращаться друг к другу не только по
IP-адресам
, но также по названиям. Данная возможность называется автоматическим обнаружением сервиса (automatic service discovery). Подключаемся кalpine1
:
docker container attach alpine1
ping -c 2 alpine2
# success
ping -c 2 alpine 4
# success
ping -c 2 alpine1
# success
- Пробуем обратиться к
alpine3
:
ping -c 2 alpine3
# failure
- Отключаемся от
alpine1
и подключаемся кalpine4
:
docker container attach alpine4
ping -c 2 alpine1
# success
ping -c 2 alpine2
# success
ping -c 2 alpine3
# failure
# но
ping -c 2 172.17.0.2 # IP-адрес alpine3
# success
ping -c 2 alpine4
# success
- Все контейнеры могут подключаться к Интернету:
ping -c 2 google.com
# success
- Останавливаем и удаляем контейнеры и сеть
alpine-net
:
docker container stop alpine1 alpine2 alpine3 alpine4
docker container rm alpine1 alpine2 alpine3 alpine4
docker network rm alpine-net
Рассмотрим парочку примеров Dockerfile
для Node.js-пр иложений
.
Пример с официального сайта Node.js
FROM node:16
# создание директории приложения
WORKDIR /usr/src/app
# установка зависимостей
# символ астериск ("*") используется для того чтобы по возможности
# скопировать оба файла: `package.json` и `package-lock.json`
COPY package*.json ./
RUN npm install
# для создания сборки для п родакшн
# RUN npm ci --only=production
# копируем исходный код
COPY . .
EXPOSE 4000
CMD [ "node", "server.js" ]
Инструкции COPY package*.json ./
и COPY . .
выполняются по отдельности в целях извлечения максимальной выгоды из кеширования слоев. Зависимости проекта меняются на так часто, как файлы, не имеет смысла устанавливать их при каждой сборке образа.
Пример из статьи "10 лучших практик по контейнеризации Node.js-приложений с помощью Docker"
Пример является сокращенным и для продакшна.
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ['dumb-init', 'node', 'server.js']
Docker Compose
Docker Compose
- это инструмент для определения и запуска Docker-приложений
, состоящих из нескольких контейнеров. Для настройки сервисов приложения используется файл docker-compose.yml
.
Процесс использования Compose
, как правило, состоит из 3 этапов:
- определение среды приложения с помощью
Dockerfile
; - определение сервисов, из которых состоит приложение, в
docker-compose.yml
(для совместного запуска сервисов в изолированной среде); - выполнение команды
docker compose up
для запуска приложения.
Пример файла docker-compose.yml
:
version: "3.9" # опционально
services:
web:
build: .
ports:
- "5000:5000"
volumes:
- .:/code
- logvolume:/var/log
links:
- redis
redis:
image: redis
volumes:
logvolume: {}
Compose
позволяет делать следующее:
- запускать, останавливать и повторно собирать сервисы;
- получать статус запущенных сервисов;
- получать логи запущенных сервисов;
- выполнять команды в сервисах.
Compose
предоставляет следующие возможности:
- создание нескольких изолированных сред на одном хосте;
- сохранение данных томов при создании контейнеров;
- повторное создание только модифицированных контейнеров;
- передача переменных среды окружения и возможность создания разных сред (для разработки, продакшна и т.д.).
Начало работы с Docker Compose.
docker compose
Команда docker compose
является альтернативой docker-compose CLI
и используется для управления Compose
.
# сигнатура
docker-compose [-f <arg>...] [--profile <name>...] [options] [COMMAND] [ARGS...]
# основные флаги
-f - путь к docker-compose.yml
-p - название проекта
--project-path - альтернативная рабочая директория (по умолчанию рабочей является директория, содержащая docker-compose.yml)
# основные команды
up - создание и запуск сервисов
down - остановка и удаление контейнеров, сетей, образов и томов
start - запуск сервисов
stop - остановка сервисов
restart - перезапуск сервисов
create - создание сервисов
rm - удаление остановленных контейнеров
run - выполнение одноразовой команды
exec - выполнение команды в запущенном контейнере
Полный список флагов и команд.
docker-compose.yml
Файл Compose
- это файл в формате YAML
, определяющий сервисы, сети и тома. Дефолтным путем этого файла является ./docker-compose.yml
.
Определение сервиса включает в себя установку настроек, которые применяются к каждому контейнеру, запущенному для этого сервиса. Это похоже на передачу аргументов при выполнении команды docker run
. Определения сети и тома аналогично выполнению команд docker network create
и docker volume create
.
Настройки, определенные в Dockerfile
, такие как CMD
, EXPOSE
, VOLUME
и ENV
не нуждаются в дублировании в docker-compose.yml
.
Рассмотрим основные настройки сервисов.
build
Настройки, применяемые во время сборки.
build
может определяться в виде строки - пути к контексту сборки:
version: "3.9"
services:
webapp:
build: ./dir
Или в виде объекта, где context
- путь к контексту, dockerfile
- используемый Dockerfile
и args
- аргументы:
version: "3.9"
services:
webapp:
build:
context: ./dir
dockerfile: Dockerfile-alternate
args:
buildno: 1
В случае с args
аргументы должны быть определены в Dockerfile
:
# syntax=docker/dockerfile:1
ARG buildno
ARG gitcommithash
RUN echo "Номер сборки: $buildno"
RUN echo "Основано на коммите: $gitcommithash"
build:
context: .
args:
buildno: 1
gitcommithash: cdc3b19
# or
- buildno=1
- gitcommithash=cdc3b19
network
Сеть, к которой подключается контейнер во время сборки (для использования при выполнении команды RUN
):
build:
context: .
network: host
# or
build:
context: .
network: custom_network_1
command
Перезапись дефолтной команды:
command: bundle exec thin -p 3000
depends_on
Зависимость между сервисами. Это означает следующее:
docker compose up
запускает сервисы в определенном порядке. В примере нижеdb
иredis
запускаются передweb
;docker compose up SERVICE
автоматически включает зависимостиSERVICE
. В примереdocker compose up web
также создает и запускаетdb
иredis
;docker compose stop
останавливает сервисы в определенном порядке. В примереweb
останавливается передdb
иredis
.
version: "3.9"
services:
web:
build: .
depends_on:
- db
- redis
redis:
image: redis
db:
image: postgres
restart_policy
Политика перезапуска - определяет, как и когда контейнер должен перезапускаться:
condition
: условие перезапуска -none
,on-failure
илиany
(значение по умолчанию);delay
: время между попытками (по умолчанию равняется5s
);max_attempts
: количество попыток (по умолчанию - бесконечное);window
: время принятия решения об успехе перезапуска (по умолчанию - немедленно).
version: "3.9"
services:
redis:
image: redis:alpine
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
entrypoint
Перезапись дефолтной точки входа:
entrypoint: /code/entrypoint.sh
env_file
Извлечение переменных среды окружения из файла. Может быть единичным значением или списком.
Если файл Compose
определен с помощью docker compose -f FILE
, пути в env_file
будут относительными директории, в которой находится этот файл.
Переменные, определенные в разделе environment
, перезаписывают эти значения.
env_file: .env
# or
env_file:
- ./common.env
- ./apps/web.env
- /opt/runtime_opts.env
expose
Выставление портов без их публикации на хосте - порты будут доступны только связанным (linked) сервисам. Могут определяться только внутренние порты:
expose:
- "3000"
- "8000"
external_links
Подключение к контейнеру, запущенному за пределами docker-compose.yml
или даже за пределами Compose
. Особенно полезно для контейнеров, предоставляющих общие или распределенные сервисы:
external_links:
- redis_1
- project_db_1:mysql
- project_db_1:postgresql
Обратите внимание: внешние контейнеры должны быть подключены хотя бы к одной сети, к которой подключен сервис.
image
Образ для контейнера. Может быть репозиторием/тегом или частичным идентификатором (partial identifier):
image: redis
image: node:16
image: example-registry.com:4000/postgresql
links
Подключение контейнера к другому сервису. Подключаемый сервис определяется с помощью названия сервиса и синонима ссылки (link alias) ("SERVICE:ALIAS"
) или только названия:
web:
links:
- "db"
- "db:database"
- "redis"
network_mode
Сетевой режим:
network_mode: "bridge"
network_mode: "host"
network_mode: "none"
networks
Сети для подключения:
services:
some-service:
networks:
- some-network
- other-network
ports
Выставление портов.
Короткий синтаксис позволяет делать следующее:
- определять оба порта (
HOST:CONTAINER
); - определять только порт контейнера (для хоста выбирается эфемерный порт);
- определять
IP-адрес
хоста для привязки (bind) и оба порта (значением по умолчанию является0.0.0.0
, что означает все интерфейсы) (IPADDR:HOSTPORT:CONTAINERPORT
).
ports:
- "3000"
- "8000:8000"
- "9090-9091:8080-8081"
- "127.0.0.1:8001:8001"
- "127.0.0.1::5000"
- "6060:6060/udp"
Длинный синтаксис позволяет настраивать дополнительные поля:
target
: порт контейнера;published
: порт хоста (доступный публично);protocol
: протокол порта (tcp
илиudp
);mode
:host
|ingress
.
restart
Определение политики перезапуска. Значением по умолчанию является no
, что означает отключение автоматического перезапуска. always
означает перезапуск в любом случае. on-failure
означает перезапуск только в случае аварийной остановки контейнера. unless-stopped
означает перезапуск контейнера во всех случаев, кроме преднамеренной остановки:
restart: "no"
restart: always
restart: on-failure
restart: unless-stopped
volumes
Монтирование путей хоста (host paths) или именованных томов (named volumes), определенных в виде дополнительных настроек сервиса.
Пути хоста могут монтироваться как часть определе ния сервиса. Их не нужно указывать в ключе volume
на верхнем уровне.
Однако, если необходимо, чтобы тома использовались несколькими сервисами, они должны быть перечислены в таком volume
.
В следующем примере именованный том mydata
используется сервисом web
, для отдельного сервиса определяется bind mount
(первый путь в volumes
сервиса db
). db
также использует именованный том dbdata
(второй путь), но определяет его с помощью устаревшего строкового формата для монтирования именованных томов. Именованные тома указываются в ключе volume
верхнего уровня:
version: "3.9"
services:
web:
image: nginx:alpine
volumes:
- type: volume
source: mydata
target: /data
volume:
nocopy: true
- type: bind
source: ./static
target: /opt/app/static
db:
image: postgres:latest
volumes:
- "/var/run/postgres/postgres.sock:/var/run/postgres/postgres.sock"
- "dbdata:/var/lib/postgresql/data"
volumes:
mydata:
dbdata:
Короткий синтаксис
В этом случае используется формат [SOURCE:]TARGET[:MODE]
, где SOURCE
- это путь хоста или именованный том, TARGET
- это путь монтирования тома в контейнере и MODE
- ro
для доступа только для чтения и rw
для доступа для чтения и записи (дефолтное значение).
Для монтирования могут использоваться относительные пути (путь вычисляется, начиная с директории с файлом Compose
). Относительные пути должны начинаться с .
или ..
.
volumes:
# определяем только путь и делегируем создание тома движку
- /var/lib/mysql
# определяем связывание (mapping) абсолютных путей
- /opt/data:/var/lib/mysql
# путь хоста относительно директории с файлом `Compose`
- ./cache:/tmp/cache
# путь относительно пользователя
- ~/configs:/etc/configs/:ro
# именованный том
- datavolume:/var/lib/mysql
Длинный синтаксис
Длинный синтаксис позволяет настраивать дополнительные поля:
type
: тип монтирования -volume
,bind
,tmpfs
илиnpipe
;source
: источник монтирования, путь хоста дляbind mount
или название тома, определенное в верхнеуровневомvolumes
;target
: путь монтирования тома в контейнере;read_only
: индикатор доступности тома только для чтения;bind
: дополнительные настройки связывания:propagation
: режим распространения, используемый для связывания;volume
: дополнительные настройки тома:nocopy
: индикатор запрета копирования данных тома.
version: "3.9"
services:
web:
image: nginx:alpine
ports:
- "80:80"
volumes:
- type: volume
source: mydata
target: /data
volume:
nocopy: true
- type: bind
source: ./static
target: /opt/app/static
networks:
webnet:
volumes:
mydata:
Другие настройки, соответствующие настройкам команды docker run
user: postgres
working_dir: /code
domainname: foo.com
hostname: foo
ipc: host
mac_address: 02:42:ac:11:65:43
privileged: true
read_only: true
shm_size: 64M
stdin_open: true
tty: true
Примеры определения продолжительности
2.5s
10s
1m30s
2h32m
5h34m56s
Примеры определения байтовых значений
2b
1024kb
2048k
300m
1gb
Замена переменных
Настройки могут содержать переменные среды окружения. Compose
использует значения переменных из терминала при выполнении команды docker compose
. Например, предположим, что терминал содержит POSTGRES_VERSION=9.3
и применяется такая настройка:
db:
image: "postgres:${POSTGRES_VERSION}"
При выполнении docker compose
значение переменной POSTGRES_VERSION
в настройках заменяется на 9.3
и мы получаем postgres:9.3
.
Если значение переменной не установлено, переменная в настройках заменяется пустой строкой и мы получаем postgres:
.
Дефолтные значения переменных могут быть установлены в файле .env
, который должен находиться в той же директории, что и файл Compose
. Значения переменных в терминале перезаписывают значения, определенные в .env
.
Поддерживается 2 варианта синтаксиса: $VAR
и ${VAR}
. Второй вариант предоставляет такие дополнительные возможности, как:
- определение значений по умолчанию:
${VAR:-default}
: оценивается какdefault
, когдаVAR
не установлена или является пустой;${VAR-default}
: оценивается какdefault
только когдаVAR
не установлена;- определение обязательных значений:
${VAR:?error}
: ошибка возникает, еслиVAR
не установлена или является пустой;${VAR?error}
: ошибка возникает, только еслиVAR
не установлена.
Расширенные возможности интерполяции переменных типа ${VAR/foo/bar}
в настоящее время не поддерживаются.
Спецификация файла Compose
версии 3.
Разработка приложения
Подготовка и настройка проекта
Предполагается, что вы хотя бы вкратце ознакомились с содержанием предыдущих частей или изучали другие материалы, посвящ енные работе с Docker
. Впрочем, в этой части Docker
будет совсем чуть-чуть.
Также предполагается, что на вашей машине установлен Docker
и Node.js
.
Хорошо, если на вашей машине установлен Yarn
и вы имеете опыт работы с React
, Vue
, Node.js
, PostgreSQL
и sh или bash
(все это опционально).
Как я сказал, наше приложение будет состоять из трех сервисов:
- клиента на
React.js
; - админки на
Vue.js
; - сервера (API) на
Node.js
.
В качестве базы данных мы будем использовать PostgreSQL
, а для взаимодействия с ней - Prisma.
Функционал нашего приложения будет следующим:
- в админке задаются настройки для приветствия, темы и базового размера шрифта;
- эти настройки записываются в БД и применяются на клиенте;
- на клиенте реализована "тудушка";
- задачи записываются в БД;
- все это обслуживается сервером.
Создаем директорию для проекта, переходим в нее и создаем еще парочку директорий:
mkdir docker-test
cd !$ # docker-test
mkdir services sh uploads
В директории services
будут находиться наши сервисы, в директории sh
- скрипты для терминала, директорию uploads
мы использовать не будем, но обычно в ней хранятся различные файлы, загружаемые админом или пользователями.
Переходим в директорию services
, создаем директорию для API, генерируем шаблон клиента с помощью Create React App
и шаблон админки с помощью Vue CLI
:
cd services
mkdir api
yarn create react-app client
# or
npx create-react-app client
yarn create vue-app admin
# or
npx vue create admin
Начнем с API.
API
Переходим в директорию api
, инициализируем Node.js-проект
и устанавливаем зависимости:
cd api
yarn init -yp
# or
npm init -y
# производственные зависимости
yarn add express cors
# зависимости для разработки
yarn add -D nodemon prisma
- express -
Node.js-фреймворк
для разработки веб-серверов; - cors - утилита для работы с CORS;
- nodemon - утилита для запуска сервера для разработки;
- prisma - ядро (core) ORM, которое мы будем использовать для взаимодействия с
postgres
.
Инициализируем prisma
:
npx prisma init
Это приводит к генерации директории prisma
, а также файлов prisma/schema.prisma
и .env
.
Определяем генератор, источник данных и модели в файле prisma/schema.prisma
:
// https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
// это нужно для контейнера
binaryTargets = ["native"]
}
datasource db {
provider = "postgresql"
// путь к БД извлекается из переменной среды окружения `DATABASE_URL`
url = env("DATABASE_URL")
}
// модель для настроек
model Settings {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
greetings String
theme String
base_font_size String
}
// модель для задачи
model Todo {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
text String
done Boolean
}
Определяем путь к БД в файле .env
:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb?schema=public
Здесь:
postgres
- имя пользователя и пароль;localhost
- хост, на котором запущен серверpostgres
;5432
- порт, на котором запущен серверpostgres
;mydb
- название БД.
Определяем команду для запуска контейнера postgres
в файле sh/db
(без расширения):
docker run --rm --name postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=mydb -dp 5432:5432 -v $HOME/docker/volumes/postgres:/var/lib/postgresql/data postgres
Обратите внимание: если вы работаете на Mac
, вам потребуется предоставить самому себе разрешение на выполнение кода из файла sh/db
. Это можно сделать так:
# мы находимся в директории `sh`
chmod +x db
# or
sudo chmod +x db
Находясь в корневой директории проекта, открываем терминал и выполняем команду:
sh/db
Происходит загрузка образа postgres
из Docker Hub
и запуск контейнера под названием postgres
.
Обратите внимание: иногда может возникнуть ошибка, связанная с тем, что порт 5432 занят другим процессом. В этом случае необходимо найти PID
данного процесса и "убить" его. На Mac
это делается так:
# получаем `PID` процесса, запущенного на порту `5432`
sudo lsof -i :5432
# предположим, что `PID` имеет значение `103`
# "убиваем" процесс
sudo kill 103
Убедиться в запуске контейнера можно, выполнив команд у docker ps
:
![](https://habrastorage.org/webt/qz/cb/jn/qzcbjnikl7acjtp3u90sd1xcf2o.png)
Или запустив Docker Desktop
:
![](https://habrastorage.org/webt/xr/zu/n9/xrzun9hxa_zlwwvueumwjzyzq98.png)
Или в разделе Individual Containers
расширения Docker
для VSCode
:
![](https://habrastorage.org/webt/1l/e0/96/1le096w7kcbze_0outprie0ua1g.png)
Выполняем миграцию:
# мы находимся в директории `api`
# migrate dev - миграция для разработки
# --name init - название миграции
npx prisma migrate dev --name init
Это приводит к генерации файла prisma/migrations/[Date]-init/migration.sql
, подключению к БД, созданию в ней таблиц, установке и настройке @prisma/client
.
Создаем файл prisma/seed.js
с кодом для заполнения БД начальными данными:
import Prisma from '@prisma/client'
const { PrismaClient } = Prisma
// инициализируем клиента
const prisma = new PrismaClient()
// начальные настройки
const initialSettings = {
greetings: 'Welcome to Docker Test App',
theme: 'light',
base_font_size: '16px'
}
// начальные задачи
const initialTodos = [
{
text: 'Eat',
done: true
},
{
text: 'Code',
done: true
},
{
text: 'Sleep',
done: false
},
{
text: 'Repeat',
done: false
}
]
async function main() {
try {
// если таблица настроек является пустой
if (!(await prisma.settings.findFirst())) {
await prisma.settings.create({ data: initialSettings })
}
// если таблица задач является пустой
if (!(await (await prisma.todo.findMany()).length)) {
await prisma.todo.createMany({ data: initialTodos })
}
console.log('Database has been successfully seeded 🚀 ')
} catch (e) {
console.log(e)
} finally {
await prisma.$disconnect()
}
}
main()
В package.json
определяем тип кода сервера (модуль), команды для запуска сервера в режиме для разработки и производственном режиме, а также команду для заполнения БД начальными данными:
"type": "module",
"scripts": {
"dev": "nodemon",
"start": "prisma generate && prisma migrate deploy && node index.js"
},
"prisma": {
"seed": "node prisma/seed.js"
}
Заполняем БД начальными данными:
# мы находимся в директории `api`
npx prisma db seed
Открываем нашу БД в интерактивном режиме:
npx prisma studio
Это приводит к открытию вкладки браузера по адресу http://localhost:5555
:
![](https://habrastorage.org/webt/6-/ar/y1/6-ary1tso3uyjb0imkqlwg4aw94.png)
Приступаем к разработке сервера.
Структура сервера будет следующей:
- routes
- index.js
- settings.routes.js - маршруты (роуты) для настроек
- todo.routes.js - роуты для задач
- index.js
Содержание файла index.js
:
// импортируем библиотеки и утилиты
import express from 'express'
import cors from 'cors'
import Prisma from '@prisma/client'
import apiRoutes from './routes/index.js'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const { PrismaClient } = Prisma
// создаем и экспортируем экземпляр `prisma`
export const prisma = new PrismaClient()
// путь к текущей директории
const __dirname = dirname(fileURLToPath(import.meta.url))
// создаем экземпляр приложения `express`
const app = express()
// отключаем `cors`
app.use(cors())
// включаем парсинг `json` в объекты
app.use(express.json())
// это пригодится нам при запуске приложения в производственном режиме
if (process.env.ENV === 'prod') {
// обратите внимание на пути
// путь к текущей директории + `client/build`
const clientBuildPath = join(__dirname, 'client', 'build')
// путь к текущей директории + `admin/dist`
const adminDistPath = join(__dirname, 'admin', 'dist')
// обслуживание статических файлов
// клиент будет доступен по пути сервера
app.use(express.static(clientBuildPath))
app.use(express.static(adminDistPath))
// админка будет доступна по пути сервера + `/admin`
app.use('/admin', (req, res) => {
res.sendFile(join(adminDistPath, decodeURIComponent(req.url)))
})
}
// роутинг
app.use('/api', apiRoutes)
// обработчик ошибок
app.use((err, req, res, next) => {
console.log(err)
const status = err.status || 500
const message = err.message || 'Something went wrong. Try again later'
res.status(status).json({ message })
})
// запускаем сервер на порту 5000
app.listen(5000, () => {
console.log(`Server ready 🚀 `)
})
Рассмотрим роуты.
Начнем с роутера приложения (routes/index.js
):
import { Router } from 'express'
import todoRoutes from './todo.routes.js'
import settingsRoutes from './settings.routes.js'
const router = Router()
router.use('/todo', todoRoutes)
router.use('/settings', settingsRoutes)
export default router
Роутер для настроек (routes/settings.routes.js
):
import { Router } from 'express'
import { prisma } from '../index.js'
const router = Router()
// получение настроек
router.get('/', async (req, res, next) => {
try {
const settings = await prisma.settings.findFirst()
res.status(200).json(settings)
} catch (e) {
next(e)
}
})
// обновление настроек
router.put('/:id', async (req, res, next) => {
const id = Number(req.params.id)
try {
const settings = await prisma.settings.update({
data: req.body,
where: { id }
})
res.status(201).json(settings)
} catch (e) {
next(e)
}
})
export default router
Роутер для задач (routes/todo.routes.js
):
import { Router } from 'express'
import { prisma } from '../index.js'
const router = Router()
// получение задач
router.get('/', async (req, res, next) => {
try {
const todos = (await prisma.todo.findMany()).sort(
(a, b) => a.created_at - b.created_at
)
res.status(200).json(todos)
} catch (e) {
next(e)
}
})
// создание задачи
router.post('/', async (req, res, next) => {
try {
const newTodo = await prisma.todo.create({
data: req.body
})
res.status(201).json(newTodo)
} catch (e) {
next(e)
}
})
// обновление задачи
router.put('/:id', async (req, res, next) => {
const id = Number(req.params.id)
try {
const updatedTodo = await prisma.todo.update({
data: req.body,
where: { id }
})
res.status(201).json(updatedTodo)
} catch (e) {
next(e)
}
})
// удаление задачи
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id)
try {
await prisma.todo.delete({
where: { id }
})
res.sendStatus(201)
} catch (e) {
next(e)
}
})
export default router
Это все, что требуется от нашего сервера.
Админка
Структура админки будет следующей (admin/src
):
- components
- App.vue - основной компонент приложения
- Settings.vue - компонент для обновления настроек
- index.js
Начнем с основного компонента (components/App.vue
).
Разметка:
<template>
<div id="app">
<h1>Admin</h1>
<!-- Загрузка -->
<h2 v-if="loading">Loading...</h2>
<!-- Ошибка -->
<h3 v-else-if="error" class="error">
{{ error.message || 'Something went wrong. Try again later' }}
</h3>
<!-- Компонент для обновления настроек -->
<div v-else>
<h2>Settings</h2>
<!-- Пропы: настройки, полученные от сервера (из БД), метод для их получения и адрес API -->
<Settings :settings="settings" :getSettings="getSettings" :apiUri="apiUri" />
</div>
</div>
</template>
Стили:
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');
:root {
--primary: #0275d8;
--success: #5cb85c;
--warning: #f0ad4e;
--danger: #d9534f;
--light: #f7f7f7;
--dark: #292b2c;
}
* {
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
}
body.light {
background-color: var(--light);
color: var(--dark);
}
body.dark {
background-color: var(--dark);
color: var(--light);
}
#app {
display: flex;
flex-direction: column;
text-align: center;
}
h2 {
font-size: 1.4rem;
}
form div {
display: flex;
flex-direction: column;
align-items: center;
}
label {
margin: 0.5rem 0;
}
input {
padding: 0.5rem;
max-width: 220px;
width: max-content;
outline: none;
border: 1px solid var(--dark);
border-radius: 4px;
text-align: center;
}
input:focus {
border-color: var(--primary);
}
button {
margin: 1rem 0;
padding: 0.5rem 1rem;
background: none;
border: none;
border-radius: 4px;
outline: none;
background-color: var(--success);
color: var(--light);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
cursor: pointer;
user-select: none;
transition: 0.2s;
}
button:active {
box-shadow: none;
}
.error {
color: var(--danger);
}
Скрипт:
// импортируем компонент для обновления настроек
import Settings from './Settings'
export default {
// название компонента
name: 'App',
// дочерние компоненты
components: {
Settings
},
// начальное состояние
data() {
return {
loading: true,
error: null,
settings: {},
apiUri: 'http://localhost:5000/api/settings'
}
},
// монтирование компонента
created() {
// получаем настройки
this.getSettings()
},
// методы
methods: {
// для получения настроек
async getSettings() {
this.loading = true
try {
const response = await fetch(API_URI)
if (!response.ok) throw response
this.settings = await response.json()
} catch (e) {
this.error = e
} finally {
this.loading = false
}
}
}
}
Теперь рассмотрим компонент для обновления настроек (components/Settings.vue
).
Разметка:
<template>
<!-- Загрузка -->
<div v-if="loading">Loading...</div>
<!-- Ошибка -->
<div v-else-if="error">
{{ error.message || JSON.stringify(error, null, 2) }}
</div>
<!-- Настройки -->
<form v-else @submit.prevent="saveSettings">
<!-- Приветствие -->
<div>
<label for="greetings">Greetings</label>
<input
type="text"
id="greetings"
name="greetings"
:value="settings.greetings"
required
/>
</div>
<!-- Тема -->
<div>
<label for="theme">Theme</label>
<input
type="text"
id="theme"
name="theme"
:value="settings.theme"
required
/>
</div>
<!-- Базовый размер шрифта -->
<div>
<label for="base_font_size">Base font size</label>
<input
type="text"
id="base_font_size"
name="base_font_size"
:value="settings.base_font_size"
required
/>
</div>
<button>Save</button>
</form>
</template>
Скрипт:
export default {
// название компонента
name: 'Settings',
// пропы
props: {
settings: {
type: Object,
required: true
},
getSettings: {
type: Function,
required: true
},
apiUri: {
type: String,
required: true
}
},
// начальное состояние
data() {
return {
loading: false,
error: null
}
},
// методы
methods: {
// для обновления настроек в БД
async saveSettings(e) {
this.loading = true
const formDataObj = [...new FormData(e.target)].reduce(
(obj, [key, val]) => ({
...obj,
[key]: val
}),
{}
)
try {
const response = await fetch(`${this.apiUri}/${this.settings.id}`, {
method: 'PUT',
body: JSON.stringify(formDataObj),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) throw response
// получаем обновленные настройки
await this.getSettings()
} catch (e) {
this.error = e
} finally {
this.loading = false
}
}
}
}
На этом с админкой мы закончили.
Клиент
Структура клиента будет следующей (client/src
):
- api
- settings.api.js - API для настроек
- todo.api.js - API для задач
- components
- TodoForm.js - компонент для создания задачи
- TodoList.js - компонент для формирования списка задач
- hooks
- useStore.js - хранилище состояние в виде пользовательского хука
- App.js - основной компонент приложения
- App.css
- index.js
Для управления состоянием приложения мы будем использовать Zustand
.
Устанавливаем его:
yarn add zustand
Начнем с API для настроек (api/settings.api.js
):
// конечная точка
const API_URI = 'http://localhost:5000/api/settings'
// метод для получения настроек
const fetchSettings = async () => {
try {
const response = await fetch(API_URI)
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
const settingsApi = { fetchSettings }
export default settingsApi
API для задач (api/todo.api.js
):
// конечная точка
const API_URI = 'http://localhost:5000/api/todo'
// метод для получения задач
const fetchTodos = async () => {
try {
const response = await fetch(API_URI)
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
// метод для создания новой задачи
const addTodo = async (newTodo) => {
try {
const response = await fetch(API_URI, {
method: 'POST',
body: JSON.stringify(newTodo),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
// метод для обновления задачи
const updateTodo = async (id, changes) => {
try {
const response = await fetch(`${API_URI}/${id}`, {
method: 'PUT',
body: JSON.stringify(changes),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) throw response
return await response.json()
} catch (e) {
throw e
}
}
// метод для удаления задачи
const removeTodo = async (id) => {
try {
const response = await fetch(`${API_URI}/${id}`, {
method: 'DELETE'
})
if (!response.ok) throw response
} catch (e) {
throw e
}
}
const todoApi = { fetchTodos, addTodo, updateTodo, removeTodo }
export default todoApi
Хранилище состояния в виде пользовательского хука (hooks/useStore.js
):
import create from 'zustand'
// API для настроек
import settingsApi from '../api/settings.api'
// API для задач
import todoApi from '../api/todo.api'
const useStore = create((set, get) => ({
// начальное состояние
settings: {},
todos: [],
loading: false,
error: null,
// методы для
// получения настроек
fetchSettings() {
set({ loading: true })
settingsApi
.fetchSettings()
.then((settings) => {
set({ settings })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// получения задач
fetchTodos() {
set({ loading: true })
todoApi
.fetchTodos()
.then((todos) => {
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// создания задачи
addTodo(newTodo) {
set({ loading: true })
todoApi
.addTodo(newTodo)
.then((newTodo) => {
const todos = [...get().todos, newTodo]
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// обновления задачи
updateTodo(id, changes) {
set({ loading: true })
todoApi
.updateTodo(id, changes)
.then((updatedTodo) => {
const todos = get().todos.map((todo) =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
},
// удаления задачи
removeTodo(id) {
set({ loading: true })
todoApi
.removeTodo(id)
.then(() => {
const todos = get().todos.filter((todo) => todo.id !== id)
set({ todos })
})
.catch((error) => {
set({ error })
})
.finally(() => {
set({ loading: false })
})
}
}))
export default useStore
Компонент для создания новой задачи (components/TodoForm.js
):
import { useState, useEffect } from 'react'
import useStore from '../hooks/useStore'
export default function TodoForm() {
// метод для создания задачи из хранилища
const addTodo = useStore(({ addTodo }) => addTodo)
// состояние для текста новой задачи
const [text, setText] = useState('')
const [disable, setDisable] = useState(true)
useEffect(() => {
setDisable(!text.trim())
}, [text])
// метод для обновления текста задачи
const onChange = ({ target: { value } }) => {
setText(value)
}
// метод для отправки формы
const onSubmit = (e) => {
e.preventDefault()
if (disable) return
const newTodo = {
text,
done: false
}
addTodo(newTodo)
}
return (
<form onSubmit={onSubmit}>
<label htmlFor='text'>New todo text</label>
<input type='text' id='text' value={text} onChange={onChange} />
<button className='add'>Add</button>
</form>
)
}
Компонент для формирования списка задач (components/TodoList.js
):
import useStore from '../hooks/useStore'
export default function TodoList() {
// задачи и методы для обновления и удаления задачи из хранилища
const { todos, updateTodo, removeTodo } = useStore(
({ todos, updateTodo, removeTodo }) => ({ todos, updateTodo, removeTodo })
)
return (
<ul>
{todos.map(({ id, text, done }) => (
<li key={id}>
<input
type='checkbox'
checked={done}
onChange={() => {
updateTodo(id, { done: !done })
}}
/>
<span>{text}</span>
<button
onClick={() => {
removeTodo(id)
}}
>
Remove
</button>
</li>
))}
</ul>
)
}
Основной компонент приложения (App.js
):
import { useEffect } from 'react'
import './App.css'
import useStore from './hooks/useStore'
import TodoForm from './components/TodoForm'
import TodoList from './components/TodoList'
// получаем настройки
useStore.getState().fetchSettings()
// получаем задачи
useStore.getState().fetchTodos()
function App() {
// настройки, индикатор загрузки и ошибка из хранилища
const { settings, loading, error } = useStore(
({ settings, loading, error }) => ({ settings, loading, error })
)
useEffect(() => {
if (Object.keys(settings).length) {
// применяем базовый размер шрифта к элементу `html`
document.documentElement.style.fontSize = settings.base_font_size
// применяем тему
document.body.className = settings.theme
}
}, [settings])
// загрузка
if (loading) return <h2>Loading...</h2>
// ошибка
if (error)
return (
<h3 className='error'>
{error.message || 'Something went wrong. Try again later'}
</h3>
)
return (
<div className='App'>
<h1>Client</h1>
<h2>{settings.greetings}</h2>
<TodoForm />
<TodoList />
</div>
)
}
export default App
Стили (App.css
):
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');
:root {
--primary: #0275d8;
--success: #5cb85c;
--warning: #f0ad4e;
--danger: #d9534f;
--light: #f7f7f7;
--dark: #292b2c;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
}
/* Тема */
body.light {
background-color: var(--light);
color: var(--dark);
}
body.dark {
background-color: var(--dark);
color: var(--light);
}
/* --- */
#root {
padding: 1rem;
display: flex;
justify-content: center;
}
.App {
display: flex;
flex-direction: column;
align-items: center;
}
h1,
h2 {
margin: 1rem 0;
}
h1 {
font-size: 1.6rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.2rem;
}
label {
margin-bottom: 0.5rem;
display: block;
}
form {
margin: 1rem 0;
}
form input {
padding: 0.5rem;
max-width: 220px;
width: max-content;
outline: none;
border: 1px solid var(--dark);
border-radius: 4px;
text-align: center;
}
form input:focus {
border-color: var(--primary);
}
ul {
list-style: none;
}
li {
margin: 0.75rem 0;
display: flex;
align-items: center;
justify-content: space-between;
}
li input {
width: 18px;
height: 18px;
}
li span {
display: block;
width: 120px;
word-break: break-all;
}
button {
padding: 0.5rem 1rem;
background: none;
border: none;
border-radius: 4px;
outline: none;
background-color: var(--danger);
color: var(--light);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
cursor: pointer;
user-select: none;
transition: 0.2s;
}
button:active {
box-shadow: none;
}
button.add {
background-color: var(--success);
}
.error {
color: var(--danger);
}
.App {
text-align: center;
}
Поскольку отступы и размеры заданы с помощью rem, мы легко можем манипулировать этими значениями, меняя размер шрифта элемента html
.
На этом с клиентом мы также закончили.
Проверка работоспособности приложения
Поднимаемся в корневую директорию (docker-test
), инициализируем Node.js-проект
и устанавливаем concurrently
- утилиту для одновременного выполнения команд, определенных в файле package.json
:
# мы находимся в директории `docker-test`
yarn init -yp
yarn add concurrently
Определяем команды для запуска серверов для разработки в package.json
:
"scripts": {
"dev:client": "yarn --cwd services/client start",
"dev:admin": "yarn --cwd services/admin dev",
"dev:api": "yarn --cwd services/api dev",
"dev": "concurrently \"yarn dev:client\" \"yarn dev:admin\" \"yarn dev:api\""
}
Выполняем команду yarn dev
или npm run dev
.
Это приводит к запуску 3 серверов для разработки:
- для клиента по адресу
http://localhost:3000
:
![](https://habrastorage.org/webt/ve/jd/5q/vejd5qrgzgbzll2pyb0jxpg0ucw.png)
- для админки по адресу
http://localhost:4000
":
![](https://habrastorage.org/webt/tz/uy/7-/tzuy7-muwq_lxdant4hgpnfemjs.png)
- для сервера по адресу
http://localhost:5000
.
Меняем настройки в админке:
![](https://habrastorage.org/webt/kp/63/wu/kp63wu9r_cdnd-cyqwc9-p2dle0.png)
Перезагружаем клиента:
![](https://habrastorage.org/webt/85/fy/cw/85fycwhapup8y_auvgrldgwke1m.png)
Видим, что настройки успешно применились.
Работаем с задачами:
![](https://habrastorage.org/webt/sw/yr/nc/swyrncbic8rxv1s0rxeczhg1bhk.png)
Задачи успешно создаются/обновляются/удаляются и сохраняются в БД.
Отлично. Приложение работает, как ожидается.
Репозиторий с кодом приложения.
Если вы используете npm, команды для запуска серверов для разработки в файле package.json
должны выглядеть так:
"scripts": {
"dev:client": "npm run start --prefix services/client",
"dev:admin": "npm run dev --prefix services/admin",
"dev:api": "npm run dev --prefix services/api",
"dev": "concurrently \"npm run dev:client\" \"npm run dev:admin\" \"npm run dev:api\""
}
"Контейнеризация" приложения
Dockerfile
Начнем с определения Dockerfile
для сервисов нашего приложения.
В директории client
создаем файл Dockerfile
следующего содержания:
# дефолтная версия `Node.js`
ARG NODE_VERSION=16.13.1
# используемый образ
FROM node:$NODE_VERSION
# рабочая директория
WORKDIR /client
# копируем указанные файлы в рабочую директорию
COPY package.json yarn.lock ./
# устанавливаем зависимости
RUN yarn
# копируем остальные файлы
COPY . .
# выполняем сборку приложения
RUN yarn build
Обратите внимание: на данном этапе вместо сборки (RUN yarn build
) мы могли бы выполнять команду start
для запуска сервера для разработки: CMD ["yarn", "start"]
, но если мы так сделаем, то впоследствии нам придется создавать отдельный Dockerfile
для продакшна. Проще сразу определить производственную версию Dockerfile
, а команду start
запускать из docker-compose.yml
.
Создаем практический идентичный Dockerfile
в директории admin
:
ARG NODE_VERSION=16.13.1
FROM node:$NODE_VERSION as build
WORKDIR /admin
COPY package.json yarn.lock ./
RUN yarn
COPY . .
RUN yarn build
Обратите внимание: сборка клиента будет находится в директории client/build
, а сборка админки - в директории admin/dist
. В файле api/index.js
можно найти такие строки:
if (process.env.ENV === 'production') {
const clientBuildPath = join(__dirname, 'client', 'build')
const adminDistPath = join(__dirname, 'admin', 'dist')
app.use(express.static(clientBuildPath))
app.use(express.static(adminDistPath))
app.use('/admin', (req, res) => {
res.sendFile(join(adminDistPath, decodeURIComponent(req.url)))
})
}
Эти строки говорят нам о том, что при запуске сервера в производственном режиме (process.env.ENV === 'production'
), он будет обслуживать статические файлы из названных выше директорий: клиент будет доступен по маршруту (роуту) /
, а админка - по роуту /admin
. Мы вернемся к этому позже.
Создаем похожий Dockerfile
в директории api
:
ARG NODE_VERSION=16.13.1
FROM node:$NODE_VERSION
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn
COPY . .
# выставляем порт
EXPOSE 5000
# запускаем сервер в производственном режиме
CMD ["yarn", "start"]
Обратите внимание: инструкции EXPOSE 5000
и CMD ["yarn", "start"]
на данном этапе можно опустить, но они потребуются нам в продакшне. На самом деле, нам потребуется кое-что еще, но позвольте пока сохранить интригу.
Также обратите внимание, что я внес парочку изменений в проект:
- Содержание файла
.env
, находящегося корневой директории проекта:
# добавил название приложения
APP_NAME=my-app
# уточнил версию `Node.js`
NODE_VERSION=16.13.1
POSTGRES_VERSION=14
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=mydb
# перенес сюда путь к БД из файла `api/.env`
# обратите внимание, что вместо `localhost` после символа `@` мы указываем название контейнера - `postgres`
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/mydb?schema=public
ENV=development
- Команда для запуска сервера для разработки (файл
api/package.json
, разделscripts
):
"dev": "prisma migrate dev && prisma db seed && nodemon",
Хорошей практикой считается исключение файлов из образа с помощью .dockerignore
:
node_modules
yarn-error.log
# mac
.DS_Store
Такой файл нужно создать в каждом сервисе.
После создания Dockerfile
для каждого сервиса мы готовы к "контейнеризации" приложения.
Docker Compose
Создаем в корневой директории файл docker-compose.dev.yml
следующего содержания:
# версия `compose`
version: '3.9'
# сервисы
services:
# БД
postgres:
# файл, содержащий переменные среды окружения
env_file: .env
# название контейнера
container_name: ${APP_NAME}_postgres
# используемый образ
image: postgres:${POSTGRES_VERSION}
# именованный том для хранения данных
volumes:
- data_postgres:/var/lib/postgresql/data
# порт для доступа к БД
ports:
- 5432:5432
# политика перезапуска контейнера
restart: on-failure
client:
env_file: .env
container_name: ${APP_NAME}_client
image: node:${NODE_VERSION}
# рабочая директория
working_dir: /app
# анонимный том
# `rw` означает `read/write` - чтение/запись
volumes:
- ./services/client:/app:rw
# сервис, от которого зависит работоспособность данного сервиса
depends_on:
- api
ports:
- 3000:3000
restart: on-failure
# команда для запуска сервера для разработки
command: bash -c "yarn start"
admin:
env_file: .env
container_name: ${APP_NAME}_admin
image: node:${NODE_VERSION}
working_dir: /app
volumes:
- ./services/admin:/app:rw
depends_on:
- api
ports:
- 4000:4000
restart: on-failure
command: bash -c "yarn dev"
api:
env_file: .env
container_name: ${APP_NAME}_api
# ссылка на `Dockerfile`, на основе которого выполняется сборка
build: services/api
ports:
- 5000:5000
depends_on:
- postgres
restart: on-failure
# перезапись команды `yarn start`, определенной в `Dockerfile`
command: bash -c "yarn dev"
# тома
volumes:
data_postgres:
Определим в package.json
несколько команд для управления compose
:
"dev:compose:up": "docker compose -f docker-compose.dev.yml up -d",
"dev:compose:stop": "docker compose -f docker-compose.dev.yml stop",
"dev:compose:rm": "docker compose -f docker-compose.dev.yml rm",
"compose:up": "docker compose up -d",
"compose:stop": "docker compose stop",
"compose:rm": "docker compose rm"
Команда compose:up
поднимает, команда compose:stop
- останавливает, а команда compose:rm
- удаляет сервис. Префикс dev:
означает что поднимается/останавливается/удаляется сервис для разработки. В свою очередь, отсутствие данного префикса означает управление производственным сервисом (по умолчанию compose
использует файл docker-compose.yml
, которым мы займемся позже).
Еще несколько команд, которые могут пригодится при работе с compose
при отладке приложения:
# список запущенн ых контейнеров
docker ps
# список запущенных сервисов
docker compose ls
# список образов
docker images
# удаление образа
# [image-name] - название образа
docker image rm [image-name]
# например
docker image rm docker-test_api
# список томов
docker volume ls
# удаление тома
# [volume-name] - название тома
docker volume rm [volume-name]
# например
docker volume rm postgres_data
# очистка системы (тома не удаляются)
docker system prune -a
Поднимаем сервис в режиме для разработки с помощью команды yarn dev:compose:up
или npm run dev:compose:up
:
![](https://habrastorage.org/webt/8v/nn/wd/8vnnwdtoonf8r4shpr79a7zp7iu.png)
![](https://habrastorage.org/webt/nk/le/j2/nklej2vgp4nc9q-bhy5nbhesjli.png)
После создания контейнеров сервисам потребуется какое-то время на запуск, после чего они будут доступны по следующим адресам:
- клиент:
localhost:3000
; - админка:
localhost:4000
; - сервер:
localhost:5000
(нет прямого доступа; доступен для клиента и админки); - БД:
postgres:5432
(нет прямого доступа; доступен только для сервера).
По сути, команда dev:compose:up
делает то же самое, что и команда dev
+ скрипт из файла db
.
Чем производственный сервис будет отличаться от сервиса для разработки? Предположим, что мы хотим, чтобы всю статику приложения обслуживал сервер, поэтому нам требуется какой-то способ передать api
сборки клиента и админки. Существует несколько способов это сделать. Одним из самых простых и удобных является использование Docker Hub.
Переходим по ссылке и создаем аккаунт.
Переходим в директорию client
и создаем образ с тегом:
cd client
# [username] - ваш логин для входа в dockerhub
# тег образа обязательно должен начинаться с вашего логина
docker build . -t [username]/docker-test_client
# мой логин - aio350
docker build . -t aio350/docker-test_client
Авторизуемся в dockerhub
и отправляем образ в свой реестр:
docker login
docker push aio350/docker-test_client
Делаем то же самое для админки:
cd admin
docker build . -t aio350/docker-test_admin
docker push aio350/docker-test_admin
После этого в своем реестре dockerhub
мы увидим следующую картину:
![](https://habrastorage.org/webt/nk/8k/qw/nk8kqwzyg2atkg7znuf9maep9pe.png)
Немного отредактируем файл api/Dockerfile
:
ARG NODE_VERSION=16.13.1
# копируем образ клиента из `dockerhub`
# `AS` позволяет ссылаться на этот слой в других инструкциях
FROM aio350/docker-test_client AS client
# образ админки
FROM aio350/docker-test_admin AS admin
FROM node:$NODE_VERSION
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn
COPY . .
# копируем сборку клиента
COPY --from=client /client/build /app/client/build
# копируем сборку админки
COPY --from=admin /admin/dist /app/admin/dist
EXPOSE 5000
CMD ["yarn", "start"]
Создаем в корневой директории проекта файл docker-compose.yml
следующего содержания:
version: '3.9'
services:
postgres:
env_file: .env
container_name: ${APP_NAME}_postgres
image: postgres:${POSTGRES_VERSION}
volumes:
- data_postgres:/var/lib/postgresql/data
ports:
- 5432:5432
restart: on-failure
# статика нашего приложения обслуживается сервером
# поэтому нам не нужно поднимать сервисы `client` и `admin`
api:
env_file: .env
# перезаписываем переменную `ENV`, определенную в файле `.env`
environment:
- ENV=production
container_name: ${APP_NAME}_api
build: services/api
depends_on:
- postgres
ports:
- 5000:5000
restart: on-failure
# выполняется команда `yarn start`, определенная в `Dockerfile`
volumes:
data_postgres:
Удаляем сервис для разработки, удаляем образ docker-test_api
, удаляем том docker-test_data_postgres
и поднимаем производственный сервис:
yarn dev:compose:stop
yarn dev:compose:rm
# для чистоты эксперимента
docker image rm docker-test_api
docker volume rm docker-test_data_postgres
yarn compose:up
![](https://habrastorage.org/webt/kb/cb/bu/kbcbbu9_p16qtrzv6ejbvlg7jpy.png)
Теперь наш сервис состоит всего из 2 контейнеров.
Клиент доступен по адресу: localhost:5000
, а админка - по адресу localhost:5000/admin
.
![](https://habrastorage.org/webt/v4/_1/71/v4_171hi0yhfqv-jhmhbyjbhqo8.png)
![](https://habrastorage.org/webt/2r/jy/bh/2rjybhdepx7ozyifsuvdyi9-udk.png)
Приложение работает, как ожидается.
На этом "контейнеризацию" нашего приложения можно считать завершенной.