Урок курса

Приложение и база в 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. Какое изменение исправляет именно этот дефект?

Выберите один вариант
Без регистрации · результат не сохраняется

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку