Rootless Podman с veth интерфейсом вместо pasta

Podman - инструмент для управления контейнерами без демона (daemonless). Совместим с docker (команды почти полностью повторяют docker). Может запускать rootless контейнеры. Умеет запускать pod (несколько контейнеров, делящих один сетевой неймспейс). Умеет интегрироваться с systemd. Доступен в репозитории ос (по крайней мере в debian).

Для rootless контейнеров, в качестве сети используется pasta. Pasta программа, которая выполняет трансляцию для TCP, UDP, ICMP/ICMPv6 echo, между канальным уровнем (L2) контейнера и транспортным уровнем L4 хоста.

Как работает pasta
flowchart LR

  subgraph Container [Контейнер]
      direction TB
      eth0[eth0]
  end
  
  subgraph Pasta [pasta]
      direction TB

      tap[tap <br> Интерфейс]
  end

  subgraph Host [Хост]
      direction TB

      tcp_sock[tcp сокет]
      udp_sock[udp сокет]
      icmp_sock[icmp сокет]
  end

  Internet[("Внешняя сеть")]

  eth0 ==>|Уровень L2| tap

  tap ===> tcp_sock
  tap ===> udp_sock
  tap ===> icmp_sock

  tcp_sock ===> Internet
  udp_sock ===> Internet
  icmp_sock ===> Internet

  style Pasta fill:#e1f5fe,stroke:#0288d1,stroke-width:2px
  style Container fill:#efebe9,stroke:#5d4037,stroke-width:2px
  style Host fill:#f1f8e9,stroke:#558b2f,stroke-width:2px

Приложение в неймспейсе контейнера создает сокет, отправляет данные. Данные проходят по OSI до L2 и попадают в tap интерфейс в контейнере, откуда они вычитываются pasta, распаковываются до L4, после чего pasta на хосте создает соответствующий сокет и отправляет через него данные. И не нужно никакой NAT трансляции. Пакет уходит в сеть от адреса хоста. Обратные пакеты попадают обратно в сокет, который создан pasta на хосте и pasta упаковывает их в L3 и затем в L2 пакеты и отправляет обратно в tap интерфейс. В случае локальных подключений, между хостом и контейнером, данные копируются напрямую между сокетами контейнера и хоста. Это вероятно используется при обращении к службе, которая работает на хосте через адрес host.containers.internal, который добавляется в hosts (169.254.1.2 host.containers.internal host.docker.internal). Так следует из схемы на https://passt.top. Хотя tcpdump показывает этот трафик в интерфейсе контейнера. А вот с хоста в контейнер, как и из другого контейнера (если это не pod), без проброса порта (например -p 8080), попасть не получится.

И здесь есть ограничения с доступом с хоста в контейнер и между контейнерами. А если нужно два контейнера с одинаковым портом (SIP 5060), широкие диапазоны RTP, отдельные IP, доступные с хоста и друг другу (из LAN с доработкой)? И здесь хочется оставить rootless контейнер, но дать ему “честную” сеть.

Подобную схему можно использовать например на рабочем компьютере, чтобы что-то протестировать. Сразу оговорюсь, что совсем без root доступа не получится это сделать. В общих чертах схема следующая: запустить контейнер без сети, от рута создать интерфейс, поместить его в контейнер и настроить на нем ip адрес. В качестве интерфейса я буду использовать пару veth интерфейсов, один конец которого подключается к локальному бриджу, с ip адресом, который будет являться шлюзом для контейнера, а второй помещается в контейнер. Адреса я буду раздавать с помощью dhcp сервера. Настройку я выполняю на debian 13 без NetworkManager (при установке выбраны ssh server и standard system utilities).

Предостережения

Предполагаю что настраивая вы понимаете что делаете. flush ruleset в /etc/nftables.conf например очистит существующие правила, настройка интерфейса br0 может конфликтовать с вашей сетью, правка /etc/resolv.conf может повлиять на возможность разрешать имена через DNS сервер и т.д. и т.п.

Установка

sudo apt update
sudo apt --no-install-recommends install podman catatonit dnsmasq jq bridge-utils \
    curl uidmap liblxc-common busybox

Настройка

Включаем пересылку пакетов между интерфейсами

echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/ip_forward.conf
sudo sysctl -p /etc/sysctl.d/ip_forward.conf

Бридж

sudo tee /etc/network/interfaces.d/br0 << EOF

auto br0
iface br0 inet static
    bridge_ports none
    address 172.16.0.1/24
    bridge_stp off
    bridge_fd 0
EOF

sudo ifup br0

DHCP и DNS (dnsmasq)

Чтобы иметь возможность обращаться к контейнеру по имени, нужно чтобы хост делал DNS запросы к dnsmasq. Для этого я использую опцию no-resolv и явно прописываю сервера имен в dnsmasq.conf, а в файле /etc/resolv.conf указываю nameserver 127.0.0.1. /etc/dnsmasq.conf:

interface=lo
interface=br0
bind-interfaces
no-resolv
server=77.88.8.1
server=77.88.8.8
domain-needed

dhcp-range=set:net1,172.16.0.10,172.16.0.200,255.255.255.0,1d
dhcp-option=tag:net1,option:router,172.16.0.1
dhcp-option=tag:net1,option:dns-server,172.16.0.1
sudo dnsmasq --test
sudo systemctl restart dnsmasq

Файрвол

В качестве файрвола у меня nftables. Кстати в podman есть замечательная возможность отключить вмешательство в файрвол хоста. Правда в текущей настройке эта возможность не понадобится, так как стартовать контейнеры будут вообще без сети и podman и так не будет ничего настраивать. Так как я собираю стенд на чистой системе, то добавлю только NAT трансляцию. Если же у вас уже настроен файрвол и в цепочках input и forward по умолчанию например drop, то нужно открыть DHCP и DNS запросы к интерфейсу br0 в цепочке input и разрешить прохождение трафика от и к сети за br0 в forward. /etc/nftables.conf:

#!/usr/sbin/nft -f

# This will erase all current rules
flush ruleset

define container_net = 172.16.0.0/24
define container_net_if = br0
define container_net_oif = eth0

table inet filter {
	chain input {
		type filter hook input priority filter;
		# iifname "$container_net_if" udp sport 68 udp dport 67 accept;
		# iifname "$container_net_if" udp dport 53 accept;
		# iifname "$container_net_if" tcp dport 53 accept;
	}
	chain forward {
		type filter hook forward priority filter;
		# iifname "$container_net_if" ip saddr $container_net accept
		# oifname "$container_net_if" ip daddr $container_net accept
	}
	chain output {
		type filter hook output priority filter;
	}
}

table ip nat {
    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        ip saddr $container_net oifname $container_net_oif masquerade
    }
}

Ну и если nftables не настроен, то:

sudo systemctl enable nftables
sudo systemctl start nftables

sudo nft -n -a list ruleset

OCI Hook

Общая схема запуска настройки сети будет выглядеть следующим образом: При старте контейнера podman запустит oci createRuntime хук, который запустит скрипт настройки сети. Хук выполняется в runtime неймспейсе и пользователь, от которого запускается контейнер в нем root, но без реальных привилегий на хосте. Чтобы это обойти я буду использовать systemd сокет и сервис. Systemd будет слушать сокет и как только получит в него данные, вызовет соответствующий сервис и отправит данные ему. Сервис уже будет выполняться в пространстве пользователя и ему доступен запуск бинарника с установленным suid битом.

graph TD
  P[podman run] --> H[oci createRuntime hook]
  H --> S[systemd socket]
  S --> Srv[systemd service]
  Srv --> veth[create veth <br> run DHCP client]

Настройка директории где искать хуки:

mkdir -p ~/.config/containers
cat << EOF > ~/.config/containers/containers.conf
[engine]
hooks_dir = ["/home/user/.config/containers/oci/hooks.d"]
EOF

Сам хук:

mkdir -p ~/.config/containers/oci/hooks.d
cat << EOF > ~/.config/containers/oci/hooks.d/netd.json
{
  "version": "1.0.0",
  "hook": {
    "path": "/home/user/bin/podman_netd_hook.sh"
  },
  "when": {
    "annotations": {"netd.use": ".*"}
  },
  "stages": ["createRuntime"]
}
EOF

Скрипт /home/user/bin/podman_netd_hook.sh. Хук получает данные о контейнере на stdin. Здесь я получаю PID контейнера, его hostname и отправляю это в сокет. В ответ приходит код работы скрипта, который является кодом выхода. Если будет не 0, то контейнер не запустится. Также здесь нужно получить uid пользователя, который запустил контейнер, чтобы определить путь к сокету, в который писать данные. Также здесь создается временный файл, в который затем запишутся настройки сервера имен от DHCP. Для работы с сокетом я буду использовать curl. Мне так показалось удобнее, чем сначала писать, а затем читать ответ. Curl и отправит данные и ответ подождет.

mkdir -p ~/bin
# Скопировать файл podman_netd_hook.sh из текста или репозитория
chmod 755 ~/bin/podman_netd_hook.sh
podman_netd_hook.sh
#!/bin/bash
set -euo pipefail

TAG="podman-netd-hook"
CURL_WAIT_TIMEOUT=30
log()  { logger -t "$TAG" -- "$*"; }
fail() { log "$2"; exit "$1"; }
warn_exit() { log "$1"; exit 0; }

for b in jq curl logger awk; do
    command -v "$b" >/dev/null || { echo "$TAG: no $b" >&2; exit 110; }
done

HOOK_STATE=$(cat)
PID=$(jq -r '.pid' <<<"$HOOK_STATE") || fail 2 "no pid in state"
BUNDLE=$(jq -r '.bundle' <<<"$HOOK_STATE") || fail 3 "no bundle in state"
HAVE_SANDBOX=$(jq -r '.annotations."io.kubernetes.cri-o.SandboxID" // empty' <<<"$HOOK_STATE")
CONFIG="$BUNDLE/config.json"
test -f "$CONFIG" || fail 4 "bundle config not found"

# Skip infra/service containers
if [ -z "$HAVE_SANDBOX" ]; then
    case "$(basename "$(jq -r '.process.args[0] // empty' "$CONFIG")")" in
        catatonit|pause)
            warn_exit "infra or service container. skip it"
        ;;
    esac
fi

HOSTNAME_=$(jq -r '.hostname // empty' "$CONFIG")
if [ -z "$HOSTNAME_" ]; then
    HOSTNAME_=$(jq -r '.process.env[]? | select(startswith("HOSTNAME="))
                        | sub("^HOSTNAME=";"") // empty' "$CONFIG")
fi
if [ -n "$HOSTNAME_" ]; then
    [[ "$HOSTNAME_" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,61}[A-Za-z0-9])?$ ]] || fail 5 "Bad hostname"
fi

OWNER_UID=$(awk '$1 == 0 {print $2; exit}' "/proc/$PID/uid_map")
[[ $OWNER_UID =~ ^[0-9]{1,10}$ ]] || fail 6 "cannot determine uid from uid_map"

SOCK="/run/user/$OWNER_UID/netd.sock"
test -S "$SOCK" || fail 7 "no service socket found"

RESOLV_CONF=$(mktemp)
trap 'rm -f "$RESOLV_CONF"' EXIT

ARGS=$(jq -cn --argjson pid "$PID" --arg hostname "$HOSTNAME_" \
              --arg resolvconf "$RESOLV_CONF" \
              '{pid:$pid, hostname:$hostname, resolvconf:$resolvconf}')

RESPONSE=$(curl -sS -f --max-time $CURL_WAIT_TIMEOUT --unix-socket "$SOCK" \
                -H 'Content-Type: application/json' -d "$ARGS" \
                http://localhost/ 2> >(logger -t "$TAG")) || fail 8 "netd request failed"

EXITCODE=$(jq -r '.exitcode' <<<"$RESPONSE") || fail 9 "bad response from netd: $RESPONSE"
[[ $EXITCODE =~ ^[0-9]+$ ]] || fail 10 "invalid exitcode: $EXITCODE"

if [ "$EXITCODE" -ne 0 ]; then
    log "netd returned exitcode $EXITCODE"
    [ "$EXITCODE" -le 255 ] || EXITCODE=1
    exit "$EXITCODE"
fi

# Install resolv.conf
ROOT=$(jq -r '.root // empty' <<<"$HOOK_STATE")
TARGET="$ROOT/etc/resolv.conf"
test -s "$RESOLV_CONF" || warn_exit "no resolv.conf for install"
test -d "$ROOT/etc" || warn_exit "destination container does not have /etc dir"
test -L "$TARGET" && warn_exit "destination /etc/resolv.conf is symlink. skip install"

install -m 0644 "$RESOLV_CONF" "$TARGET" || fail 11 "cannot install resolv.conf"

exit 0
Данные, которые получает createRuntime хук
{
    "ociVersion": "1.0",
    "id": "d34d960afce4875a0745cb32e07a9c03e06ba20f8a8f4b59a2ea9f7047dcd86a",
    "pid": 6676,
    "root": "/home/user/.local/share/containers/storage/overlay/564fe7349052df07b3b7ce997d13d840b84363149ec54871c9a3cad8c7ede06d/merged",
    "bundle": "/home/user/.local/share/containers/storage/overlay-containers/d34d960afce4875a0745cb32e07a9c03e06ba20f8a8f4b59a2ea9f7047dcd86a/userdata",
    "status": "created",
    "annotations": {
    "io.container.manager": "libpod",
    "io.podman.annotations.autoremove": "TRUE",
    "netd.use": "true",
    "org.opencontainers.image.stopSignal": "15",
    "org.systemd.property.KillSignal": "15",
    "org.systemd.property.TimeoutStopUSec": "uint64 10000000"
    }
}

Systemd сервис и сокет

mkdir -p ~/.config/systemd/user
cat << EOF > ~/.config/systemd/user/netd.socket
[Unit]
Description=Network setup socket for containers

[Socket]
# %t/netd.sock is converted to /run/user/user_uid/netd.sock
ListenStream=%t/netd.sock
# Launch a separate process for each connection
Accept=yes
SocketMode=0600

[Install]
WantedBy=sockets.target
EOF

cat << EOF > ~/.config/systemd/user/netd\@.service
[Unit]
Description=Network setup handler

[Service]
ExecStart=%h/bin/netd-handler.sh
StandardInput=socket
StandardOutput=socket
StandardError=journal
EOF

systemctl --user daemon-reload
systemctl --user enable --now netd.socket

Настраивать пару veth интерфейсов я буду с помощью утилиты lxc-user-nic, которая используется в lxc для этого. На этом бинарнике установлен suid бит и поэтому sudo не нужно. lxc-user-nic смотрит в конфигурационный файл /etc/lxc/lxc-usernet для проверки разрешений. Файл имеет формат: пользователь тип_интерфейса бридж количество. Тип поддерживается только veth.

sudo mkdir -p /etc/lxc
sudo tee /etc/lxc/lxc-usernet << EOF
user veth br0 10
EOF

Что касается удаления veth интерфейсов, то система сама это сделает, при удалении контейнера. А lxc-user-nic сохраняет имена используемых интерфейсов в файле /run/lxc/nics и обновляет этот список при старте нового контейнера.

Для раздачи адресов контейнерам я буду использовать DHCP. Почему вообще DHCP. Ну первое я не хочу отслеживать занятые адреса и второе, это возможность передать имя хоста в DNS сервер. В качестве DHCP клиента я буду использовать udhcpc из busybox. В качестве скрипта настройки адреса используется скрипт /usr/share/udhcpc/default.script из alpine. Небольшое изменение, которое нужно сделать в нем, чтобы он читал переменную RESOLV_CONF из env.

--- /usr/share/udhcpc/default.script
+++ /home/user/bin/dhcp.sh
@@ -6,7 +6,7 @@
 UDHCPC="/etc/udhcpc"
 UDHCPC_CONF="$UDHCPC/udhcpc.conf"

-RESOLV_CONF="/etc/resolv.conf"
+RESOLV_CONF="${RESOLV_CONF:-/tmp/resolv.conf.$$}"
 [ -f "$UDHCPC_CONF" ] && . "$UDHCPC_CONF"

 export RESOLV_CONF
# Скопировать файл dhcp.sh из репозитория в ~/bin
chmod 755 ~/bin/dhcp.sh

И теперь понадобится скрипт самого сервиса /home/user/bin/netd-handler.sh. В нем, нужно вычитать запрос, проверить данные, проверить что интерфейс еще не настроен, так как если это pod, то возможно это было сделано при старте первого контейнера. Далее нужно запустить lxc-user-nic и DHCP клиент.

mkdir -p ~/bin
# Скопировать файл netd-handler.sh из текста или репозитория
chmod 755 ~/bin/netd-handler.sh
netd-handler.sh
#!/bin/bash
set -u

TAG="netd-handler"
IFACE_TYPE="veth"
BRIDGE_NAME="br0"
READ_TIMEOUT=5
LXC_USER_NIC=/usr/libexec/lxc/lxc-user-nic
DHCP_SCRIPT=/home/user/bin/dhcp.sh

write_response() {
    EXITCODE=$1

    RESPONSE_BODY=$(jq -cn --argjson exitcode "$EXITCODE" '{"exitcode":$exitcode}')
    RESPONSE_LENGTH=$(printf "%s" "$RESPONSE_BODY" | wc -c)

    printf "HTTP/1.1 200 OK\r\n"
    printf "Content-Length: %d\r\n" "$RESPONSE_LENGTH"
    printf "Content-Type: application/json; charset=utf-8\r\n"
    printf "Connection: close\r\n"
    printf "\r\n"
    printf "%s" "$RESPONSE_BODY"
}

succeed() { write_response 0; exit 0; }

fail() {
    EXITCODE=$1
    ERROR=$2

    logger -t "$TAG" "$ERROR"
    write_response "$EXITCODE"

    exit 0
}

CONTENT_LENGTH=0
METHOD=""
PATH_REQ=""

while IFS= read -r -t $READ_TIMEOUT line; do
    line="${line%$'\r'}"

    [ -z "$line" ] && break

    if [ -z "$METHOD" ]; then
        read -r METHOD PATH_REQ _ <<< "$line"
        continue
    fi

    if [[ "${line,,}" =~ ^content-length:[[:space:]]*([0-9]+) ]]; then
        CONTENT_LENGTH="${BASH_REMATCH[1]}"
    fi
done

BODY=""
if [ "$CONTENT_LENGTH" -gt 0 ]; then
    read -r -t $READ_TIMEOUT -N "$CONTENT_LENGTH" BODY || fail 2 "cannot read body"
fi

CONTAINER_PID=$(jq -r '.pid' <<<"$BODY")
CONTAINER_HOSTNAME=$(jq -r '.hostname // empty' <<<"$BODY")
RESOLV_CONF=$(jq -r '.resolvconf // empty' <<<"$BODY")

if [ -n "$CONTAINER_HOSTNAME" ]; then
    [[ "$CONTAINER_HOSTNAME" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,61}[A-Za-z0-9])?$ ]] || fail 2 "Bad hostname"
fi

if [ -w "$RESOLV_CONF" ]; then
    export RESOLV_CONF
fi

[[ "$CONTAINER_PID" =~ ^[0-9]{1,7}$ ]] || fail 1 "Invalid PID"

exec {pfd}< "/proc/$CONTAINER_PID" || fail 3 "no such pid"
exec {userfd}< "/proc/self/fd/$pfd/ns/user" || fail 4 "no userns"
exec {netfd}<  "/proc/self/fd/$pfd/ns/net" || fail 5 "no netns"
exec {pfd}<&-

ns() { nsenter --user="/proc/self/fd/$userfd" --net="/proc/self/fd/$netfd" \
               --preserve-credentials -- "$@" > /dev/null 2>&1; }

if ! ns ip link show eth0; then
    "$LXC_USER_NIC" create "" "" "$CONTAINER_PID" "$IFACE_TYPE" "$BRIDGE_NAME" eth0 > /dev/null \
        || fail 6 "lxc-user-nic failed"
    ns ip link set eth0 up || fail 7 "cannot up eth0"
fi

HOSTNAME_ARGS=()
if [ -n "$CONTAINER_HOSTNAME" ]; then
    HOSTNAME_ARGS=(-x "hostname:$CONTAINER_HOSTNAME")
fi

# If it’s a pod, we still run a DHCP client so that it can generate a `resolv.conf` file for each container.
# Once the address has been obtained for the first time, the process runs quickly.
ns busybox udhcpc -q -n -t 3 -T 3 -i eth0 -s "$DHCP_SCRIPT" "${HOSTNAME_ARGS[@]}" || fail 8 "DHCP failed"

succeed

Запуск

В хуке была настройка when, и чтобы хук сработал нужно передать соответствующую аннотацию. Также стоит отметить что так как podman запускается с опцией --network none, то не получится использовать опции вроде --dns, --add-host. И теперь строка запуска контейнера примет следующий вид:

podman run -it --rm --network none --annotation "netd.use=true" --entrypoint /bin/sh --hostname 'host1' alpine:latest

Чтобы запустить контейнер через systemd, нужно создать unit файл

mkdir -p ~/.config/containers/systemd

cat << EOF > ~/.config/containers/systemd/host2.container
[Unit]
Description=Banner host2

[Container]
Image=quay.io/libpod/banner
Network=none
Annotation="netd.use=true"
HostName=host2

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user start host2

Стоит отметить, что если нужно чтобы сервис мог работать независимо от сессии пользователя, то нужно выполнить команду loginctl enable-linger user.

Для запуска pod через systemd я буду использовать .kube файл и описание контейнеров в kubernetes yaml.

cat << EOF > ~/.config/containers/systemd/host3.kube
[Kube]
Yaml=/home/user/host3/host3.yml
Network=none
PodmanArgs=--annotation="netd.use=true"

[Service]
Restart=on-failure

[Install]
WantedBy=default.target
EOF
mkdir -p /home/user/host3

cat << EOF > /home/user/host3/host3.yml
apiVersion: v1
kind: Pod
metadata:
  name: host3
spec:
  hostname: host3
  containers:
  - name: banner1
    image: quay.io/libpod/banner
  - name: banner2
    image: quay.io/libpod/banner
    volumeMounts:
      - name: banner2-http-d
        mountPath: /etc/nginx/http.d
  volumes:
  - name: banner2-http-d
    hostPath:
      path: /home/user/host3/banner2-http-d
      type: Directory
EOF

Так как в pod контейнеры делят сетевой неймспейс, настроим nginx у banner2 на другой порт.

mkdir -p ~/host3/banner2-http-d

cat << EOF > ~/host3/banner2-http-d/default.conf
server {
    listen 81 default_server;
    listen [::]:81 default_server;
    
    # Everything is a 404
    location / {
        root /;
        index podman.txt;
    }
    
    # You may need this to prevent return 404 recursion.
    location = /404.html {
        internal;
    }
}
EOF

systemctl --user daemon-reload
systemctl --user start host3

Итоги

В результате rootless-контейнеры получили «честную» сеть. У каждого контейнера или пода свой IP-адрес из подсети за br0. Адрес выдаёт DHCP, а по имени контейнер доступен с хоста через dnsmasq. Порты пробрасывать не нужно, поэтому два контейнера могут слушать один и тот же порт (например, SIP 5060). Контейнеры по-прежнему запускаются без привилегий. Когда это подходит: тестовые стенды и лаборатории, а также рабочая станция, где нужно проверить сетевой сервис (VoIP, DNS, DHCP и т. п.), не поднимая виртуальные машины и не отдавая контейнерам root.

Ограничения

  • DHCP клиент получает адрес и выходит, поэтому аренда адреса не будет продлеваться и освобождаться. Для стендов, наверное это будет работать, если конечно вы не запускаете много контейнеров. В другом случае нужно это поправить.
  • Нестабильность ip адресов при перезапуске и отсутствие на текущий момент возможности установить автоматически статический ip.
  • --dns, --add-host и подобные опции не работают с --network none
  • IPv4
  • Ограничение количества veth интерфейсов в /etc/lxc/lxc-usernet

Что можно сделать еще

  • Подобрать образы, которые имеют DHCP клиент и просто запускать его. Это решит проблему с продлением аренды и чисткой адресов в пуле DHCP.
  • Добавить в бридж интерфейс локальной сети и получить контейнеры в локальной сети, которые могут спрашивать адреса у локального DHCP
  • Добавить возможность через аннотацию указать статический адрес или интерфейс бриджа, к которому подключать veth пару.
  • Создавать macvlan интерфейс вместо пары veth. Для этого нужно написать свой скрипт, который будет это делать.