17.1. Engine API и Unix socket
Цели
После этого материала вы сможете:
- объяснить, что делает CLI при каждой команде, и повторить это одним
curl; - обратиться к API через Unix socket и разобрать формат ответа;
- объяснить, зачем API версионирован и что произойдёт без указания версии;
- читать поток событий через API и понимать, чем он отличается от
docker events; - написать скрипт, работающий с Docker без установленного CLI;
- объяснить, почему доступ к socket равносилен
root, — на уровне протокола.
Предварительные знания
- 1.5. Docker group и socket;
- 12.2. Daemon и socket — риск доступа;
- 13.2. Inspect, events, stats.
Ключевые термины
| Термин | Объяснение |
|---|---|
Engine API | HTTP-интерфейс daemon |
Unix socket | Файл-сокет /var/run/docker.sock |
версия API | Префикс пути вида /v1.51/ |
hijacked connection | Соединение, переходящее в двусторонний поток |
chunked | Потоковая передача ответа частями |
Теория
Что происходит при docker ps
docker ps
│
├─ CLI разбирает аргументы
├─ формирует HTTP-запрос
│ GET /v1.51/containers/json
├─ отправляет его в /var/run/docker.sock
│
▼
dockerd принимает, выполняет, отвечает JSON
│
▼
CLI форматирует таблицу
CLI не делает ничего, кроме преобразования аргументов в HTTP-запрос и форматирования ответа. Вся работа — на стороне daemon.
Отсюда практическое следствие: любую команду можно выполнить без CLI, обратившись к API напрямую. Это нужно там, где CLI ставить не хочется: в минимальном образе, в скрипте, в системе мониторинга.
Проверить соответствие можно так:
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 без TLS | tcp://0.0.0.0:2375 | Никакой |
| TCP с TLS | tcp://0.0.0.0:2376 | Клиентский сертификат |
Права на файл сокета — единственный механизм разграничения при работе через сокет. Обычно это владелец root и группа docker.
Отсюда утверждение из урока 12.2: членство в группе docker равносильно root. Теперь оно объясняется на уровне протокола — в следующем разделе.
Версионирование
GET /v1.51/containers/json явная версия
GET /containers/json последняя, поддерживаемая daemon
Указание версии в пути фиксирует формат запроса и ответа. Без него клиент получает поведение текущей версии daemon, которое может измениться после обновления.
| Подход | Когда применять |
|---|---|
| Явная версия в пути | Скрипты и интеграции |
| Без версии | Разовые проверки вручную |
DOCKER_API_VERSION | Принудить CLI к версии |
Узнать поддерживаемый диапазон:
curl -s --unix-socket /var/run/docker.sock http://localhost/version
Ответ содержит ApiVersion и MinAPIVersion. Запрос версии ниже минимальной даёт ошибку.
Основные эндпоинты
| Команда CLI | Запрос API |
|---|---|
docker ps | GET /containers/json |
docker ps -a | GET /containers/json?all=true |
docker inspect ИМЯ | GET /containers/ИМЯ/json |
docker logs ИМЯ | GET /containers/ИМЯ/logs?stdout=1&stderr=1 |
docker images | GET /images/json |
docker run | POST /containers/create + POST /containers/ИМЯ/start |
docker stop | POST /containers/ИМЯ/stop |
docker events | GET /events |
docker stats | GET /containers/ИМЯ/stats |
docker info | GET /info |
Строка docker run показывает существенное: это не одна операция, а две — создание и запуск. Отсюда возможность docker create без запуска (урок 15.5).
Три формата ответа
| Формат | Где применяется | Особенность |
|---|---|---|
| Обычный JSON | inspect, version, список container'ов | Один документ |
| Поток JSON | events, stats, pull | По объекту на строку, соединение не закрывается |
| Мультиплексированный поток | logs, attach | Кадры с заголовком: поток и длина |
Третий формат — источник затруднений. Вывод docker logs через API приходит не обычным текстом: каждому фрагменту предшествует восьмибайтовый заголовок, где первый байт указывает поток (1 — stdout, 2 — stderr), а последние четыре — длину.
[0x01][0x00 0x00 0x00][0x00 0x00 0x00 0x0e]строка в stdout
поток зарезервировано длина 14
Без разбора заголовков в выводе появляются посторонние байты. Обойти это можно, запросив логи container'а, созданного с Tty: true, — тогда потоки не разделяются и заголовков нет.
Внутренний механизм
Почему доступ к сокету равносилен root
Протокол объясняет это точнее словесного описания.
Эндпоинт POST /containers/create принимает объект с полем HostConfig, а в нём — Binds и Privileged:
{
"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 нельзя — нужен клиент, умеющий работать с таким переходом. Для чтения логов и статистики этого не требуется.
Команды и примеры
Первое обращение
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']}\")
"
Ожидаемый вывод:
═══ версия 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 на самом деле
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
Ожидаемый вывод:
═══ CLI с отладочным выводом ═══
GET /_ping
GET /v1.51/containers/json
═══ те же запросы через curl ═══
/_ping HTTP 200
/v1.51/containers/json HTTP 200
═══ вывод ═══
CLI — это преобразователь: аргументы → HTTP-запрос,
ответ JSON → таблица.
...
Отладочный вывод CLI показывает ровно те запросы, которые повторяются вручную. Совпадение полное.
Создание и запуск: две операции
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
Ожидаемый вывод:
═══ создание 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 — штатный ответ на успешный запуск: тела у ответа нет.
Мультиплексированный поток логов
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
Ожидаемый вывод:
═══ логи через 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 занимает больше, чем символов.
Поток событий
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
Ожидаемый вывод:
═══ события в реальном времени ═══
получено событий: 2
container/kill api-demo
container/die api-demo
═══ чем поток отличается от обычного ответа ═══
Соединение НЕ закрывается: daemon шлёт по объекту JSON на строку
по мере наступления событий.
...
Скрипт без Docker CLI
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/^/ /'
Ожидаемый вывод:
═══ работа без 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
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
Ожидаемый вывод:
═══ что принимает 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.
Требования:
- Обратиться к API через Unix-сокет и получить версию.
- Повторить
docker ps -aодним запросом и отформатировать вывод. - Создать и запустить container двумя отдельными запросами; показать состояние между ними.
- Прочитать логи, разобрав мультиплексированный поток.
- Прочитать поток событий с ограничением по времени.
- Показать, что скрипт работает в образе без установленного Docker CLI.
- Объяснить на уровне протокола, почему доступ к сокету равносилен
root.
Подсказки
Подсказка 1
HTTP поверх Unix-сокета реализуется подменой метода connect у http.client.HTTPConnection.
Подсказка 2
Заголовок кадра логов: 8 байт, формат >BxxxI — байт потока, три зарезервированных, длина.
Подсказка 3
Для пункта 5 используйте параметры since и until — иначе соединение не закроется.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требования 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 слушает только сокет. Наконец, демонстрация риска выполняется на учебной машине и монтирует корень только на чтение: цель — показать механизм, а не воспроизвести атаку целиком.
Проверка результата
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 или стандартной библиотеки |
Контрольные вопросы
На понимание:
- Что делает CLI при выполнении
docker ps? - Почему
docker run— это два запроса к API, а не один? - Как устроен кадр мультиплексированного потока логов?
- Зачем указывать версию API в пути запроса?
- Почему ограничения container'а не защищают от доступа к сокету?
На применение:
- Как получить список container'ов без установленного CLI?
- Как прочитать поток событий и не зависнуть?
- Как проверить, какие запросы отправляет CLI?
На диагностику:
- Логи через API приходят с посторонними байтами. Причина?
- Скрипт перестал работать после обновления Docker. Гипотеза?
Краткое резюме
- CLI преобразует аргументы в HTTP-запрос и форматирует ответ; работу делает daemon.
- По умолчанию транспорт — Unix-сокет, а разграничение — права на файл сокета.
- Версия в пути фиксирует формат; без неё поведение меняется с обновлением daemon.
docker run— этоPOST /containers/createплюсPOST /containers/ID/start.- Состояние
createdсуществует потому, что создание и запуск разделены. - Логи и
attachприходят мультиплексированным потоком: кадр с заголовком из 8 байт. - Первый байт заголовка — поток, последние четыре — длина фрагмента.
- Потоки
eventsиstatsне закрываются: нуженuntilили ограничение по времени. execиattachтребуют перехода соединения — простымcurlне реализуются.- Клиент API пишется на стандартной библиотеке подменой метода
connect. - Поле
HostConfig.Bindsв теле запроса объясняет, почему сокет равносиленroot. - Ограничения применяются к создаваемому container'у, а не к тому, кто отдаёт команду.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker Engine API | https://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: dockerd | https://docs.docker.com/reference/cli/dockerd/ | -H, сокет и TCP |
| Docker: защита сокета | https://docs.docker.com/engine/security/protect-access/ | TLS для TCP |
Python: http.client | https://docs.python.org/3/library/http.client.html | Подмена соединения |
Навигация
Вернуться к разделу
Следующий материал → containerd, shim и runc
Главное оглавление