Главная/Docker Compose/Урок

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Настройка адресации сети

Теория

Что происходит без объявлений

yaml
services:
  web:
    image: nginx:alpine
  db:
    image: postgres:17-alpine

Compose создаёт сеть <проект>_default и подключает к ней оба сервиса. Они видят друг друга по именам web и db — потому что это user-defined сеть (урок 8.2).

Это умолчание покрывает большинство простых случаев, и в них раздел networks не нужен вовсе.

Главная ловушка: объявление отключает от default

yaml
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).

Правило: либо ни один сервис не объявляет сети, либо все.

Объявление сетей

yaml
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Метки

Подключение сервиса к сети

yaml
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

yaml
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 не владеет ресурсом

yaml
volumes:
  shared:
    external: true

networks:
  corporate:
    external: true
    name: corp-net
СвойствоОбычныйexternal: true
Создаётся ComposeДаНет; должен существовать
Удаляется down -vДаНет
Имя получает префикс проектаДаНет
Отсутствие ресурсаСоздаётсяОшибка

Применение: данные, переживающие проект целиком; сеть, общая для нескольких стеков; volume, созданный администратором с особыми параметрами.

Это же — надёжная защита от down -v: внешний volume не удаляется даже с флагом.

Ключ name: точное имя

yaml
volumes:
  db-data:
    name: myapp-production-db

Volume будет называться ровно так, без префикса проекта.

Опасность: префикс проекта — механизм изоляции. Убрав его, вы получаете глобальное имя, которое может совпасть с чужим. Два проекта с name: db-data разделят один volume — и одна база перезапишет другую.

Применяйте name только когда имя действительно должно быть общим, и делайте его уникальным.

Что удаляют команды

КомандаContainer'ыСетиNamed volumesAnonymous volumesExternal
stopОстанавливает
downУдаляетУдаляетОставляетУдаляетНе трогает
down -vУдаляетУдаляетУдаляетУдаляетНе трогает
rm -vУдаляет остановленныеУдаляет
down --rmi allУдаляетУдаляетОставляетУдаляетНе трогает

Столбец External — единственный, где везде «не трогает». Это делает external: true рабочим способом защитить данные от случайного down -v.


Внутренний механизм

Имена и метки

Compose создаёт сети и volumes с метками:

text
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 его не создавал.

Порядок создания

  1. Сети (в порядке объявления).
  2. Volumes.
  3. Container'ы в порядке зависимостей (урок 9.4).

Проверка существования external-ресурсов происходит на первом шаге: отсутствующая внешняя сеть останавливает запуск до создания container'ов.


Команды и примеры

Умолчание: одна сеть на проект

bash
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'))
"

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

text
═══ созданные сети ═══
  compnet_default
═══ к чему подключены сервисы ═══
  web: ['default']
  db: ['default']
═══ связь по имени ═══
  db → web: 172.30.0.3

Ни одного объявления в файле — сеть создана, оба сервиса в ней, имена работают.

Ловушка: частичное объявление сетей

bash
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}')
"

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

text
═══ сети проекта ═══
  compnet_default
  compnet_frontend
═══ кто в какой сети ═══
  web  compnet_frontend 
  db   compnet_default 
═══ связь ═══
  ✗ web → db: Name or service not known

Создались две сети, сервисы разошлись по разным, связь пропала.

Обратите внимание: compnet_default всё равно создана — ради db, который сети не объявлял. Именно эта пара сетей в выводе docker network ls и есть первый признак ошибки.

Исправление — объявить сеть обоим:

bash
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'))
"

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

text
  сеть: compnet_frontend
  web → db: 172.31.0.3

Сеть теперь одна, default не создаётся.

Изоляция уровней

bash
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'

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

text
═══ карта сетей ═══
  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: объявление и типы

bash
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'

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

text
═══ 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

bash
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)"

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

text
═══ до down ═══
  volumes: 3
═══ после down ═══
  compnet_mem-vol
  compnet_named-vol
  данные: важное
═══ после down -v ═══
  volumes: 0

down убрал только anonymous volume (было 3, осталось 2), named сохранились вместе с данными. down -v удалил всё.

external защищает данные

bash
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

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

text
  данные читаются: не удалять
═══ down -v ═══
  volume существует:  1
  данные на месте:    не удалять
  сеть существует:    1
═══ что будет, если внешнего ресурса нет ═══
  external volume "protected-data" not found

down -v не тронул внешние ресурсы — это и есть способ защитить production-данные от случайной команды.

Последний блок показывает обратную сторону: отсутствующий внешний ресурс останавливает запуск. Это осознанный выбор Compose — не создавать молча то, чем он не владеет.

Ключ name и риск столкновения

bash
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)

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

text
═══ запускаем проект A ═══
  содержимое: данные проекта a
═══ запускаем проект B (другой проект!) ═══
  содержимое: данные проекта b
═══ сколько volumes создано ═══
  1

Два разных проекта, один volume — данные проекта A перезаписаны проектом B.

Без ключа name создались бы project-a_shared и project-b_shared, и столкновения не произошло бы. Префикс проекта — не украшение имени, а механизм изоляции.

bash
cd /tmp && rm -rf /tmp/compnet

Практическое упражнение

Задание. Постройте конфигурацию с тремя уровнями изоляции и проверяемой судьбой данных.

Требования:

  1. Три сети: edge, app, data; data без выхода наружу.
  2. Четыре сервиса распределены так, что db недоступна из edge.
  3. Ни один сервис не остался в default — сети объявлены всем.
  4. Данные базы лежат во внешнем volume и переживают down -v.
  5. Кэш лежит в tmpfs-volume и исчезает при перезапуске.
  6. Псевдоним сервиса работает в одной сети и не работает в другой.

Скрипт проверки различает gaierror, refused и таймаут и возвращает ненулевой код при нарушении.

Подсказки

Подсказка 1

Пункт 3 проверяется отсутствием сети <проект>_default в выводе docker network ls.

Подсказка 2

Внешний volume нужно создать до up, иначе Compose откажется запускаться.

Подсказка 3

Для пункта 6 объявите псевдоним внутри одной сети сервиса, состоящего в двух.

Решение

Показать решение
bash
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"

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

text
═══ Подготовка: внешний 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 обращаться к соседям по своей сети — это верно, но осталось за рамками требований.

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

bash
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 остаются

Контрольные вопросы

На понимание:

  1. Что создаётся без единого объявления networks и volumes?
  2. Что происходит, когда сервис объявляет собственные сети?
  3. Чем external: true отличается от обычного объявления? Назовите четыре различия.
  4. Чем опасен ключ name у volume?
  5. Что удаляют down, down -v и что не удаляет ни один из них?

На применение:

  1. Как изолировать базу от внешнего уровня и проверить изоляцию?
  2. Как защитить production-данные от случайного down -v?
  3. Как сделать volume в памяти средствами Compose?

На диагностику:

  1. После добавления сети одному сервису другие перестали его находить. Причина?
  2. Два проекта делят один volume, хотя объявлены отдельно. Где искать?

Краткое резюме

  1. Без объявлений Compose создаёт <проект>_default и подключает к ней все сервисы.
  2. Сервис, объявивший networks, перестаёт подключаться к default.
  3. Частичное объявление сетей — частая причина «имя не разрешается».
  4. internal: true убирает маршрут по умолчанию: ни выхода, ни входа.
  5. Псевдоним действует только в той сети, в которой объявлен.
  6. Имя сервиса разрешается во всех его сетях, псевдоним — нет.
  7. priority определяет, какая сеть даёт маршрут по умолчанию при нескольких подключениях.
  8. Volume с driver_opts: type=tmpfs живёт в памяти и исчезает при пересоздании.
  9. external: true означает: Compose не создаёт и не удаляет ресурс.
  10. Внешние ресурсы не удаляются даже при down -v — это защита данных.
  11. Ключ name убирает префикс проекта и вместе с ним изоляцию между проектами.
  12. down сохраняет named volumes; anonymous удаляются в обоих случаях.

Официальные источники

ИсточникСсылкаЧто подтверждает
Compose: networks top-levelhttps://docs.docker.com/reference/compose-file/networks/Объявление, internal, external, ipam, name
Compose: volumes top-levelhttps://docs.docker.com/reference/compose-file/volumes/Объявление, driver_opts, external, name
Compose: service networkshttps://docs.docker.com/reference/compose-file/services/#networksaliases, ipv4_address, priority
Compose: networking how-tohttps://docs.docker.com/compose/how-tos/networking/Сеть по умолчанию, разрешение имён
Compose: downhttps://docs.docker.com/reference/cli/docker/compose/down/Что удаляется, флаг -v
Docker: volumeshttps://docs.docker.com/engine/storage/volumes/Опции драйвера local

Навигация

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

Markdown на GitHub ↗