В этой заметке я покажу вам, как и почему полезен Docker Compose, создав простое приложение, написанное на Python и использующее PostgreSQL. Я думаю, что стоит пройти через такое упражнение, чтобы увидеть, как технологии, с которыми мы, возможно, уже знакомы, на самом деле упрощают рабочие процессы, которые в противном случае были бы определенно сложнее.
Название демонстрационного приложения, которое я буду разрабатывать, — это очень незамысловатое whale
, которое не должно конфликтовать с любым другим названием, введенным инструментами, которые я буду использовать. Каждый раз, когда вы видите что-то с whale
в нем, вы знаете, что я имею в виду значение, которое вы можете изменить в соответствии с вашими настройками.
Прежде чем мы начнем, пожалуйста, создайте каталог для размещения всех файлов, которые мы будем создавать. Я буду называть эту директорию «директория проекта».
PostgreSQL
Поскольку приложение будет подключаться к базе данных PostgreSQL, первое, что мы можем изучить, это как запустить ее в контейнере Docker.
Официальный образ Postgres можно найти здесь, и я настоятельно рекомендую найти время, чтобы как следует ознакомиться с документацией, поскольку она содержит огромное количество деталей, с которыми вы должны быть знакомы.
Пока же давайте сосредоточимся на переменных окружения, которые необходимо задать в образе.
Пароль
Первая переменная — POSTGRES_PASSWORD
, которая является единственным обязательным значением конфигурации (если вы не отключите аутентификацию, что не рекомендуется). Действительно, если вы запустите образ без установки этого значения, вы получите следующее сообщение
$ docker run postgres
Error: Database is uninitialized and superuser password is not specified.
You must specify POSTGRES_PASSWORD to a non-empty value for the
superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".
You may also use "POSTGRES_HOST_AUTH_METHOD=trust" to allow all
connections without a password. This is *not* recommended.
See PostgreSQL documentation about "trust":
https://www.postgresql.org/docs/current/auth-trust.html
Это значение очень интересно, потому что оно является секретным. Поэтому, хотя на первых этапах настройки я буду рассматривать его как простое конфигурационное значение, позже нам нужно будет обсудить, как правильно им управлять.
Суперпользователь
Будучи базой данных производственного класса, Postgres позволяет задавать пользователей, группы и права доступа очень тонко. Я не буду углубляться в эту тему, так как обычно это больше относится к администрированию базы данных и разработке приложений, но нам нужно определить хотя бы суперпользователя. Значение по умолчанию для этого образа postgres
, но вы можете изменить его, установив POSTGRES_USER
.
Имя базы данных
Если вы не укажете значение POSTGRES_DB
, этот образ создаст базу данных по умолчанию с именем суперпользователя.
Здесь следует сделать предупреждение. Если вы опустите и имя базы данных, и пользователя, то в итоге вы получите суперпользователя postgres
и базу данных postgres
. В официальной документации говорится, что
After initialization, a database cluster will contain a database named
postgres, which is meant as a default database for use by utilities,
users and third party applications. The database server itself does not
require the postgres database to exist, but many external utility programs
assume it exists.
Это означает, что не идеально использовать эту базу данных в качестве базы данных для нашего приложения. Поэтому, если только вы не просто пробуете быстрый кусок кода, я рекомендую всегда настраивать все три значения: POSTGRES_PASSWORD
, POSTGRES_USER
, и POSTGRES_DB
.
Мы можем запустить изображение с помощью
$ docker run -d
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
postgres:13
Как вы можете видеть, я запускаю изображение в отстраненном режиме. Это изображение не предназначено для интерактивного использования, поскольку Postgres по своей природе является демоном. Для интерактивного подключения нам нужно использовать инструмент psql
, который предоставляется этим образом. Обратите внимание, что я использую postgres:13
только для того, чтобы пост соответствовал тому, что вы увидите, если будете читать его в будущем, вы можете использовать любую версию движка.
ID контейнера возвращается командой docker run
, но мы можем получить его в любое время, выполнив команду docker ps
. Однако использование идентификаторов довольно сложно, и при просмотре истории команд не сразу понятно, что вы делали в определенный момент времени. По этой причине хорошей идеей будет давать контейнерам имена.
Остановите предыдущий контейнер и запустите его снова с помощью команды
$ docker run -d
--name whale-postgres
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
postgres:13
Остановка контейнеров
Вы можете остановить контейнеры, используя docker stop ID
. Это дает контейнерам отсрочку для реакции на сигнал SIGTERM
, например, для правильного закрытия файлов и разрыва соединений, а затем завершает его с помощью SIGKILL
. Вы также можете заставить его остановиться безоговорочно, используя docker kill ID
, который немедленно посылает SIGKILL
.
В любом случае, однако, вы можете захотеть удалить контейнер, который в противном случае будет храниться в Docker неограниченное время. Это может стать проблемой, когда контейнерам присваиваются имена, поскольку вы не можете повторно использовать имя, которое в данный момент присвоено контейнеру.
Для удаления контейнера необходимо выполнить команду docker rm ID
, но вы можете использовать тот факт, что и docker stop
, и docker kill
возвращают идентификатор контейнера, чтобы передать завершение работы и удаление.
$ docker stop ID | xargs docker rm
В противном случае вы можете использовать docker rm -f ID
, что соответствует docker kill
, за которым следует docker rm
. Однако если вы присваиваете контейнеру имя, вы можете использовать его имя вместо ID.
Теперь мы можем подключиться к базе данных, используя исполняемый файл psql
, предоставленный в самом образе. Для выполнения команды внутри контейнера мы используем docker exec
, и на этот раз мы укажем -it
для открытия интерактивной сессии. psql
по умолчанию использует имя пользователя root
, и базу данных с тем же именем, что и у пользователя, поэтому нам нужно указать и то, и другое. Заголовок сообщает, что на изображении запущен PostgreSQL 13.5 на Debian.
$ docker exec -it whale-postgres psql -U whale_user whale_db
psql (13.5 (Debian 13.5-1.pgdg110+1))
Type "help" for help.
whale_db=#
Здесь я могу перечислить все базы данных с l
. Вы можете посмотреть все команды psql
и остальную документацию здесь.
$ docker exec -it whale-postgres psql -U whale_user whale_db
psql (13.5 (Debian 13.5-1.pgdg110+1))
Type "help" for help.
whale_db=# l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+------------+----------+------------+------------+---------------------------
postgres | whale_user | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | whale_user | UTF8 | en_US.utf8 | en_US.utf8 | =c/whale_user +
| | | | | whale_user=CTc/whale_user
template1 | whale_user | UTF8 | en_US.utf8 | en_US.utf8 | =c/whale_user +
| | | | | whale_user=CTc/whale_user
whale_db | whale_user | UTF8 | en_US.utf8 | en_US.utf8 |
(4 rows)
whale_db=#
Как вы видите, база данных postgres
была создана в рамках инициализации, как было указано ранее. Вы можете выйти из psql
с помощью Ctrl-D
или q
.
Доверие к Postgres
Вас может удивить тот факт, что psql
не запросил пароль, который мы задали при запуске контейнера. Это происходит потому, что сервер доверяет локальным соединениям, а когда мы запускаем psql
внутри контейнера, мы находимся на localhost
.
Если вам интересно узнать о доверии в Postgres, вы можете посмотреть конфигурационный файл с
$ docker exec -it whale-postgres
cat /var/lib/postgresql/data/pg_hba.conf
где вы можете заметить строки
# TYPE DATABASE USER ADDRESS METHOD
# "local" is for Unix domain socket connections only
local all all trust
Более подробную информацию о доверии Postgres вы можете найти в официальной документации.
Если мы хотим, чтобы база данных была доступна извне, нам нужно опубликовать порт. Изображение раскрывает порт 5432 (см. исходный код), который сообщает нам, где прослушивается сервер. Чтобы опубликовать порт для хост-системы, мы можем добавить -p 5432:5432
. Помните, что опубликовать порт в Docker означает добавить некоторые метаданные, которые информируют пользователя об образе, но не влияют на его работу.
Остановите контейнер (теперь вы можете использовать его имя) и запустите его снова с помощью команды
$ docker run -d
--name whale-postgres
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
-p 5432:5432 postgres:13
Запустив docker ps
, мы видим, что контейнер теперь публикует порт (0.0.0.0:5432->5432/tcp
). Мы можем перепроверить это с помощью ss
(«статистика сокетов»).
$ ss -nulpt | grep 5432
tcp LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:*
tcp LISTEN 0 4096 [::]:5432 [::]:*
Обратите внимание, что обычно ss
не сообщает вам имя процесса, использующего этот порт, потому что процесс запущен root
. Если вы запустите ss
с sudo
, вы увидите это.
$ sudo ss -nulpt | grep 5432
tcp LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:* users:(("docker-proxy",pid=1262717,fd=4))
tcp LISTEN 0 4096 [::]:5432 [::]:* users:(("docker-proxy",pid=1262724,fd=4))
К сожалению, ss
недоступен на macOS. На этой платформе (а также на Linux) вы можете использовать lsof
с grep
.
$ sudo lsof -i -p -n | grep 5432
docker-pr 219643 root 4u IPv4 2945982 0t0 TCP *:5432 (LISTEN)
docker-pr 219650 root 4u IPv6 2952986 0t0 TCP *:5432 (LISTEN)
или непосредственно с помощью опции -i
.
$ sudo lsof -i :5432
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
docker-pr 219643 root 4u IPv4 2945982 0t0 TCP *:postgresql (LISTEN)
docker-pr 219650 root 4u IPv6 2952986 0t0 TCP *:postgresql (LISTEN)
Обратите внимание, что docker-pr
в приведенном выше выводе — это просто усеченный docker-proxy
, что соответствует тому, что мы видели ранее с ss
.
Если вы хотите опубликовать порт контейнера 5432 на другой порт на хосте, вы можете просто использовать -p ANY_NUMBER:5432
. Однако помните, что номера портов ниже 1024 являются привилегированными или общеизвестными, что означает, что они по умолчанию назначаются определенным службам (перечисленным здесь).
Это означает, что теоретически вы можете использовать -p 80:5432
для контейнера базы данных, открывая его на 80-м порту вашего хоста. На практике это приведет к множеству головных болей и куче разработчиков, преследующих вас с шипами и лопатами.
Теперь, когда мы открыли порт, мы можем подключиться к базе данных, запущенной psql
в эфемерном контейнере. «Эфемерный» означает, что ресурс (в данном случае контейнер Docker) запускается только на время, необходимое для выполнения определенной цели, в отличие от «постоянного». Таким образом, мы можем имитировать кого-то, кто пытается подключиться к контейнеру Docker с другого компьютера в сети.
Поскольку psql
предоставляется образом postgres
, теоретически мы можем запустить его, передав имя хоста с помощью -h localhost
, но если вы попробуете это сделать, то будете разочарованы.
$ docker run -it postgres:13 psql -h localhost -U whale_user whale_db
psql: error: connection to server at "localhost" (127.0.0.1), port 5432 failed: Connection refused
Is the server running on that host and accepting TCP/IP connections?
connection to server at "localhost" (::1), port 5432 failed: Cannot assign requested address
Is the server running on that host and accepting TCP/IP connections?
Это правильно, так как контейнер работает в мостовой сети, где localhost
— это сам контейнер. Чтобы это работало, нам нужно запустить контейнер как часть сети хоста (то есть той же сети, в которой работает наш компьютер). Это можно сделать с помощью команды --network=host
.
$ docker run -it
--network=host postgres:13
psql -h localhost -U whale_user whale_db
Password for user whale_user:
psql (13.5 (Debian 13.5-1.pgdg110+1))
Type "help" for help.
whale_db=#
Обратите внимание, что теперь psql
запрашивает пароль (который вы знаете, потому что задали его при запуске контейнера whale-postgres
). Это происходит потому, что инструмент больше не запускается на том же узле, что и сервер базы данных, поэтому PostgreSQL не доверяет ему.
Тома
Если бы мы использовали структурированную структуру на Python, мы могли бы использовать ORM, например SQLAlchemy, для сопоставления классов с таблицами базы данных. Определения модели (или изменения) могут быть записаны в небольшие скрипты, называемые миграциями, которые применяются к базе данных, и они также могут быть использованы для вставки некоторых начальных данных. Для этого примера я пойду более простым путем, то есть инициализирую базу данных с помощью SQL напрямую.
Я не рекомендую использовать этот подход в реальном проекте, но в данном случае он должен быть достаточно хорош. В частности, это позволит мне продемонстрировать, как использовать тома в Docker.
Убедитесь, что контейнер whale-postgres
запущен (с публикацией или без публикации порта, в данный момент это не важно). Подключитесь к контейнеру с помощью psql
и выполните следующие две команды SQL (убедитесь, что вы подключены к базе данных whale_db
)
CREATE TABLE recipes (
recipe_id INT NOT NULL,
recipe_name VARCHAR(30) NOT NULL,
PRIMARY KEY (recipe_id),
UNIQUE (recipe_name)
);
INSERT INTO recipes
(recipe_id, recipe_name)
VALUES
(1,'Tacos'),
(2,'Tomato Soup'),
(3,'Grilled Cheese');
Этот код создает таблицу recipes
и вставляет 3 строки с id
и name
. Вывод вышеуказанных команд должен быть следующим
CREATE TABLE
INSERT 0 3
Вы можете дважды проверить, что база данных содержит таблицу с dt
.
whale_db=# dt
List of relations
Schema | Name | Type | Owner
--------+---------+-------+------------
public | recipes | table | whale_user
(1 row)
и что таблица содержит три строки с select
.
whale_db=# select * from recipes;
recipe_id | recipe_name
-----------+----------------
1 | Tacos
2 | Tomato Soup
3 | Grilled Cheese
(3 rows)
Проблема с контейнерами заключается в том, что они не хранят данные постоянно. Пока контейнер работает, проблем нет, на самом деле вы можете завершить psql
, подключиться и снова запустить select
, и вы увидите те же данные.
Однако если мы остановим контейнер и запустим его снова, то быстро поймем, что значения, сохраненные в базе данных, исчезли.
$ docker stop whale-postgres | xargs docker rm
whale-postgres
$ docker run -d
--name whale-postgres
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
-p 5432:5432 postgres:13
4a647ebef78e32bb4733484a6e435780e17a69b643e872613ca50115d60d54ce
$ docker exec -it whale-postgres
psql -U whale_user whale_db -c "select * from recipes"
ERROR: relation "recipes" does not exist
LINE 1: select * from recipes
^
Контейнеры были созданы с учетом изоляции, поэтому по умолчанию ничего из того, что происходит внутри контейнера, не связано с хостом и сохраняется при его уничтожении.
Однако, как и в случае с портами, нам необходимо установить связь между контейнерами и хост-системой, а также сохранить данные после уничтожения контейнера. Решением в Docker является использование томов.
В Docker существует три типа томов: хостовые, анонимные и именованные. Хостовые тома — это способ монтирования внутри контейнера пути в файловой системе хоста, и хотя они полезны для обмена данными между хостом и контейнером, у них также часто возникают проблемы с правами доступа. Как правило, контейнеры определяют пользователей, чьи идентификаторы не сопоставлены с идентификаторами хоста, что означает, что файлы, записанные контейнером, могут принадлежать несуществующим пользователям.
Анонимные и именованные тома — это просто виртуальные файловые системы, созданные и управляемые независимо от контейнеров. Они могут быть связаны с запущенным контейнером, чтобы последний мог использовать содержащиеся в них данные и хранить данные, которые переживут его завершение. Единственное различие между именованными и анонимными томами — это имя, которое позволяет легко управлять ими. По этой причине я считаю, что рассматривать анонимные тома не очень полезно, поэтому я сосредоточусь на именованных.
Вы можете управлять томами с помощью команды docker volume
, которая предоставляет несколько подкоманд, таких как create
, и rm
. Затем вы можете прикрепить именованный том к контейнеру при его запуске с помощью опции -v
в docker run
. Это создаст том, если он еще не существует, поэтому многие из нас создают тома именно таким стандартным способом.
Остановите и удалите запущенный контейнер Postgres и запустите его снова с именованным томом
$ docker stop whale-postgres | xargs docker rm
$ docker run -d
--name whale-postgres
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
-p 5432:5432
-v whale_dbdata:/var/lib/postgresql/data
postgres:13
Это создаст том с именем whale_dbdata
и подключит его к пути /var/lib/postgresql/data
в запущенном контейнере. Этот путь является тем, где Postgres хранит фактическую базу данных, как вы можете видеть из официальной документации. Есть конкретная причина, почему я использовал префикс whale_
для названия тома, которая станет понятна позже, когда мы представим Docker Compose.
docker ps
не дает никакой информации о томах, поэтому, чтобы увидеть, что подключено к вашему контейнеру, нужно использовать docker inspect
.
$ docker inspect whale-postgres
[...]
"Mounts": [
{
"Type": "volume",
"Name": "whale_dbdata",
"Source": "/var/lib/docker/volumes/whale_dbdata/_data",
"Destination": "/var/lib/postgresql/data",
"Driver": "local",
"Mode": "z",
"RW": true,
"Propagation": ""
}
],
[...]
Значение для "Source"
— это место хранения тома на хосте, то есть на вашем компьютере, но в целом вы можете игнорировать эту деталь. Вы можете увидеть все тома, используя docker volume ls
(используя grep
, если список длинный, как в моем случае).
$ docker volume ls | grep whale
local whale_dbdata
Теперь, когда контейнер запущен и подключен к тому, мы можем попробовать инициализировать базу данных снова. Подключитесь к psql
с помощью командной строки, которую мы разработали ранее, и выполните SQL-команды, создающие таблицу recipes
и вставляющие три строки.
Весь смысл использования тома заключается в том, чтобы сделать информацию постоянной, поэтому теперь завершите и удалите контейнер Postgres, и запустите его снова, используя тот же том. Вы можете проверить, что база данных по-прежнему содержит данные, используя запрос, показанный ранее.
$ docker rm -f whale-postgres
whale-postgres
$ docker run -d
--name whale-postgres
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
-p 5432:5432
-v whale_dbdata:/var/lib/postgresql/data
postgres:13
893378f044204e5c1a87473a038b615a08ad08e5da9225002a470caeac8674a8
$ docker exec -it whale-postgres
psql -U whale_user whale_db
-c "select * from recipes"
recipe_id | recipe_name
-----------+----------------
1 | Tacos
2 | Tomato Soup
3 | Grilled Cheese
(3 rows)
Приложение Python
Отлично! Теперь, когда у нас есть база данных, которая может быть перезапущена без потери данных, мы можем создать Python-приложение, которое будет взаимодействовать с ней. Опять же, пожалуйста, помните, что цель этого поста — показать, что такое оркестровка контейнеров и как Docker compose может упростить ее, поэтому приложение, разработанное в этом разделе, абсолютно минимально.
Сначала я создам приложение и запущу его на хосте, используя порт, открытый контейнером, для подключения к базе данных. Позже я перенесу приложение в его собственный контейнер.
Чтобы создать приложение, сначала создайте виртуальную среду Python, используя предпочитаемый вами метод. В настоящее время я использую pyenv
(GitHub).
pyenv virtualenv whale_docker
pyenv activate whale_docker
Теперь нам нужно поместить наши требования в файл и установить их. Я предпочитаю поддерживать порядок с нулевого дня, поэтому создайте каталог whaleapp
в директории проекта и внутри него файл requirements.txt
.
mkdir whaleapp
touch whaleapp/requirements.txt
Единственное требование, которое у нас есть для этого простого приложения, это psycopg2
, поэтому я добавляю его в файл и затем устанавливаю. Поскольку мы устанавливаем требования, полезно также обновить pip
.
echo "psycopg2" >> whaleapp/requirements.txt
pip install -U pip
pip install -r whaleapp/requirements.txt
Теперь создайте файл whaleapp/whaleapp.py
и поместите в него этот код
import time
import psycopg2
connection_data = {
"host": "localhost",
"database": "whale_db",
"user": "whale_user",
"password": "whale_password",
}
while True:
try:
conn = None
# Connect to the PostgreSQL server
print("Connecting to the PostgreSQL database...")
conn = psycopg2.connect(**connection_data)
# Create a cursor
cur = conn.cursor()
# Execute the query
cur.execute("select * from recipes")
# Fetch all results
results = cur.fetchall()
print(results)
# Close the connection
cur.close()
except (Exception, psycopg2.DatabaseError) as error:
print(error)
finally:
if conn is not None:
conn.close()
print("Database connection closed.")
# Wait three seconds
time.sleep(3)
Как видите, код не сложный. Приложение представляет собой бесконечный цикл while
, который каждые 3 секунды устанавливает соединение с БД, используя заданную конфигурацию. После этого выполняется запрос select * from recipes
, все результаты выводятся на стандартный вывод, и соединение закрывается.
Если контейнер Postgres запущен и публикует порт 5432, это приложение может быть запущено непосредственно на хосте
$ python whaleapp.py
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
и будет продолжаться бесконечно, пока мы не нажмем Ctrl-C
, чтобы остановить его.
По тем же причинам изоляции и безопасности, которые мы обсуждали ранее, мы хотим запустить приложение в контейнере Docker. Это можно сделать довольно легко, но мы столкнемся с теми же проблемами, что и при попытке запустить psql
в отдельном контейнере. На данный момент приложение пытается подключиться к базе данных на localhost
, что хорошо, пока приложение работает непосредственно на хосте, но больше не будет работать, когда оно будет перенесено в контейнер Docker.
Чтобы решить одну проблему за раз, давайте сначала поместим приложение в контейнер и запустим его, используя сеть host
. Как только это заработает, мы сможем посмотреть, как решить проблему связи между контейнерами.
Самый простой способ контейнеризации приложения Python — создать новый образ, начиная с образа python:3
. Следующий Dockerfile
помещается в каталог приложения (whaleapp/Dockerfile
).
FROM python:3
WORKDIR /usr/src/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD [ "python", "-u", "./whaleapp.py" ]
Файл Docker содержит описание слоев, из которых строится образ. Здесь мы начинаем с официального образа Python 3 (DockerHub), устанавливаем рабочий каталог, копируем файл требований и устанавливаем требования, затем копируем остальную часть приложения и запускаем его. Опция Python -u
позволяет избежать буферизации вывода, см. документацию.
Важно помнить о многослойной природе образов Docker, поскольку это может привести к простым оптимизационным трюкам. В данном случае загрузка файла требований и их установка создает слой из файла, который меняется не очень часто, в то время как слой, созданный с помощью COPY
, вероятно, меняется очень быстро, пока мы разрабатываем приложение. Если бы мы запустили что-то вроде
[...]
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD [ "python", "-u", "./app.py" ]
нам пришлось бы устанавливать требования каждый раз, когда мы изменяем код приложения, поскольку это перестроило бы слой COPY
и, таким образом, сделало бы недействительным слой, содержащий команду RUN
.
Как только Dockerfile
будет создан, мы можем собрать изображение
$ cd whaleapp
$ docker build -t whaleapp .
Sending build context to Docker daemon 6.144kB
Step 1/6 : FROM python:3
---> 768307cdb962
Step 2/6 : WORKDIR /usr/src/app
---> Using cache
---> b00189756ddb
Step 3/6 : COPY requirements.txt .
---> a7aef12f562c
Step 4/6 : RUN pip install --no-cache-dir -r requirements.txt
---> Running in 153a3ca6a1b2
Collecting psycopg2
Downloading psycopg2-2.9.3.tar.gz (380 kB)
Building wheels for collected packages: psycopg2
Building wheel for psycopg2 (setup.py): started
Building wheel for psycopg2 (setup.py): finished with status 'done'
Created wheel for psycopg2: filename=psycopg2-2.9.3-cp39-cp39-linux_x86_64.whl size=523502 sha256=1a3aac3cf72cc86b63a3e0f42b9b788c5237c3e5d23df649ca967b29bf89ecf5
Stored in directory: /tmp/pip-ephem-wheel-cache-ow3d1yop/wheels/b3/a1/6e/5a0e26314b15eb96a36263b80529ce0d64382540ac7b9544a9
Successfully built psycopg2
Installing collected packages: psycopg2
Successfully installed psycopg2-2.9.3
WARNING: You are using pip version 20.2.4; however, version 21.3.1 is available.
You should consider upgrading via the '/usr/local/bin/python -m pip install --upgrade pip' command.
Removing intermediate container 153a3ca6a1b2
---> b18aead1ef15
Step 5/6 : COPY . .
---> be7c3c11e608
Step 6/6 : CMD [ "python", "-u", "./app.py" ]
---> Running in 9e2f4f30b59e
Removing intermediate container 9e2f4f30b59e
---> b735eece4f86
Successfully built b735eece4f86
Successfully tagged whaleapp:latest
Вы можете видеть, как слои строятся один за другим (здесь отмечены как Step x/6
). Как только изображение будет построено, вы сможете увидеть его в списке изображений, имеющихся в вашей системе
$ docker image ls | grep whale
whaleapp latest 969b15466905 9 minutes ago 894MB
Вы можете провести 1 минуту тишины, размышляя о том, что мы использовали почти 900 мегабайт пространства для запуска 40 строк Python. Как видите, преимущества имеют свою цену, и ее не стоит недооценивать. В наше время 900 мегабайт может показаться не так уж много, но если вы продолжите создавать образы, то вскоре израсходуете все место на жестком диске или будете платить большие деньги за место в удаленном хранилище.
Кстати, именно по этой причине Docker разделяет образ на слои и повторно использует их. Пока мы можем игнорировать эту часть игры, но помните, что поддержание системы в чистоте и удаление прошлых артефактов очень важно.
Как я уже говорил, мы можем запустить этот образ, но нам нужно использовать сетевую конфигурацию host
.
$ docker run -it --rm --network=host --name whale-app whaleapp
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Обратите внимание, что я использовал --rm
, чтобы Docker автоматически удалил контейнер при завершении его работы. Таким образом, я могу запустить его снова с тем же именем без необходимости явного удаления прошлого контейнера с помощью docker rm
.
Запускать контейнеры в одной сети
Контейнеры Docker по умолчанию изолированы от хоста и других контейнеров. Однако это не означает, что они не могут взаимодействовать друг с другом, если мы запустим их в определенной конфигурации. В частности, важную роль в сетевом взаимодействии Docker играют мостовые сети.
Когда контейнеры запускаются в одной и той же пользовательской мостовой сети, Docker обеспечивает им разрешение DNS с использованием имен контейнеров. Это означает, что мы можем заставить приложение взаимодействовать с базой данных без необходимости запуска первого в сети хоста.
Пользовательская сеть может быть создана с помощью команды docker network
.
$ docker network create whale
Как обычно, Docker вернет идентификатор только что созданного объекта, но мы можем пока проигнорировать его, поскольку мы можем обращаться к сети по имени.
Остановите и удалите контейнер Postgres и запустите его снова, используя сеть whale
.
$ docker rm -f whale-postgres
whale-postgres
$ docker run -d
--name whale-postgres
-e POSTGRES_PASSWORD=whale_password
-e POSTGRES_DB=whale_db
-e POSTGRES_USER=whale_user
--network=whale
-v whale_dbdata:/var/lib/postgresql/data
postgres:13
Обратите внимание, что в этой настройке нет необходимости публиковать порт 5432, так как хосту не нужен доступ к контейнеру. Если это будет необходимо, добавьте опцию -p 5432:5432
снова.
Как и в случае с томами, docker ps
не дает информации о сети, которую используют контейнеры, поэтому вам снова придется использовать docker inspect
.
$ docker inspect whale-postgres
[...]
"NetworkSettings": {
"Networks": {
"whale": {
[...]
Как я уже упоминал, мостовые сети Docker обеспечивают разрешение DNS, используя имя контейнера. Мы можем проверить это, запустив контейнер и используя ping
.
$ docker run -it --rm --network=whale whaleapp ping whale-postgres
PING whale-postgres (172.19.0.2) 56(84) bytes of data.
64 bytes from whale-postgres.whale (172.19.0.2): icmp_seq=1 ttl=64 time=0.064 ms
64 bytes from whale-postgres.whale (172.19.0.2): icmp_seq=2 ttl=64 time=0.100 ms
64 bytes from whale-postgres.whale (172.19.0.2): icmp_seq=3 ttl=64 time=0.115 ms
64 bytes from whale-postgres.whale (172.19.0.2): icmp_seq=4 ttl=64 time=0.101 ms
^C
--- whale-postgres ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 80ms
rtt min/avg/max/mdev = 0.064/0.095/0.115/0.018 ms
Здесь я запустил образ whaleapp
, который мы создали ранее, но переопределил команду по умолчанию и запустил вместо нее ping whale-postgres
. Это хороший способ проверить, может ли хост разрешить имя в сети (dig
— еще один полезный инструмент, но он не установлен по умолчанию в этом образе).
Как вы видите, контейнер Postgres доступен, и мы также знаем, что в настоящее время он работает с IP 172.19.0.2
. Это значение может быть другим в вашей системе, но оно будет соответствовать информации, которую вы получите, если запустите docker network inspect whale
.
Смысл всего этого разговора о DNS в том, что теперь мы можем изменить код приложения Python так, чтобы оно подключалось к whale-postgres
вместо localhost
.
connection_data = {
"host": "whale-postgres",:@:
"database": "whale_db",
"user": "whale_user",
"password": "whale_password",
}
Как только это будет сделано, пересоберите образ и запустите его в сети whale
.
$ docker build -t whaleapp .
[...]
$ docker run -it --rm --network=whale --name whale-app whaleapp
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Вы также можете взять сеть непосредственно из другого контейнера, что является полезным сокращением.
$ docker build -t whaleapp .
[...]
$ docker run -it --rm
--network=container:whale-postgres
--name whale-app whaleapp
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Управление сетью Docker
Команда docker network
может быть использована для изменения сетевой конфигурации запущенных контейнеров.
Вы можете отключить запущенный контейнер от сети с помощью команды
$ docker network disconnect NETWORK_ID CONTAINER_ID
и подключить его с помощью
$ docker network connect NETWORK_ID CONTAINER_ID
Вы можете увидеть, какие контейнеры используют данную сеть, осмотрев ее
$ docker network inspect NETWORK_ID
Помните, что отключение контейнера от сети делает его недоступным, поэтому, хотя хорошо, что мы можем делать это на работающих контейнерах, обслуживание всегда должно быть тщательно спланировано, чтобы избежать неожиданного простоя.
Конфигурация во время выполнения
Жесткое кодирование конфигурационных значений в приложении никогда не является хорошей идеей, и хотя это очень простой пример, стоит немного расширить настройки, чтобы сделать их аккуратными.
В частности, мы можем заменить данные подключения host
, database
и user
переменными среды, которые позволят нам повторно использовать приложение, конфигурируя его во время выполнения. Для простоты я буду хранить пароль также в переменной окружения и передавать его открытым текстом, когда мы запускаем контейнер. Более подробную информацию о том, как управлять секретными значениями, смотрите во врезке.
Чтение значений из переменных окружения легко выполняется в Python
import os
import time
import psycopg2
DB_HOST = os.environ.get("WHALEAPP__DB_HOST", None)
DB_NAME = os.environ.get("WHALEAPP__DB_NAME", None)
DB_USER = os.environ.get("WHALEAPP__DB_USER", None)
DB_PASSWORD = os.environ.get("WHALEAPP__DB_PASSWORD", None)
connection_data = {
"host": DB_HOST,
"database": DB_NAME,
"user": DB_USER,
"password": DB_PASSWORD,
}
Обратите внимание, что все переменные окружения я снабдил префиксом WHALEAPP__
. Это не является обязательным и не имеет особого значения для операционной системы. По моему опыту, сложные системы могут иметь много переменных окружения, и использование префиксов — простой и эффективный способ отслеживать, какой части системы нужно то или иное значение.
Мы уже знаем, как передавать переменные окружения в контейнеры Docker, поскольку мы делали это при запуске контейнера Postgres. Соберите образ снова, а затем запустите его, передав нужные переменные
$ docker build -t whaleapp .
[...]
$ docker run -it --rm --network=whale
-e WHALEAPP__DB_HOST=whale-postgres
-e WHALEAPP__DB_NAME=whale_db
-e WHALEAPP__DB_USER=whale_user
-e WHALEAPP__DB_PASSWORD=password
--name whale-app whaleapp
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Connecting to the PostgreSQL database...
[(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
Database connection closed.
Управление секретами
Секрет — это значение, которое никогда не следует показывать открытым текстом, поскольку оно используется для предоставления доступа к системе. Это может быть пароль или закрытый ключ, например, для работы SSH, и, как это бывает со всем, что связано с безопасностью, управлять ими довольно сложно. Пожалуйста, помните, что безопасность — это сложно, и что лучше всего иметь следующее отношение: каждый раз, когда вы думаете, что что-то в безопасности просто, это означает, что вы ошиблись.
Вообще говоря, вы хотите, чтобы секреты были зашифрованы и хранились в безопасном месте, доступ к которым предоставляется узкому кругу лиц. Эти секреты должны быть доступны для вашего приложения безопасным способом, и не должно быть возможности получить доступ к секретам, размещенным в памяти приложения.
Например, многие сообщения в Интернете показывают, как можно использовать AWS Secrets Manager для хранения секретов и доступа к ним из приложения, используя jq для их получения во время выполнения. Хотя это работает, если JSON-секрет содержит синтаксическую ошибку, jq
сбрасывает все значение в стандартный вывод приложения, что означает, что в логах секрет содержится в виде обычного текста.
Vault — это инструмент, созданный компанией Hashicorp, который многие используют для хранения секретов, необходимых контейнерам. Интересно прочитать в описании изображения, что при определенной конфигурации контейнер предотвращает загрузку памяти на диск, что привело бы к утечке незашифрованных значений. Как видите, безопасность — это сложно.
Инструменты оркестровки всегда предоставляют возможность управлять секретами и передавать их контейнерам. Например, смотрите Секреты Docker Swarm, Секреты Kubernetes и Секреты для AWS Elastic Container Service.
Вводим Docker Compose
Настройка, которую мы создали в предыдущих разделах, хороша, но далеко не оптимальна. Нам пришлось создать пользовательскую мостовую сеть, а затем запустить Postgres и подключенные к нему контейнеры приложений. Чтобы остановить систему, нам нужно вручную завершить работу контейнеров и не забыть удалить их, чтобы избежать блокировки имени контейнера. Мы также должны вручную удалить сеть, если хотим сохранить систему чистой.
Следующим шагом будет создание bash-скрипта, а затем его эволюция в Makefile или аналогичное решение. К счастью, Docker предлагает лучшее решение с помощью Docker Compose.
Docker Compose можно описать как инструмент оркестровки одного хоста. Инструменты оркестровки — это части программного обеспечения, которые позволяют нам решать проблемы, описанные ранее, такие как запуск и завершение работы нескольких контейнеров, создание сетей и томов, управление секретами и так далее. Docker Compose работает в режиме одного хоста, поэтому это отличное решение для среды разработки, в то время как для производственных многохостовых сред лучше перейти к более продвинутым инструментам, таким как AWS ECS или Kubernetes.
Docker Compose считывает конфигурацию системы из файла docker-compose.yml
(значение по умолчанию, его можно изменить), в котором в компактной и удобочитаемой форме собрано все, что мы делали вручную в предыдущих разделах.
Для установки Docker Compose следуйте инструкциям, которые вы найдете здесь. Перед началом использования Docker Compose убедитесь, что вы убили контейнер Postgres, если он все еще запущен, и удалили сеть, которую мы создали.
$ docker rm -f whale-postgres
whale-postgres
$ docker network remove whale
whale
Затем создайте файл docker-compose.yml
в каталоге проекта (не в каталоге app) и поместите в него следующий код
version: '3.8'
services:
Это еще не действительный файл Docker Compose, но вы можете видеть, что здесь есть значение, определяющее версию синтаксиса, и значение, перечисляющее сервисы. Ссылку на файл Compose можно найти здесь, вместе с подробным описанием различных версий.
Первая служба, которую мы хотим запустить, это Postgres, и базовая конфигурация для нее следующая
version: '3.8'
services:
db:
image: postgres:13
environment:
POSTGRES_DB: whale_db
POSTGRES_PASSWORD: whale_password
POSTGRES_USER: whale_user
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
Как вы можете видеть, этот файл содержит переменные окружения, которые мы передали контейнеру Postgres, и конфигурацию томов. Последний volumes
объявляет, какие тома должны присутствовать (поэтому он создает их, если их нет), а volumes
внутри службы db
создает соединение так же, как это делала ранее опция -v
.
Теперь из каталога проекта вы можете запустить Docker Compose с помощью команды
$ docker-compose -p whale up -d
Creating network "whale_default" with the default driver
Creating whale_db_1 ... done
Опция -p
задает имя проекта, которое по умолчанию будет соответствовать имени директории, в которой вы находитесь в данный момент (что может иметь или не иметь значения), а команда up -d
запускает все контейнеры в отсоединенном режиме.
Как видно из вывода, Docker Compose создает (мостовую) сеть под названием whale_default
. Обычно вы бы увидели сообщение типа Creating volume "whale_dbdata" with default driver
, но в данном случае том уже присутствует, так как мы создали его ранее. И сеть, и том имеют префикс PROJECTNAME_
, и именно по этой причине, когда мы впервые создали том, я назвал его whale_dbdata
. Однако следует помнить, что все эти стандартные действия можно настроить в файле Compose.
Если вы запустите docker ps
, то увидите, что контейнер имеет имя whale_db_1
. Это происходит из имени проекта (whale_
), имени сервиса в файле Compose (db_
) и номера контейнера, который равен 1, потому что в данный момент мы запускаем только один контейнер для этого сервиса.
Чтобы остановить службы, необходимо выполнить следующие действия
$ docker-compose -p whale down
Stopping whale_db_1 ... done
Removing whale_db_1 ... done
Removing network whale_default
Как видно из вывода, Docker Compose останавливает и удаляет контейнер, а затем удаляет сеть. Это очень удобно, так как уже избавляет нас от большей части работы, которую ранее приходилось выполнять вручную.
Теперь мы можем добавить контейнер приложения в файл Compose
version: '3.8'
services:
db:
image: postgres:13
environment:
POSTGRES_DB: whale_db
POSTGRES_PASSWORD: whale_password
POSTGRES_USER: whale_user
volumes:
- dbdata:/var/lib/postgresql/data
app:
build:
context: whaleapp
dockerfile: Dockerfile
environment:
WHALEAPP__DB_HOST: db
WHALEAPP__DB_NAME: whale_db
WHALEAPP__DB_USER: whale_user
WHALEAPP__DB_PASSWORD: whale_password
volumes:
dbdata:
Это определение немного отличается, поскольку контейнер приложения должен быть построен с помощью созданного нами Dockerfile. Docker Compose позволяет нам хранить здесь конфигурацию сборки, чтобы нам не нужно было передавать все опции в docker build
вручную, но обратите внимание, что настройка сборки здесь не означает, что Docker Compose будет собирать образ за вас каждый раз. Вам все равно придется запускать docker-compose -p whale build
каждый раз, когда вам нужно будет пересобрать его.
Обратите внимание, что переменная WHALEAPP__DB_HOST
установлена на имя сервиса, а не на имя контейнера. Теперь, когда мы запускаем Docker Compose, мы получаем следующее
$ docker-compose -p whale up -d
Creating network "whale_default" with the default driver
Creating whale_db_1 ... done
Creating whale_app_1 ... done
и вывод сообщает нам, что на этот раз также был создан контейнер whale_app_1
. Мы можем посмотреть журналы контейнера с помощью docker logs
, но использование docker-compose
позволяет нам вызывать сервисы по имени, а не по ID
$ docker-compose -p whale logs -f app
Attaching to whale_app_1
app_1 | Connecting to the PostgreSQL database...
app_1 | [(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
app_1 | Database connection closed.
app_1 | Connecting to the PostgreSQL database...
app_1 | [(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
app_1 | Database connection closed.
Проверка работоспособности и зависимости
Вы могли заметить, что в самом начале в журналах приложения появляются ошибки подключения, а через некоторое время приложению удается подключиться к базе данных
$ docker-compose -p whale logs -f app
Attaching to whale_app_1
app_1 | Connecting to the PostgreSQL database...
app_1 | could not translate host name "db" to address: Name or service not known
app_1 |
app_1 | Connecting to the PostgreSQL database...
app_1 | could not translate host name "db" to address: Name or service not known
app_1 |
app_1 | Connecting to the PostgreSQL database...
app_1 | Connecting to the PostgreSQL database...
app_1 | could not connect to server: Connection refused
app_1 | Is the server running on host "db" (172.31.0.3) and accepting
app_1 | TCP/IP connections on port 5432?
app_1 |
app_1 | Connecting to the PostgreSQL database...
app_1 | [(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
app_1 | Database connection closed.
app_1 | Connecting to the PostgreSQL database...
app_1 | [(1, 'Tacos'), (2, 'Tomato Soup'), (3, 'Grilled Cheese')]
app_1 | Database connection closed.
Эти ошибки возникают из-за того, что контейнер приложения запущен до того, как база данных готова обслуживать соединения. В производственной среде этого обычно не происходит, поскольку база данных запускается гораздо раньше, чем приложение развертывается в первый раз, и затем работает (надеемся) без перерыва. В среде разработки такая ситуация является нормальной.
Обратите внимание, что в вашей среде этого может не произойти, поскольку это тесно связано со скоростью работы Docker Compose и контейнеров. Ошибки, чувствительные ко времени, — это один из худших типов ошибок, с которыми приходится иметь дело, и именно по этой причине управление распределенными системами является сложной задачей. Важно, чтобы вы поняли, что даже если сейчас это может работать на вашей системе, проблема существует, и нам нужно найти решение.
Стандартным решением, когда часть системы зависит от другой, является создание проверки работоспособности, которая периодически тестирует первую службу, и запуск второй службы только при успешной проверке. Мы можем сделать это в файле Compose, используя healthcheck
и depends_on
.
version: '3.8'
services:
db:
image: postgres:13
environment:
POSTGRES_DB: whale_db
POSTGRES_PASSWORD: whale_password
POSTGRES_USER: whale_user
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: whaleapp
dockerfile: Dockerfile
environment:
WHALEAPP__DB_HOST: db
WHALEAPP__DB_NAME: whale_db
WHALEAPP__DB_USER: whale_user
WHALEAPP__DB_PASSWORD: whale_password
depends_on:|@|
db:|@|
condition: service_healthy|@|
volumes:
dbdata:
Проверка работоспособности контейнера Postgres использует инструмент командной строки pg_isready
, который работает успешно только тогда, когда база данных готова принимать соединения, и делает попытки каждые 10 секунд в течение 5 раз. Теперь, когда вы запустите up -d
на этот раз, вы должны заметить явную задержку перед запуском приложения, но в журналах не будет ошибок подключения.
Заключительные слова
Что ж, это было долго, но я надеюсь, что вам понравилось путешествие, и вы получили лучшее представление о том, какие проблемы решает Docker Compose, а также почувствовали, насколько сложным может быть проектирование архитектуры. Все, что мы делали, было сделано для «простой» среды разработки с парой контейнеров, так что вы можете представить, что будет, когда мы перейдем к живым средам.
Фото Verstappen Photography on Unsplash