251 lines
15 KiB
Markdown
Executable File
251 lines
15 KiB
Markdown
Executable File
# macOS Docker Desktop — что НЕ проезжает в контейнер (virtiofs)
|
||
|
||
> Создан 2026-09-26. Статус: **проверено живыми тестами** (alpine:3.20, linux/arm64,
|
||
> **Colima** на macOS 26.6.1, Apple Silicon; `colima start --cpu 8 --memory 24 --disk 100`).
|
||
> Тема: границы проброса путей из мира macOS в контейнер.
|
||
> Все выводы получены прогонами, не по памяти. Команды воспроизведения — в §6.
|
||
|
||
---
|
||
|
||
## 0. Главный вывод одной строкой
|
||
|
||
На macOS рантайм контейнеров (Colima, Docker Desktop) отдаёт контейнеру файлы
|
||
**не через Linux `mount`**, а через
|
||
**virtiofs** — демон, который строит маппинг путей **в момент старта контейнера**
|
||
и знает только пути **мира macOS**, известные ему на старте.
|
||
|
||
Всё, что появляется **позже** и **внутри VM** (ручной `mount`, шара, новый каталог),
|
||
изнутри VM видно корректно — **до контейнера не доходит**. Это не кэш: рестарт
|
||
не помогает, `sleep` не помогает.
|
||
|
||
📌 На Linux-хосте (нативный Docker) этих ограничений **нет** — там
|
||
`nsenter`-трюки с `mount --bind` работают. Все выводы ниже — про **macOS**.
|
||
|
||
---
|
||
|
||
## 1. Симлинки в контейнер НЕ проезжают
|
||
|
||
Симлинк, чья цель лежит **вне** смонтированного каталога, в контейнере **битый**.
|
||
|
||
```
|
||
host: /tmp/symtest/outside/file.txt = "REAL CONTENT ON HOST"
|
||
bind: -v /tmp/symtest/bind:/mnt (внутри /mnt/target -> /tmp/symtest/outside)
|
||
контейнер: cat /mnt/target/file.txt
|
||
→ can't open '/mnt/target/file.txt': No such file or directory
|
||
```
|
||
|
||
Сам симлинк внутри **виден** (`lrwxrwxrwx target -> /tmp/symtest/outside`), но цель
|
||
резолвится **относительно корня контейнера**, где этого пути нет. Docker монтирует
|
||
только реальные пути — симлинки не разворачивает.
|
||
|
||
**Следствие:** папка с симлинками на внешние цели в контейнере нерабочая, даже если
|
||
на хосте всё открывается.
|
||
|
||
---
|
||
|
||
## 2. Live `mount --bind` (nsenter) контейнеру НЕ виден
|
||
|
||
Схема обычная для Linux-хоста:
|
||
|
||
```bash
|
||
docker run --rm --privileged --pid=host alpine nsenter -t 1 -m \
|
||
mount --bind /host/source /host/agents/target
|
||
```
|
||
|
||
⚠️ **Первая ловушка (до самого mount):** `nsenter -t 1 -m` заходит в **VM**, а не в
|
||
macOS. Пути **мира macOS там не существуют**. Поэтому хост-путь для VM нужно
|
||
создавать **внутри VM** — например, в том же `nsenter -t 1 -m sh -c '...'`.
|
||
|
||
Проверено **тремя** прогонами, результат одинаковый:
|
||
|
||
| Прогон | Условие | Результат |
|
||
|---|---|---|
|
||
| 1 | каталог создан **после** старта контейнера + live bind | в контейнере пусто |
|
||
| 2 | ожидание 5 с (гипотеза «кэш атрибутов virtiofs») | пусто |
|
||
| 3 | каталог существовал **до** старта контейнера + bind **поверх** него | **пусто** |
|
||
|
||
Изнутри VM всё корректно:
|
||
|
||
```
|
||
VM: cat /tmp/bindtest2/agents/existing/f.txt → "DATA2" ✓ файл есть
|
||
mount: BIND_OK ✓ смонтировалось
|
||
контейнер: cat /agents/existing/f.txt → No such file or directory ✗
|
||
```
|
||
|
||
**Вывод:** live-bind для добавления каталогов в контейнер на macOS **недоступен**.
|
||
Единственный рабочий путь — **`-v` в compose + пересоздание контейнера**
|
||
(`docker compose up -d` = 2-3 сек, `--privileged` не нужен).
|
||
|
||
---
|
||
|
||
## 3. Сетевые шары (SMB/NFS) в контейнер НЕ протянуть тем же путём
|
||
|
||
- `mount_smbfs`/`mount_nfs` в macOS создаёт точку в **мире macOS** → virtiofs её
|
||
не видит → `-v /Volumes/share:/data` **не работает**.
|
||
- mount **внутри VM** через `nsenter` технически проходит, но:
|
||
- **не переживает рестарт Docker** (нужен startup-хук);
|
||
- требует `--privileged`;
|
||
- SMB/NFS не дают hardlink между экспортами, блокировки файлов ведут себя иначе —
|
||
сборщики/редакторы на шаре работают хуже, чем на локальном диске.
|
||
|
||
**Рекомендация:** не тянуть шару в контейнер. Если данные на NAS — **`rsync` в
|
||
локальную папку** по требованию. Копия переживает всё и не требует privileged.
|
||
|
||
---
|
||
|
||
## 4. Hardlink — чего он НЕ может (независимо от Docker)
|
||
|
||
Это свойство ФС, не Docker:
|
||
|
||
1. **Hardlink на каталог не делается.** Ни в APFS, ни в ext4, ни в NTFS.
|
||
`ln dir1 dir2` → `Operation not permitted`. У каталога появилась бы ссылка на
|
||
себя → цикл в дереве. Запрещено на уровне syscall.
|
||
2. **Между томами не работает.** Только внутри одной ФС (один `st_dev`),
|
||
иначе `EXDEV: Cross-device link`.
|
||
|
||
**Следствие:** «подключить проект-каталог hardlink'ом извне» — **физически
|
||
невозможно**. Hardlink годится только для **файлов внутри одного тома**.
|
||
|
||
---
|
||
|
||
## 5. Матрица: чем подключать путь в контейнер
|
||
|
||
| Способ | Папку? | Через тома? | Видно в контейнере (macOS) | Итог |
|
||
|---|---|---|---|---|
|
||
| symlink | да | да | ✗ битый | ❌ для внешних целей |
|
||
| hardlink | **✗ нет** | ✗ нет | — | ✓ только файлы внутри тома |
|
||
| live `mount --bind` (nsenter) | да | да | ✗ | ❌ на macOS |
|
||
| **`-v` в compose + recreate** | да | да | ✓ | ✅ **рабочий путь** |
|
||
| rsync-копия | да | да | ✓ | ✓ когда данные на шаре/NAS |
|
||
|
||
---
|
||
|
||
## 6. Команды воспроизведения
|
||
|
||
```bash
|
||
# 1. Симлинк в контейнер
|
||
mkdir -p /tmp/symtest/outside /tmp/symtest/bind
|
||
echo "REAL CONTENT ON HOST" > /tmp/symtest/outside/file.txt
|
||
ln -sfn /tmp/symtest/outside /tmp/symtest/bind/target
|
||
docker run --rm --platform linux/arm64 -v /tmp/symtest/bind:/mnt alpine:3.20 \
|
||
sh -c 'ls -la /mnt/; cat /mnt/target/file.txt' # → No such file or directory
|
||
|
||
# 2. Live bind (внимание: пути создавать ВНУТРИ VM)
|
||
docker run -d --name t -v /tmp/bindtest2/agents:/agents alpine:3.20 sleep 300
|
||
docker run --rm --privileged --pid=host alpine:3.20 nsenter -t 1 -m sh -c \
|
||
'mkdir -p /tmp/bindtest2/agents/existing /tmp/bindtest2/ext2
|
||
echo DATA2 > /tmp/bindtest2/ext2/f.txt
|
||
mount --bind /tmp/bindtest2/ext2 /tmp/bindtest2/agents/existing'
|
||
docker exec t cat /agents/existing/f.txt # → No such file or directory
|
||
|
||
# 3. Hardlink на каталог (должен упасть)
|
||
ln /tmp/a /tmp/b # → Operation not permitted
|
||
```
|
||
|
||
⚠️ Тестовые `docker run` тянут образы — гонять их на Mac, не на слабом хосте
|
||
(Kraken: buildkit-кэш и слои лягут на диск зря).
|
||
|
||
---
|
||
|
||
## 7. Что делать в реальном проекте
|
||
|
||
1. **Внешние каталоги** — только через **`-v` в compose** + `docker compose up -d`.
|
||
Манифест монтажей (список `name` + `host_path`) генерирует
|
||
`compose.override.yaml` — composefile не правится скриптом вручную.
|
||
2. **auth/секреты CLI — на хосте, ВНЕ рабочего каталога агента.** Иначе агент,
|
||
которому рабочий каталог смонтирован на запись, получает доступ к своим же
|
||
токенам. Монтировать отдельным `-v`.
|
||
3. **Шары** — `rsync` в локальную папку, не прямой mount.
|
||
4. **Hardlink** — только файлы внутри одного тома, с проверкой `st_dev`.
|
||
5. **Не строить архитектуру на live-mount'ах** — на macOS это тупик.
|
||
|
||
---
|
||
|
||
## 8. ⚠️ Реализация на macOS: `docker-compose`, а не `docker compose`
|
||
|
||
Проверено 2026-09-26 на этой машине (Docker CLI 29.4.1, macOS 26.6.1, Apple Silicon).
|
||
Compose здесь — **отдельный бинарь**, не подкоманда `docker`:
|
||
|
||
| Что | Значение |
|
||
|---|---|
|
||
| CLI | `/opt/homebrew/bin/docker` (v29.4.1) |
|
||
| buildx | плагин, симлинк в `~/.docker/cli-plugins/docker-buildx` |
|
||
| compose | `/opt/homebrew/bin/docker-compose` (**v5.1.3**), симлинк в `/opt/homebrew/lib/docker/cli-plugins/` |
|
||
|
||
```bash
|
||
docker compose config # → docker: unknown command: docker compose
|
||
docker compose config --quiet # → unknown flag: --quiet
|
||
docker-compose config # ✅ работает
|
||
docker-compose config --services
|
||
```
|
||
|
||
📌 **Всегда `docker-compose` (с дефисом) в этом окружении.** Сообщение об ошибке
|
||
выглядит как «docker не знает такой команды», а не «compose не установлен» — легко
|
||
принять за сломанный Docker.
|
||
|
||
⚠️ Дефолтный билдер (`default` → `/var/run/docker.sock`) в статусе **`error`**.
|
||
Для arm64-сборок использовать именованный билдер:
|
||
```bash
|
||
docker buildx build --builder arm64builder --platform linux/arm64 \
|
||
--provenance=false --load -t <tag> .
|
||
```
|
||
|
||
### Порядок файлов при генерации override
|
||
|
||
`compose.override.yaml` подхватывается compose **автоматически**, но если он ссылается на
|
||
несуществующий путь на хосте, `up` падает на монтировании. Поэтому порядок строго такой:
|
||
|
||
```
|
||
1. .env ← env_file, иначе up падает сразу
|
||
2. agents-mounts.yaml ← манифест (можно пустой: mounts: [])
|
||
3. tools/render-mounts.py ← → compose.override.yaml
|
||
4. docker-compose up -d
|
||
```
|
||
|
||
### Ловушка: `deploy.resources.limits` в не-swarm compose
|
||
|
||
Секция `deploy.resources.limits.memory` **молча игнорируется** запуском через
|
||
`docker-compose up` (это формат swarm). Лимит создаёт ложное ощущение защиты —
|
||
что-то ограничивать нужно иначе (cgroups напрямую или `mem_limit`, зависят от версии).
|
||
|
||
### Проверено 2026-09-26 (сборка `balda-executor`)
|
||
|
||
Дополнения по факту сборки Фазы 4:
|
||
|
||
| Факт | Значение |
|
||
|---|---|
|
||
| Сборка образа | `docker buildx build --builder arm64builder --platform linux/arm64 --provenance=false --load -t balda-executor:0.1.0 .` — прошла, ~1 мин |
|
||
| `compose.override.yaml` при `mounts: []` | должен существовать обязательно: compose подхватывает его автоматически, а без файла `up` падает на монтировании |
|
||
| Имя каталога в выводе | `docker-compose` пишет путь через **нижний регистр** (`/Users/admin/docker/...`) — macOS ФС регистронезависима, для человека разницы нет |
|
||
| Пересоздание контейнера | `docker-compose up -d` после правки манифеста: `Recreate` → `Started`, 2-3 сек, `--privileged` не нужен |
|
||
| Отвязка проекта | убрать запись из манифеста → `render-mounts.py` → `up -d`. Файлы на хосте целы (проверено: `ls` на хосте после отвязки) |
|
||
| Hardlink в тот же том | `ln -F` даёт `links=2` и **одинаковый inode** — проверено через `stat -f "%l %i"` |
|
||
| Hardlink на каталог | API возвращает 400 до вызова `ln` (проверка `is_dir()`), ядро тоже откажет |
|
||
|
||
### Питфолл: `psutil` отсутствует в venv Eagle Dashboard
|
||
|
||
`process/terminate`-операции процесса Hermes (`process kill`) падают с
|
||
`No module named 'psutil'`. Из-за этого supervisor **не может трекать мост по PID**,
|
||
и для сервиса моста выбран health `type: http`, а не `type: process` —
|
||
иначе карточка в дашборде всегда показывает «down», хотя сервис жив.
|
||
|
||
### Проверка, что сервис под launchd действительно автозапускается
|
||
|
||
```bash
|
||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agents.bridge.plist
|
||
launchctl list | grep agents.bridge # pid + last exit code
|
||
curl -s http://127.0.0.1:8790/healthz # сервис отвечает
|
||
```
|
||
|
||
`KeepAlive: true` + `RunAtLoad: true` в plist → сервис переживает падение и перезагрузку.
|
||
Требовать `PATH` в `EnvironmentVariables` обязательно: под launchd PATH минимальный,
|
||
и `/opt/homebrew/bin/docker` иначе не находится.
|
||
|
||
---
|
||
|
||
## Связанные заметки
|
||
- [[personal/plans/balda-mm-phase4-agents]] — применение этих выводов (Фаза 4)
|
||
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план проекта
|
||
- скилл `eagle-dashboard` — сервис-реестр, health-типы, launchd
|
||
- скилл `docker-service-administration` — lifecycle контейнеров
|