Контейнеризация переживает тихую эволюцию. Годами Docker доминировал как единственный инструмент для запуска изолированных окружений. Но архитектура с центральным демоном, требующим root-привилегий, всегда несла в себе потенциальную уязвимость. Podman предлагает радикально иной подход: работа без демонов, контейнеры от обычных пользователей, встроенная совместимость с OCI-спецификацией. Для разработчиков на Go это означает прямой доступ к библиотеке libpod через нативные привязки, без необходимости общаться с сокетами и API удаленных служб.

Архитектура без демонов и последствия для безопасности

Традиционный подход Docker опирается на клиент-серверную модель. Пользователь отправляет команду клиенту, клиент связывается с демоном через Unix-сокет, демон выполняет операции с привилегиями root. Каждый контейнер фактически запускается как дочерний процесс демона. Если злоумышленник получает контроль над демоном, он контролирует все контейнеры и саму хост-систему.

Podman устраняет этот узкое место. Команда запускается напрямую, используя fork/exec модель. Каждый контейнер становится независимым процессом, порожденным напрямую вызывающим пользователем. Нет центральной точки отказа, нет привилегированного процесса, который нужно атаковать. Это не просто косметическое изменение - это фундаментальный пересмотр архитектуры:

package main

import (
    "context"
    "fmt"
    "github.com/containers/podman/v4/pkg/bindings"
    "github.com/containers/podman/v4/pkg/bindings/containers"
)

func main() {
    // Подключение к Podman API без демона
    sockDir := "unix:///run/podman/podman.sock"
    conn, err := bindings.NewConnection(context.Background(), sockDir)
    if err != nil {
        panic(err)
    }
    
    // Создание контейнера напрямую
    spec := &containers.CreateOptions{}
    spec.WithImage("alpine:latest")
    spec.WithCmd([]string{"/bin/sh", "-c", "echo hello"})
    
    created, err := containers.CreateWithSpec(conn, spec, nil)
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("Container created: %s\n", created.ID)
}

Этот код демонстрирует работу с Podman через Go-привязки. Обратите внимание: сокет указывается явно, но в rootless-режиме он живет в пространстве пользователя, обычно в /run/user/$UID/podman/podman.sock. Никаких привилегий не требуется.

Rootless-контейнеры через пространства имен пользователей

Запуск контейнеров без прав суперпользователя - это не компромисс безопасности ради удобства. Это полноценное решение, основанное на user namespaces ядра Linux. Механизм работает через отображение UID/GID: процесс с UID 0 внутри контейнера на самом деле выполняется с UID обычного пользователя на хосте.

Для работы требуется предварительная настройка файлов /etc/subuid и /etc/subgid:

# Добавить диапазон UID для пользователя alice
echo "alice:100000:65536" | sudo tee -a /etc/subuid
echo "alice:100000:65536" | sudo tee -a /etc/subgid

# Проверить настройки
podman unshare cat /proc/self/uid_map

Первое число (100000) - начальный UID, второе (65536) - размер диапазона. Внутри контейнера UID 0 отображается на UID пользователя alice, UID 1 - на 100000, UID 2 - на 100001, и так далее. Процесс, думающий что он root, на самом деле ограничен правами обычного пользователя.

Посмотрим на практический пример с созданием файла:

func runRootlessContainer() {
    conn, _ := bindings.NewConnection(context.Background(), 
        "unix:///run/user/1000/podman/podman.sock")
    
    spec := &containers.CreateOptions{}
    spec.WithImage("fedora:latest")
    spec.WithCmd([]string{"touch", "/tmp/testfile"})
    spec.WithUser("0:0") // Root внутри контейнера
    
    created, _ := containers.CreateWithSpec(conn, spec, nil)
    containers.Start(conn, created.ID, nil)
    containers.Wait(conn, created.ID, nil)
    
    // Файл создан root-ом контейнера, 
    // но владелец на хосте - UID вызывающего пользователя
}

Техническая реализация опирается на newuidmap и newgidmap - утилиты, работающие с привилегиями для создания namespace mappings. Сами утилиты имеют setuid-биты, но Podman их вызывает только для настройки пространств имен, не для запуска контейнерных процессов.

OCI runtime: runc против crun

Podman не выполняет низкоуровневые операции с контейнерами самостоятельно. Эту работу делегируют OCI-совместимым runtime. Два основных варианта - runc и crun.

runc написан на Go, он эталонная реализация OCI Runtime Specification. Стабилен, широко используется, проверен временем. Но у него есть особенность: Go runtime добавляет накладные расходы на память и скорость старта.

crun разработан на C. Быстрее, меньше потребляет памяти, поддерживает экспериментальные возможности раньше их включения в спецификацию. Сравнение производительности показывает разницу:

func benchmarkRuntime(runtime string) time.Duration {
    start := time.Now()
    
    spec := &containers.CreateOptions{}
    spec.WithImage("alpine:latest")
    spec.WithCmd([]string{"/bin/true"})
    spec.WithRuntime(runtime)
    
    conn, _ := bindings.NewConnection(context.Background(), sockPath)
    created, _ := containers.CreateWithSpec(conn, spec, nil)
    containers.Start(conn, created.ID, nil)
    containers.Wait(conn, created.ID, nil)
    containers.Remove(conn, created.ID, nil)
    
    return time.Since(start)
}

// Типичные результаты:
// runc: ~200ms для 100 контейнеров
// crun: ~150ms для 100 контейнеров

Выбор runtime происходит через containers.conf:

[engine]
runtime = "crun"
runtime_path = [
    "/usr/bin/crun",
    "/usr/local/bin/crun"
]

Для rootless-окружений файл размещается в ~/.config/containers/containers.conf. Podman ищет runtime в определенных директориях: /usr/bin, /usr/sbin, /usr/local/bin. Если crun не найден, происходит откат на runc.

Seccomp-профили и фильтрация системных вызовов

Secure Computing Mode (seccomp) предоставляет механизм фильтрации syscall на уровне ядра. Процесс может объявить, какие системные вызовы ему разрешены, остальные будут заблокированы. Для контейнеров это критично: даже если атакующий получит контроль над процессом, набор доступных syscall ограничен.

По умолчанию Docker и Podman используют профиль, созданный Jesse Frazelle. Он блокирует около 44 вызовов из более чем 300 доступных. Баланс между безопасностью и совместимостью: блокируются опасные вещи вроде reboot, swapon, создания kernel-модулей, но остается достаточно для запуска большинства приложений.

Создание кастомного профиля начинается с трассировки syscall. Podman поддерживает автоматическую генерацию через eBPF:

# Установка oci-seccomp-bpf-hook
sudo dnf install oci-seccomp-bpf-hook

# Генерация профиля для конкретной команды
sudo podman run --annotation io.containers.trace-syscall="of:/tmp/ls.json" \
    fedora:latest ls / > /dev/null

# Просмотр сгенерированного профиля
cat /tmp/ls.json

Полученный JSON содержит список разрешенных syscall:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": ["read", "write", "open", "close", "stat", "fstat"],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Применение профиля при запуске контейнера:

func runWithSeccomp(profilePath string) {
    conn, _ := bindings.NewConnection(context.Background(), sockPath)
    
    spec := &containers.CreateOptions{}
    spec.WithImage("fedora:latest")
    spec.WithCmd([]string{"ls", "/"})
    
    // Применение seccomp-профиля
    securityOpts := []string{fmt.Sprintf("seccomp=%s", profilePath)}
    spec.WithSecurityOpt(securityOpts)
    
    created, _ := containers.CreateWithSpec(conn, spec, nil)
    containers.Start(conn, created.ID, nil)
}

Если приложение попытается вызвать заблокированный syscall, ядро вернет EPERM:

// Тестирование профиля
spec.WithCmd([]string{"chmod", "400", "/etc/hosts"})
// Результат: Operation not permitted, если chmod не в списке разрешенных

Генерация профилей должна покрывать все пути кода. Запустите ls /, профиль работает. Запустите ls -l /, получите ошибку - дополнительный флаг требует syscall типа getxattr или lstat, которых нет в профиле. Решение - итеративная трассировка:

# Расширение существующего профиля
sudo podman run --annotation io.containers.trace-syscall="if:/tmp/ls.json;of:/tmp/lsl.json" \
    fedora:latest ls -l / > /dev/null
    
# Сравнение профилей
diff <(jq -S . /tmp/ls.json) <(jq -S . /tmp/lsl.json)

Интеграция с Go-приложениями

Podman предоставляет официальные Go-привязки, позволяющие управлять контейнерами программно. Импортируйте github.com/containers/podman/v4/pkg/bindings, установите соединение, выполняйте операции:

package main

import (
    "context"
    "fmt"
    "github.com/containers/podman/v4/pkg/bindings"
    "github.com/containers/podman/v4/pkg/bindings/containers"
    "github.com/containers/podman/v4/pkg/bindings/images"
)

func main() {
    ctx := context.Background()
    conn, err := bindings.NewConnection(ctx, "unix:///run/podman/podman.sock")
    if err != nil {
        panic(err)
    }
    
    // Скачивание образа
    pullOptions := &images.PullOptions{}
    pullOptions.WithReference("docker.io/library/nginx:alpine")
    
    _, err = images.Pull(conn, "docker.io/library/nginx:alpine", pullOptions)
    if err != nil {
        fmt.Printf("Pull failed: %v\n", err)
        return
    }
    
    // Создание и запуск контейнера
    createSpec := &containers.CreateOptions{}
    createSpec.WithImage("nginx:alpine")
    createSpec.WithPublishPorts([]string{"8080:80"})
    
    created, err := containers.CreateWithSpec(conn, createSpec, nil)
    if err != nil {
        panic(err)
    }
    
    // Старт контейнера
    if err := containers.Start(conn, created.ID, nil); err != nil {
        panic(err)
    }
    
    fmt.Printf("Container %s started\n", created.ID)
    
    // Получение состояния
    inspect, err := containers.Inspect(conn, created.ID, nil)
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("State: %s\n", inspect.State.Status)
}

Для rootless-режима необходимо убедиться, что API-сервис запущен:

# Запуск Podman API в пользовательском режиме
systemctl --user enable --now podman.socket

# Проверка работы
curl -H "Content-Type: application/json" \
    --unix-socket /run/user/1000/podman/podman.sock \
    http://localhost/_ping

Интеграция seccomp-профилей в Go-код требует указания пути к JSON-файлу:

func createSecureContainer(imageName, seccompProfile string) (string, error) {
    conn, _ := bindings.NewConnection(context.Background(), sockPath)
    
    spec := &containers.CreateOptions{}
    spec.WithImage(imageName)
    
    // Применение seccomp и других опций безопасности
    secOpts := []string{
        fmt.Sprintf("seccomp=%s", seccompProfile),
        "no-new-privileges=true",
    }
    spec.WithSecurityOpt(secOpts)
    
    // Ограничение capabilities
    capDrop := []string{"all"}
    capAdd := []string{"NET_BIND_SERVICE"}
    spec.WithCapDrop(capDrop)
    spec.WithCapAdd(capAdd)
    
    created, err := containers.CreateWithSpec(conn, spec, nil)
    if err != nil {
        return "", err
    }
    
    return created.ID, nil
}

Особенности сетевой подсистемы

Rootless Podman сталкивается с проблемой: непривилегированные пользователи не могут настраивать сетевые пространства имен напрямую. Решение - slirp4netns или pasta, которые эмулируют TCP/IP stack в userspace.

slirp4netns создает виртуальный TAP-интерфейс для каждого контейнера. Процесс slirp4netns получает пакеты из network namespace контейнера, маршрутизирует их через хостовую сеть. Производительность ниже, чем при нативной маршрутизации, но работает без привилегий.

pasta - современная замена slirp4netns. Поддерживает IPv6 полностью, использует новые механизмы изоляции ядра, работает в отдельном процессе для дополнительной безопасности. Начиная с Podman 5.0, pasta стал настройкой по умолчанию.

func createNetworkContainer() {
    spec := &containers.CreateOptions{}
    spec.WithImage("nginx:alpine")
    
    // Создание пользовательской сети
    netOpts := &networks.CreateOptions{}
    netOpts.WithDriver("bridge")
    netConn, _ := bindings.NewConnection(context.Background(), sockPath)
    
    networkName := "mynet"
    networks.Create(netConn, netOpts, &networkName)
    
    // Подключение контейнера к сети
    spec.WithNetworks([]string{networkName})
    
    conn, _ := bindings.NewConnection(context.Background(), sockPath)
    created, _ := containers.CreateWithSpec(conn, spec, nil)
    containers.Start(conn, created.ID, nil)
}

Ограничение портов: непривилегированные пользователи не могут биндить порты ниже 1024. Обход через sysctl:

# Разрешить порты от 80 и выше
sudo sysctl net.ipv4.ip_unprivileged_port_start=80

# Сделать изменение постоянным
echo "net.ipv4.ip_unprivileged_port_start=80" | \
    sudo tee /etc/sysctl.d/podman-ports.conf

Альтернатива - использовать проксирование на уровне iptables или внешний редиректор вроде nginx.

Управление ресурсами через cgroups v2

Control groups позволяют ограничивать потребление CPU, памяти, I/O. Для rootless-контейнеров требуется cgroups v2 с правильно настроенной делегацией.

Проверка версии cgroups:

# Определить версию
mount | grep cgroup

# Для v2 увидите: cgroup2 on /sys/fs/cgroup
# Для v1: tmpfs on /sys/fs/cgroup

Применение ресурсных лимитов:

func createLimitedContainer() {
    spec := &containers.CreateOptions{}
    spec.WithImage("alpine:latest")
    spec.WithCmd([]string{"sleep", "3600"})
    
    // Ограничение CPU (0.5 ядра)
    spec.WithCPUQuota(50000)
    spec.WithCPUPeriod(100000)
    
    // Ограничение памяти (256MB)
    spec.WithMemory(256 * 1024 * 1024)
    spec.WithMemorySwap(256 * 1024 * 1024) // Без swap
    
    // Ограничение I/O
    spec.WithBlkioWeight(500) // 100-1000, default 500
    
    conn, _ := bindings.NewConnection(context.Background(), sockPath)
    created, _ := containers.CreateWithSpec(conn, spec, nil)
    containers.Start(conn, created.ID, nil)
}

Для rootless cgroups v2 пользователь должен иметь делегированные контроллеры. Проверка:

cat /sys/fs/cgroup/user.slice/user-1000.slice/cgroup.controllers
# Должно показать: cpuset cpu io memory hugetlb pids

Если контроллеры отсутствуют, systemd должен быть настроен на делегирование:

# /etc/systemd/system/user@.service.d/delegate.conf
[Service]
Delegate=yes

Практический сценарий: безопасный CI/CD пайплайн

Соединим все компоненты в реальный пример - запуск тестов в изолированных контейнерах:

package main

import (
    "context"
    "fmt"
    "github.com/containers/podman/v4/pkg/bindings"
    "github.com/containers/podman/v4/pkg/bindings/containers"
)

type SecureRunner struct {
    conn          context.Context
    seccompProfile string
}

func NewSecureRunner(sockPath, seccompPath string) (*SecureRunner, error) {
    conn, err := bindings.NewConnection(context.Background(), sockPath)
    if err != nil {
        return nil, err
    }
    
    return &SecureRunner{
        conn:          conn,
        seccompProfile: seccompPath,
    }, nil
}

func (r *SecureRunner) RunTest(image string, cmd []string) error {
    spec := &containers.CreateOptions{}
    spec.WithImage(image)
    spec.WithCmd(cmd)
    
    // Максимальная изоляция
    spec.WithSecurityOpt([]string{
        fmt.Sprintf("seccomp=%s", r.seccompProfile),
        "no-new-privileges=true",
    })
    
    // Минимальные capabilities
    spec.WithCapDrop([]string{"all"})
    spec.WithCapAdd([]string{"CHOWN", "SETUID", "SETGID"})
    
    // Ограничение ресурсов
    spec.WithMemory(512 * 1024 * 1024)
    spec.WithCPUQuota(100000)
    
    // Read-only filesystem, кроме /tmp
    spec.WithReadonly(true)
    tmpfs := map[string]string{"/tmp": "rw,noexec,nosuid,size=100m"}
    spec.WithTmpfs(tmpfs)
    
    created, err := containers.CreateWithSpec(r.conn, spec, nil)
    if err != nil {
        return err
    }
    
    // Запуск и ожидание завершения
    if err := containers.Start(r.conn, created.ID, nil); err != nil {
        return err
    }
    
    exitCode, err := containers.Wait(r.conn, created.ID, nil)
    if err != nil {
        return err
    }
    
    // Очистка
    removeOpts := &containers.RemoveOptions{}
    removeOpts.WithForce(true)
    containers.Remove(r.conn, created.ID, removeOpts)
    
    if exitCode != 0 {
        return fmt.Errorf("test failed with exit code %d", exitCode)
    }
    
    return nil
}

func main() {
    runner, err := NewSecureRunner(
        "unix:///run/user/1000/podman/podman.sock",
        "/etc/containers/seccomp.json",
    )
    if err != nil {
        panic(err)
    }
    
    tests := []struct {
        image string
        cmd   []string
    }{
        {"golang:1.21", []string{"go", "test", "./..."}},
        {"node:18-alpine", []string{"npm", "test"}},
        {"python:3.11", []string{"pytest"}},
    }
    
    for _, test := range tests {
        fmt.Printf("Running tests in %s...\n", test.image)
        if err := runner.RunTest(test.image, test.cmd); err != nil {
            fmt.Printf("Failed: %v\n", err)
        } else {
            fmt.Println("Passed")
        }
    }
}

Этот код демонстрирует production-ready подход: каждый тест запускается в полностью изолированном контейнере с ограниченными ресурсами, минимальными привилегиями и seccomp-фильтрацией. Даже если тестовый код скомпрометирован, атакующий не получит доступа к хосту.

Podman с его архитектурой без демонов, поддержкой rootless-режима и встроенной интеграцией seccomp представляет собой современный подход к контейнеризации. Для разработчиков на Go это означает прямой доступ к мощному API через нативные привязки. Безопасность перестает быть компромиссом - она встроена в саму архитектуру. Контейнеры запускаются с минимальными привилегиями, системные вызовы фильтруются, ресурсы ограничены. Это не просто альтернатива Docker - это следующий виток эволюции контейнерных технологий.