Урок курса

Почему localhost ведёт не туда

Docker и Docker Compose: контейнеризация приложений

1. Куда ведёт адрес зависимости

Когда сервис course-notes запускается с аргументом serve, он слушает HTTP-запросы на адресе 0.0.0.0:8000. Приложение предоставляет маршрут GET /upstream. Если приложению передана переменная окружения DEPENDENCY_URL, обработчик маршрута выполняет внутренний GET-запрос по этому адресу и возвращает полученный JSON внутри ключа dependency. Если переменная не задана или зависимый сервис не ответил, возвращается ошибка с кодом состояния 503.

Каждый контейнер запускается в собственном изолированном сетевом пространстве имён (network namespace). Виртуальный сетевой интерфейс loopback lo с адресом 127.0.0.1 и именем localhost существует внутри каждого контейнера независимо. По этой причине для процесса внутри контейнера localhost всегда указывает на этот же самый контейнер, а не на хост-машину разработчика и не на соседний сервис.

Проверим это поведение на практике, используя ранее собранный локальный образ course-notes:1.0.0. Создадим изолированную пользовательскую bridge-сеть cf_docker_course_l17_net:

docker network create cf_docker_course_l17_net

Запустим два экземпляра сервиса в этой сети. Первый контейнер будет играть роль бэкенда с сообщением backend-peer. Второй контейнер — роль клиента с сообщением frontend-client, но в переменной DEPENDENCY_URL укажем ошибочный адрес http://127.0.0.1:8000/:

docker run -d \
  --name cf_docker_course_l17_backend \
  --network cf_docker_course_l17_net \
  -e MESSAGE="backend-peer" \
  course-notes:1.0.0
docker run -d \
  --name cf_docker_course_l17_client \
  --network cf_docker_course_l17_net \
  -e MESSAGE="frontend-client" \
  -e DEPENDENCY_URL="http://127.0.0.1:8000/" \
  -p 127.0.0.1:18087:8000 \
  course-notes:1.0.0

Перед отправкой запроса убедимся по логам, что оба сервиса вывели JSON-событие started:

docker logs cf_docker_course_l17_client

Чтобы сделать проверочный HTTP-запрос из терминала без установки сторонних утилит, используем стандартную библиотеку Python, уже присутствующую в образе. Опция python -c принимает исполняемый код строкой; модуль urllib.request открывает соединение, а параметр timeout=3 защищает вызов от зависания при отсутствии ответа:

docker exec cf_docker_course_l17_client python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/upstream', timeout=3).read().decode())"

Вывод команды показывает неожиданный результат:

{"dependency": {"service": "course-notes", "message": "frontend-client", "storage": "memory"}}

Запрос завершился успешно со статусом 200, но в поле dependency.message вернулся текст frontend-client. Контейнер-клиент обратился по адресу 127.0.0.1:8000/ к собственному интерфейсу loopback, где его собственный сервер ответил на запрос корневого маршрута GET /. Запрос замкнулся сам на себя и вообще не ушёл в сеть Docker к контейнеру cf_docker_course_l17_backend.

Если бы клиент слушал другой внутренний порт, обращение к localhost завершилось бы ошибкой Connection refused. Но когда порты совпадают, ошибка оказывается скрытой: сервис получает статус 200, однако данные поступают из самого себя, а не от зависимого компонента.

Чтобы направить запрос к бэкенду, адрес в переменной окружения должен содержать имя целевого контейнера в общей пользовательской сети. Остановим и удалим ошибочно сконфигурированный клиент:

docker stop cf_docker_course_l17_client
docker rm cf_docker_course_l17_client

Запустим контейнер клиента снова, передав корректный адрес с DNS-именем целевого контейнера cf_docker_course_l17_backend:

docker run -d \
  --name cf_docker_course_l17_client \
  --network cf_docker_course_l17_net \
  -e MESSAGE="frontend-client" \
  -e DEPENDENCY_URL="http://cf_docker_course_l17_backend:8000/" \
  -p 127.0.0.1:18087:8000 \
  course-notes:1.0.0

После фиксации события started в логах клиента повторим вызов маршрута /upstream:

docker exec cf_docker_course_l17_client python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/upstream', timeout=3).read().decode())"

В ответе появляется сообщение, сформированное бэкендом:

{"dependency": {"service": "course-notes", "message": "backend-peer", "storage": "memory"}}

Встроенный DNS-сервер пользовательской сети разрешил имя cf_docker_course_l17_backend в его IP-адрес внутри сети cf_docker_course_l17_net, и клиент обратился напрямую к соседнему сервису.

2. Внутренний запрос и внешний порт

Разработчики часто путают порт, на котором слушает процесс внутри контейнера, с портом, который опубликован на компьютере разработчика через ключ -p.

Предположим, контейнер бэкенда запущен с публикацией порта на хост-машину:

docker run -d \
  --name cf_docker_course_l17_backend_pub \
  --network cf_docker_course_l17_net \
  -p 127.0.0.1:18088:8000 \
  -e MESSAGE="backend-published" \
  course-notes:1.0.0

Здесь порт 18088 — это порт на loopback-интерфейсе хоста, а порт 8000 — внутренний порт, который слушает сервер app.py внутри контейнера. Доступ с хоста разработчика выполняется по адресу хоста:

curl -s http://127.0.0.1:18088/

Ожидаемый ответ хосту подтверждает, что привязка сработала:

{"service": "course-notes", "message": "backend-published", "storage": "memory"}

Однако соседний контейнер в сети cf_docker_course_l17_net обращается не к хосту. Когда клиент запрашивает имя cf_docker_course_l17_backend_pub, встроенный DNS Docker возвращает собственный IP-адрес сетевого интерфейса целевого контейнера. Пакеты идут напрямую между виртуальными интерфейсами контейнеров через виртуальный мост, минуя сетевой стек хоста.

Если в конфигурации клиента указать опубликованный порт хоста:

DEPENDENCY_URL="http://cf_docker_course_l17_backend_pub:18088/"

произойдёт ошибка. Контейнер-клиент отправит сетевые пакеты на IP-адрес целевого контейнера и порт 18088. Но внутри целевого контейнера на порту 18088 никто не ожидает соединений: процесс слушает исключительно порт 8000. Сетевой стек целевого контейнера вернёт пакет TCP RST, а клиент получит ошибку отказа в соединении.

Проверим это, пересоздав клиент с ошибочным портом:

docker stop cf_docker_course_l17_client
docker rm cf_docker_course_l17_client
docker run -d \
  --name cf_docker_course_l17_client \
  --network cf_docker_course_l17_net \
  -e MESSAGE="frontend-client" \
  -e DEPENDENCY_URL="http://cf_docker_course_l17_backend_pub:18088/" \
  -p 127.0.0.1:18087:8000 \
  course-notes:1.0.0

Выполним запрос к маршруту /upstream через Python внутри клиента, перехватив исключение для анализа HTTP-статуса:

docker exec cf_docker_course_l17_client python -c "import urllib.request, urllib.error; 
try:
    urllib.request.urlopen('http://127.0.0.1:8000/upstream', timeout=3)
except urllib.error.HTTPError as e:
    print('Status:', e.code)"

Вывод подтверждает отказ зависимости:

Status: 503

Для взаимодействия между сервисами внутри общей сети Docker внешняя публикация портов (-p) не нужна. Публикация необходима только тогда, когда к контейнеру нужно обратиться извне — например, из браузера разработчика или curl-запросом с хоста. Внутрисетевые клиенты всегда должны использовать внутренний порт приложения — 8000.

Остановим и удалим вспомогательный контейнер бэкенда, чтобы освободить порт 18088:

docker stop cf_docker_course_l17_backend_pub
docker rm cf_docker_course_l17_backend_pub

3. Имя правильное, сети разные

Корректно составленный URL вида http://cf_docker_course_l17_backend:8000/ не гарантирует успешного соединения, если контейнеры не имеют общей сети. Встроенный DNS-сервер Docker обслуживает только контейнеры, входящие в одну и ту же сеть.

Воспроизведём ситуацию изоляции. Запустим новый экземпляр клиента вообще без указания сети. В этом случае Docker помещает контейнер в стандартную сеть по умолчанию с именем bridge:

docker stop cf_docker_course_l17_client
docker rm cf_docker_course_l17_client
docker run -d \
  --name cf_docker_course_l17_client \
  -e MESSAGE="frontend-client" \
  -e DEPENDENCY_URL="http://cf_docker_course_l17_backend:8000/" \
  -p 127.0.0.1:18087:8000 \
  course-notes:1.0.0

Проверим вызов /upstream. При попытке обратиться к несуществующему в текущей сети имени системный резолвер внутри course-notes может пытаться разрешить хост дольше, чем длится быстрый таймаут внешнего клиента. Поэтому готовая диагностическая команда должна явно различать HTTP-ответ с ошибкой и клиентский таймаут:

docker exec cf_docker_course_l17_client python -c "import urllib.request, urllib.error, socket
try:
    with urllib.request.urlopen('http://127.0.0.1:8000/upstream', timeout=3) as r:
        print('HTTP status:', r.status, r.read().decode())
except urllib.error.HTTPError as e:
    print('HTTP status:', e.code, e.read().decode())
except (urllib.error.URLError, TimeoutError, socket.timeout) as e:
    print('Client request error:', type(e).__name__, e)"

В зависимости от того, успел ли внутренний резолвер сервера завершиться ошибкой до истечения трёх секунд клиентского таймаута, вы увидите либо HTTP-ответ HTTP status: 503 {"error": "storage_or_dependency_unavailable"}, либо клиентскую ошибку таймаута Client request error: TimeoutError timed out. Ни тот, ни другой результат сам по себе не доказывает конкретную сетевую причину: таймаут или 503 лишь сигнализируют о сбое при обращении к зависимости.

Посмотрим логи контейнера-клиента:

docker logs --tail 5 cf_docker_course_l17_client

Обратите внимание на поведение логов: если клиент завершился по таймауту через 3 секунды, фоновый вызов к DNS внутри сервера всё ещё может продолжаться, и в журнале на этот момент останется только событие started. Если же сервер успел завершить внутреннюю попытку соединения и вернуть 503, в выводе появится событие вида {"event": "dependency_error", "kind": "URLError"} (или другое перехваченное сетевое исключение). Опираться исключительно на немедленный лог нельзя.

Чтобы точно установить причину изоляции, сверим принадлежность обоих контейнеров к сетям с помощью docker inspect и Go-шаблона:

docker inspect --format '{{json .NetworkSettings.Networks}}' cf_docker_course_l17_backend

В выводе ключом верхнего уровня выступает созданная ранее пользовательская сеть:

{"cf_docker_course_l17_net":{"IPAMConfig":null,"Links":null,"Aliases":["cf_docker_course_l17_backend"],"NetworkID":"...","EndpointID":"...","Gateway":"...","IPAddress":"...","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"...","DriverOpts":null}}

Запросим ту же информацию для клиента:

docker inspect --format '{{json .NetworkSettings.Networks}}' cf_docker_course_l17_client

Вывод показывает дефолтную сеть:

{"bridge":{"IPAMConfig":null,"Links":null,"Aliases":null,"NetworkID":"...","EndpointID":"...","Gateway":"...","IPAddress":"...","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"...","DriverOpts":null}}

Сравнение подтверждает причину: контейнеры находятся в разных сетях. Дефолтная сеть bridge изолирована от cf_docker_course_l17_net, а встроенный DNS Docker не резолвит имена контейнеров в стандартной сети bridge.

Чтобы устранить изоляцию, пересоздавать контейнер не требуется. Команда docker network connect подключает работающий контейнер к целевой сети на лету:

docker network connect cf_docker_course_l17_net cf_docker_course_l17_client

Сверим сетевые настройки клиента повторно:

docker inspect --format '{{json .NetworkSettings.Networks}}' cf_docker_course_l17_client

Теперь в словаре присутствуют обе сети: bridge и cf_docker_course_l17_net. Контейнер получил дополнительный сетевой интерфейс в сети cf_docker_course_l17_net, а его имя зарегистрировалось во встроенном DNS этой сети.

Повторим запрос к /upstream без изменения URL и без перезапуска сервисов:

docker exec cf_docker_course_l17_client python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/upstream', timeout=3).read().decode())"

Запрос завершается успешно и возвращает ожидаемый JSON от бэкенда:

{"dependency": {"service": "course-notes", "message": "backend-peer", "storage": "memory"}}

Завершим работу и удалим созданные в этом уроке учебные ресурсы:

docker stop cf_docker_course_l17_client cf_docker_course_l17_backend
docker rm cf_docker_course_l17_client cf_docker_course_l17_backend
docker network rm cf_docker_course_l17_net

Локальная лабораторная / самопроверка

Что уже дано

Работайте в POSIX-терминале с Linux-контейнерами. Сохраните выданные файлы в новый каталог l17_lab, который ещё не существует; если занят, выберите другой. Выберите уникальный префикс, например LAB=cf_docker_task_l17_run01; замените run01 при повторе. Перед созданием проверьте свои предполагаемые имена через docker container ls -a, network inspect и volume inspect, если они используются. Занятый объект не удаляйте: выберите другой префикс. Ошибку связи с Engine не принимайте за отсутствие ресурса. Порты хоста должны быть свободны; PORTS не исключает отдельный процесс ОС. При неожиданном bind-конфликте остановите опыт и согласованно замените host-порт/URL, не останавливая прежние службы. Все новые имена контейнеров/сетей/томов и проект Compose начинаются с выбранного LAB. Готовые app.py/SQL менять или писать не требуется. Соберите выданный Dockerfile из этого каталога в свой образ ${LAB}:app как подготовку; рецепт уже известен, кроме явно дефектного файла текущего задания. Для HTTP внутри Python-контейнера используйте готовую команду docker exec <клиент> python -c 'from urllib.request import urlopen; r=urlopen("http://<сервер>:8000/", timeout=3); print(r.status); print(r.read().decode())': заменяются только имена/URL. -c выполняет готовый код, timeout ограничивает ожидание; сторонние curl/wget в slim не предполагаются.

Даны готовые сервисы и client.env: MESSAGE=frontend-client, DEPENDENCY_URL=http://localhost:8000/. Backend должен отвечать MESSAGE=backend-peer. Оба экземпляра сначала в вашей пользовательской сети; backend не публикуется на хост.

Что нужно сделать

Получите /upstream от клиента готовой командой stdlib и сравните вложенное message с сообщениями обоих экземпляров. Исправьте адрес зависимости при пересоздании только клиента так, чтобы ответ пришёл от backend. Затем в отдельном собственном экземпляре клиента с правильным URL воспроизведите отсутствие общей сети и восстановите связь без замены URL. Различайте полученный HTTP503 и клиентский таймаут; отсутствие мгновенного dependency_error не означает, что DNS уже завершился.

Ввод и вывод

Вы самостоятельно вводите Docker/Compose-команды на хосте и сохраняете их реальные результаты в локальный текстовый отчёт. Печатать кодовое слово, писать Python/SQL или заполнять форму ответа не требуется. Эта TEXT-лабораторная не имеет автоматической платформенной проверки.

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

  • Исходный localhost-запрос может вернуть200, но dependency.message=frontend-client; успешный статус не принят за ответ backend.
  • После адресного ремонта получен200 и backend-peer; после отдельного подключения сети клиент с правильным URL также получает backend-peer. В отчёте есть фактический URL, внутренний порт и членство сетей, а не выбранный постоянныйIP.

Сохраните команды, фактические ID/коды и наблюдения до очистки. После опыта штатно остановите и удалите только контейнеры своего LAB; для Compose используйте то же -p и down без-v, затем удалите только свою свободную сеть. Тома с контрольными записями сохраняйте, кроме отдельно оговорённого разрушительного сравнения урока27. Если позже удаляете собственный одноразовый том, сначала назовите точное имя и потерю его данных; чужие ресурсы и общие образы сохраняйте.

Ограничения:

  • Локальная самопроверка; платформа не запускает весь Docker-проект, не оценивает отчёт автоматически и не подтверждает выполнение ученика.
  • Никакого prune, privileged, Docker socket или реальных секретов. Только собственные новые учебные ресурсы.
  • Эталон/ошибочные варианты проверяются отдельно на локальном Engine29.6.2; это не сертификация Latorn Docker27 или всех ОС.

Файл app.py:

import json
import os
from pathlib import Path
import signal
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import urlopen

META = json.loads(Path(__file__).with_name("app_metadata.json").read_text())
VERSION = Path(__file__).with_name("version.txt").read_text().strip()
MODE = os.getenv("STORAGE", "memory")
LOCK = threading.RLock()
MEMORY = []

def log(event, **fields):
    print(json.dumps({"event": event, **fields}), flush=True)

def password():
    direct, filename = os.getenv("PASSWORD", ""), os.getenv("PASSWORD_FILE", "")
    if direct and filename:
        raise ValueError("PASSWORD and PASSWORD_FILE are mutually exclusive")
    value = Path(filename).read_text().rstrip("\r\n") if filename else direct
    if not value:
        raise ValueError("postgres mode requires PASSWORD or PASSWORD_FILE")
    return value

def connect():
    import psycopg
    return psycopg.connect(host=os.getenv("DB_HOST", "db"), port=int(os.getenv("DB_PORT", "5432")),
        dbname=os.getenv("DB_NAME", "course"), user=os.getenv("DB_USER", "course"),
        password=password(), connect_timeout=2, options="-c statement_timeout=3000")

def file_path():
    return Path(os.getenv("DATA_FILE", "/data/items.json"))

def rows():
    with LOCK:
        if MODE == "memory":
            return list(MEMORY)
        if MODE == "file":
            p = file_path()
            return json.loads(p.read_text()) if p.exists() else []
        with connect() as db:
            records = db.execute("SELECT id, text FROM course_items ORDER BY id").fetchall()
            return [{"id": r[0], "text": r[1]} for r in records]

def add(text):
    with LOCK:
        if MODE == "postgres":
            with connect() as db:
                row = db.execute("INSERT INTO course_items (text) VALUES (%s) RETURNING id, text", (text,)).fetchone()
                return {"id": row[0], "text": row[1]}
        current = rows()
        item = {"id": max([r["id"] for r in current], default=0) + 1, "text": text}
        current.append(item)
        if MODE == "memory":
            MEMORY[:] = current
        else:
            p = file_path()
            temp = p.with_suffix(".tmp")
            temp.write_text(json.dumps(current), encoding="utf-8")
            temp.replace(p)
        return item

def ready():
    if MODE == "postgres":
        with connect() as db:
            found = db.execute("SELECT version FROM course_schema WHERE singleton = TRUE").fetchone()
            if found != (1,):
                raise RuntimeError("schema 1 required; run supplied init command")
            db.execute("SELECT id FROM course_items LIMIT 1")
    elif MODE == "file":
        p = file_path()
        if not p.parent.is_dir() or not os.access(p.parent, os.W_OK):
            raise RuntimeError("DATA_FILE parent must exist and be writable")
        rows()
    elif MODE != "memory":
        raise ValueError("STORAGE must be memory, file or postgres")

def init():
    if MODE == "postgres":
        with connect() as db:
            db.execute("CREATE TABLE IF NOT EXISTS course_schema (singleton boolean PRIMARY KEY CHECK (singleton), version integer NOT NULL)")
            db.execute("INSERT INTO course_schema VALUES (TRUE, 1) ON CONFLICT (singleton) DO NOTHING")
            version = db.execute("SELECT version FROM course_schema").fetchone()
            if version != (1,):
                raise RuntimeError("refusing incompatible schema")
            db.execute("CREATE TABLE IF NOT EXISTS course_items (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, text varchar(200) NOT NULL)")
    elif MODE == "file":
        p = file_path()
        if not p.exists():
            p.write_text("[]", encoding="utf-8")
    ready()
    print(json.dumps({"status": "initialized", "schema": 1}))

class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        log("request", method=self.command, path=self.path, detail=fmt % args)

    def send(self, code, data):
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        try:
            if self.path == "/":
                filename = os.getenv("MESSAGE_FILE")
                message = Path(filename).read_text().strip() if filename else os.getenv("MESSAGE", "hello from Docker")
                self.send(200, {"service": META["service"], "message": message, "storage": MODE})
            elif self.path == "/health":
                self.send(200, {"status": "alive"})
            elif self.path == "/ready":
                ready()
                self.send(200, {"status": "ready"})
            elif self.path == "/version":
                self.send(200, {"service": META["service"], "version": VERSION, "schema": 1})
            elif self.path == META["collection"]:
                ready()
                self.send(200, {"items": rows()})
            elif self.path == "/upstream":
                url = os.environ["DEPENDENCY_URL"]
                with urlopen(url, timeout=2) as reply:
                    self.send(200, {"dependency": json.load(reply)})
            else:
                self.send(404, {"error": "not_found"})
        except Exception as exc:
            log("dependency_error", kind=type(exc).__name__)
            self.send(503, {"status": "not_ready"} if self.path == "/ready" else {"error": "storage_or_dependency_unavailable"})

    def do_POST(self):
        if self.path != META["collection"]:
            self.send(404, {"error": "not_found"})
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
            if not 0 < length <= 4096:
                raise ValueError("body size")
            data = json.loads(self.rfile.read(length))
            text = data.get("text") if isinstance(data, dict) else None
            if not isinstance(text, str) or not 1 <= len(text.strip()) <= 200:
                raise ValueError("text must have 1..200 characters")
        except (ValueError, TypeError):
            self.send(400, {"error": "invalid_text"})
            return
        try:
            ready()
            self.send(201, add(text.strip()))
        except Exception as exc:
            log("storage_error", kind=type(exc).__name__)
            self.send(503, {"error": "storage_unavailable"})

def serve():
    if MODE not in ("memory", "file", "postgres"):
        raise ValueError("invalid STORAGE")
    if MODE == "postgres":
        password()  # configuration errors fail immediately; DB outages do not kill HTTP
    Path("/tmp/course-app.pid").write_text(str(os.getpid()))
    server = ThreadingHTTPServer((os.getenv("HOST", "0.0.0.0"), int(os.getenv("PORT", "8000"))), Handler)
    server.daemon_threads = True
    stopping = threading.Event()
    def stop(signum, _frame):
        if not stopping.is_set():
            stopping.set()
            log("signal_received", signal=signal.Signals(signum).name)
            threading.Thread(target=server.shutdown, daemon=True).start()
    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)
    log("started", service=META["service"], version=VERSION, storage=MODE, uid=os.getuid())
    server.serve_forever(poll_interval=0.1)
    server.server_close()
    log("stopped")

def main():
    command = sys.argv[1] if len(sys.argv) > 1 else "serve"
    if command == "serve":
        serve()
    elif command == "init":
        init()
    elif command == "seed":
        ready()
        for text in META["seed"]:
            if not any(row["text"] == text for row in rows()):
                add(text)
        print(json.dumps({"items": rows()}))
    elif command == "inspect":
        ready()
        print(json.dumps({"items": rows()}))
    elif command == "check":
        ready()
        print(json.dumps({"status": "ready", "schema": 1}))
    elif command == "http-check":
        with urlopen("http://127.0.0.1:" + os.getenv("PORT", "8000") + "/ready", timeout=2) as r:
            print(r.read().decode())
    elif command == "fail":
        log("intentional_failure")
        sys.exit(7)
    elif command == "allocate":
        # Use ONLY with a bounded container memory limit; held blocks force observable allocation.
        blocks = []
        while True:
            blocks.append(bytearray(1024 * 1024))
    else:
        raise ValueError("unknown supplied command")

if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        log("fatal", kind=type(exc).__name__)
        sys.exit(1)

Файл requirements.txt:

psycopg==3.2.6
psycopg-binary==3.2.6
typing-extensions==4.12.2

Файл app_metadata.json:

{
  "service": "course-notes",
  "collection": "/notes",
  "seed": [
    "first supplied note",
    "data survives container recreation"
  ]
}

Файл version.txt:

1.0.0

Файл Dockerfile:

FROM python:3.12.10-slim-bookworm
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app.py app_metadata.json version.txt ./
EXPOSE 8000
CMD ["python", "-u", "app.py", "serve"]

Файл client.env:

MESSAGE=frontend-client
DEPENDENCY_URL=http://localhost:8000/

Лабораторная выполняется локально. Latorn не запускает Docker Compose или весь проект и не подтверждает выполнение лабораторной автоматически.

Задача 1

frontend и backend — два course-notes в общей сети, оба слушают8000. Их MESSAGE равны front и back. У frontend DEPENDENCY_URL=http://localhost:8000/. В ответе /upstream получены200 и dependency.message=front. Почему это ещё не успешное обращение к backend?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

api-service входит в my-net, слушает0.0.0.0:8000 и опубликован как127.0.0.1:9090→8000. По какому адресу сосед в my-net должен обратиться напрямую к корню API, без выхода на хост?

Выберите один вариант
Без регистрации · результат не сохраняется

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку