При развертывании пары десятков контейнеров в Podman в rootless-режиме мы внезапно обнаружили, что они не могут одновременно использовать одни и те же порты, как это обычно работает при использовании Docker.

Решений несколько, но в рамках наложенных ограничений стандартные варианты не подходили. В итоге мы обратились к функционалу Podman Kube, который помог нам решить эту проблему. Подробнее о том, как это работает, и чем может быть полезно, ниже..

Как простая задачка с контейнерами внезапно стала непростой

У нас много заказчиков, которым мы оказываем услуги DevOps. В какой-то момент для одной поддерживаемой нами облачной платформы потребовалось внедрить стороннее приложение – продвинутый чат-бот. Приложение разрабатывалось внешней организацией и поставлялось «как есть» без доступа к исходному коду. Архитектурно оно представляло собой порядка двух десятков Docker-контейнеров, разворачивающихся в Docker с помощью compose-файла. Именно в таком виде код для развертывания и сам дистрибутив передаются для установки. Из базовых наработок по автоматизации, предоставляемых вендором –  только bash-скрипты для загрузки образов и docker-compose файл. 

В качестве ограничений – утвержденный заказчиком стек инструментов, где для контейнерной оркестрации следовало использовать Podman или Kubernetes.

Kubernetes мы откинули сразу, поскольку потребовалось бы очень много времени на адаптацию приложения, а его не было ни у нас, ни у заказчика, ни у вендора. Поэтому выбор сократился до безальтернативного Podman.

Казалось бы, в Podman мы точно так же, как и в Docker, можем поставить podman-compose, а при желании даже docker-compose, который будет работать с Podman и запускать все в исходном виде – в том, как это предоставляется вендором. Но нам мешало дополнительное строгое требование заказчика – использовать Podman только в rootless-режиме. То есть, для работы инженеру предоставляется заранее подготовленная виртуальная машина с Podman, и работать с ним можно только под непривилегированным пользователем.

Далее мы столкнулись с рядом  проблем.

Первая – в Podman иная реализация разрешения имен контейнеров. Суть в том, что при создании виртуальной сети для контейнеров DNS-резолвером выступает шлюз этой сети. Если создать другую сеть, то будет другой шлюз и, соответственно, другой адрес резолвера.

На что это влияет? Например, если у вас в конфигурации nginx присутствуют бэкенды, резолв имен которых опирается на имена контейнеров, то в директиве resolver следует менять адрес на шлюз вашей Podman-сети.

Например, ниже конфигурация nginx:

server {
  server_name _;
  listen 80 default_server;
  # optional SSL
  include ssl*.conf;
 …
  # global platform timeouts
…
  resolver <шлюз podman сети>;
  # variables for the the proxy_pass forcing NGINX to re‑resolve the domain name when its TTL expires
  set $front http://front.dns.podman:80;
 ...
 ...
}

В директиве resolver требуется указать адрес gateway, который мы получаем из команды podman network inspect <network>.Для docker это обычно 127.0.0.11).

Также при указании фронтов рекомендуется добавлять домен dns.podman, чтобы избежать проблем с разрешением имен при обращении друг к другу через nginx.

Важно помнить, что в случае, когда используется CNI для настройки сетевого окружения контейнеров, необходимо убедиться, что установлены все требуемые плагины. Например, плагин dnsname отвечает за работу DNS в виртуальной podman-сети. 

При этом зачастую из коробки этот плагин не подключен к default сети, и требуется создать новую podman сеть, чтобы в ней заработал dnsname.

Далее  начались проблемы, связанные непосредственно с rootless-режимом. 

Доступ к привилегированным портам в rootless-режиме

Публикация портов для контейнеров без root-прав возможна только для «высоких портов». Все порты ниже 1024 являются привилегированными и не могут быть использованы для публикации.

Обычно проблема решается разрешением на изменение непривилегированных портов с помощью команды sysctl net.ipv4.ip_unprivileged_port_start=80как это описано в решении от RedHat.

После этого, казалось бы, уже все должно было заработать, однако появилась другая проблема, которая заставила нас погрузиться глубже в теорию. При запуске контейнеров в непривилегированном режиме с помощью обычного compose они конфликтуют за одинаковые порты, которые используются сервисами, например порт 80.

Конфликт портов

Суть в том, что в привычном нам Docker контейнеры работают в режиме bridge networking. В нем каждый микросервис может сделать bind 0.0.0.0:80 внутри контейнера, и контейнер становится доступен в Docker-сети как <имя контейнера>:80. При этом “0.0.0.0” внутри контейнера – это ip-адрес <имя контейнера> (или имя сервиса) внутри виртуальной контейнерной подсети, то есть это не IP самого хоста. Следовательно, в Docker в привычном режиме bridge networking конфликта за порт 80 между контейнерами нет.

Но Podman использует другой подход. При использовании rootless-доступа настройка сети происходит автоматически с помощью режима сети slirp4netns, который создает изолированный сетевой стек, позволяющий подключаться к сети изнутри контейнера и привязывать определенные порты контейнера к тем портам, которые доступны для пользователя на самом хосте. Иными словами slirp4netns создает туннель от хоста в контейнер для пересылки трафика.

С помощью slirp4netns контейнеры полностью изолированы друг от друга. Виртуальной сети нет, поэтому для связи друг с другом контейнеры могут использовать проброс портов на хостовую систему - port mapping, и в этом месте как раз возникает проблема, что тот или иной порт уже занят первым стартовавшим контейнером. Или же их можно поместить в один Pod, где они будут использовать одно и то же сетевое пространство имен, где также будет конфликт за порты.

В описании от RedHat это объясняется следующим образом:

When using Podman as a rootless user, the network setup is automatic. Technically, the container itself does not have an IP address, because without root privileges, network device association cannot be achieved. If you're running Podman without root, it can only use the network mode slirp4netns, which will create an isolated network stack so that you can connect to the internet from the inside of your container and bind certain ports of your container to the user-bindable ports on you host, but nothing more. To be able to select the network mode bridged, which does exactly what I need, you'll have to run Podman as root.

Или тоже самое объяснение из документации Podman на GitHub:

One of the drawbacks of slirp4netns is that the containers are completely isolated from each other. Unlike the bridge approach, there is no virtual network. For containers to communicate with each other, they can use the port mappings with the host system, or they can be put into a Pod where they share the same network namespace.

Примерно так это можно проиллюстрировать схематически:

Небольшое отступление про slirp4nets.

Slirp4netns — это сетевой инструмент, используемый контейнерными средами исполнения для обеспечения сетевого подключения контейнеров без необходимости повышения привилегий.

Slirp — это, изначально, программа, эмулирующая подключение PPP, SLIP или CSLIP к Интернет с использованием учетной записи текстовой оболочки. Это уже давно legacy инструмент. Еще в 90х годах прошлого столетия студенты из США активно использовали slirp, чтобы серфить в сети через выдаваемые университетами dial-up shell-терминалы. И в сети до сих пор можно найти эти инструкции. Однако возможности slirp до сих пор активно используются как в QEMU, так и для сетевой работы контейнеров, особенно для непривилегированных сетевых пространств имен.

Но получается, что этот режим крайне неудобен в случае, когда разные контейнеры используют изнутри одни и те же порты.  Поскольку контейнеры работают в одном и том же пространстве имен сети, они делят один и тот же эмулированный сетевой стек, что приводит к конфликту портов внутри их изолированной сети.

А режим сети bridged доступен только при запуске Podman с root доступом.

Варианты решения ситуации с портами

Мы сформулировали четыре варианта решения проблемы:

  1. Заменить rootless Podman на rootless Docker, который не имеет таких ограничений. 

  2. Использовать Podman в привилегированном режиме.

  3. Изменить порты микросервисов, чтобы они отличались от порта 80.

  4. Обойти ограничение rootless Podman путем распределения контейнеров на:

  1. разные виртуальные машины

  2. разные виртуальные сети

  3. разные поды

Первые два варианта не проходили по административным ограничениям, установленным заказчиком. Третий вариант слишком ресурсозатратный и во многом бессмысленный. В итоге нужно было думать, как изолировать контейнеры, конкурирующие за 80-й порт. 

Разделение на разные виртуальные машины — слишком ресурсозатратно.

Разделение на сети вызывает сразу много вопросов, учитывая, что в slirp4netns режиме мы не оперируем сетями в принципе. Даже если их создать отдельно, то как будет работать связь между контейнерами в разных сетях, как будет работать разрешение имен, ведь получится, что на каждую сеть будет свой DNS resolver адрес?

И тут на помощь приходит такой функционал Podman, как Podman Kube.

Podman Kube теория

Про функцию Podman Kube статей мало, и описанных случаев применения на практике в реальных кейсах мне тоже не встретилось. И даже если спросить любую GPT-модель про основные команды Podman, в ответе не найдем ничего про Podman kube. Возможно, для большинства ситуаций эта возможность Podman выглядит избыточной, но нам она очень помогла.

Попробуем разобраться, как это работает.

В теории, когда мы создаем несколько подов, каждый под имеет свой собственный сетевой namespace. Это обеспечивает базовую изоляцию сетевого стека для каждого пода.

Важной особенностью оказалось то, что для коммуникации между подами при запуске через kube play даже в непривилегированном (rootless) режиме Podman подключает поды к bridged-сети. Эта сеть может быть создана заранее, либо Podman создает ее автоматически. Таким образом, если специально не использовать хостовую сеть (параметр network_mode:host), создается новый сетевой стек bridged, что делает возможным взаимодействие между подами.

При этом, хотя сеть и называется bridge, в rootless-режиме она реализуется как виртуальная сеть в пользовательском пространстве с помощью slirp4netns и CNI плагинов. Это позволяет создавать изолированные сетевые окружения для контейнеров без привилегий root, обеспечивая при этом функциональность, похожую на сетевой мост.​​​​​​​​ ​Таким образом, каждый под изолирован в своем сетевом namespace, но при этом все они подключены к одной виртуальной сети, обслуживаемой одним процессом slirp4netns, что позволяет использовать в разных подах одни и те же порты.​​​​​​​​

На узле мы можем это увидеть, например, с помощью определенных команд. Начнем с вывода списка сетевых namespaces:

# sudo lsns -t net 
        NS TYPE NPROCS   PID USER      NETNSID NSFS COMMAND
4026531840 net     290     1 root   unassigned      /sbin/init
4026532564 net       8 15259 podman unassigned      /catatonit -P
…
4026532635 net       1 15242 podman unassigned      /usr/sbin/dnsmasq -u root --conf-file=/run/user/915/containers/cni/dnsname/...

Здесь мы можем определить, что NS 15242 принадлежит процессу slirp4netns, в котором настраивается сетевое окружение с помощью CNI плагинов.

Смотрим, что делает сам slirp4netns:

# ps aux |grep slirp4netns 
podman   15173  0.1  0.1  37428 35512 ?        S    Jun11  76:02 /usr/bin/slirp4netns --disable-host-loopback --mtu=65520 --enable-sandbox --enable-seccomp --enable-ipv6 -c -r 3 --netns-type=path /run/user/915/netns/rootless-netns-5df09f647b449af857cc tap0

С помощью этой команды можно ходить по разным нейспейсам и смотреть сетевые настройки: # sudo nsenter -t <ns_pid> -n ip a

В namespaces, принадлежащих подам, мы увидим только lo и eth0 порты. В namespace, принадлежащему процессу slirp4netns, мы увидим tap0, cni-podman0 и veth пары с соответствующими link-netnsid. Также в namespace slirp4netns можно проверить правила iptables, созданные для реализации NAT.

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
   …
2: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc pfifo_fast state UNKNOWN group default qlen 1000
    ..
    inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0
    3: cni-podman0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    …
    inet 10.89.0.1/24 brd 10.89.0.255 scope global cni-podman0
       …
4: veth3c23371b@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni-podman0 state UP group default
    link/ether <mac_address> link-netnsid 0
   …
5: veth71b41f66@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni-podman0 state UP group default
    link/ether <mac_address> link-netnsid 1
    …

Процесс следующий:

  • при запуске пода slirp4netns создает виртуальный сетевой стек в пользовательском пространстве.

  • CNI управляет распределением IP-адресов для контейнеров в виртуальной сети и отвечает за маршрутизацию.

  • Внутри каждого сетевого namespace создается виртуальный eth0 интерфейс. Этот eth0 виртуально подключается к общей сети через veth-пару. А сама сеть представляет собой интерфейс cni-podman0 — виртуальный мост, созданный CNI. С точки зрения пода, он имеет прямое подключение к сети. Для связи с хостом slirp4netns использует tap0 интерфейс. Он также существует в контексте slirp4netns, а не в namespace подов.

  • Подам назначаются IP-адреса из диапазона, определенного для созданной bridge сети. Эти адреса видны только внутри виртуальной сети и недоступны напрямую с хоста. При этом поды могут общаться друг с другом через виртуальную сеть. Slirp4netns обеспечивает NAT для исходящего трафика от подов, но входящие соединения обычно требуют явного проброса портов. 

А чтобы под оставался «живым» при перезапуске или остановке содержащегося в нем контейнера, Podman использует так называемый infra-контейнер, основанный на образе pause, который не делает ровным счетом ничего. Его задача — резервировать и поддерживать в рабочем состоянии сам под и, соответственно, связанный с ним namespace на протяжении всего жизненного цикла всех входящих в него контейнеров.

Примерно так это можно проиллюстрировать схематически:

Таким образом эта конфигурация обеспечивает баланс между изоляцией (отдельные namespaces) и связностью (общая виртуальная сеть). Каждый под изолирован, но при этом все они подключены к одной виртуальной сети, обслуживаемой одним процессом slirp4netns.​​​​​​​​​​​​​​​​

В этой парадигме kube play предоставляет особые возможности, потому что он, вероятно, по задумке разработчиков RedHat, должен быть как можно ближе по поведению к Kubernetes.

Интересно, что упоминание об этом алгоритме работы Podman попадается в основном только в обсуждениях багов на github тут и тут.

Podman Kube практика

Теперь рассмотрим, как можно управлять этой конструкцией.

Во многом функционал Podman Kube можно описать как некий Kubernetes на минималках. Для запуска контейнеров используются манифесты в формате YAML, которые максимально приближены к YAML-манифестам K8s. В них есть возможность указать ConfigMap, Volumes, VolumeMounts и описать запускаемый контейнер.По аналогии с compose-файлом можно описывать несколько контейнеров и запускать их либо в одном поде, либо в разных.

Примеры манифестов:

1. Описание Пода

В нем мы по аналогии с K8s описываем apiVersion, kind, metadata и spec. В spec указываем описание контейнеров, вольюмов и параметров окружения.

apiVersion: v1
kind: Pod
metadata:
  labels:
    app: nats
  name: nats
spec:
  containers:
  - name: nats
    image: nats:2.9-alpine
    args:
    - --jetstream
    - --port
    - "4222"
    - --http_port
    - "8222"
    - --store_dir
    - /data
    volumeMounts:
    - mountPath: /data
      name: nats-data
  volumes:
  - hostPath:
      path: /home/podman/nats/data
      type: Directory
    name: nats-data

2. Описание Пода вместе с ConfigMap

Также по аналогии с K8s в манифесте для ConfigMap описываем apiVersion, kind, metadata и указываем наши переменные среды. Далее в описании пода ссылаемся на ConfigMap.

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
data:
  QUERY_PREFIX: /api/v1
  CRUD_PREFIX: /api/v1
  SSL_KEY_PATH: /ssl/tls.key
  SSL_CERT_PATH: /ssl/tls.crt
---
apiVersion: v1
kind: Pod
metadata:
  labels:
    app: nginx-router
  name: nginx-router
spec:
  containers:
  - name: nginx-router
    image: nginx-router:5.6
    args:
    - nginx
    - -g
    - daemon off;
    envFrom:
    - configMapRef:
        name: nginx-config
        optional: false
    ports:
    - containerPort: 80
      hostPort: 80
    - containerPort: 443
      hostPort: 443
    volumeMounts:
    - mountPath: /ssl/tls.crt
      name: https-crt
      subPath: /home/podman/cert/tls.crt
    - mountPath: /ssl/tls.key
      name: https-key
      subPath: /home/podman/cert/tls.key
    - mountPath: /var/cache/nginx
      name: nginx-cache
  volumes:
  - hostPath:
      path: /home/podman/cert/tls.crt
      type: File
    name: https-crt
  - hostPath:
      path: /home/podman/cert/tls.key
      type: File
    name: https-key
  - hostPath:
      path: /home/podman/nginx/nginx-cache
      type: Directory
    name: nginx-cache

Автоматическое создание YAML-манифестов

Чтобы использовать функционал Podman Kube, требуется писать YAML-манифесты с нуля, либо делать ручной рефакторинг compose-файлов в YAML-манифесты.

Хорошо, что Podman упрощает жизнь возможностью генерации YAML-манифестов с помощью команды podman kube generate. Условие одно: контейнеры должны быть запущены. Тут можно воспользоваться либо командами podman run, либо тем же compose-файлом. При этом не обязательно, чтобы контейнер работал корректно и без ошибок. Требуется только, чтобы Podman видел их запущенными.

Получив таким образом основное описание, мы можем его модифицировать. Например, удалить лишние или добавить требуемые ENV-переменные, скорректировать имена и параметры для volumes и volume-mounts. В общем, привести к виду, удовлетворяющему все ваши требования для корректного запуска.

Пример последовательности команд:

  1. Запуск контейнера: podman run -d -it imageID

  2. Смотрим id контейнера: podman ps

  3. Создаем манифест: podman kube generate ctrID

Прочие детали можно найти в документации RedHat.

Итак, мы получили желаемое — скомпоновали контейнеры в поды, точнее, распределили их по манифестам для запуска в изолированных подах. После подготовки манифестов используются команды podman kube play и podman kube down для запуска и остановки подов.

Например:

podman kube play pod.yaml --network mynet
podman kube down pod.yaml

Сразу стоит отметить, что в Podman нет аналогов ReplicationController или ReplicaSet. То есть мы, к сожалению, не можем управлять количеством запущенных инстансов нашего пода.

Работа с Podman secrets

Каждый раз при создании манифестов мы сталкиваемся с необходимостью передавать чувствительные данные, например пароли подключения к базе данных или элементы учетных записей. Работа с чувствительными данным в Podman Kube основывается на стандартной функции podman secret. В манифесте YAML для Podman Kube мы можем определить, какие переменные мы будем брать из созданных секретов.

К сожалению, на момент написания статьи не было возможности интегрировать создание «секрета» в общий манифест описания пода. С другой стороны, это позволяет использовать в разных подах одни и те же «секреты», что удобно, если сервисы обращаются к одним и тем же базам или IAM-провайдерам.

Например, мы можем создать «секрет», описав его в отдельном YAML-манифесте:

apiVersion: v1
data:
  password: base64encodedvalue
kind: Secret
metadata:
  creationTimestamp: null
  name: my-password

И выполнить команду podman kube play secret.yml.

Затем можем обратиться к нему в манифесте с помощью инструкции:

 env:
    - name: MY_PASSWORD
      valueFrom:
        secretKeyRef:
          name: my-password
          key: password

Подробнее это описано в документации RedHat.

Трудности доступа к логам подов

Отдельное внимание придется уделить правильным настройкам логирования. «Из коробки» мы получали ошибки при попытках выводить логи с помощью podman logs и флагом --follow.

В режиме rootless с драйвером journald у Podman возникают проблемы доступа к логам, и для решения этой проблемы требуется внести правки в локальный файл настройки .config/containers/containers.conf:

[containers]
log_driver = "k8s-file"

Больше деталей по этой теме есть в базе знаний RedHat.

Хранение данных и прочие настройки

И пару слов про хранение данных. В Podman на момент написания статьи поддерживается два вида volumes: persistentVolumeClaim и hostPath.

Задать тип persistentVolumeClaim можно непосредственно в основном YAML описания пода, например:

 apiVersion: v1
 kind: PersistentVolumeClaim
 metadata:
   name: example-pv-claim
   labels:
     app: example
 spec:
   accessModes:
   - ReadWriteOnce
   resources:
     requests:
       storage: 20Gi

И далее использовать его в самом поде по аналогии с манифестами в K8s:

 volumes:
   - name: example-persistent-storage
     persistentVolumeClaim:
       claimName: example-pv-claim

Надо заметить, что проект Podman развивается достаточно динамично, и первые версии полны всевозможных багов.

Например, чтобы Podman Kube мог использовать нативные podman secretes для интеграции их с ENV-переменными, требуется версия не ниже 4.4, в которой исправлен баг, не позволяющий правильно преобразовывать содержимое «секретов» в режиме play kube.

В версии Podman 4.4 таже есть возможность интегрировать управление подами в systemd с помощью Quadled.

Коротко о том, как это работает.

Мы можем создать манифест описания пода, как показано в примерах выше. И создать конфигурационный файл с расширением .kube, например example.kube, в котором укажем наш манифест:

[Install]
WantedBy=default.target
[Kube]
Yaml=example.yml

Дополнительно мы можем вносить директивы по аналогии с конфигурацией systemd сервисов, например, если требуется, чтобы под стартовал после другого пода:

[Unit]
Requires=first-pod.service
After=first-pod.service

Далее оба файла необходимо поместить в директорию ~/.config/containers/systemd/ и перезапустить systemd daemon: systemctl --user daemon-reload

Если мы описываем ConfigMap в отдельном файле, его можно указывать в блоке [Kube]: ConfigMap=example-configmap.yml

В таком случае этот файл также требуется поместить в ~/.config/containers/systemd/.

В блоке [Kube] также есть возможность указывать отдельно сеть и пробрасывать порты:

Network=example.networkPublishPort=8000:8080

В итоге мы можем запустить под с помощью systemctl следующим образом: systemctl --user start example.service

Подробнее это описано в документации RedHat.

Краткие итоги

Оказалось, что для работы в непривилегированном режиме, что всегда считалось одним из главных преимуществ, Podman предлагает пользователям уйти от парадигмы compose-файлов к использованию более современных и повсеместно используемых YAML-манифестов и их запуску через функционал “play kube”.

Это позволяет обходить ограничения непривилегированного сетевого стека slipr4netns. А с точки зрения разработки и дальнейшего развертывания приложений в Kubernetes такой подход выглядит даже более рациональным, так как разработчики могут с самого начала запускать и тестировать сервисы локально уже с помощью “K8s-ready” YAML-манифестов, делая процессы непрерывной интеграции и доставки более бесшовными.

Надеюсь, наш опыт послужит своеобразной подсказкой тем, кто окажется в подобной ситуации, и поможет увидеть большинство подводных камней, которые попадаются в процессе реализации данного подхода.

Комментарии (2)


  1. vanyas
    01.11.2024 09:59

    Так если вы в итоге написали уже ямлы для подов, секретов и т.д. то можно было это дело и в кубе уже запускать...


  1. GritsanY
    01.11.2024 09:59

    В Podman5 вместо slip4netns доступна pasta (passt). Я в подробности не вдавался, но у меня rootless-контейнеры в разных pod могут байндить порт 80