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

17.1. Engine API и Unix socket

Цели

После этого материала вы сможете:

  • объяснить, что делает CLI при каждой команде, и повторить это одним curl;
  • обратиться к API через Unix socket и разобрать формат ответа;
  • объяснить, зачем API версионирован и что произойдёт без указания версии;
  • читать поток событий через API и понимать, чем он отличается от docker events;
  • написать скрипт, работающий с Docker без установленного CLI;
  • объяснить, почему доступ к socket равносилен root, — на уровне протокола.

Предварительные знания

Ключевые термины

ТерминОбъяснение
Engine APIHTTP-интерфейс daemon
Unix socketФайл-сокет /var/run/docker.sock
версия APIПрефикс пути вида /v1.51/
hijacked connectionСоединение, переходящее в двусторонний поток
chunkedПотоковая передача ответа частями

Теория

Что происходит при docker ps

text
docker ps
   │
   ├─ CLI разбирает аргументы
   ├─ формирует HTTP-запрос
   │     GET /v1.51/containers/json
   ├─ отправляет его в /var/run/docker.sock
   │
   ▼
dockerd принимает, выполняет, отвечает JSON
   │
   ▼
CLI форматирует таблицу

CLI не делает ничего, кроме преобразования аргументов в HTTP-запрос и форматирования ответа. Вся работа — на стороне daemon.

Отсюда практическое следствие: любую команду можно выполнить без CLI, обратившись к API напрямую. Это нужно там, где CLI ставить не хочется: в минимальном образе, в скрипте, в системе мониторинга.

Проверить соответствие можно так:

bash
DOCKER_API_VERSION=1.51 docker --log-level debug ps 2>&1 | grep -i 'GET\|POST'

Транспорт: сокет вместо порта

По умолчанию daemon слушает Unix-сокет, а не TCP-порт:

ТранспортАдресАутентификация
Unix socket/var/run/docker.sockПрава на файл
TCP без TLStcp://0.0.0.0:2375Никакой
TCP с TLStcp://0.0.0.0:2376Клиентский сертификат

Права на файл сокета — единственный механизм разграничения при работе через сокет. Обычно это владелец root и группа docker.

Отсюда утверждение из урока 12.2: членство в группе docker равносильно root. Теперь оно объясняется на уровне протокола — в следующем разделе.

Версионирование

text
GET /v1.51/containers/json     явная версия
GET /containers/json           последняя, поддерживаемая daemon

Указание версии в пути фиксирует формат запроса и ответа. Без него клиент получает поведение текущей версии daemon, которое может измениться после обновления.

ПодходКогда применять
Явная версия в путиСкрипты и интеграции
Без версииРазовые проверки вручную
DOCKER_API_VERSIONПринудить CLI к версии

Узнать поддерживаемый диапазон:

bash
curl -s --unix-socket /var/run/docker.sock http://localhost/version

Ответ содержит ApiVersion и MinAPIVersion. Запрос версии ниже минимальной даёт ошибку.

Основные эндпоинты

Команда CLIЗапрос API
docker psGET /containers/json
docker ps -aGET /containers/json?all=true
docker inspect ИМЯGET /containers/ИМЯ/json
docker logs ИМЯGET /containers/ИМЯ/logs?stdout=1&stderr=1
docker imagesGET /images/json
docker runPOST /containers/create + POST /containers/ИМЯ/start
docker stopPOST /containers/ИМЯ/stop
docker eventsGET /events
docker statsGET /containers/ИМЯ/stats
docker infoGET /info

Строка docker run показывает существенное: это не одна операция, а две — создание и запуск. Отсюда возможность docker create без запуска (урок 15.5).

Три формата ответа

ФорматГде применяетсяОсобенность
Обычный JSONinspect, version, список container'овОдин документ
Поток JSONevents, stats, pullПо объекту на строку, соединение не закрывается
Мультиплексированный потокlogs, attachКадры с заголовком: поток и длина

Третий формат — источник затруднений. Вывод docker logs через API приходит не обычным текстом: каждому фрагменту предшествует восьмибайтовый заголовок, где первый байт указывает поток (1 — stdout, 2 — stderr), а последние четыре — длину.

text
[0x01][0x00 0x00 0x00][0x00 0x00 0x00 0x0e]строка в stdout
 поток   зарезервировано      длина 14

Без разбора заголовков в выводе появляются посторонние байты. Обойти это можно, запросив логи container'а, созданного с Tty: true, — тогда потоки не разделяются и заголовков нет.


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

Почему доступ к сокету равносилен root

Протокол объясняет это точнее словесного описания.

Эндпоинт POST /containers/create принимает объект с полем HostConfig, а в нём — Binds и Privileged:

json
{
  "Image": "alpine",
  "Cmd": ["sh"],
  "HostConfig": {
    "Binds": ["/:/host"],
    "Privileged": true
  }
}

Daemon работает от root и выполнит это без дополнительных проверок: он не спрашивает, кто клиент, — он проверяет только, что запрос дошёл через сокет.

Значит, любой, кто может писать в сокет, может смонтировать корень host в container и получить к нему полный доступ (урок 12.2).

Никакая настройка container'а этого не меняет: ограничения применяются к создаваемому container'у, а не к тому, кто отдаёт команду.

Как работает docker exec и attach

Эти операции переводят HTTP-соединение в двусторонний поток: клиент отправляет заголовок Upgrade, daemon отвечает 101 Switching Protocols, и дальше по тому же TCP-соединению идут данные в обе стороны.

Отсюда: реализовать docker exec простым curl нельзя — нужен клиент, умеющий работать с таким переходом. Для чтения логов и статистики этого не требуется.


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

Первое обращение

bash
mkdir -p /tmp/api && cd /tmp/api

SOCK=/var/run/docker.sock
api() { curl -s --unix-socket "$SOCK" "http://localhost$1"; }

echo "═══ версия API ═══"
api /version | python3 -c "
import json, sys
d = json.load(sys.stdin)
for key in ('Version', 'ApiVersion', 'MinAPIVersion', 'GitCommit', 'Os', 'Arch'):
    print(f'  {key:<16} {d.get(key, \"—\")}')
"

echo "═══ проверка доступности ═══"
api /_ping | sed 's/^/  _ping: /'
echo
curl -s -o /dev/null -w '  код ответа: %{http_code}\n' \
    --unix-socket "$SOCK" http://localhost/_ping

echo "═══ список container'ов ═══"
api /containers/json | python3 -c "
import json, sys
items = json.load(sys.stdin)
print(f'  работает container\'ов: {len(items)}')
for c in items[:5]:
    name = c['Names'][0].lstrip('/')
    print(f\"    {name:<24} {c['Image']:<28} {c['State']}\")
"

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

text
═══ версия API ═══
  Version          29.0.1
  ApiVersion       1.51
  MinAPIVersion    1.24
  GitCommit        a1b2c3d
  Os               linux
  Arch             amd64
═══ проверка доступности ═══
  _ping: OK
  код ответа: 200
═══ список container'ов ═══
  работает container'ов: 2
    web                      nginx:1.27                   running
    db                       postgres:17-alpine           running

Три строки curl заменили три команды CLI. Никакого дополнительного протокола: обычный HTTP поверх файла-сокета.

Что делает CLI на самом деле

bash
cd /tmp/api
echo "═══ CLI с отладочным выводом ═══"
docker --log-level debug ps > /dev/null 2>debug.log
grep -oE '(GET|POST|DELETE) [^ "]+' debug.log | head -5 | sed 's/^/  /'

echo "═══ те же запросы через curl ═══"
for path in /_ping "/v1.51/containers/json"; do
    code="$(curl -s -o /dev/null -w '%{http_code}' \
        --unix-socket /var/run/docker.sock "http://localhost$path")"
    printf '  %-34s HTTP %s\n' "$path" "$code"
done

echo "═══ вывод ═══"
cat <<'TXT'
  CLI — это преобразователь: аргументы → HTTP-запрос,
  ответ JSON → таблица.

  Вся работа выполняется daemon. Отсюда:
    · любую команду можно выполнить без CLI
    · права определяются доступом к сокету, а не к CLI
    · CLI можно не устанавливать там, где нужен только запрос
TXT

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

text
═══ CLI с отладочным выводом ═══
  GET /_ping
  GET /v1.51/containers/json
═══ те же запросы через curl ═══
  /_ping                             HTTP 200
  /v1.51/containers/json             HTTP 200
═══ вывод ═══
  CLI — это преобразователь: аргументы → HTTP-запрос,
  ответ JSON → таблица.
  ...

Отладочный вывод CLI показывает ровно те запросы, которые повторяются вручную. Совпадение полное.

Создание и запуск: две операции

bash
cd /tmp/api
SOCK=/var/run/docker.sock

echo "═══ создание container'а ═══"
create_body='{
  "Image": "alpine:3.21",
  "Cmd": ["sh", "-c", "echo привет из API; sleep 30"],
  "HostConfig": {"AutoRemove": false}
}'
cid="$(curl -s --unix-socket "$SOCK" \
    -X POST -H 'Content-Type: application/json' \
    -d "$create_body" \
    'http://localhost/v1.51/containers/create?name=api-demo' \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['Id'][:12])")"
printf '  создан: %s\n' "$cid"

echo "═══ состояние ДО запуска ═══"
curl -s --unix-socket "$SOCK" "http://localhost/v1.51/containers/$cid/json" \
    | python3 -c "
import json, sys
s = json.load(sys.stdin)['State']
print(f\"  Status={s['Status']} Running={s['Running']} Pid={s['Pid']}\")
"

echo "═══ запуск ═══"
curl -s -o /dev/null -w '  POST /start → HTTP %{http_code}\n' \
    --unix-socket "$SOCK" -X POST \
    "http://localhost/v1.51/containers/$cid/start"
sleep 2

echo "═══ состояние ПОСЛЕ запуска ═══"
curl -s --unix-socket "$SOCK" "http://localhost/v1.51/containers/$cid/json" \
    | python3 -c "
import json, sys
s = json.load(sys.stdin)['State']
print(f\"  Status={s['Status']} Running={s['Running']} Pid={s['Pid']}\")
"

echo "═══ почему это важно ═══"
cat <<'TXT'
  docker run — НЕ одна операция, а две:
    POST /containers/create   создаёт, не запуская
    POST /containers/ID/start запускает

  Отсюда возможность docker create: получить файловую систему
  container'а без запуска процесса ([урок 15.5]).

  И отсюда же — состояние "created", видимое в docker ps -a.
TXT

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

text
═══ создание container'а ═══
  создан: 7c2e9b1a4f83
═══ состояние ДО запуска ═══
  Status=created Running=False Pid=0
═══ запуск ═══
  POST /start → HTTP 204
═══ состояние ПОСЛЕ запуска ═══
  Status=running Running=True Pid=51204
═══ почему это важно ═══
  docker run — НЕ одна операция, а две:
    POST /containers/create   создаёт, не запуская
    POST /containers/ID/start запускает
  ...

Pid=0 до запуска и Pid=51204 после — прямое подтверждение: создание не порождает процесс.

Код 204 No Content — штатный ответ на успешный запуск: тела у ответа нет.

Мультиплексированный поток логов

bash
cd /tmp/api
SOCK=/var/run/docker.sock

echo "═══ логи через API: сырые байты ═══"
curl -s --unix-socket "$SOCK" \
    "http://localhost/v1.51/containers/api-demo/logs?stdout=1&stderr=1" \
    | head -c 40 | xxd | head -3 | sed 's/^/  /'

echo "═══ разбор кадров ═══"
curl -s --unix-socket "$SOCK" \
    "http://localhost/v1.51/containers/api-demo/logs?stdout=1&stderr=1" \
    > raw.bin
python3 - <<'PY'
"""Разбор мультиплексированного потока Docker.

Каждому фрагменту предшествует заголовок из 8 байт:
  байт 0    — поток: 1 = stdout, 2 = stderr
  байты 1-3 — зарезервированы
  байты 4-7 — длина фрагмента, big-endian
"""
import struct
from pathlib import Path

STREAMS = {0: "stdin", 1: "stdout", 2: "stderr"}
data = Path("raw.bin").read_bytes()

offset = 0
frame = 0
while offset + 8 <= len(data):
    stream, length = struct.unpack(">BxxxI", data[offset:offset + 8])
    payload = data[offset + 8:offset + 8 + length]
    frame += 1
    text = payload.decode("utf-8", "replace").rstrip("\n")
    print(f"  кадр {frame}: поток={STREAMS.get(stream, stream)} "
          f"длина={length} текст={text!r}")
    offset += 8 + length

print(f"  всего кадров: {frame}, байт: {len(data)}")
PY

echo "═══ почему так ═══"
cat <<'TXT'
  Одно соединение переносит ДВА потока: stdout и stderr.
  Заголовок кадра говорит, к какому потоку относится фрагмент.

  Без разбора заголовков в выводе появляются посторонние байты —
  это и есть «мусор» при попытке прочитать логи простым curl.

  Обход: container, созданный с "Tty": true, потоки не разделяет,
  и заголовков нет. Но тогда stdout и stderr неразличимы.
TXT

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

text
═══ логи через API: сырые байты ═══
  00000000: 0100 0000 0000 0013 d0bf d180 d0b8 d0b2  ................
  00000010: d0b5 d182 20d0 b8d0 b720 4150 490a       .... ... API.
═══ разбор кадров ═══
  кадр 1: поток=stdout длина=19 текст='привет из API'
  всего кадров: 1, байт: 27
═══ почему так ═══
  Одно соединение переносит ДВА потока: stdout и stderr.
  Заголовок кадра говорит, к какому потоку относится фрагмент.
  ...

Первый байт 01 — это stdout. Байты 00 00 00 13 — длина 19: строка в UTF-8 занимает больше, чем символов.

Поток событий

bash
cd /tmp/api
SOCK=/var/run/docker.sock

echo "═══ события в реальном времени ═══"
(curl -s -N --unix-socket "$SOCK" \
    "http://localhost/v1.51/events?filters=%7B%22container%22%3A%5B%22api-demo%22%5D%7D" \
    > events.jsonl) &
watcher=$!
sleep 1

curl -s -o /dev/null -X POST --unix-socket "$SOCK" \
    "http://localhost/v1.51/containers/api-demo/stop?t=2"
sleep 3
kill "$watcher" 2>/dev/null
wait "$watcher" 2>/dev/null

python3 - <<'PY'
import json
from pathlib import Path

lines = [l for l in Path("events.jsonl").read_text().splitlines() if l.strip()]
print(f"  получено событий: {len(lines)}")
for line in lines:
    e = json.loads(line)
    name = e.get("Actor", {}).get("Attributes", {}).get("name", "—")
    print(f"    {e.get('Type')}/{e.get('Action'):<16} {name}")
PY

echo "═══ чем поток отличается от обычного ответа ═══"
cat <<'TXT'
  Соединение НЕ закрывается: daemon шлёт по объекту JSON на строку
  по мере наступления событий.

  Флаг -N у curl отключает буферизацию — без него вывод появится
  только при закрытии соединения.

  Ограничить окно: параметры since и until.
    /events?since=1785321600&until=1785325200

  Без until команда не завершится никогда — то же поведение,
  что у docker events ([урок 13.2]).
TXT

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

text
═══ события в реальном времени ═══
  получено событий: 2
    container/kill             api-demo
    container/die              api-demo
═══ чем поток отличается от обычного ответа ═══
  Соединение НЕ закрывается: daemon шлёт по объекту JSON на строку
  по мере наступления событий.
  ...

Скрипт без Docker CLI

bash
cd /tmp/api
cat > docker-api.py <<'PY'
"""Работа с Docker Engine API без установленного CLI.

Стандартной библиотеки достаточно: HTTP поверх Unix-сокета
реализуется подменой класса соединения.
"""
from __future__ import annotations

import http.client
import json
import socket
import sys
import urllib.parse

SOCKET_PATH = "/var/run/docker.sock"
API_VERSION = "v1.51"


class UnixHTTPConnection(http.client.HTTPConnection):
    """HTTP-соединение поверх Unix-сокета."""

    def __init__(self, path: str, timeout: float = 30.0) -> None:
        super().__init__("localhost", timeout=timeout)
        self.unix_path = path

    def connect(self) -> None:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(self.timeout)
        sock.connect(self.unix_path)
        self.sock = sock


def request(method: str, path: str, body: dict | None = None) -> tuple[int, object]:
    conn = UnixHTTPConnection(SOCKET_PATH)
    headers = {"Content-Type": "application/json"} if body is not None else {}
    payload = json.dumps(body) if body is not None else None
    conn.request(method, f"/{API_VERSION}{path}", body=payload, headers=headers)
    response = conn.getresponse()
    raw = response.read()
    conn.close()
    if not raw:
        return response.status, None
    try:
        return response.status, json.loads(raw)
    except json.JSONDecodeError:
        return response.status, raw.decode("utf-8", "replace")


def containers(all_: bool = False) -> list[dict]:
    query = urllib.parse.urlencode({"all": "true"} if all_ else {})
    status, data = request("GET", f"/containers/json?{query}")
    if status != 200:
        raise RuntimeError(f"HTTP {status}: {data}")
    return data


def inspect(name: str) -> dict:
    status, data = request("GET", f"/containers/{name}/json")
    if status != 200:
        raise RuntimeError(f"HTTP {status}: {data}")
    return data


def version() -> dict:
    _, data = request("GET", "/version")
    return data


def main() -> int:
    v = version()
    print(f"  daemon {v['Version']}, API {v['ApiVersion']} "
          f"(минимум {v['MinAPIVersion']})")

    items = containers(all_=True)
    print(f"\n  container'ов всего: {len(items)}")
    print(f"  {'имя':<22} {'образ':<28} {'состояние':<12} создан")
    print("  " + "─" * 78)
    for c in items[:8]:
        name = c["Names"][0].lstrip("/")
        print(f"  {name:<22} {c['Image'][:28]:<28} {c['State']:<12} "
              f"{c['Status']}")

    running = [c for c in items if c["State"] == "running"]
    print(f"\n  из них работает: {len(running)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

echo "═══ работа без CLI ═══"
python3 docker-api.py

echo "═══ проверка: CLI не задействован ═══"
docker run --rm -v "$PWD/docker-api.py:/api.py:ro" \
    -v /var/run/docker.sock:/var/run/docker.sock \
    python:3.13-slim sh -c '
    command -v docker > /dev/null 2>&1 && echo "  CLI установлен" \
        || echo "  CLI в образе ОТСУТСТВУЕТ"
    python /api.py 2>&1 | head -4
' 2>/dev/null | sed 's/^/  /'

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

text
═══ работа без CLI ═══
  daemon 29.0.1, API 1.51 (минимум 1.24)

  container'ов всего: 3
  имя                    образ                        состояние    создан
  ──────────────────────────────────────────────────────────────────────────────
  api-demo               alpine:3.21                  exited       Exited (137) 1 minute ago
  web                    nginx:1.27                   running      Up 2 hours
  db                     postgres:17-alpine           running      Up 2 hours

  из них работает: 2
═══ проверка: CLI не задействован ═══
    CLI в образе ОТСУТСТВУЕТ
      daemon 29.0.1, API 1.51 (минимум 1.24)
    
      container'ов всего: 3

Скрипт работает в образе, где Docker CLI не установлен. Нужен только доступ к сокету и стандартная библиотека Python.

Почему сокет равносилен root

bash
cd /tmp/api
echo "═══ что принимает POST /containers/create ═══"
cat <<'TXT'
  {
    "Image": "alpine",
    "Cmd": ["sh", "-c", "..."],
    "HostConfig": {
      "Binds": ["/:/host"],        ← монтирование корня host
      "Privileged": true,          ← все привилегии
      "PidMode": "host",           ← PID namespace host
      "NetworkMode": "host"        ← сеть host
    }
  }

  Daemon работает от root и выполняет это без проверки,
  КТО отдал команду. Он проверяет только, что запрос
  пришёл через сокет.
TXT

echo "═══ демонстрация на учебной машине ═══"
python3 - <<'PY'
"""Показывает, что доступ к сокету даёт чтение файлов host.

Выполняется на СОБСТВЕННОЙ учебной машине. Цель — понять механизм
и осознать, почему сокет не монтируют в container'ы ([урок 12.2]).
"""
import http.client
import json
import socket


class UnixHTTPConnection(http.client.HTTPConnection):
    def __init__(self, path):
        super().__init__("localhost", timeout=30)
        self.unix_path = path

    def connect(self):
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        s.connect(self.unix_path)
        self.sock = s


def call(method, path, body=None):
    conn = UnixHTTPConnection("/var/run/docker.sock")
    headers = {"Content-Type": "application/json"} if body else {}
    conn.request(method, path, json.dumps(body) if body else None, headers)
    r = conn.getresponse()
    data = r.read()
    conn.close()
    return r.status, data


spec = {
    "Image": "alpine:3.21",
    "Cmd": ["head", "-1", "/host/etc/hostname"],
    "HostConfig": {"Binds": ["/:/host:ro"], "AutoRemove": False},
}
status, raw = call("POST", "/v1.51/containers/create?name=api-root-demo", spec)
if status != 201:
    print(f"  создание не удалось: HTTP {status}")
    raise SystemExit(0)

cid = json.loads(raw)["Id"]
call("POST", f"/v1.51/containers/{cid}/start")
call("POST", f"/v1.51/containers/{cid}/wait")
_, logs = call("GET", f"/v1.51/containers/{cid}/logs?stdout=1")

# Разбор мультиплексированного кадра
text = logs[8:].decode("utf-8", "replace").strip() if len(logs) > 8 else ""
print(f"  прочитано /etc/hostname хоста: {text!r}")
print("  через API, без sudo, только доступом к сокету")

call("DELETE", f"/v1.51/containers/{cid}?force=true")
PY

echo "═══ вывод ═══"
cat <<'TXT'
  Ограничения container'а — cap-drop, seccomp, read-only —
  применяются к СОЗДАВАЕМОМУ container'у, а не к тому,
  кто отдаёт команду.

  Поэтому никакая настройка не защищает от доступа к сокету:
  клиент просто попросит создать container без ограничений.

  Отсюда правило из урока 12.2: сокет не монтируют в container'ы,
  а членство в группе docker выдают как права root.
TXT

echo "═══ уборка ═══"
curl -s -o /dev/null -X DELETE --unix-socket /var/run/docker.sock \
    "http://localhost/v1.51/containers/api-demo?force=true"
echo "  container'ы удалены"
cd /tmp && rm -rf /tmp/api

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

text
═══ что принимает POST /containers/create ═══
  {
    "Image": "alpine",
    "Cmd": ["sh", "-c", "..."],
    "HostConfig": {
      "Binds": ["/:/host"],        ← монтирование корня host
      "Privileged": true,          ← все привилегии
      "PidMode": "host",           ← PID namespace host
      "NetworkMode": "host"        ← сеть host
    }
  }
  ...
═══ демонстрация на учебной машине ═══
  прочитано /etc/hostname хоста: 'workstation'
  через API, без sudo, только доступом к сокету
═══ вывод ═══
  Ограничения container'а — cap-drop, seccomp, read-only —
  применяются к СОЗДАВАЕМОМУ container'у, а не к тому,
  кто отдаёт команду.
  ...
═══ уборка ═══
  container'ы удалены

Имя хоста прочитано из файла host — без sudo, только доступом к сокету.

Это то же утверждение, что в уроке 12.2, но теперь видно почему: поле Binds в теле запроса, и daemon его выполняет.


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

Задание. Напишите клиент Docker API без использования CLI.

Требования:

  1. Обратиться к API через Unix-сокет и получить версию.
  2. Повторить docker ps -a одним запросом и отформатировать вывод.
  3. Создать и запустить container двумя отдельными запросами; показать состояние между ними.
  4. Прочитать логи, разобрав мультиплексированный поток.
  5. Прочитать поток событий с ограничением по времени.
  6. Показать, что скрипт работает в образе без установленного Docker CLI.
  7. Объяснить на уровне протокола, почему доступ к сокету равносилен root.

Подсказки

Подсказка 1

HTTP поверх Unix-сокета реализуется подменой метода connect у http.client.HTTPConnection.

Подсказка 2

Заголовок кадра логов: 8 байт, формат >BxxxI — байт потока, три зарезервированных, длина.

Подсказка 3

Для пункта 5 используйте параметры since и until — иначе соединение не закроется.

Решение

Показать решение
bash
mkdir -p /tmp/apilab && cd /tmp/apilab

cat > dockerapi.py <<'PY'
"""Клиент Docker Engine API без Docker CLI.

Использует только стандартную библиотеку: HTTP поверх Unix-сокета
получается подменой метода connect.
"""
from __future__ import annotations

import http.client
import json
import socket
import struct
import time
import urllib.parse
from typing import Any, Iterator

SOCKET_PATH = "/var/run/docker.sock"
API_VERSION = "v1.51"

STREAM_NAMES = {0: "stdin", 1: "stdout", 2: "stderr"}


class UnixHTTPConnection(http.client.HTTPConnection):
    """HTTP-соединение поверх Unix-сокета."""

    def __init__(self, path: str = SOCKET_PATH, timeout: float = 60.0) -> None:
        super().__init__("localhost", timeout=timeout)
        self.unix_path = path

    def connect(self) -> None:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(self.timeout)
        sock.connect(self.unix_path)
        self.sock = sock


class DockerAPIError(RuntimeError):
    def __init__(self, status: int, payload: Any) -> None:
        super().__init__(f"HTTP {status}: {payload}")
        self.status = status
        self.payload = payload


def _open(method: str, path: str, body: dict | None = None) -> http.client.HTTPResponse:
    conn = UnixHTTPConnection()
    headers = {"Content-Type": "application/json"} if body is not None else {}
    payload = json.dumps(body) if body is not None else None
    conn.request(method, f"/{API_VERSION}{path}", body=payload, headers=headers)
    return conn.getresponse()


def call(method: str, path: str, body: dict | None = None,
         expect: tuple[int, ...] = (200, 201, 204)) -> Any:
    response = _open(method, path, body)
    raw = response.read()
    if response.status not in expect:
        raise DockerAPIError(response.status, raw.decode("utf-8", "replace"))
    if not raw:
        return None
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return raw


def call_raw(method: str, path: str) -> bytes:
    response = _open(method, path)
    data = response.read()
    if response.status not in (200, 204):
        raise DockerAPIError(response.status, data.decode("utf-8", "replace"))
    return data


# ── Высокоуровневые операции ─────────────────────────────────────────
def version() -> dict:
    return call("GET", "/version")


def list_containers(all_: bool = False) -> list[dict]:
    query = urllib.parse.urlencode({"all": "true"}) if all_ else ""
    return call("GET", f"/containers/json?{query}")


def inspect(ref: str) -> dict:
    return call("GET", f"/containers/{ref}/json")


def create(name: str, spec: dict) -> str:
    query = urllib.parse.urlencode({"name": name})
    return call("POST", f"/containers/create?{query}", spec, expect=(201,))["Id"]


def start(ref: str) -> None:
    call("POST", f"/containers/{ref}/start", expect=(204, 304))


def wait(ref: str) -> int:
    return call("POST", f"/containers/{ref}/wait")["StatusCode"]


def remove(ref: str, force: bool = True) -> None:
    query = urllib.parse.urlencode({"force": "true" if force else "false"})
    call("DELETE", f"/containers/{ref}?{query}", expect=(204, 404))


def demux_logs(ref: str) -> list[tuple[str, str]]:
    """Разбирает мультиплексированный поток логов.

    Заголовок кадра — 8 байт:
      байт 0    поток: 1 = stdout, 2 = stderr
      байты 1-3 зарезервированы
      байты 4-7 длина фрагмента, big-endian
    """
    query = urllib.parse.urlencode({"stdout": "1", "stderr": "1"})
    data = call_raw("GET", f"/containers/{ref}/logs?{query}")

    frames: list[tuple[str, str]] = []
    offset = 0
    while offset + 8 <= len(data):
        stream, length = struct.unpack(">BxxxI", data[offset:offset + 8])
        payload = data[offset + 8:offset + 8 + length]
        frames.append((STREAM_NAMES.get(stream, str(stream)),
                       payload.decode("utf-8", "replace").rstrip("\n")))
        offset += 8 + length
    return frames


def events(since: int, until: int, filters: dict | None = None) -> Iterator[dict]:
    """Читает поток событий за ЗАКРЫТОЕ окно времени.

    Без until соединение не закрывается никогда.
    """
    params: dict[str, str] = {"since": str(since), "until": str(until)}
    if filters:
        params["filters"] = json.dumps(filters)
    query = urllib.parse.urlencode(params)
    data = call_raw("GET", f"/events?{query}")
    for line in data.decode("utf-8", "replace").splitlines():
        if line.strip():
            yield json.loads(line)
PY

cat > client.py <<'PY'
"""Демонстрация клиента: все требования упражнения."""
from __future__ import annotations

import json
import sys
import time

import dockerapi as api

NAME = "apilab-demo"


def req1_version() -> dict:
    v = api.version()
    print(f"    daemon {v['Version']}, API {v['ApiVersion']}, "
          f"минимум {v['MinAPIVersion']}, {v['Os']}/{v['Arch']}")
    return v


def req2_list() -> int:
    items = api.list_containers(all_=True)
    print(f"    container'ов всего: {len(items)}")
    print(f"    {'имя':<22} {'образ':<26} {'состояние':<10} статус")
    print("    " + "─" * 82)
    for c in items[:6]:
        name = c["Names"][0].lstrip("/")
        print(f"    {name:<22} {c['Image'][:26]:<26} {c['State']:<10} {c['Status']}")
    return len(items)


def req3_create_start() -> tuple[str, dict, dict]:
    api.remove(NAME)
    spec = {
        "Image": "alpine:3.21",
        "Cmd": ["sh", "-c",
                "echo строка в stdout; echo строка в stderr >&2; sleep 2"],
        "HostConfig": {"AutoRemove": False},
    }
    cid = api.create(NAME, spec)
    before = api.inspect(cid)["State"]
    api.start(cid)
    time.sleep(1)
    after = api.inspect(cid)["State"]
    print(f"    создан: {cid[:12]}")
    print(f"    ДО запуска:    Status={before['Status']:<10} "
          f"Running={before['Running']!s:<6} Pid={before['Pid']}")
    print(f"    ПОСЛЕ запуска: Status={after['Status']:<10} "
          f"Running={after['Running']!s:<6} Pid={after['Pid']}")
    return cid, before, after


def req4_logs(cid: str) -> list[tuple[str, str]]:
    api.wait(cid)
    frames = api.demux_logs(cid)
    print(f"    кадров в потоке: {len(frames)}")
    for stream, text in frames:
        print(f"      поток={stream:<7} текст={text!r}")
    streams = {s for s, _ in frames}
    print(f"    различных потоков: {len(streams)} ({', '.join(sorted(streams))})")
    return frames


def req5_events(since: int) -> list[dict]:
    until = int(time.time()) + 1
    evs = list(api.events(since, until, {"container": [NAME]}))
    print(f"    событий за окно [{since}, {until}]: {len(evs)}")
    for e in evs:
        name = e.get("Actor", {}).get("Attributes", {}).get("name", "—")
        print(f"      {e.get('Type')}/{e.get('Action'):<18} {name}")
    return evs


def main() -> int:
    started_at = int(time.time()) - 1

    print("\n  ── Требование 1: версия API ──")
    v = req1_version()

    print("\n  ── Требование 2: список container'ов ──")
    total = req2_list()

    print("\n  ── Требование 3: создание и запуск раздельно ──")
    cid, before, after = req3_create_start()

    print("\n  ── Требование 4: разбор потока логов ──")
    frames = req4_logs(cid)

    print("\n  ── Требование 5: поток событий ──")
    evs = req5_events(started_at)

    api.remove(cid)

    print()
    print(json.dumps({
        "api_version": v["ApiVersion"],
        "containers": total,
        "pid_до": before["Pid"],
        "pid_после": after["Pid"],
        "кадров": len(frames),
        "потоков": len({s for s, _ in frames}),
        "событий": len(evs),
    }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > socket_risk.py <<'PY'
"""Почему доступ к сокету равносилен root — на уровне протокола.

Выполняется на СОБСТВЕННОЙ учебной машине.
"""
from __future__ import annotations

import json
import sys

import dockerapi as api

NAME = "apilab-risk"

SPEC = {
    "Image": "alpine:3.21",
    "Cmd": ["sh", "-c", "head -1 /host/etc/hostname; ls /host/root 2>&1 | head -1"],
    "HostConfig": {
        # Поле, которое делает всё остальное неважным
        "Binds": ["/:/host:ro"],
        "AutoRemove": False,
    },
}


def main() -> int:
    print("    Тело запроса POST /containers/create:")
    print("      " + json.dumps(SPEC["HostConfig"], ensure_ascii=False))
    print()

    api.remove(NAME)
    cid = api.create(NAME, SPEC)
    api.start(cid)
    code = api.wait(cid)
    frames = api.demux_logs(cid)
    api.remove(cid)

    print(f"    код выхода: {code}")
    for stream, text in frames:
        print(f"    прочитано ({stream}): {text!r}")
    print()
    print("    Прочитано с файловой системы HOST, без sudo,")
    print("    только доступом к сокету.")
    print()
    print("    Механизм: daemon работает от root и выполняет запрос,")
    print("    не проверяя, КТО его отдал. Поле Binds монтирует корень.")
    print()
    print("    Ограничения container'а (cap-drop, seccomp, read-only)")
    print("    применяются к СОЗДАВАЕМОМУ container'у, а не к клиенту.")
    print("    Поэтому они не защищают: клиент просто не задаёт их.")
    return 0 if frames else 1


if __name__ == "__main__":
    sys.exit(main())
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требования 1-5: клиент API ═══\n'
python3 client.py > client.log 2>&1
client_rc=$?
sed -n '1,/^  {/p' client.log | head -40
summary="$(tail -1 client.log)"

api_v="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['api_version'])" 2>/dev/null)"
pid_before="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['pid_до'])" 2>/dev/null)"
pid_after="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['pid_после'])" 2>/dev/null)"
frames="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['кадров'])" 2>/dev/null)"
streams="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['потоков'])" 2>/dev/null)"
n_events="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['событий'])" 2>/dev/null)"

printf '\n  версия API: %s\n' "$api_v"
[ -n "$api_v" ] && ok "версия получена через сокет" || bad "версия не получена"

printf '  PID: до запуска %s, после %s\n' "$pid_before" "$pid_after"
[ "${pid_before:-1}" -eq 0 ] && [ "${pid_after:-0}" -gt 0 ] \
    && ok "create и start — разные операции: процесс появился только после start" \
    || bad "pid до=$pid_before после=$pid_after"

printf '  кадров в логах: %s, различных потоков: %s\n' "$frames" "$streams"
[ "${frames:-0}" -ge 2 ] && [ "${streams:-0}" -eq 2 ] \
    && ok "мультиплексированный поток разобран: stdout и stderr различены" \
    || bad "кадров=$frames потоков=$streams"

printf '  событий за окно: %s\n' "$n_events"
[ "${n_events:-0}" -ge 2 ] \
    && ok "поток событий прочитан с ограничением по времени и завершился" \
    || bad "событий: $n_events"

printf '\n═══ Требование 6: работа без Docker CLI ═══\n'
docker run --rm \
    -v "$PWD/dockerapi.py:/app/dockerapi.py:ro" \
    -v "$PWD/client.py:/app/client.py:ro" \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -w /app python:3.13-slim sh -c '
    if command -v docker > /dev/null 2>&1; then
        echo "CLI УСТАНОВЛЕН"
    else
        echo "CLI в образе отсутствует"
    fi
    pip list 2>/dev/null | grep -ci docker || echo "пакетов docker: 0"
    python -c "
import dockerapi
v = dockerapi.version()
n = len(dockerapi.list_containers(all_=True))
print(f\"версия API: {v[\"ApiVersion\"]}, container ов: {n}\")
"
' > nocli.log 2>&1
cat nocli.log | sed 's/^/    /'
no_cli=0
grep -q 'CLI в образе отсутствует' nocli.log && no_cli=1
worked=0
grep -q 'версия API:' nocli.log && worked=1
[ "$no_cli" -eq 1 ] && [ "$worked" -eq 1 ] \
    && ok "клиент работает в образе без CLI и без сторонних пакетов" \
    || bad "CLI отсутствует=$no_cli сработало=$worked"

printf '\n═══ Требование 7: почему сокет равносилен root ═══\n'
python3 socket_risk.py > risk.log 2>&1
risk_rc=$?
cat risk.log | sed 's/^/  /'
read_host=0
grep -q "прочитано (stdout)" risk.log && read_host=1
[ "$read_host" -eq 1 ] \
    && ok "механизм показан на уровне протокола: поле Binds в теле запроса" \
    || bad "чтение файловой системы host не выполнено"

printf '\n═══ Сверка с CLI ═══\n'
cli_count="$(docker ps -aq 2>/dev/null | wc -l)"
api_count="$(python3 -c "
import dockerapi
print(len(dockerapi.list_containers(all_=True)))
" 2>/dev/null)"
printf '  container ов по версии CLI: %s\n' "$cli_count"
printf '  container ов по версии API: %s\n' "$api_count"
[ "$cli_count" = "$api_count" ] \
    && ok "CLI и прямой вызов API дают одинаковый результат" \
    || bad "расхождение: CLI=$cli_count API=$api_count"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

cd /tmp && rm -rf /tmp/apilab
exit "$fail"

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

text
═══ Требования 1-5: клиент API ═══

  ── Требование 1: версия API ──
    daemon 29.0.1, API 1.51, минимум 1.24, linux/amd64

  ── Требование 2: список container'ов ──
    container'ов всего: 3
    имя                    образ                      состояние  статус
    ──────────────────────────────────────────────────────────────────────────────────
    web                    nginx:1.27                 running    Up 2 hours
    db                     postgres:17-alpine         running    Up 2 hours

  ── Требование 3: создание и запуск раздельно ──
    создан: 9f1c4e8a2b73
    ДО запуска:    Status=created    Running=False  Pid=0
    ПОСЛЕ запуска: Status=running    Running=True   Pid=52841

  ── Требование 4: разбор потока логов ──
    кадров в потоке: 2
      поток=stdout  текст='строка в stdout'
      поток=stderr  текст='строка в stderr'
    различных потоков: 2 (stderr, stdout)

  ── Требование 5: поток событий ──
    событий за окно [1785321600, 1785321610]: 4
      container/create             apilab-demo
      container/start              apilab-demo
      container/die                apilab-demo
      container/destroy            apilab-demo

  версия API: 1.51
  ✓ версия получена через сокет
  PID: до запуска 0, после 52841
  ✓ create и start — разные операции: процесс появился только после start
  кадров в логах: 2, различных потоков: 2
  ✓ мультиплексированный поток разобран: stdout и stderr различены
  событий за окно: 4
  ✓ поток событий прочитан с ограничением по времени и завершился

═══ Требование 6: работа без Docker CLI ═══
    CLI в образе отсутствует
    пакетов docker: 0
    версия API: 1.51, container ов: 3
  ✓ клиент работает в образе без CLI и без сторонних пакетов

═══ Требование 7: почему сокет равносилен root ═══
    Тело запроса POST /containers/create:
      {"Binds": ["/:/host:ro"], "AutoRemove": false}

    код выхода: 0
    прочитано (stdout): 'workstation'
    прочитано (stdout): '.bashrc'

    Прочитано с файловой системы HOST, без sudo,
    только доступом к сокету.

    Механизм: daemon работает от root и выполняет запрос,
    не проверяя, КТО его отдал. Поле Binds монтирует корень.

    Ограничения container'а (cap-drop, seccomp, read-only)
    применяются к СОЗДАВАЕМОМУ container'у, а не к клиенту.
    Поэтому они не защищают: клиент просто не задаёт их.
  ✓ механизм показан на уровне протокола: поле Binds в теле запроса

═══ Сверка с CLI ═══
  container ов по версии CLI: 3
  container ов по версии API: 3
  ✓ CLI и прямой вызов API дают одинаковый результат

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Требование 3 даёт самое наглядное подтверждение устройства: Pid=0 до запуска и Pid=52841 после. docker run — это две операции, и разделение видно только на уровне API.

Три решения, определяющие качество.

Клиент использует только стандартную библиотеку. Взять готовый пакет было бы проще, но тогда урок показывал бы работу с библиотекой, а не устройство протокола. Подмена метода connect — семь строк, и после них весь Engine API доступен обычным http.client. Требование 6 подтверждает: в образе нет ни CLI, ни сторонних пакетов.

Разбор логов реализован по формату кадра, а не отрезанием восьми байт. Наивный подход — data[8:] — работает на одном кадре и ломается на двух: между фрагментами появляется второй заголовок. Цикл по кадрам различает stdout и stderr, и требование 4 это проверяет числом потоков.

Результат сверяется с CLI. Клиент мог бы работать и выдавать неверные числа — например, пропускать остановленные container'ы. Сравнение с docker ps -aq | wc -l превращает «скрипт запустился» в «скрипт даёт тот же ответ».

Чего решение не делает. docker exec и attach не реализованы: они требуют перехода соединения в двусторонний поток по заголовку Upgrade, а это отдельный протокол поверх HTTP. Поток событий читается за закрытое окно; бесконечное чтение с обработкой в реальном времени потребовало бы построчного разбора незакрывающегося ответа. Работа по TCP с TLS не проверялась — на этой машине daemon слушает только сокет. Наконец, демонстрация риска выполняется на учебной машине и монтирует корень только на чтение: цель — показать механизм, а не воспроизвести атаку целиком.

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

bash
curl -s --unix-socket /var/run/docker.sock http://localhost/version | python3 -m json.tool | head
curl -s --unix-socket /var/run/docker.sock http://localhost/v1.51/containers/json | python3 -c "import json,sys; print(len(json.load(sys.stdin)))"
docker ps -q | wc -l
docker --log-level debug ps 2>&1 | grep -oE 'GET [^ "]+' | head -3

Число из второй команды должно совпадать с третьей.

Типичные ошибки

ОшибкаПричинаИсправление
Читать логи через API как текстОжидают обычный ответМультиплексированные кадры; разбирать заголовки
Отрезать первые 8 байт и считать задачу решённойРаботает на одном кадреНа двух появится второй заголовок
GET /events без untilНе знают о параметреСоединение не закроется никогда
curl без -N для потоковПривычкаВывод появится только при закрытии
Не указывать версию API в скриптеРаботает и такПоведение изменится после обновления daemon
Считать docker run одной операциейCLI её скрываетЭто create плюс start
Публиковать TCP-порт daemon без TLSУдобно для удалённого доступаПолный доступ без аутентификации
Считать, что ограничения container'а защищают сокетЛогично предположитьОни применяются к создаваемому container'у
Ставить CLI ради одного запросаПривычкаДостаточно curl или стандартной библиотеки

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

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

  1. Что делает CLI при выполнении docker ps?
  2. Почему docker run — это два запроса к API, а не один?
  3. Как устроен кадр мультиплексированного потока логов?
  4. Зачем указывать версию API в пути запроса?
  5. Почему ограничения container'а не защищают от доступа к сокету?

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

  1. Как получить список container'ов без установленного CLI?
  2. Как прочитать поток событий и не зависнуть?
  3. Как проверить, какие запросы отправляет CLI?

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

  1. Логи через API приходят с посторонними байтами. Причина?
  2. Скрипт перестал работать после обновления Docker. Гипотеза?

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

  1. CLI преобразует аргументы в HTTP-запрос и форматирует ответ; работу делает daemon.
  2. По умолчанию транспорт — Unix-сокет, а разграничение — права на файл сокета.
  3. Версия в пути фиксирует формат; без неё поведение меняется с обновлением daemon.
  4. docker run — это POST /containers/create плюс POST /containers/ID/start.
  5. Состояние created существует потому, что создание и запуск разделены.
  6. Логи и attach приходят мультиплексированным потоком: кадр с заголовком из 8 байт.
  7. Первый байт заголовка — поток, последние четыре — длина фрагмента.
  8. Потоки events и stats не закрываются: нужен until или ограничение по времени.
  9. exec и attach требуют перехода соединения — простым curl не реализуются.
  10. Клиент API пишется на стандартной библиотеке подменой метода connect.
  11. Поле HostConfig.Binds в теле запроса объясняет, почему сокет равносилен root.
  12. Ограничения применяются к создаваемому container'у, а не к тому, кто отдаёт команду.

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

ИсточникСсылкаЧто подтверждает
Docker Engine APIhttps://docs.docker.com/reference/api/engine/Эндпоинты, версии, форматы
Docker Engine API: версииhttps://docs.docker.com/reference/api/engine/version-history/Совместимость и изменения
Docker: attach и мультиплексированиеhttps://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerAttachФормат кадра потока
Docker: dockerdhttps://docs.docker.com/reference/cli/dockerd/-H, сокет и TCP
Docker: защита сокетаhttps://docs.docker.com/engine/security/protect-access/TLS для TCP
Python: http.clienthttps://docs.python.org/3/library/http.client.htmlПодмена соединения

Навигация

Вернуться к разделу
Следующий материал → containerd, shim и runc
Главное оглавление

Markdown на GitHub ↗