Урок курса
Порты хоста и порты контейнера
Docker и Docker Compose: контейнеризация приложений1. Порт компьютера и порт приложения
Флаг -p связывает сокет операционной системы хоста с сетевым пространством имён контейнера. В записи -p 127.0.0.1:8080:8000 левая числовая часть (8080) указывает порт хоста, на котором публикуется сервис для внешних обращений. Правая числовая часть (8000) указывает целевой порт приложения внутри изолированной среды контейнера.
Когда клиент на хосте отправляет HTTP-запрос по адресу http://127.0.0.1:8080/health, сетевой стек хоста перенаправляет пакеты по правилам публикации, настроенным Docker, на виртуальный сетевой интерфейс контейнера к порту 8000. Внутри контейнера процесс слушает сокет 0.0.0.0:8000 и ничего не знает о номере 8080: приложение получает запрос на своём внутреннем порту.
Хост (клиент curl)
-> http://127.0.0.1:8080/health
|
v
Сетевой стек хоста (публикация порта, настроенная Docker)
|
v (перенаправление в eth0 контейнера)
Сетевой стек контейнера (порт 8000)
|
v
Процесс python app.py serve (слушает 0.0.0.0:8000)
Номер порта на стороне хоста можно свободно выбирать в зависимости от доступности сетевых сокетов операционной системы. Изменение хостового порта с 8080 на 18081 или любой другой свободный номер не требует изменения конфигурации сервера внутри образа и не требует повторной сборки Docker-образа.
2. Хостовый порт уже занят
Попытка открыть два сетевых сокета на одном и том же IP-адресе и порту хоста приводит к системному конфликту. Если один процесс или контейнер уже занял 127.0.0.1:18115, повторный запуск другого контейнера с тем же хостовым портом завершается ошибкой демона.
Для воспроизведения сетевой коллизии перейдите в терминале в каталог учебного сервиса main_app из распакованного архива курса. Если образ course-notes:1.0.0 из предыдущих уроков отсутствует в локальном хранилище, соберите его с помощью команды docker build:
docker build -f Dockerfile.simple -t course-notes:1.0.0 .
Флаг -f явно указывает путь к файлу инструкций, -t задаёт имя и тег образа, а точка в конце команды определяет текущую директорию как контекст сборки. Приложение работает в режиме memory, поэтому подготовка постоянного дискового хранилища не требуется.
Запустим первый контейнер с уникальным именем cf_docker_course_l15_app1 в фоновом режиме на порту 18115:
docker run -d --name cf_docker_course_l15_app1 -p 127.0.0.1:18115:8000 course-notes:1.0.0
Дождёмся записи о старте в логах (docker logs cf_docker_course_l15_app1), где появится JSON с полем "event": "started". Проверим фактическую привязку портов в списке запущенных контейнеров:
docker container ls --filter "name=cf_docker_course_l15_app1"
В колонке PORTS отображается сопоставление 127.0.0.1:18115->8000/tcp. Контрольный запрос к эндпоинту подтверждает готовность сервиса:
curl -i http://127.0.0.1:18115/health
Сервер возвращает статус 200 и тело {"status":"alive"}. Теперь намеренно попытаемся запустить второй контейнер cf_docker_course_l15_app2 с тем же хостовым портом 18115:
docker run -d --name cf_docker_course_l15_app2 -p 127.0.0.1:18115:8000 course-notes:1.0.0
Команда завершается ошибкой демона Docker:
Error response from daemon: driver failed programming external connectivity on endpoint cf_docker_course_l15_app2 (...): Bind for 127.0.0.1:18115 failed: port is already allocated
Контейнер не может стартовать, поскольку сетевой порт хоста уже заблокирован первым контейнером. Проверим статус созданного объекта:
docker container inspect cf_docker_course_l15_app2 --format '{{.State.Status}}'
Вывод created показывает, что контейнер был создан на уровне метаданных, но сетевая подсистема прервала его переход в рабочее состояние. Изменить порт уже существующего контейнера через docker start невозможно. Останавливать чужие процессы операционной системы ради освобождения порта также недопустимо.
Корректный порядок действий: удалить объект со статусом created, выбрать свободный порт хоста (например, 18116) и выполнить запуск заново:
docker rm cf_docker_course_l15_app2
docker run -d --name cf_docker_course_l15_app2 -p 127.0.0.1:18116:8000 course-notes:1.0.0
Оба сервиса теперь функционируют независимо: первый принимает запросы по адресу http://127.0.0.1:18115/, а второй — по адресу http://127.0.0.1:18116/. При этом внутри каждого контейнера сервер по-прежнему работает на целевом порту 8000.
Перед переходом к разбору сетевых интерфейсов контейнера штатно остановим и удалим запущенные экземпляры:
docker stop cf_docker_course_l15_app1 cf_docker_course_l15_app2
docker rm cf_docker_course_l15_app1 cf_docker_course_l15_app2
3. Порт опубликован, ответа нет
Статус контейнера Up и наличие записи в выводе docker port подтверждают только то, что Docker слушает сокет на хосте и процесс в контейнере выполняется. Они не гарантируют доступность HTTP-эндпоинта, если сокет внутри контейнера привязан к неверному сетевому интерфейсу.
Каждый контейнер имеет собственный виртуальный сетевой интерфейс loopback (127.0.0.1, интерфейс lo) и внешний интерфейс моста (eth0). Если процесс внутри контейнера привязывается к адресу 127.0.0.1, ядро контейнера принимает пакеты только от процессов, работающих внутри этого же контейнера. Трафик, поступающий с хоста через интерфейс eth0, отбрасывается.
Смоделируем эту ситуацию, передав переменную HOST=127.0.0.1 при запуске нового экземпляра cf_docker_course_l15_isolated из собранного ранее образа course-notes:1.0.0:
docker run -d --name cf_docker_course_l15_isolated -e HOST=127.0.0.1 -p 127.0.0.1:18117:8000 course-notes:1.0.0
Проверим сетевые параметры контейнера. Команда docker port показывает корректную привязку со стороны демона:
docker port cf_docker_course_l15_isolated
Вывод содержит сопоставление 8000/tcp -> 127.0.0.1:18117. Дождёмся запуска и проверим журнал контейнера:
docker logs cf_docker_course_l15_isolated
Лог содержит информационную запись JSON с полем "event": "started", но не сообщает адрес прослушивания сокета. Узнать фактическую конфигурацию окружения запущенного контейнера позволяет команда printenv:
docker exec cf_docker_course_l15_isolated printenv HOST
Вывод возвращает 127.0.0.1. Проверим доступность эндпоинта с хоста с помощью curl:
curl -i http://127.0.0.1:18117/health
Запрос завершается ошибкой сети (например, curl: (56) Recv failure: Connection reset by peer или сообщением об отсутствии ответа). Контейнер при этом сохраняет статус Up, но внешний запрос до сокета приложения не доходит.
Для исправления необходимо переключить сокет приложения на 0.0.0.0 (inaddr_any), что позволяет принимать соединения с любого интерфейса контейнера. Остановим и удалим изолированный экземпляр, затем запустим контейнер cf_docker_course_l15_fixed с явной переменной HOST=0.0.0.0:
docker stop cf_docker_course_l15_isolated
docker rm cf_docker_course_l15_isolated
docker run -d --name cf_docker_course_l15_fixed -e HOST=0.0.0.0 -p 127.0.0.1:18117:8000 course-notes:1.0.0
Дождёмся сообщения "event": "started" в docker logs cf_docker_course_l15_fixed и повторим контрольный HTTP-запрос с хоста:
curl -i http://127.0.0.1:18117/health
Сервер возвращает статус 200 и тело {"status":"alive"}. Это подтверждает, что для прохождения сетевого пакета с хоста к приложению требуются два согласованных условия: проброс хостового порта через -p на целевой порт контейнера и прослушивание процессом адреса 0.0.0.0 внутри самого контейнера.
Завершим работу и удалим учебный контейнер:
docker stop cf_docker_course_l15_fixed
docker rm cf_docker_course_l15_fixed
Локальная лабораторная / самопроверка
Что уже дано
Работайте в POSIX-терминале с Linux-контейнерами. Сохраните выданные файлы в новый каталог l15_lab, который ещё не существует; если занят, выберите другой. Выберите уникальный префикс, например LAB=cf_docker_task_l15_run01; замените run01 при повторе. Перед созданием проверьте свои предполагаемые имена через docker container ls -a, network inspect и volume inspect, если они используются. Занятый объект не удаляйте: выберите другой префикс. Ошибку связи с Engine не принимайте за отсутствие ресурса. Порты хоста должны быть свободны; PORTS не исключает отдельный процесс ОС. При неожиданном bind-конфликте остановите опыт и согласованно замените host-порт/URL, не останавливая прежние службы. Все новые имена контейнеров/сетей/томов и проект Compose начинаются с выбранного LAB. Готовые app.py/SQL менять или писать не требуется. Соберите выданный Dockerfile из этого каталога в свой образ ${LAB}:app как подготовку; рецепт уже известен, кроме явно дефектного файла текущего задания.
Готовый course-notes собирается из выданных файлов без правки Python. Первый экземпляр использует порт18215, второй —19215, либо выбранные свободные альтернативы; внутренний порт8000 сохраняется. В broken.env задан HOST=127.0.0.1.
Что нужно сделать
Запустите первый свой экземпляр и подтвердите HTTP-готовность. Намеренно создайте второй на том же host-порту и сохраните bind-ошибку и состояние его собственного объекта. После диагностики запустите второй на другом host-порту с broken.env. Сопоставьте его Running, публикацию, HOST и неуспех HTTP. Исправьте настройку при новом создании второго экземпляра; не меняйте приложение и его внутренний порт.
Ввод и вывод
Вы самостоятельно вводите Docker/Compose-команды на хосте и сохраняете их реальные результаты в локальный текстовый отчёт. Печатать кодовое слово, писать Python/SQL или заполнять форму ответа не требуется. Эта TEXT-лабораторная не имеет автоматической платформенной проверки.
Проверка результата:
- Намеренный конфликт воспроизведён двумя собственными контейнерами; первый остаётся доступен, чужие процессы не остановлены.
- Для второго показаны Running и публикация при неуспехе HTTP до ремонта, затем HTTP200/{status:alive} по тому же второму URL после изменения listen; первое приложение продолжает отвечать.
Сохраните команды, фактические 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"]
Файл broken.env:
HOST=127.0.0.1
PORT=8000
Лабораторная выполняется локально. Latorn не запускает Docker Compose или весь проект и не подтверждает выполнение лабораторной автоматически.
Задача 1
Сервис внутри образа слушает 0.0.0.0:8000. Хостовый 8000 занят системной службой, останавливать её нельзя. Свой Created-контейнер после bind-ошибки уже удалён; 18081 свободен. Какой параметр повторного run даст доступ с loopback хоста без изменения приложения?
Задача 2
Публикация 127.0.0.1:18081→8000 создана, контейнер Up. У supplied сервиса printenv HOST возвращает127.0.0.1, лог содержит started без адреса. Внешний HTTP-запрос ответа не получает. Какое изменение устраняет показанную ошибку конфигурации?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
