Урок курса
Приложение и база в Compose
Docker и Docker Compose: контейнеризация приложений1. Приложение обращается к db
В изолированном запуске двух контейнеров приходилось вручную вызывать docker network create, давать сети имя и затем подключать каждый контейнер отдельным флагом --network. Если в файле compose.yaml описать несколько сервисов, Compose берёт эту связку на себя.
Когда запускается проект, Docker Compose автоматически создаёт изолированную bridge-сеть с именем <имя_проекта>_default и включает в неё контейнеры всех описанных сервисов. В отличие от стандартного системного моста Docker (bridge), внутри этой пользовательской сети активен встроенный DNS-сервер Docker Engine. Имена сервисов первого уровня в манифесте — например, app и db — становятся доменными именами, которые автоматически преобразуются во внутренние IP-адреса контейнеров.
По этой причине приложению для обращения к базе данных не нужны статические IP-адреса или сложные сетевые параметры хоста. Достаточно передать переменные окружения, указывающие на сетевое имя сервиса и его стандартный внутренний порт:
environment:
STORAGE: postgres
DB_HOST: db
DB_PORT: "5432"
DB_NAME: course
DB_USER: course
PASSWORD: secret
При такой архитектуре порт базы данных 5432 не требуется публиковать на компьютере разработчика. Контейнер app обращается к db:5432 напрямую по внутреннему сетевому мосту контейнеров. Директива ports: необходима исключительно сервису app, чтобы принимать HTTP-запросы с хоста (127.0.0.1:18124:8000).
Отсутствие публикации порта базы данных защищает СУБД от неконтролируемых внешних входящих соединений и исключает конфликты с локальными сервисами, которые могут занимать порт 5432 на самом компьютере.
Проверить, что база изолирована и её порт не проброшен наружу, можно через анализ структуры проекта в docker compose config либо через список контейнеров проекта в docker compose ps. В выводе docker compose ps запись вида 5432/tcp у сервиса db лишь отражает метаданные образа (объявленный внутренний порт), но не свидетельствует о том, что процесс внутри уже слушает сокет. Главное — в этой строке отсутствует стрелка сопоставления с адресом хоста (нет записи вида host_ip:host_port->5432/tcp), тогда как у сервиса app явно указано loopback-сопоставление 127.0.0.1:18124->8000/tcp. Фактическая же готовность сервера принимать подключения проверяется отдельно утилитой pg_isready и проверкой прикладного эндпоинта /ready.
Ошибочно пытаться проверять изоляцию порта СУБД с помощью curl http://127.0.0.1:5432: протокол HTTP не совместим с бинарным протоколом PostgreSQL. Отказ соединения curl к произвольному адресу ничего не говорит о правилах трансляции портов конкретного контейнера. Доказательством изоляции служит именно отсутствие директивы публикации в манифесте и отсутствие хостового маппинга со стрелкой в docker compose ps.
2. Объявить и подключить данные базы
Данные PostgreSQL должны сохраняться между пересозданиями контейнеров сервиса. В Compose для управления персистентным хранилищем используется именованный том (named volume). Однако модель Compose строго разграничивает объявление тома как ресурса уровня проекта и его монтирование в конкретную файловую систему сервиса.
Именованный том обязан быть объявлен в корневой секции volumes: на самом верхнем уровне файла compose.yaml. Если в сервисе db указать монтирование тома, но пропустить его корневое объявление, Compose откажется работать со структурой:
services:
db:
image: postgres:17.4-bookworm
volumes:
- db_data:/var/lib/postgresql/data
# Ошибка: корневая секция volumes отсутствует
При проверке такого манифеста валидатор немедленно сообщает о нарушении контракта:
docker compose config
Команда завершится с ненулевым кодом и выведет диагностическое сообщение:
named volume "db_data" is used in service "db" but no declaration was found in the additional_contexts or volumes section
Для устранения ошибки и создания управляемого ресурса том объявляется в корне манифеста. Корневой ключ сообщает Compose, что жизненный цикл тома управляется проектом (будет создан физический том Docker вида <проект>_db_data), а секция внутри сервиса связывает этот том с целевым путем в контейнере:
services:
db:
image: postgres:17.4-bookworm
environment:
POSTGRES_DB: course
POSTGRES_USER: course
POSTGRES_PASSWORD: secret
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Здесь соблюдены оба условия контракта PostgreSQL 17: переменная PGDATA направлена в подкаталог /var/lib/postgresql/data/pgdata, а точка монтирования самого тома покрывает родительский каталог /var/lib/postgresql/data. Остановка контейнеров через docker compose down не удаляет данные: физический том останется в системе и будет подключен заново при следующем запуске.
3. Поднять связку и записать заметку
Перейдём к запуску связки веб-приложения и PostgreSQL. Развернём отдельный проект в каталоге cf_docker_course_l24 рядом с распакованным архивом приложения.
Перед запуском убедимся, что ресурсы проекта отсутствуют: проверим контейнеры через docker container ls -a --filter name=cf_docker_course_l24, сеть docker network inspect cf_docker_course_l24_default и том docker volume inspect cf_docker_course_l24_notes_db_data. Ни одного из этих объектов ещё быть не должно. Прежние контейнеры предыдущих уроков не трогаем и не останавливаем.
Также убедимся, что порт хоста 18124 свободен. Запись в docker ps показывает только публикации Docker, но не исключает занятость порта локальным процессом операционной системы. Если порт 18124 занят или возникает ошибка привязки адреса, необходимо выбрать другой свободный порт хоста и согласованно обновить его в секции ports и во всех URL запросов.
Создадим файл compose.yaml:
services:
db:
image: postgres:17.4-bookworm
environment:
POSTGRES_DB: course
POSTGRES_USER: course
POSTGRES_PASSWORD: secret
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- notes_db_data:/var/lib/postgresql/data
app:
image: course-notes:1.0.0
ports:
- "127.0.0.1:18124:8000"
environment:
STORAGE: postgres
DB_HOST: db
DB_PORT: "5432"
DB_NAME: course
DB_USER: course
PASSWORD: secret
volumes:
notes_db_data:
Проверим валидность структуры манифеста:
docker compose config
Убедившись, что сервис app публикует 127.0.0.1:18124:8000, а у сервиса db блок ports отсутствует, запустим контейнеры проекта в фоновом режиме:
docker compose up -d
Compose создаст сеть cf_docker_course_l24_default, том cf_docker_course_l24_notes_db_data и запустит контейнеры cf_docker_course_l24-db-1 и cf_docker_course_l24-app-1. Но завершение команды up -d означает лишь запуск процессов, а не готовность PostgreSQL принимать запросы.
Перед инициализацией приложения выполним ограниченную проверку доступности TCP-порта базы с помощью встроенной утилиты pg_isready:
docker compose exec db pg_isready -h 127.0.0.1 -p 5432 -U course -d course
Флаги -h 127.0.0.1 и -p 5432 задают адрес и TCP-порт сервера, а -U и -d — пользователя и базу. Повторяйте команду не чаще раза в секунду до получения кода завершения 0 (но не дольше 60 секунд). Код 0 подтверждает, что СУБД принимает сетевые подключения, но ещё не означает наличия схемы таблиц или успешной авторизации приложения.
После того как pg_isready вернул 0, выполним однократную команду инициализации схемы базы из запущенного контейнера приложения:
docker compose exec app python app.py init
Команда должна напечатать {"status": "initialized", "schema": 1} и завершиться с кодом 0. Теперь убедимся в готовности хранилища через HTTP-эндпоинт /ready:
curl -i http://127.0.0.1:18124/ready
Запрос возвращает статус 200 OK с телом {"status": "ready"}. Дополнительно проверим корень приложения:
curl -s http://127.0.0.1:18124/
Ответ подтверждает, что сервис работает именно в режиме PostgreSQL: {"service": "course-notes", "message": "hello from Docker", "storage": "postgres"}.
Сохраним новую запись через POST-запрос:
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"text":"compose note"}' \
http://127.0.0.1:18124/notes
Сервер возвращает статус 201 Created и объект заметки: {"id": 1, "text": "compose note"}.
Проверим сохранение данных запросом коллекции:
curl -s http://127.0.0.1:18124/notes
В ответе возвращается список с созданной заметкой: {"items": [{"id": 1, "text": "compose note"}]}.
Убедимся в изоляции сервиса db, сопоставив вывод docker compose ps:
docker compose ps
В столбце PORTS у сервиса app присутствует маппинг 127.0.0.1:18124->8000/tcp, тогда как у db отображается только внутренний порт 5432/tcp без стрелки сопоставления с хостом.
По окончании работы остановим только созданный стек текущего урока без удаления сохранённых данных:
docker compose down
Команда down без ключа -v удаляет контейнеры и сеть проекта, сохраняя именованный том cf_docker_course_l24_notes_db_data со всеми записанными строками таблицы.
Локальная лабораторная / самопроверка
Что уже дано
Работайте в POSIX-терминале с Linux-контейнерами. Сохраните выданные файлы в новый каталог l24_lab, который ещё не существует; если занят, выберите другой. Выберите уникальный префикс, например LAB=cf_docker_task_l24_run01; замените run01 при повторе. Перед созданием проверьте свои предполагаемые имена через docker container ls -a, network inspect и volume inspect, если они используются. Занятый объект не удаляйте: выберите другой префикс. Ошибку связи с Engine не принимайте за отсутствие ресурса. Порты хоста должны быть свободны; PORTS не исключает отдельный процесс ОС. При неожиданном bind-конфликте остановите опыт и согласованно замените host-порт/URL, не останавливая прежние службы. Все новые имена контейнеров/сетей/томов и проект Compose начинаются с выбранного LAB. Готовые app.py/SQL менять или писать не требуется. Соберите выданный Dockerfile из этого каталога в свой образ ${LAB}:app как подготовку; рецепт уже известен, кроме явно дефектного файла текущего задания.
Даны файлы приложения и Dockerfile. Соберите образ ${LAB}:app. Создайте compose.yaml для app/db с postgres17.4, DB_HOST=db, DB_PORT5432, course/course и PASSWORD=task-demo-24 у приложения/POSTGRES_PASSWORD у базы. Новый project-scoped том notes_db_data покрывает /var/lib/postgresql/data, PGDATA в его подкаталоге pgdata. HTTP18224, db не публикуется.
Что нужно сделать
Самостоятельно оформите обе секции volumes и среду сервисов. Выполните config/up, дождитесь TCPpg_isready0, затем exec app python app.py init. Получите /ready200, сохраните compose-db-proof-24 и прочитайте её. На отдельном собственном варианте измените DB_HOST на localhost при прежней готовой БД, зафиксируйте живой /health и отказ /ready; восстановите адрес без удаления тома.
Ввод и вывод
Вы самостоятельно вводите Docker/Compose-команды на хосте и сохраняете их реальные результаты в локальный текстовый отчёт. Печатать кодовое слово, писать Python/SQL или заполнять форму ответа не требуется. Эта TEXT-лабораторная не имеет автоматической платформенной проверки.
Проверка результата:
- Полный config не публикует DB5432; голое5432/tcp в ps не объявлено проверкой сокета. Имена сервисов и внутренний5432 согласованы.
- Init0, /ready200, POST201/GET с compose-db-proof-24; неверный DB_HOST даёт отказ прикладной готовности при живомHTTP. После восстановления та же заметка сохранена.
Сохраните команды, фактические ID/коды и наблюдения до очистки. После опыта штатно остановите и удалите только контейнеры своего LAB; для Compose используйте то же -p и down без-v, затем удалите только свою свободную сеть. Тома с контрольными записями сохраняйте, кроме отдельно оговорённого разрушительного сравнения урока27. Если позже удаляете собственный одноразовый том, сначала назовите точное имя и потерю его данных; чужие ресурсы и общие образы сохраняйте.
Ограничения:
- Локальная самопроверка; платформа не запускает весь Docker-проект, не оценивает отчёт автоматически и не подтверждает выполнение ученика.
- Никакого prune, privileged, Docker socket или реальных секретов. Только собственные новые учебные ресурсы.
- Эталон/ошибочные варианты проверяются отдельно на локальном Engine29.6.2; это не сертификация Latorn Docker27 или всех ОС.
Файл app.py:
import json
import os
from pathlib import Path
import signal
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import urlopen
META = json.loads(Path(__file__).with_name("app_metadata.json").read_text())
VERSION = Path(__file__).with_name("version.txt").read_text().strip()
MODE = os.getenv("STORAGE", "memory")
LOCK = threading.RLock()
MEMORY = []
def log(event, **fields):
print(json.dumps({"event": event, **fields}), flush=True)
def password():
direct, filename = os.getenv("PASSWORD", ""), os.getenv("PASSWORD_FILE", "")
if direct and filename:
raise ValueError("PASSWORD and PASSWORD_FILE are mutually exclusive")
value = Path(filename).read_text().rstrip("\r\n") if filename else direct
if not value:
raise ValueError("postgres mode requires PASSWORD or PASSWORD_FILE")
return value
def connect():
import psycopg
return psycopg.connect(host=os.getenv("DB_HOST", "db"), port=int(os.getenv("DB_PORT", "5432")),
dbname=os.getenv("DB_NAME", "course"), user=os.getenv("DB_USER", "course"),
password=password(), connect_timeout=2, options="-c statement_timeout=3000")
def file_path():
return Path(os.getenv("DATA_FILE", "/data/items.json"))
def rows():
with LOCK:
if MODE == "memory":
return list(MEMORY)
if MODE == "file":
p = file_path()
return json.loads(p.read_text()) if p.exists() else []
with connect() as db:
records = db.execute("SELECT id, text FROM course_items ORDER BY id").fetchall()
return [{"id": r[0], "text": r[1]} for r in records]
def add(text):
with LOCK:
if MODE == "postgres":
with connect() as db:
row = db.execute("INSERT INTO course_items (text) VALUES (%s) RETURNING id, text", (text,)).fetchone()
return {"id": row[0], "text": row[1]}
current = rows()
item = {"id": max([r["id"] for r in current], default=0) + 1, "text": text}
current.append(item)
if MODE == "memory":
MEMORY[:] = current
else:
p = file_path()
temp = p.with_suffix(".tmp")
temp.write_text(json.dumps(current), encoding="utf-8")
temp.replace(p)
return item
def ready():
if MODE == "postgres":
with connect() as db:
found = db.execute("SELECT version FROM course_schema WHERE singleton = TRUE").fetchone()
if found != (1,):
raise RuntimeError("schema 1 required; run supplied init command")
db.execute("SELECT id FROM course_items LIMIT 1")
elif MODE == "file":
p = file_path()
if not p.parent.is_dir() or not os.access(p.parent, os.W_OK):
raise RuntimeError("DATA_FILE parent must exist and be writable")
rows()
elif MODE != "memory":
raise ValueError("STORAGE must be memory, file or postgres")
def init():
if MODE == "postgres":
with connect() as db:
db.execute("CREATE TABLE IF NOT EXISTS course_schema (singleton boolean PRIMARY KEY CHECK (singleton), version integer NOT NULL)")
db.execute("INSERT INTO course_schema VALUES (TRUE, 1) ON CONFLICT (singleton) DO NOTHING")
version = db.execute("SELECT version FROM course_schema").fetchone()
if version != (1,):
raise RuntimeError("refusing incompatible schema")
db.execute("CREATE TABLE IF NOT EXISTS course_items (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, text varchar(200) NOT NULL)")
elif MODE == "file":
p = file_path()
if not p.exists():
p.write_text("[]", encoding="utf-8")
ready()
print(json.dumps({"status": "initialized", "schema": 1}))
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log("request", method=self.command, path=self.path, detail=fmt % args)
def send(self, code, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
try:
if self.path == "/":
filename = os.getenv("MESSAGE_FILE")
message = Path(filename).read_text().strip() if filename else os.getenv("MESSAGE", "hello from Docker")
self.send(200, {"service": META["service"], "message": message, "storage": MODE})
elif self.path == "/health":
self.send(200, {"status": "alive"})
elif self.path == "/ready":
ready()
self.send(200, {"status": "ready"})
elif self.path == "/version":
self.send(200, {"service": META["service"], "version": VERSION, "schema": 1})
elif self.path == META["collection"]:
ready()
self.send(200, {"items": rows()})
elif self.path == "/upstream":
url = os.environ["DEPENDENCY_URL"]
with urlopen(url, timeout=2) as reply:
self.send(200, {"dependency": json.load(reply)})
else:
self.send(404, {"error": "not_found"})
except Exception as exc:
log("dependency_error", kind=type(exc).__name__)
self.send(503, {"status": "not_ready"} if self.path == "/ready" else {"error": "storage_or_dependency_unavailable"})
def do_POST(self):
if self.path != META["collection"]:
self.send(404, {"error": "not_found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= 4096:
raise ValueError("body size")
data = json.loads(self.rfile.read(length))
text = data.get("text") if isinstance(data, dict) else None
if not isinstance(text, str) or not 1 <= len(text.strip()) <= 200:
raise ValueError("text must have 1..200 characters")
except (ValueError, TypeError):
self.send(400, {"error": "invalid_text"})
return
try:
ready()
self.send(201, add(text.strip()))
except Exception as exc:
log("storage_error", kind=type(exc).__name__)
self.send(503, {"error": "storage_unavailable"})
def serve():
if MODE not in ("memory", "file", "postgres"):
raise ValueError("invalid STORAGE")
if MODE == "postgres":
password() # configuration errors fail immediately; DB outages do not kill HTTP
Path("/tmp/course-app.pid").write_text(str(os.getpid()))
server = ThreadingHTTPServer((os.getenv("HOST", "0.0.0.0"), int(os.getenv("PORT", "8000"))), Handler)
server.daemon_threads = True
stopping = threading.Event()
def stop(signum, _frame):
if not stopping.is_set():
stopping.set()
log("signal_received", signal=signal.Signals(signum).name)
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
log("started", service=META["service"], version=VERSION, storage=MODE, uid=os.getuid())
server.serve_forever(poll_interval=0.1)
server.server_close()
log("stopped")
def main():
command = sys.argv[1] if len(sys.argv) > 1 else "serve"
if command == "serve":
serve()
elif command == "init":
init()
elif command == "seed":
ready()
for text in META["seed"]:
if not any(row["text"] == text for row in rows()):
add(text)
print(json.dumps({"items": rows()}))
elif command == "inspect":
ready()
print(json.dumps({"items": rows()}))
elif command == "check":
ready()
print(json.dumps({"status": "ready", "schema": 1}))
elif command == "http-check":
with urlopen("http://127.0.0.1:" + os.getenv("PORT", "8000") + "/ready", timeout=2) as r:
print(r.read().decode())
elif command == "fail":
log("intentional_failure")
sys.exit(7)
elif command == "allocate":
# Use ONLY with a bounded container memory limit; held blocks force observable allocation.
blocks = []
while True:
blocks.append(bytearray(1024 * 1024))
else:
raise ValueError("unknown supplied command")
if __name__ == "__main__":
try:
main()
except Exception as exc:
log("fatal", kind=type(exc).__name__)
sys.exit(1)
Файл requirements.txt:
psycopg==3.2.6 psycopg-binary==3.2.6 typing-extensions==4.12.2
Файл app_metadata.json:
{
"service": "course-notes",
"collection": "/notes",
"seed": [
"first supplied note",
"data survives container recreation"
]
}
Файл version.txt:
1.0.0
Файл Dockerfile:
FROM python:3.12.10-slim-bookworm AS dependencies
WORKDIR /build
COPY requirements.txt .
RUN python -m pip install --no-cache-dir --prefix=/install -r requirements.txt && printf 'build stage only\n' > /build/build-only.txt
FROM python:3.12.10-slim-bookworm AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --from=dependencies /install /usr/local
COPY app.py app_metadata.json ./
ARG APP_VERSION=1.0.0
COPY versions/${APP_VERSION}.txt ./version.txt
RUN groupadd --gid 10001 app && useradd --uid 10001 --gid 10001 --no-create-home app && mkdir /data && chown 10001:10001 /data
USER 10001:10001
EXPOSE 8000
STOPSIGNAL SIGTERM
CMD ["python", "-u", "app.py", "serve"]
Файл versions/1.0.0.txt:
1.0.0
Лабораторная выполняется локально. Latorn не запускает Docker Compose или весь проект и не подтверждает выполнение лабораторной автоматически.
Задача 1
Сервисы app и db входят в одну Compose default-сеть. PostgreSQL в db слушает5432. Доступ к базе с хоста не нужен. Какая настройка позволяет app обращаться к db внутри этой сети?
Задача 2
Сервис db содержит volumes: ["pgdata:/var/lib/postgresql/data"]. Других объявлений томов нет. Docker compose config сообщает об undefined volume pgdata. Какое изменение исправляет именно этот дефект?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
