Главная/Docker Networking/Практика

Раздел 8. Практические задания

Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.

Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.

Подготовка:

bash
mkdir -p ~/docker-course/08-networking && cd ~/docker-course/08-networking
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim alpine:3.21 postgres:17-alpine; do docker pull -q "$img"; done
docker network ls        # зафиксируйте исходное состояние
ip -brief link | head     # интерфейсы host до начала работы

Задание 1. Связь по имени в user-defined сети [обяз.]

Постановка. Создайте сеть, подключите к ней два container'а, установите между ними соединение по имени, а не по адресу.

Дополнительно покажите, что после пересоздания одного из них связь по имени продолжает работать, хотя адрес изменился.

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

Проверка:

bash
docker exec <client> getent hosts <server>

Разбор — в уроке 8.2 и уроке 8.4.


Задание 2. Публикация порта [обяз.]

Постановка. Запустите HTTP-приложение в container'е и опубликуйте его порт. Подтвердите доступность с host тремя способами:

  1. curl возвращает 200;
  2. docker port показывает трансляцию;
  3. правило DNAT существует, и его счётчик растёт при запросе.

Ожидаемый результат. Три подтверждения одного факта из независимых источников.

Проверка:

bash
docker port <container>
sudo iptables -t nat -L DOCKER -n -v | grep <порт>

Разбор — в уроке 8.3.


Задание 3. Привязка к 127.0.0.1 [обяз.]

Постановка. Запустите приложение, слушающее 127.0.0.1, с опубликованным портом. Воспроизведите недоступность и покажите четыре наблюдения:

  1. container в состоянии running, в логах ошибок нет;
  2. docker port показывает корректную трансляцию;
  3. изнутри container'а сервис отвечает;
  4. с host и из соседнего container'а — нет.

Затем исправьте и покажите разницу.

Ожидаемый результат. Полная картина ошибки и объяснение, почему пакет не доходит.

Проверка:

bash
docker exec <c> python -c "
from pathlib import Path
for l in Path('/proc/net/tcp').read_text().splitlines()[1:]:
    f = l.split()
    if f[3] == '0A':
        a, p = f[1].rsplit(':', 1)
        print('.'.join(str(int(a[i:i+2], 16)) for i in (6,4,2,0)) + ':' + str(int(p, 16)))
"

Разбор — в уроке 8.3 и уроке 8.6.


Задание 4. Интерфейсы и парный veth [обяз.]

Постановка. Для работающего container'а найдите:

  1. его сетевые интерфейсы и адрес;
  2. индекс парного интерфейса;
  3. имя veth-интерфейса на host;
  4. bridge, к которому он подключён;
  5. подтверждение, что адрес bridge — это шлюз container'а.

Всё — командами, без предположений.

Ожидаемый результат. Замкнутая цепочка от eth0 внутри до bridge на host.

Проверка:

bash
iflink=$(docker exec <c> cat /sys/class/net/eth0/iflink)
ip -o link | awk -F': ' -v n="$iflink" '$1+0 == n {print $2}'

Разбор — в уроке 8.1.


Задание 5. DNS в двух типах сетей [обяз.]

Постановка. Запустите пары container'ов в default bridge и в user-defined сети. Для каждой пары покажите:

  1. содержимое /etc/resolv.conf;
  2. результат разрешения имени соседа;
  3. результат соединения по адресу соседа.

Объясните, почему resolv.conf одинаков, а поведение различается.

Ожидаемый результат. Таблица сравнения и объяснение механизма.

Проверка:

bash
docker exec <c> getent hosts <сосед> || echo "не разрешается"

Разбор — в уроке 8.4.


Задание 6. Правило DNAT [доп.]

Постановка. Найдите правило, созданное публикацией порта, и разберите его по частям: условие входного интерфейса, порт назначения, адрес трансляции.

Дополнительно найдите разрешающее правило в таблице filter и объясните, зачем нужны оба.

Ожидаемый результат. Разбор каждого условия правила и доказательство, что через него идёт трафик.

Проверка:

bash
sudo iptables -t nat -S DOCKER | grep <порт>
sudo iptables -S DOCKER | grep <ip-container>

Разбор — в уроке 8.3.


Задание 7. Режим host [доп.]

Постановка. Запустите container с --network host -p 8080:80. Покажите:

  1. предупреждение Docker;
  2. пустой вывод docker port;
  3. отсутствие правила DNAT;
  4. что сервис доступен на своём порту, а не на 8080;
  5. что hostname внутри совпадает с именем host.

Объясните, почему публикация не нужна и невозможна.

Ожидаемый результат. Пять наблюдений и объяснение через отсутствие отдельного адреса.

Проверка:

bash
docker port <container> || echo "публикаций нет"

Разбор — в уроке 8.5.


Задание 8. Изоляция базы данных [доп.]

Постановка. Постройте конфигурацию из трёх сервисов: web, api, db. Требования:

  1. web доступен с host;
  2. api доступен из web, но не с host;
  3. db доступен из api, но не из web и не с host;
  4. db не имеет выхода в интернет.

Каждое утверждение подтвердите, различая gaierror и таймаут.

Ожидаемый результат. Работающая изоляция с доказательством для каждого уровня.

Проверка:

bash
docker inspect <c> --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}'

Разбор — в уроке 8.2.


Задание 9. Диагностика: localhost:5432 [диаг.]

Постановка. Дана конфигурация:

yaml
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb

  api:
    image: python:3.13-slim
    environment:
      DATABASE_URL: "postgresql://postgres:secret@localhost:5432/appdb"
    command: ["python", "-c", "import os; print(os.environ['DATABASE_URL'])"]
    depends_on:
      - db

Приложение не может подключиться к базе. Определите причину, подтвердите её и предложите исправление.

Дополнительно: после исправления адреса приложение иногда всё ещё падает при первом запуске. Объясните и устраните вторую причину.

Ожидаемый результат. Две независимые причины, каждая с доказательством и исправлением.

Разбор

Причина 1: localhost внутри container'а означает сам container.

У каждого container'а свой сетевой namespace со своим loopback (урок 8.1). Адрес 127.0.0.1 в api не имеет никакого отношения к db.

Доказательство:

bash
cd ~/docker-course/08-networking && mkdir -p diag9 && cd diag9
cat > compose.yaml <<'EOF'
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb

  api:
    image: python:3.13-slim
    command: ["sleep", "300"]
    depends_on:
      - db
EOF

docker compose up -d > /dev/null 2>&1
sleep 10

docker compose exec -T api python -c "
import socket, time

def probe(host, port):
    t = time.monotonic()
    try:
        addr = socket.gethostbyname(host)
    except socket.gaierror as e:
        return f'gaierror  {(time.monotonic()-t)*1000:6.1f} мс  {e.strerror}'
    s = socket.socket(); s.settimeout(3)
    try:
        s.connect((addr, port))
        return f'ok        {(time.monotonic()-t)*1000:6.1f} мс  {addr}'
    except ConnectionRefusedError:
        return f'refused   {(time.monotonic()-t)*1000:6.1f} мс  {addr}'
    except OSError as e:
        return f'{type(e).__name__:<9} {(time.monotonic()-t)*1000:6.1f} мс  {e}'
    finally:
        s.close()

for target in ('localhost', '127.0.0.1', 'db'):
    print(f'  {target:<12} {probe(target, 5432)}')
"

Ожидаемый вывод:

text
  localhost    refused      0.3 мс  127.0.0.1
  127.0.0.1    refused      0.3 мс  127.0.0.1
  db           ok           1.9 мс  172.27.0.2

Подпись однозначна: refused за доли миллисекунды. Мгновенный отказ означает, что ядро само отклонило соединение — на loopback этого container'а порт 5432 никто не слушает. До сети дело не дошло.

Строка db показывает, что правильное имя работает.

Исправление 1:

yaml
    environment:
      DATABASE_URL: "postgresql://postgres:secret@db:5432/appdb"

Имя сервиса Compose является DNS-именем в сети проекта (урок 8.4).

Причина 2: depends_on без условия ждёт запуска container'а, а не готовности базы.

PostgreSQL начинает принимать соединения через несколько секунд после старта container'а. Всё это время db:5432 даёт refused.

Доказательство:

bash
cd ~/docker-course/08-networking/diag9
cat > compose.yaml <<'EOF'
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb

  early:
    image: python:3.13-slim
    depends_on:
      - db
    command:
      - python
      - -c
      - |
        import socket, sys
        s = socket.socket(); s.settimeout(3)
        code = s.connect_ex(('db', 5432))
        print('early: соединение установлено' if code == 0
              else f'early: НЕ ГОТОВО, errno={code}')
EOF

docker compose down -v > /dev/null 2>&1
docker compose up --abort-on-container-exit > /tmp/diag9.log 2>&1
grep -E 'early:' /tmp/diag9.log | sed 's/^/  /'

Ожидаемый вывод:

text
  early-1  | early: НЕ ГОТОВО, errno=111

errno=111 — это ECONNREFUSED. Container базы уже запущен, но PostgreSQL ещё инициализирует каталог данных.

Исправление 2 — healthcheck и условие:

bash
cd ~/docker-course/08-networking/diag9
cat > compose.yaml <<'EOF'
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      timeout: 3s
      retries: 20
      start_period: 5s

  api:
    image: python:3.13-slim
    environment:
      DATABASE_URL: "postgresql://postgres:secret@db:5432/appdb"
    depends_on:
      db:
        condition: service_healthy
    command:
      - python
      - -c
      - |
        import os, socket, sys
        from urllib.parse import urlparse
        url = urlparse(os.environ['DATABASE_URL'])
        s = socket.socket(); s.settimeout(3)
        code = s.connect_ex((url.hostname, url.port))
        print(f'api: подключение к {url.hostname}:{url.port} — '
              + ('успешно' if code == 0 else f'ошибка errno={code}'))
        sys.exit(0 if code == 0 else 1)
EOF

docker compose down -v > /dev/null 2>&1
docker compose up --abort-on-container-exit --exit-code-from api > /tmp/diag9b.log 2>&1
echo "  код: $?"
grep -E 'api:' /tmp/diag9b.log | sed 's/^/  /'
docker compose down -v > /dev/null 2>&1

Ожидаемый вывод:

text
  код: 0
  api-1  | api: подключение к db:5432 — успешно

Как различить две причины. Обе дают ECONNREFUSED, но по-разному:

Причина 1 (localhost)Причина 2 (гонка при старте)
Адрес в ошибке127.0.0.1адрес db
ВоспроизводитсяВсегдаНе всегда
Перезапуск помогаетНетЧасто да
ИсправлениеИзменить конфигурациюДобавить healthcheck

Строка «перезапуск помогает» — самый быстрый способ различить их на практике.

Третья причина, которой здесь нет, но которая встречается рядом. Если бы api и db оказались в разных сетях, отказ был бы не refused, а gaierror: имя db не разрешилось бы вовсе. Compose помещает все сервисы проекта в одну сеть автоматически, поэтому такое возможно только при явном объявлении разных networks (урок 8.2).


Задание 10. Вход в network namespace через nsenter [★]

Постановка. Выполните полную диагностику container'а, в образе которого нет ни одной сетевой утилиты, не устанавливая туда ничего.

Требования:

  1. Внутри namespace container'а выполнены ip addr, ip route, ss -tlnp.
  2. То же самое получено вторым способом — через --network container:<имя>.
  3. Объяснено, почему ss -tlnp показывает процесс при одном способе и не показывает при другом.
  4. Захвачен трафик container'а с host, без входа в него.
  5. Показано, что образ container'а не изменился: сетевых утилит в нём по-прежнему нет.
  6. Собран скрипт netshell.sh, дающий оболочку в namespace указанного container'а.

Подсказки

Подсказка 1

PID процесса container'а даёт docker inspect -f '{{.State.Pid}}'.

Подсказка 2

Требование 3 связано с тем, что nsenter -n меняет только сетевой namespace, а --network container: создаёт отдельный PID namespace.

Подсказка 3

Для требования 4 нужен парный veth-интерфейс: /sys/class/net/eth0/iflink.

Подсказка 4

nsenter требует прав root на host.

Решение

Показать решение
bash
mkdir -p ~/docker-course/08-networking/nsdemo && cd ~/docker-course/08-networking/nsdemo

cat > srv.py <<'PY'
"""Сервис без единой сетевой утилиты в образе."""
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        body = f"pid={os.getpid()}\n".encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *a):
        pass


def serve(port: int) -> None:
    HTTPServer(("0.0.0.0", port), H).serve_forever()


if __name__ == "__main__":
    print("слушаю 0.0.0.0:8000 и 0.0.0.0:9000", flush=True)
    threading.Thread(target=serve, args=(9000,), daemon=True).start()
    serve(8000)
PY

cat > netshell.sh <<'SH'
#!/usr/bin/env bash
# Требование 6: оболочка в сетевом namespace container'а.
#   ./netshell.sh <container> [команда...]
# Без команды — интерактивная оболочка. Инструменты берутся с host,
# образ container'а не изменяется.
set -euo pipefail

C="${1:?укажите container}"
shift || true

if ! docker inspect "$C" > /dev/null 2>&1; then
    echo "container '$C' не найден" >&2
    exit 1
fi

pid="$(docker inspect -f '{{.State.Pid}}' "$C")"
if [ "$pid" = "0" ]; then
    echo "container '$C' не запущен" >&2
    exit 1
fi

if [ "$#" -eq 0 ]; then
    echo "Сетевой namespace container'а '$C' (PID $pid). Выход — exit."
    exec sudo nsenter -t "$pid" -n "${SHELL:-/bin/bash}"
fi
exec sudo nsenter -t "$pid" -n "$@"
SH
chmod +x netshell.sh

cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY srv.py .
CMD ["python", "srv.py"]
EOF

docker build -q -t nsdemo . > /dev/null
docker network create nsnet > /dev/null
docker run -d --network nsnet --name target -p 18700:8000 nsdemo > /dev/null
sleep 4

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 5 (предпосылка): в образе нет утилит ═══\n'
missing=1
for tool in ip ss netstat ping curl tcpdump dig nslookup; do
    docker exec target sh -c "command -v $tool" > /dev/null 2>&1 && missing=0
done
[ "$missing" -eq 1 ] && ok "ни одной сетевой утилиты в образе" \
    || bad "утилиты присутствуют — демонстрация не показательна"

printf '\n═══ Требование 1: nsenter с host ═══\n'
printf '  ip -brief addr:\n'
./netshell.sh target ip -brief addr | sed 's/^/    /'
printf '  ip route:\n'
./netshell.sh target ip route | sed 's/^/    /'
printf '  ss -tlnp:\n'
./netshell.sh target ss -tlnp | sed 's/^/    /'
ok "три команды выполнены в namespace container'а"

printf '\n═══ Требование 2: через --network container: ═══\n'
docker run --rm --network container:target alpine:3.21 sh -c '
    apk add --no-cache iproute2 > /dev/null 2>&1
    echo "  ip -brief addr:"; ip -brief addr | sed "s/^/    /"
    echo "  ss -tlnp:";       ss -tlnp | sed "s/^/    /"
' && ok "тот же namespace, другой способ"

printf '\n═══ Требование 3: почему процессы видны не всегда ═══\n'
printf '  nsenter -n (PID namespace остался host):\n'
./netshell.sh target ss -tlnp 2>/dev/null | grep -o 'users:((.*))' | head -2 | sed 's/^/    /' \
    || printf '    (нет строки users)\n'
printf '  --network container: (свой PID namespace):\n'
docker run --rm --network container:target alpine:3.21 sh -c '
    apk add --no-cache iproute2 > /dev/null 2>&1
    ss -tlnp | grep -c "users:" || true
' | xargs printf '    сокетов с указанием процесса: %s\n'
cat <<'TXT'
    Причина: nsenter -n меняет ТОЛЬКО сетевой namespace, оставляя
    PID namespace host, откуда процессы container'а видны. Container,
    запущенный с --network container:, разделяет сеть, но имеет свой
    PID namespace и чужих процессов не видит.
TXT
ok "различие объяснено"

printf '\n═══ Требование 4: захват трафика с host ═══\n'
iflink="$(docker exec target cat /sys/class/net/eth0/iflink | tr -d '\r')"
veth="$(ip -o link | awk -F': ' -v n="$iflink" '$1+0 == n {split($2, a, "@"); print a[1]}')"
printf '  iflink=%s → veth=%s\n' "$iflink" "$veth"
if [ -n "$veth" ]; then
    sudo timeout 6 tcpdump -i "$veth" -nn -c 4 'tcp port 8000' > /tmp/cap.txt 2>/dev/null &
    cap_pid=$!
    sleep 1
    for _ in 1 2 3; do curl -s -o /dev/null http://localhost:18700/; done
    wait $cap_pid 2>/dev/null || true
    printf '  захвачено пакетов: %s\n' "$(grep -c 'IP' /tmp/cap.txt 2>/dev/null || echo 0)"
    grep 'IP' /tmp/cap.txt 2>/dev/null | head -3 | sed 's/^/    /'
    rm -f /tmp/cap.txt
    ok "трафик перехвачен без входа в container"
else
    bad "парный veth не найден"
fi

printf '\n═══ Требование 5: образ не изменился ═══\n'
still_missing=1
for tool in ip ss tcpdump curl; do
    docker exec target sh -c "command -v $tool" > /dev/null 2>&1 && still_missing=0
done
[ "$still_missing" -eq 1 ] && ok "утилит по-прежнему нет" || bad "в container что-то установилось"
printf '  слоёв в образе: %s\n' \
    "$(docker image inspect nsdemo --format '{{len .RootFS.Layers}}')"
printf '  изменений в файловой системе container: %s\n' \
    "$(docker diff target | wc -l)"

printf '\n═══ Требование 6: netshell.sh ═══\n'
printf '  проверка на несуществующем container: '
./netshell.sh no-such-container true 2>&1 | head -1
printf '  проверка на остановленном: '
docker create --name stopped alpine:3.21 sleep 60 > /dev/null
./netshell.sh stopped true 2>&1 | head -1
docker rm stopped > /dev/null
ok "скрипт корректно обрабатывает ошибки"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rm -f target > /dev/null 2>&1
docker network rm nsnet > /dev/null 2>&1
docker rmi -f nsdemo > /dev/null 2>&1

Ожидаемый вывод:

text
═══ Требование 5 (предпосылка): в образе нет утилит ═══
  ✓ ни одной сетевой утилиты в образе

═══ Требование 1: nsenter с host ═══
  ip -brief addr:
    lo               UNKNOWN        127.0.0.1/8 ::1/128
    eth0@if73        UP             172.28.0.2/16
  ip route:
    default via 172.28.0.1 dev eth0
    172.28.0.0/16 dev eth0 scope link src 172.28.0.2
  ss -tlnp:
    State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
    LISTEN 0      5            0.0.0.0:9000      0.0.0.0:*     users:(("python",pid=58412,fd=4))
    LISTEN 0      5            0.0.0.0:8000      0.0.0.0:*     users:(("python",pid=58412,fd=3))
  ✓ три команды выполнены в namespace container'а

═══ Требование 2: через --network container: ═══
  ip -brief addr:
    lo               UNKNOWN        127.0.0.1/8 ::1/128
    eth0@if73        UP             172.28.0.2/16
  ss -tlnp:
    State  Recv-Q Send-Q Local Address:Port Peer Address:Port
    LISTEN 0      5            0.0.0.0:9000      0.0.0.0:*
    LISTEN 0      5            0.0.0.0:8000      0.0.0.0:*
  ✓ тот же namespace, другой способ

═══ Требование 3: почему процессы видны не всегда ═══
  nsenter -n (PID namespace остался host):
    users:(("python",pid=58412,fd=4))
    users:(("python",pid=58412,fd=3))
  --network container: (свой PID namespace):
    сокетов с указанием процесса: 0
    Причина: nsenter -n меняет ТОЛЬКО сетевой namespace, оставляя
    PID namespace host, откуда процессы container'а видны. Container,
    запущенный с --network container:, разделяет сеть, но имеет свой
    PID namespace и чужих процессов не видит.
  ✓ различие объяснено

═══ Требование 4: захват трафика с host ═══
  iflink=73 → veth=veth4a2c8f1
  захвачено пакетов: 4
    13:52:11.204 IP 172.28.0.1.51234 > 172.28.0.2.8000: Flags [S], seq 284712
    13:52:11.204 IP 172.28.0.2.8000 > 172.28.0.1.51234: Flags [S.], seq 991823
    13:52:11.205 IP 172.28.0.1.51234 > 172.28.0.2.8000: Flags [.], ack 1
  ✓ трафик перехвачен без входа в container

═══ Требование 5: образ не изменился ═══
  ✓ утилит по-прежнему нет
  слоёв в образе: 5
  изменений в файловой системе container: 1

═══ Требование 6: netshell.sh ═══
  проверка на несуществующем container: container 'no-such-container' не найден
  проверка на остановленном: container 'stopped' не запущен
  ✓ скрипт корректно обрабатывает ошибки

═══ ИТОГ ═══
  все требования выполнены

Все шесть требований выполнены.

Три решения, определяющие качество.

Требование 5 проверяется дважды: до работы и после. Первая проверка устанавливает предпосылку — если бы утилиты в образе были, вся демонстрация потеряла бы смысл. Вторая доказывает, что диагностика ничего не изменила. Одна проверка без другой не доказывает ничего: «утилит нет» в конце могло бы означать, что их и не было, а мы работали с другим container'ом.

Требование 3 — не рассуждение, а наблюдение. Скрипт печатает users:((...)) для одного способа и считает такие строки для другого, получая ноль. Объяснение приводится после данных и опирается на них. Обратный порядок — сначала теория, потом подгонка — оставил бы читателя без доказательства.

netshell.sh проверяет и отсутствие container'а, и остановленное состояние. Второй случай неочевиден: docker inspect для остановленного container'а отработает успешно, но вернёт Pid = 0, и nsenter -t 0 выдаст малопонятную ошибку. Явная проверка превращает её в понятное сообщение — это разница между инструментом и черновиком.

Чего решение не делает. nsenter требует root на host, что доступно не всегда — в управляемых средах и на CI-агентах такого доступа обычно нет. Способ с --network container: работает без root, но, как показано, не видит процессов. Не покрыт и случай, когда container использует --network host: тогда его сетевой namespace совпадает с host, nsenter -n не даст ничего нового, а парного veth не существует.


Очистка после раздела

bash
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in python:3.13-slim alpine:3.21 postgres:17-alpine; do
    docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker network prune -f
docker image prune -f
docker network ls
ip -brief link | head

Сравните с состоянием, зафиксированным в начале раздела: лишних сетей и veth-интерфейсов остаться не должно.


Критерии завершения

Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.

Дальше: Quiz 08, затем Проект 2. FastAPI service.


Навигация

← Предыдущий материал
Вернуться к разделу
Следующий раздел → Docker Compose
Главное оглавление

Markdown на GitHub ↗