9.3. Networks и volumes
Цели
После этого материала вы сможете:
- объяснить, что создаётся без единого объявления
networksиvolumes; - предсказать последствие того, что сервис объявил собственные сети;
- изолировать группы сервисов и подтвердить изоляцию проверкой;
- подключить существующие снаружи сеть или volume через
external; - переопределить имя ресурса ключом
nameи понимать, чем это опасно; - назвать точно, что удаляют
down,down -vиdocker compose rm -v.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
default | Сеть, создаваемая Compose автоматически |
external | Ресурс, созданный вне проекта; Compose его не создаёт и не удаляет |
alias | Дополнительное DNS-имя сервиса в конкретной сети |
driver_opts | Параметры драйвера — те же, что у docker network/volume create |
ipam | Настройка адресации сети |
Теория
Что происходит без объявлений
services:
web:
image: nginx:alpine
db:
image: postgres:17-alpine
Compose создаёт сеть <проект>_default и подключает к ней оба сервиса. Они видят друг друга по именам web и db — потому что это user-defined сеть (урок 8.2).
Это умолчание покрывает большинство простых случаев, и в них раздел networks не нужен вовсе.
Главная ловушка: объявление отключает от default
services:
web:
image: nginx:alpine
networks:
- frontend # теперь web ТОЛЬКО в frontend
db:
image: postgres:17-alpine
# db остался в default
networks:
frontend:
Как только сервис объявляет networks, он перестаёт подключаться к default. Здесь web и db окажутся в разных сетях и не увидят друг друга.
Ошибка тем коварнее, что возникает при частичной правке: добавили сеть одному сервису, забыли другим. Симптом — gaierror при обращении по имени (урок 8.6).
Правило: либо ни один сервис не объявляет сети, либо все.
Объявление сетей
networks:
frontend:
driver: bridge
backend:
internal: true # без выхода наружу
fixed:
ipam:
config:
- subnet: 10.30.0.0/24
gateway: 10.30.0.1
shared:
external: true # создана вне проекта
custom-name:
name: my-explicit-network # без префикса проекта
| Ключ | Назначение |
|---|---|
driver | Обычно bridge; overlay для Swarm |
internal | Нет маршрута наружу (урок 8.2) |
ipam.config | Подсеть и шлюз, когда адреса должны быть предсказуемы |
external: true | Сеть создана заранее; Compose её не трогает |
name | Точное имя без префикса проекта |
driver_opts | Например, com.docker.network.bridge.name |
labels | Метки |
Подключение сервиса к сети
services:
api:
image: myapp
networks:
backend:
aliases:
- api-internal
- service.local
frontend:
ipv4_address: 10.30.0.10
priority: 100
| Ключ | Назначение |
|---|---|
aliases | Дополнительные DNS-имена в этой сети |
ipv4_address | Статический адрес; требует ipam.config |
priority | Порядок подключения; влияет на выбор шлюза по умолчанию |
link_local_ips | Дополнительные адреса канального уровня |
priority заслуживает пояснения: при подключении к нескольким сетям маршрут по умолчанию берётся из сети с наибольшим приоритетом. Без него порядок определяется алфавитом имён — что приводит к неожиданному выбору выходного интерфейса.
Объявление volumes
volumes:
db-data: # обычный named volume
cache:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=128m
host-bound:
driver: local
driver_opts:
type: none
device: /srv/appdata
o: bind
shared-with-others:
external: true
exact:
name: prod-database # без префикса проекта
Третий вариант — volume, привязанный к каталогу host: он ведёт себя как bind mount, но конфигурация остаётся переносимой, потому что абсолютный путь указан один раз (урок 7.2).
external: когда Compose не владеет ресурсом
volumes:
shared:
external: true
networks:
corporate:
external: true
name: corp-net
| Свойство | Обычный | external: true |
|---|---|---|
| Создаётся Compose | Да | Нет; должен существовать |
Удаляется down -v | Да | Нет |
| Имя получает префикс проекта | Да | Нет |
| Отсутствие ресурса | Создаётся | Ошибка |
Применение: данные, переживающие проект целиком; сеть, общая для нескольких стеков; volume, созданный администратором с особыми параметрами.
Это же — надёжная защита от down -v: внешний volume не удаляется даже с флагом.
Ключ name: точное имя
volumes:
db-data:
name: myapp-production-db
Volume будет называться ровно так, без префикса проекта.
Опасность: префикс проекта — механизм изоляции. Убрав его, вы получаете глобальное имя, которое может совпасть с чужим. Два проекта с name: db-data разделят один volume — и одна база перезапишет другую.
Применяйте name только когда имя действительно должно быть общим, и делайте его уникальным.
Что удаляют команды
| Команда | Container'ы | Сети | Named volumes | Anonymous volumes | External |
|---|---|---|---|---|---|
stop | Останавливает | — | — | — | — |
down | Удаляет | Удаляет | Оставляет | Удаляет | Не трогает |
down -v | Удаляет | Удаляет | Удаляет | Удаляет | Не трогает |
rm -v | Удаляет остановленные | — | — | Удаляет | — |
down --rmi all | Удаляет | Удаляет | Оставляет | Удаляет | Не трогает |
Столбец External — единственный, где везде «не трогает». Это делает external: true рабочим способом защитить данные от случайного down -v.
Внутренний механизм
Имена и метки
Compose создаёт сети и volumes с метками:
com.docker.compose.project=myapp
com.docker.compose.network=backend
com.docker.compose.volume=db-data
com.docker.compose.version=2.x.y
Метки определяют, что Compose считает своим при down. Ресурс с external: true меток не получает — Compose его не создавал.
Порядок создания
- Сети (в порядке объявления).
- Volumes.
- Container'ы в порядке зависимостей (урок 9.4).
Проверка существования external-ресурсов происходит на первом шаге: отсутствующая внешняя сеть останавливает запуск до создания container'ов.
Команды и примеры
Умолчание: одна сеть на проект
mkdir -p /tmp/compnet && cd /tmp/compnet
cat > compose.yaml <<'EOF'
name: compnet
services:
web:
image: python:3.13-slim
command: ["python", "-m", "http.server", "8000", "--bind", "0.0.0.0"]
db:
image: python:3.13-slim
command: ["sleep", "600"]
EOF
docker compose up -d > /dev/null 2>&1
sleep 3
echo "═══ созданные сети ═══"
docker network ls --filter name=compnet --format ' {{.Name}}'
echo "═══ к чему подключены сервисы ═══"
docker compose config --format json 2>/dev/null | python3 -c "
import json, sys
cfg = json.load(sys.stdin)
for name, svc in cfg['services'].items():
print(f' {name}: {list((svc.get(\"networks\") or {}).keys()) or [\"(нет)\"]}')
"
echo "═══ связь по имени ═══"
docker compose exec -T db python -c "
import socket
print(' db → web:', socket.gethostbyname('web'))
"
Ожидаемый вывод:
═══ созданные сети ═══
compnet_default
═══ к чему подключены сервисы ═══
web: ['default']
db: ['default']
═══ связь по имени ═══
db → web: 172.30.0.3
Ни одного объявления в файле — сеть создана, оба сервиса в ней, имена работают.
Ловушка: частичное объявление сетей
cd /tmp/compnet
cat > compose.yaml <<'EOF'
name: compnet
services:
web:
image: python:3.13-slim
command: ["sleep", "600"]
networks:
- frontend # объявил — значит, НЕ в default
db:
image: python:3.13-slim
command: ["sleep", "600"]
# ничего не объявил — остался в default
networks:
frontend:
EOF
docker compose down > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
sleep 3
echo "═══ сети проекта ═══"
docker network ls --filter name=compnet --format ' {{.Name}}'
echo "═══ кто в какой сети ═══"
for s in web db; do
printf ' %-4s %s\n' "$s" \
"$(docker inspect "$(docker compose ps -q $s)" \
--format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}')"
done
echo "═══ связь ═══"
docker compose exec -T web python -c "
import socket
try:
print(' web → db:', socket.gethostbyname('db'))
except socket.gaierror as e:
print(f' ✗ web → db: {e.strerror}')
"
Ожидаемый вывод:
═══ сети проекта ═══
compnet_default
compnet_frontend
═══ кто в какой сети ═══
web compnet_frontend
db compnet_default
═══ связь ═══
✗ web → db: Name or service not known
Создались две сети, сервисы разошлись по разным, связь пропала.
Обратите внимание: compnet_default всё равно создана — ради db, который сети не объявлял. Именно эта пара сетей в выводе docker network ls и есть первый признак ошибки.
Исправление — объявить сеть обоим:
cd /tmp/compnet
cat > compose.yaml <<'EOF'
name: compnet
services:
web:
image: python:3.13-slim
command: ["sleep", "600"]
networks: [frontend]
db:
image: python:3.13-slim
command: ["sleep", "600"]
networks: [frontend]
networks:
frontend:
EOF
docker compose down > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
sleep 3
docker network ls --filter name=compnet --format ' сеть: {{.Name}}'
docker compose exec -T web python -c "
import socket
print(' web → db:', socket.gethostbyname('db'))
"
Ожидаемый вывод:
сеть: compnet_frontend
web → db: 172.31.0.3
Сеть теперь одна, default не создаётся.
Изоляция уровней
cd /tmp/compnet
cat > compose.yaml <<'EOF'
name: compnet
services:
proxy:
image: python:3.13-slim
command: ["sleep", "600"]
networks: [edge]
api:
image: python:3.13-slim
command: ["sleep", "600"]
networks:
edge:
aliases: [backend-api]
data:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
networks: [data]
networks:
edge:
data:
internal: true
EOF
docker compose down > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
sleep 8
echo "═══ карта сетей ═══"
for s in proxy api db; do
printf ' %-6s %s\n' "$s" \
"$(docker inspect "$(docker compose ps -q $s)" \
--format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}')"
done
echo "═══ проверка доступа ═══"
probe() {
docker compose exec -T "$1" python - "$2" "$3" <<'PY' 2>/dev/null
import socket, sys
host, port = sys.argv[1], int(sys.argv[2])
s = socket.socket(); s.settimeout(3)
try:
s.connect((host, port)); print(" ok")
except socket.gaierror:
print(" имя не разрешается")
except OSError as e:
print(f" {type(e).__name__}")
finally:
s.close()
PY
}
printf ' proxy → api: '; probe proxy api 22 | tr -d '\n'; echo
printf ' proxy → db: '; probe proxy db 5432 | tr -d '\n'; echo
printf ' api → db: '; probe api db 5432 | tr -d '\n'; echo
printf ' proxy → alias backend-api: '; probe proxy backend-api 22 | tr -d '\n'; echo
echo "═══ выход наружу из internal-сети ═══"
docker compose exec -T db sh -c 'ip route 2>/dev/null | grep -c "^default"' \
| xargs printf ' маршрутов по умолчанию у db: %s\n'
Ожидаемый вывод:
═══ карта сетей ═══
proxy compnet_edge
api compnet_data compnet_edge
db compnet_data
═══ проверка доступа ═══
proxy → api: ConnectionRefusedError
proxy → db: имя не разрешается
api → db: ok
proxy → alias backend-api: ConnectionRefusedError
═══ выход наружу из internal-сети ═══
маршрутов по умолчанию у db: 0
Разберём результат внимательно.
proxy → api даёт ConnectionRefusedError — это успех проверки связности: имя разрешилось, хост ответил, просто порт 22 закрыт. Различие между отказом и «имя не разрешается» здесь принципиально (урок 8.6).
proxy → db — имя не разрешается: сети разные, зона DNS не содержит записи.
Псевдоним backend-api работает из edge, потому что объявлен именно в этой сети.
Ноль маршрутов по умолчанию у db — прямое следствие internal: true.
Volumes: объявление и типы
cd /tmp/compnet
mkdir -p hostdir && echo "с host" > hostdir/file.txt
cat > compose.yaml <<'EOF'
name: compnet
services:
app:
image: alpine:3.21
command: ["sh", "-c", "echo данные > /named/f.txt; sleep 600"]
volumes:
- named-vol:/named # named volume
- ./hostdir:/bind:ro # bind mount
- mem-vol:/mem # volume-tmpfs
- /anon # anonymous
volumes:
named-vol:
mem-vol:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=32m
EOF
docker compose down -v > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
sleep 3
echo "═══ volumes проекта ═══"
docker volume ls --filter label=com.docker.compose.project=compnet --format ' {{.Name}}'
echo "═══ типы файловых систем внутри ═══"
docker compose exec -T app df -hT /named /bind /mem /anon 2>/dev/null | awk 'NR==1 || /named|bind|mem|anon/'
echo "═══ anonymous volume ═══"
docker volume ls -q --filter label=com.docker.compose.project=compnet \
| grep -E '^[0-9a-f]{64}$' | head -1 | xargs -r printf ' случайное имя: %.20s...\n'
Ожидаемый вывод:
═══ volumes проекта ═══
compnet_mem-vol
compnet_named-vol
8f3a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5
═══ типы файловых систем внутри ═══
Filesystem Type Size Used Avail Use% Mounted on
/dev/sda1 ext4 58G 21G 35G 38% /bind
/dev/sda1 ext4 58G 21G 35G 38% /named
tmpfs tmpfs 32M 0 32M 0% /mem
/dev/sda1 ext4 58G 21G 35G 38% /anon
═══ anonymous volume ═══
случайное имя: 8f3a91c2e7d45b6a09fe...
Четыре монтирования, четыре разных механизма. mem-vol — единственный tmpfs, размер соответствует driver_opts.
Anonymous volume получил случайное имя и метку проекта — поэтому down его удалит, в отличие от named (урок 7.2).
Что переживает down
cd /tmp/compnet
docker compose exec -T app sh -c 'echo "важное" > /named/payload.txt' 2>/dev/null
echo "═══ до down ═══"
docker volume ls -q --filter label=com.docker.compose.project=compnet | wc -l | xargs printf ' volumes: %s\n'
docker compose down > /dev/null 2>&1
echo "═══ после down ═══"
docker volume ls --filter label=com.docker.compose.project=compnet --format ' {{.Name}}'
printf ' данные: %s\n' \
"$(docker run --rm -v compnet_named-vol:/v alpine:3.21 cat /v/payload.txt 2>/dev/null || echo 'ПОТЕРЯНЫ')"
docker compose up -d > /dev/null 2>&1
sleep 2
docker compose down -v > /dev/null 2>&1
echo "═══ после down -v ═══"
printf ' volumes: %s\n' \
"$(docker volume ls -q --filter label=com.docker.compose.project=compnet | wc -l)"
Ожидаемый вывод:
═══ до down ═══
volumes: 3
═══ после down ═══
compnet_mem-vol
compnet_named-vol
данные: важное
═══ после down -v ═══
volumes: 0
down убрал только anonymous volume (было 3, осталось 2), named сохранились вместе с данными. down -v удалил всё.
external защищает данные
cd /tmp/compnet
docker volume create protected-data > /dev/null
docker run --rm -v protected-data:/v alpine:3.21 sh -c 'echo "не удалять" > /v/important.txt'
docker network create shared-net > /dev/null
cat > compose.yaml <<'EOF'
name: compnet
services:
app:
image: alpine:3.21
command: ["sleep", "600"]
volumes:
- protected:/data
networks: [shared]
volumes:
protected:
external: true
name: protected-data
networks:
shared:
external: true
name: shared-net
EOF
docker compose up -d > /dev/null 2>&1
sleep 2
printf ' данные читаются: %s\n' "$(docker compose exec -T app cat /data/important.txt)"
echo "═══ down -v ═══"
docker compose down -v > /dev/null 2>&1
printf ' volume существует: %s\n' \
"$(docker volume ls -q --filter name=protected-data | wc -l)"
printf ' данные на месте: %s\n' \
"$(docker run --rm -v protected-data:/v alpine:3.21 cat /v/important.txt)"
printf ' сеть существует: %s\n' \
"$(docker network ls -q --filter name=shared-net | wc -l)"
echo "═══ что будет, если внешнего ресурса нет ═══"
docker volume rm protected-data > /dev/null 2>&1
docker compose up -d 2>&1 | tail -2 | sed 's/^/ /'
docker network rm shared-net > /dev/null 2>&1
Ожидаемый вывод:
данные читаются: не удалять
═══ down -v ═══
volume существует: 1
данные на месте: не удалять
сеть существует: 1
═══ что будет, если внешнего ресурса нет ═══
external volume "protected-data" not found
down -v не тронул внешние ресурсы — это и есть способ защитить production-данные от случайной команды.
Последний блок показывает обратную сторону: отсутствующий внешний ресурс останавливает запуск. Это осознанный выбор Compose — не создавать молча то, чем он не владеет.
Ключ name и риск столкновения
cd /tmp/compnet
mkdir -p proj-a proj-b
for p in a b; do
cat > "proj-$p/compose.yaml" <<EOF
name: project-$p
services:
app:
image: alpine:3.21
command: ["sh", "-c", "echo 'данные проекта $p' > /d/who.txt; sleep 300"]
volumes:
- shared:/d
volumes:
shared:
name: global-volume
EOF
done
echo "═══ запускаем проект A ═══"
(cd proj-a && docker compose up -d > /dev/null 2>&1)
sleep 3
printf ' содержимое: %s\n' "$(docker run --rm -v global-volume:/v alpine:3.21 cat /v/who.txt)"
echo "═══ запускаем проект B (другой проект!) ═══"
(cd proj-b && docker compose up -d > /dev/null 2>&1)
sleep 3
printf ' содержимое: %s\n' "$(docker run --rm -v global-volume:/v alpine:3.21 cat /v/who.txt)"
echo "═══ сколько volumes создано ═══"
docker volume ls --format '{{.Name}}' | grep -cE 'global-volume|project-' | xargs printf ' %s\n'
(cd proj-a && docker compose down > /dev/null 2>&1)
(cd proj-b && docker compose down -v > /dev/null 2>&1)
Ожидаемый вывод:
═══ запускаем проект A ═══
содержимое: данные проекта a
═══ запускаем проект B (другой проект!) ═══
содержимое: данные проекта b
═══ сколько volumes создано ═══
1
Два разных проекта, один volume — данные проекта A перезаписаны проектом B.
Без ключа name создались бы project-a_shared и project-b_shared, и столкновения не произошло бы. Префикс проекта — не украшение имени, а механизм изоляции.
cd /tmp && rm -rf /tmp/compnet
Практическое упражнение
Задание. Постройте конфигурацию с тремя уровнями изоляции и проверяемой судьбой данных.
Требования:
- Три сети:
edge,app,data;dataбез выхода наружу. - Четыре сервиса распределены так, что
dbнедоступна изedge. - Ни один сервис не остался в
default— сети объявлены всем. - Данные базы лежат во внешнем volume и переживают
down -v. - Кэш лежит в
tmpfs-volume и исчезает при перезапуске. - Псевдоним сервиса работает в одной сети и не работает в другой.
Скрипт проверки различает gaierror, refused и таймаут и возвращает ненулевой код при нарушении.
Подсказки
Подсказка 1
Пункт 3 проверяется отсутствием сети <проект>_default в выводе docker network ls.
Подсказка 2
Внешний volume нужно создать до up, иначе Compose откажется запускаться.
Подсказка 3
Для пункта 6 объявите псевдоним внутри одной сети сервиса, состоящего в двух.
Решение
Показать решение
mkdir -p /tmp/netvol && cd /tmp/netvol
cat > probe.py <<'PY'
"""Проба: различает gaierror, refused, timeout."""
import socket
import sys
host, port = sys.argv[1], int(sys.argv[2])
s = socket.socket()
s.settimeout(3)
try:
addr = socket.gethostbyname(host)
except socket.gaierror:
print("gaierror")
sys.exit(0)
try:
s.connect((addr, port))
print("ok")
except ConnectionRefusedError:
print("refused")
except (socket.timeout, TimeoutError):
print("timeout")
except OSError as e:
print(f"errno{getattr(e, 'errno', '?')}")
finally:
s.close()
PY
cat > compose.yaml <<'EOF'
name: netvol
services:
proxy:
image: python:3.13-slim
command: ["sleep", "900"]
volumes:
- ./probe.py:/probe.py:ro
networks: [edge]
api:
image: python:3.13-slim
command: ["sleep", "900"]
volumes:
- ./probe.py:/probe.py:ro
- cache:/cache
networks:
edge:
aliases: [api-public] # псевдоним ТОЛЬКО в edge
app:
data:
worker:
image: python:3.13-slim
command: ["sleep", "900"]
volumes:
- ./probe.py:/probe.py:ro
networks: [app, data]
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- dbdata:/var/lib/postgresql/data
networks: [data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 3s
retries: 20
networks:
edge:
app:
data:
internal: true
volumes:
# Пункт 4: внешний — переживает down -v
dbdata:
external: true
name: netvol-production-db
# Пункт 5: в памяти — исчезает при пересоздании
cache:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=64m
EOF
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
probe() { docker compose exec -T "$1" python /probe.py "$2" "$3" 2>/dev/null | tr -d '\r\n'; }
printf '\n═══ Подготовка: внешний volume ═══\n'
docker volume create netvol-production-db > /dev/null
docker run --rm -v netvol-production-db:/v alpine:3.21 \
sh -c 'echo "маркер до запуска" > /v/marker.txt' 2>/dev/null
ok "внешний volume создан"
docker compose up -d > /dev/null 2>&1
sleep 12
printf '\n═══ Пункт 1: сети ═══\n'
docker network ls --filter label=com.docker.compose.project=netvol --format ' {{.Name}}'
internal="$(docker network inspect netvol_data --format '{{.Internal}}')"
printf ' netvol_data internal: %s\n' "$internal"
[ "$internal" = "true" ] && ok "data изолирована от внешней сети" || bad "data не internal"
printf '\n═══ Пункт 3: сети default не создано ═══\n'
if docker network ls --format '{{.Name}}' | grep -qx netvol_default; then
bad "сеть netvol_default существует — кто-то не объявил сети"
else
ok "netvol_default отсутствует"
fi
printf ' распределение:\n'
for s in proxy api worker db; do
printf ' %-7s %s\n' "$s" \
"$(docker inspect "$(docker compose ps -q $s)" \
--format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}')"
done
printf '\n═══ Пункт 2: db недоступна из edge ═══\n'
r="$(probe proxy db 5432)"
printf ' proxy → db: %s\n' "$r"
[ "$r" = "gaierror" ] && ok "имя db не разрешается из edge" || bad "получено '$r'"
r="$(probe api db 5432)"
printf ' api → db: %s\n' "$r"
[ "$r" = "ok" ] && ok "api достаёт db" || bad "api не видит db: $r"
r="$(probe worker db 5432)"
printf ' worker → db: %s\n' "$r"
[ "$r" = "ok" ] && ok "worker достаёт db" || bad "worker не видит db: $r"
r="$(probe proxy api 22)"
printf ' proxy → api: %s\n' "$r"
[ "$r" = "refused" ] && ok "proxy видит api (refused = хост ответил)" || bad "получено '$r'"
printf '\n═══ Пункт 6: псевдоним действует только в своей сети ═══\n'
r="$(probe proxy api-public 22)"
printf ' proxy → api-public (edge): %s\n' "$r"
[ "$r" = "refused" ] && ok "псевдоним работает в edge" || bad "получено '$r'"
r="$(probe worker api-public 22)"
printf ' worker → api-public (app): %s\n' "$r"
[ "$r" = "gaierror" ] && ok "псевдоним НЕ работает в app" || bad "получено '$r'"
r="$(probe worker api 22)"
printf ' worker → api (по имени): %s\n' "$r"
[ "$r" = "refused" ] && ok "по имени сервиса доступен из обеих сетей" || bad "получено '$r'"
printf '\n═══ Пункт 5: tmpfs-кэш ═══\n'
docker compose exec -T api sh -c 'echo "кэш" > /cache/entry.txt' 2>/dev/null
fs="$(docker compose exec -T api sh -c 'df -T /cache | tail -1 | awk "{print \$2}"' | tr -d '\r\n')"
printf ' файловая система /cache: %s\n' "$fs"
[ "$fs" = "tmpfs" ] && ok "кэш в памяти" || bad "ожидался tmpfs, получено $fs"
docker compose up -d --force-recreate api > /dev/null 2>&1
sleep 4
left="$(docker compose exec -T api sh -c 'ls -A /cache | wc -l' | tr -d '\r\n')"
printf ' файлов в /cache после пересоздания: %s\n' "$left"
[ "$left" = "0" ] && ok "кэш исчез при пересоздании" || bad "кэш пережил пересоздание"
printf '\n═══ Пункт 4: данные переживают down -v ═══\n'
docker compose exec -T db psql -U postgres -d appdb -q \
-c 'CREATE TABLE IF NOT EXISTS t (id int); INSERT INTO t VALUES (1),(2),(3);' > /dev/null 2>&1
before="$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM t' | tr -d ' \r')"
printf ' записей до down -v: %s\n' "$before"
docker compose down -v > /dev/null 2>&1
exists="$(docker volume ls -q --filter name=netvol-production-db | wc -l)"
printf ' внешний volume после down -v: %s\n' "$exists"
[ "$exists" = "1" ] && ok "внешний volume не удалён" || bad "volume удалён"
marker="$(docker run --rm -v netvol-production-db:/v alpine:3.21 cat /v/marker.txt 2>/dev/null)"
printf ' маркер: %s\n' "${marker:-ПОТЕРЯН}"
docker compose up -d > /dev/null 2>&1
sleep 12
after="$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM t' 2>/dev/null | tr -d ' \r')"
printf ' записей после повторного up: %s\n' "${after:-нет таблицы}"
[ "$before" = "$after" ] && ok "данные базы пережили down -v" || bad "было $before, стало ${after:-0}"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down -v > /dev/null 2>&1
docker volume rm netvol-production-db > /dev/null 2>&1
cd /tmp && rm -rf /tmp/netvol
exit "$fail"
Ожидаемый вывод:
═══ Подготовка: внешний volume ═══
✓ внешний volume создан
═══ Пункт 1: сети ═══
netvol_app
netvol_data
netvol_edge
netvol_data internal: true
✓ data изолирована от внешней сети
═══ Пункт 3: сети default не создано ═══
✓ netvol_default отсутствует
распределение:
proxy netvol_edge
api netvol_app netvol_data netvol_edge
worker netvol_app netvol_data
db netvol_data
═══ Пункт 2: db недоступна из edge ═══
proxy → db: gaierror
✓ имя db не разрешается из edge
api → db: ok
✓ api достаёт db
worker → db: ok
✓ worker достаёт db
proxy → api: refused
✓ proxy видит api (refused = хост ответил)
═══ Пункт 6: псевдоним действует только в своей сети ═══
proxy → api-public (edge): refused
✓ псевдоним работает в edge
worker → api-public (app): gaierror
✓ псевдоним НЕ работает в app
worker → api (по имени): refused
✓ по имени сервиса доступен из обеих сетей
═══ Пункт 5: tmpfs-кэш ═══
файловая система /cache: tmpfs
✓ кэш в памяти
файлов в /cache после пересоздания: 0
✓ кэш исчез при пересоздании
═══ Пункт 4: данные переживают down -v ═══
записей до down -v: 3
внешний volume после down -v: 1
✓ внешний volume не удалён
маркер: маркер до запуска
записей после повторного up: 3
✓ данные базы пережили down -v
Все требования выполнены.
Три решения, определяющие качество.
Пункт 6 проверяет псевдоним из двух сетей и дополнительно проверяет обычное имя. Проверка только «работает в edge» не доказала бы, что псевдоним привязан к сети: он мог бы работать везде. Три пробы вместе показывают полную картину — псевдоним локален для сети, имя сервиса глобально для всех его сетей.
Пункт 2 включает проверку proxy → api с ожиданием refused. Все остальные пробы в этом блоке ожидают недоступности, и без положительного контроля тест прошёл бы даже при полностью сломанной сети. refused доказывает, что механизм проверки исправен и различает случаи.
Пункт 4 проверяет и маркер, записанный до запуска стека, и данные, записанные во время работы. Первое подтверждает, что Compose подключил именно существующий volume, а не создал новый с тем же именем. Второе — что данные переживают полный цикл down -v / up. По отдельности ни одна из проверок не закрывает вопрос.
Чего решение не делает. Изоляция data работает между container'ами, но не защищает от доступа с host: администратор может обратиться к адресу db напрямую, поскольку маршрут к docker-сетям на host существует (урок 8.2). Не проверяется и то, что internal: true не мешает db обращаться к соседям по своей сети — это верно, но осталось за рамками требований.
Проверка результата
mkdir -p /tmp/nv && cd /tmp/nv
cat > compose.yaml <<'EOF'
name: nv
services:
a:
image: alpine:3.21
command: ["sleep", "60"]
networks: [priv]
b:
image: alpine:3.21
command: ["sleep", "60"]
networks: [priv]
networks:
priv:
internal: true
EOF
docker compose up -d
docker network ls --filter name=nv --format '{{.Name}}'
docker compose exec -T a getent hosts b
docker compose down
cd /tmp && rm -rf /tmp/nv
Ожидается единственная сеть nv_priv (без nv_default) и разрешение имени b.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Сети объявлены части сервисов | Правили постепенно | Объявивший сеть выходит из default; объявить всем |
Ожидают default при объявленных сетях | Считают её всегда доступной | Она создаётся только для необъявивших |
internal: true для сети приложения | Хотели безопаснее | Пропадёт доступ к внешним API |
name: у volume без нужды | Кажется аккуратнее | Теряется префикс проекта; риск столкновения |
external: true без создания ресурса | Не знали о требовании | Запуск падает |
Ждут, что down -v удалит внешний volume | Флаг «удалить всё» | Внешние ресурсы не трогаются |
| Псевдоним объявлен не в той сети | Не заметили вложенность | Псевдоним локален для сети |
Статический адрес без ipam.config | Скопировали пример | Требуется явная подсеть |
Полагаются на down для очистки данных | Ожидают полного сброса | Named volumes остаются |
Контрольные вопросы
На понимание:
- Что создаётся без единого объявления
networksиvolumes? - Что происходит, когда сервис объявляет собственные сети?
- Чем
external: trueотличается от обычного объявления? Назовите четыре различия. - Чем опасен ключ
nameу volume? - Что удаляют
down,down -vи что не удаляет ни один из них?
На применение:
- Как изолировать базу от внешнего уровня и проверить изоляцию?
- Как защитить production-данные от случайного
down -v? - Как сделать volume в памяти средствами Compose?
На диагностику:
- После добавления сети одному сервису другие перестали его находить. Причина?
- Два проекта делят один volume, хотя объявлены отдельно. Где искать?
Краткое резюме
- Без объявлений Compose создаёт
<проект>_defaultи подключает к ней все сервисы. - Сервис, объявивший
networks, перестаёт подключаться кdefault. - Частичное объявление сетей — частая причина «имя не разрешается».
internal: trueубирает маршрут по умолчанию: ни выхода, ни входа.- Псевдоним действует только в той сети, в которой объявлен.
- Имя сервиса разрешается во всех его сетях, псевдоним — нет.
priorityопределяет, какая сеть даёт маршрут по умолчанию при нескольких подключениях.- Volume с
driver_opts: type=tmpfsживёт в памяти и исчезает при пересоздании. external: trueозначает: Compose не создаёт и не удаляет ресурс.- Внешние ресурсы не удаляются даже при
down -v— это защита данных. - Ключ
nameубирает префикс проекта и вместе с ним изоляцию между проектами. downсохраняет named volumes; anonymous удаляются в обоих случаях.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose: networks top-level | https://docs.docker.com/reference/compose-file/networks/ | Объявление, internal, external, ipam, name |
| Compose: volumes top-level | https://docs.docker.com/reference/compose-file/volumes/ | Объявление, driver_opts, external, name |
| Compose: service networks | https://docs.docker.com/reference/compose-file/services/#networks | aliases, ipv4_address, priority |
| Compose: networking how-to | https://docs.docker.com/compose/how-tos/networking/ | Сеть по умолчанию, разрешение имён |
Compose: down | https://docs.docker.com/reference/cli/docker/compose/down/ | Что удаляется, флаг -v |
| Docker: volumes | https://docs.docker.com/engine/storage/volumes/ | Опции драйвера local |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Healthchecks и зависимости
Главное оглавление