Урок курса
Почему 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, без выхода на хост?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
