Урок курса
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 выполняет цепочку действий:
- Создаёт стандартную изолированную bridge-сеть проекта. Её имя формируется из названия директории и суффикса:
cf_docker_course_l23_default. - Создаёт контейнер для сервиса
webс именемcf_docker_course_l23-web-1. - Запускает контейнер с привязкой порта
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 на отступе. Что нужно исправить в первую очередь?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
