Урок курса

compose.yaml и первый проект

Docker и Docker Compose: контейнеризация приложений

1. Один запуск в compose.yaml

Для запуска контейнера через CLI требовалось помнить длинную строку с аргументами: имя образа, переменные окружения и сопоставление сокетов. Если эту команду нужно повторить на другой машине или передать коллеге, параметры приходится копировать вручную.

Файл compose.yaml сохраняет это описание в виде текстового документа. Docker Compose считывает файл и переводит записанные параметры в вызовы Engine. Формат разметки — YAML. В нём структура данных задаётся пробельными отступами, а не фигурными скобками.

Перед созданием манифеста создадим чистую рабочую директорию проекта cf_docker_course_l23 и перейдём в неё. Команда mkdir -p создаёт каталог вместе с родительскими папками, если их ещё нет, а cd меняет текущий каталог терминала:

mkdir -p cf_docker_course_l23
cd cf_docker_course_l23

Внутри каталога cf_docker_course_l23 создадим файл compose.yaml со следующим содержимым:

services:
  web:
    image: course-notes:1.0.0
    ports:
      - "127.0.0.1:18123:8000"

Разберём синтаксические элементы этого файла:

  • Словари (maps): пары «ключ-значение». Ключ отделяется от значения двоеточием с обязательным пробелом после него (key: value). Запись image: course-notes:1.0.0 означает, что ключу image сопоставлена строка с именем образа.
  • Отступы: вложенность определяется количеством пробелов в начале строки. Стандартный шаг вложенности — 2 пробела. Блок web: находится внутри секции services:, поэтому он сдвинут на 2 пробела. Поля image: и ports: относятся к сервису web и сдвинуты ещё на 2 пробела (всего 4 пробела от левого края).
  • Списки (sequences): элементы списка начинаются с дефиса и пробела (- item). Поле ports: принимает список правил публикации.
  • Строки в кавычках для портов: публикация сокета записана как "127.0.0.1:18123:8000". В уроке 15 мы изучили структуру маппинга: адрес интерфейса хоста, порт хоста и порт внутри контейнера. В YAML значения, содержащие двоеточия, следует оборачивать в двойные кавычки. В спецификации YAML 1.1 числа по основанию 60 (сексогесимальные числа) использовали формат часы:минуты:секунды, где компоненты после первого двоеточия ограничены диапазоном 0..59 (например, 22:22). Хотя запись 18123:8000 в современном Compose парсится корректно, явные кавычки гарантируют строковый тип и исключают неоднозначность при разборе файла любым парсером.

Использование символа табуляции (\t) для отступов в YAML запрещено. Если редактор вставит символ табуляции вместо пробелов, парсер прервёт чтение с ошибкой недопустимого символа.

2. Проверить файл до запуска

Перед выполнением манифеста можно убедиться, что файл не содержит синтаксических ошибок и корректно трактуется спецификацией Compose. Для этого предназначена команда docker compose config.

Команда docker compose config считывает compose.yaml в текущем рабочем каталоге, разбирает структуру YAML, сопоставляет её со схемой Compose и выводит нормализованное представление проекта в терминал.

Находясь в папке cf_docker_course_l23, выполним валидацию:

docker compose config

Команда выводит канонический вид конфигурации, в котором подставлено имя проекта (по умолчанию совпадающее с именем каталога) и нормализована структура портов:

name: cf_docker_course_l23
services:
  web:
    image: course-notes:1.0.0
    networks:
      default: null
    ports:
      - mode: ingress
        target: 8000
        published: "18123"
        protocol: tcp
        host_ip: 127.0.0.1
networks:
  default:
    name: cf_docker_course_l23_default

Если нарушить правила разметки — например, сдвинуть строку image: на неверный уровень отступа относительно web: или использовать табуляцию вместо пробелов:

services:
  web:
  image: course-notes:1.0.0
    ports:
      - "127.0.0.1:18123:8000"

то docker compose config завершится с ненулевым кодом и укажет строку со сбоем парсера:

yaml: line 4: did not find expected key

Успешное завершение docker compose config подтверждает только валидность структуры документа. Команда не проверяет, существует ли образ локально или в реестре, свободен ли порт 18123 на сетевом интерфейсе хоста и способно ли само приложение успешно запуститься и отвечать по HTTP. Эти факторы проявляются на этапе создания и запуска контейнеров.

3. Запустить и остановить проект

После валидации манифеста проект можно развернуть. Управление жизненным циклом сервисов выполняется четырьмя базовыми командами из каталога с compose.yaml.

Перед запуском убедимся, что порт хоста 18123 свободен и не занят другими процессами ОС. Затем выполним команду запуска в фоновом режиме:

docker compose up -d

Ключ -d (detach) переводит создание и запуск контейнеров в фоновый режим. Compose выполняет цепочку действий:

  1. Создаёт стандартную изолированную bridge-сеть проекта. Её имя формируется из названия директории и суффикса: cf_docker_course_l23_default.
  2. Создаёт контейнер для сервиса web с именем cf_docker_course_l23-web-1.
  3. Запускает контейнер с привязкой порта 127.0.0.1:18123 хоста к порту 8000 контейнера.

Проверим статус созданных контейнеров текущего проекта:

docker compose ps

В выводе отображается состояние сервиса:

NAME                          IMAGE                COMMAND                  SERVICE   CREATED         STATUS         PORTS
cf_docker_course_l23-web-1   course-notes:1.0.0   "python -u app.py se…"   web       5 seconds ago   Up 4 seconds   127.0.0.1:18123->8000/tcp

Убедимся, что приложение начало обслуживать запросы, прочитав журнал вывода контейнера:

docker compose logs

Compose выводит логи всех сервисов проекта, добавляя префикс с именем контейнера к каждой строке. Поскольку базовый образ course-notes:1.0.0 собран без переключения пользователя, процесс выполняется от root (uid: 0):

cf_docker_course_l23-web-1  | {"event": "started", "service": "course-notes", "version": "1.0.0", "storage": "memory", "uid": 0}

Проверим доступность сервиса по HTTP с помощью curl. Утилита отправляет GET-запрос на loopback-сокет хоста:

curl -fsS http://127.0.0.1:18123/

Приложение возвращает подтверждённый JSON-ответ:

{"service": "course-notes", "message": "hello from Docker", "storage": "memory"}

Для завершения работы проекта выполняется команда остановки и удаления:

docker compose down

Область действия docker compose down строго ограничена: команда останавливает только контейнеры текущего проекта cf_docker_course_l23, удаляет их и удаляет сеть проекта cf_docker_course_l23_default. Контейнеры и сети других уроков или соседних проектов остаются нетронутыми.

Локальная лабораторная / самопроверка

Что уже дано

Работайте в POSIX-терминале с Linux-контейнерами. Сохраните выданные файлы в новый каталог l23_lab, который ещё не существует; если занят, выберите другой. Выберите уникальный префикс, например LAB=cf_docker_task_l23_run01; замените run01 при повторе. Перед созданием проверьте свои предполагаемые имена через docker container ls -a, network inspect и volume inspect, если они используются. Занятый объект не удаляйте: выберите другой префикс. Ошибку связи с Engine не принимайте за отсутствие ресурса. Порты хоста должны быть свободны; PORTS не исключает отдельный процесс ОС. При неожиданном bind-конфликте остановите опыт и согласованно замените host-порт/URL, не останавливая прежние службы. Все новые имена контейнеров/сетей/томов и проект Compose начинаются с выбранного LAB. Готовые app.py/SQL менять или писать не требуется. Соберите выданный Dockerfile из этого каталога в свой образ ${LAB}:app как подготовку; рецепт уже известен, кроме явно дефектного файла текущего задания.

Даны готовые файлы сервиса и Dockerfile. Создайте свой compose.yaml с одним сервисом web, образом ${LAB}:app и loopback-публикацией18223:8000; значение LAB замените фактическим именем вашего нового проекта в строке image, это пока не Compose-подстановка.

Что нужно сделать

Соберите локальный образ, запишите минимальный YAML самостоятельно. Сначала сохраните config и нормализованные host_ip/target/published. Намеренно испортите один отступ в собственной копии и получите отказ config; верните валидный файл. Поднимите только свой проект, подтвердите ps/logs/HTTP, затем выполните его down и проверьте исчезновение собственных контейнеров/сети.

Ввод и вывод

Вы самостоятельно вводите Docker/Compose-команды на хосте и сохраняете их реальные результаты в локальный текстовый отчёт. Печатать кодовое слово, писать Python/SQL или заполнять форму ответа не требуется. Эта TEXT-лабораторная не имеет автоматической платформенной проверки.

Проверка результата:

  • Неверный YAML отклонён до создания ресурсов; валидный config показывает loopback18223->8000, но не выдан за подтверждение запуска.
  • Реальный web отвечает200/course-notes/memory, started получен от своего проекта. down затронул только новые проектные ресурсы; без публикации успешный внутренний процесс не заменяет хостовый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
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app.py app_metadata.json version.txt ./
EXPOSE 8000
CMD ["python", "-u", "app.py", "serve"]

Лабораторная выполняется локально. Latorn не запускает Docker Compose или весь проект и не подтверждает выполнение лабораторной автоматически.

Задача 1

В compose.yaml строки web и image вложены под services с помощью символов TAB. Команда docker compose config сообщает об ошибке разбора YAML на отступе. Что нужно исправить в первую очередь?

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

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

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

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