Раздел 8. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
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'а, установите между ними соединение по имени, а не по адресу.
Дополнительно покажите, что после пересоздания одного из них связь по имени продолжает работать, хотя адрес изменился.
Ожидаемый результат. Успешное соединение по имени до и после пересоздания, с указанием обоих адресов.
Проверка:
docker exec <client> getent hosts <server>
Разбор — в уроке 8.2 и уроке 8.4.
Задание 2. Публикация порта [обяз.]
Постановка. Запустите HTTP-приложение в container'е и опубликуйте его порт. Подтвердите доступность с host тремя способами:
curlвозвращает200;docker portпоказывает трансляцию;- правило DNAT существует, и его счётчик растёт при запросе.
Ожидаемый результат. Три подтверждения одного факта из независимых источников.
Проверка:
docker port <container>
sudo iptables -t nat -L DOCKER -n -v | grep <порт>
Разбор — в уроке 8.3.
Задание 3. Привязка к 127.0.0.1 [обяз.]
Постановка. Запустите приложение, слушающее 127.0.0.1, с опубликованным портом. Воспроизведите недоступность и покажите четыре наблюдения:
- container в состоянии
running, в логах ошибок нет; docker portпоказывает корректную трансляцию;- изнутри container'а сервис отвечает;
- с host и из соседнего container'а — нет.
Затем исправьте и покажите разницу.
Ожидаемый результат. Полная картина ошибки и объяснение, почему пакет не доходит.
Проверка:
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'а найдите:
- его сетевые интерфейсы и адрес;
- индекс парного интерфейса;
- имя
veth-интерфейса на host; - bridge, к которому он подключён;
- подтверждение, что адрес bridge — это шлюз container'а.
Всё — командами, без предположений.
Ожидаемый результат. Замкнутая цепочка от eth0 внутри до bridge на host.
Проверка:
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 сети. Для каждой пары покажите:
- содержимое
/etc/resolv.conf; - результат разрешения имени соседа;
- результат соединения по адресу соседа.
Объясните, почему resolv.conf одинаков, а поведение различается.
Ожидаемый результат. Таблица сравнения и объяснение механизма.
Проверка:
docker exec <c> getent hosts <сосед> || echo "не разрешается"
Разбор — в уроке 8.4.
Задание 6. Правило DNAT [доп.]
Постановка. Найдите правило, созданное публикацией порта, и разберите его по частям: условие входного интерфейса, порт назначения, адрес трансляции.
Дополнительно найдите разрешающее правило в таблице filter и объясните, зачем нужны оба.
Ожидаемый результат. Разбор каждого условия правила и доказательство, что через него идёт трафик.
Проверка:
sudo iptables -t nat -S DOCKER | grep <порт>
sudo iptables -S DOCKER | grep <ip-container>
Разбор — в уроке 8.3.
Задание 7. Режим host [доп.]
Постановка. Запустите container с --network host -p 8080:80. Покажите:
- предупреждение Docker;
- пустой вывод
docker port; - отсутствие правила DNAT;
- что сервис доступен на своём порту, а не на 8080;
- что
hostnameвнутри совпадает с именем host.
Объясните, почему публикация не нужна и невозможна.
Ожидаемый результат. Пять наблюдений и объяснение через отсутствие отдельного адреса.
Проверка:
docker port <container> || echo "публикаций нет"
Разбор — в уроке 8.5.
Задание 8. Изоляция базы данных [доп.]
Постановка. Постройте конфигурацию из трёх сервисов: web, api, db. Требования:
webдоступен с host;apiдоступен изweb, но не с host;dbдоступен изapi, но не изwebи не с host;dbне имеет выхода в интернет.
Каждое утверждение подтвердите, различая gaierror и таймаут.
Ожидаемый результат. Работающая изоляция с доказательством для каждого уровня.
Проверка:
docker inspect <c> --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}'
Разбор — в уроке 8.2.
Задание 9. Диагностика: localhost:5432 [диаг.]
Постановка. Дана конфигурация:
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.
Доказательство:
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)}')
"
Ожидаемый вывод:
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:
environment:
DATABASE_URL: "postgresql://postgres:secret@db:5432/appdb"
Имя сервиса Compose является DNS-именем в сети проекта (урок 8.4).
Причина 2: depends_on без условия ждёт запуска container'а, а не готовности базы.
PostgreSQL начинает принимать соединения через несколько секунд после старта container'а. Всё это время db:5432 даёт refused.
Доказательство:
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/^/ /'
Ожидаемый вывод:
early-1 | early: НЕ ГОТОВО, errno=111
errno=111 — это ECONNREFUSED. Container базы уже запущен, но PostgreSQL ещё инициализирует каталог данных.
Исправление 2 — healthcheck и условие:
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
Ожидаемый вывод:
код: 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'а, в образе которого нет ни одной сетевой утилиты, не устанавливая туда ничего.
Требования:
- Внутри namespace container'а выполнены
ip addr,ip route,ss -tlnp. - То же самое получено вторым способом — через
--network container:<имя>. - Объяснено, почему
ss -tlnpпоказывает процесс при одном способе и не показывает при другом. - Захвачен трафик container'а с host, без входа в него.
- Показано, что образ container'а не изменился: сетевых утилит в нём по-прежнему нет.
- Собран скрипт
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.
Решение
Показать решение
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
Ожидаемый вывод:
═══ Требование 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 не существует.
Очистка после раздела
# ВНИМАНИЕ: НЕ `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
Главное оглавление