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. Для этого нужно написать свой скрипт, который будет это делать.