Incepcja Kontenerów: Docker w Kontenerze nspawn

W poprzednim artykule wprowadziłem czytelników w świat kontenerów nspawn. Ten artykuł będzie rozwinięciem poprzedniego dotyczącego wprowadzenia do nspawn i skupi się na bardziej zaawansowanym temacie - uruchomieniu daemona Dockera w kontenerze nspawn. Opiszę, jak przygotować środowisko oraz zainstalować Dockera w izolowanym środowisku. Przedstawiony przykład pochodzi z mojego projektu, gdzie konfiguracja obejmowała Jenkinsa odpowiedzialnego za budowanie obrazów dockerowych i uruchamianie aplikacji za pomocą docker-compose. Problemy związane z bezpieczeństwem zmusiły mnie do poszukiwania alternatywnych rozwiązań, takich jak użycie kontenera nspawn.

Incepcja Kontenerów: Docker w Kontenerze nspawn

W poprzednim artykule wprowadziłem czytelników w świat kontenerów nspawn. W tej części chciałbym rozwinąć ten temat, prezentując, jak można uruchomić daemona Dockera w kontenerze nspawn, na przykładzie specyficznej konfiguracji. Może wydawać się to nietypowe, jednak w jednym z projektów stanęłem przed zadaniem, które wymagało takiego rozwiązania. Na serwerze zainstalowany był Jenkins, odpowiedzialny za budowanie obrazów dockerowych i uruchamianie aplikacji za pomocą docker-compose. Zarówno Jenkins, jak i Docker działały na tej samej instancji maszyny, co niosło ryzyko kompromitacji systemu, ponieważ użytkownik mógł manipulować zawartością pliku docker-compose.yaml. Aby ograniczyć potencjalne straty w przypadku ataku, jednym z rozwiązań, które rozważałem, było użycie chroota (dzięki za sugestię, Jakub!).

Jednakże chroot nie umożliwia uruchamiania nowych usług systemowych i nie jest uznawany za solidne rozwiązanie bezpieczeństwa. Rozwiązaniem okazał się kontener nspawn, który pozwolił mi odseparować oba serwisy: Jenkins został umieszczony w jednym kontenerze, a dostęp deweloperski oraz Docker w innym. Uruchomienie Jenkinsa w kontenerze nspawn jest proste i nie różni się od jego uruchomienia w standardowym systemie operacyjnym. Natomiast uruchomienie demona Dockera wymaga dodatkowych kroków, które postanowiłem szczegółowo opisać w tym artykule.

Na początku należy przygotować środowisko i obraz kontenera nspawn, który zamierzamy użyć. Przypomnijmy, że pierwszym krokiem jest instalacja niezbędnych zależności systemowych:

sudo apt install debootstrap systemd-container
sudo mkdir -p /var/lib/machines/

Kolejnym etapem jest przygotowanie bazowego obrazu kontenera, w którym uruchomiony zostanie daemon Dockera:

sudo debootstrap jammy /var/lib/machines/docker-container

Następnym bardzo ważnym etapem w procesie przygotowania bazowego obrazu kontenera jest ustawienie hasła dla użytkownika root w kontenerze nspawn. To można zrobić, uruchamiając kontener tymczasowo za pomocą poniższych komend:

sudo systemd-nspawn -D  /var/lib/machines/docker-container
passwd

W porównaniu do polecenia opisanego w poprzednim artykule, służącego do uruchomienia kontenera, tym razem musimy dodać flagę --system-call-filter z parametrami add_key, bpf i keyctl. Ta konfiguracja udostępnia kontenerowi wywołania systemowe add_key, bpf oraz keyctl.

Dodanie tej konfiguracji może nadal stanowić ryzyko bezpieczeństwa, chociaż jest ono znacznie mniejsze niż w przypadku całkowitego wyłączenia przestrzeni nazw użytkowników, jak to było konieczne przed wprowadzeniem cgroups v2. Dodatkowo do polecenia dodałem flagę --network-veth, która wymusza na kontenerze korzystanie z sieci wirtualnej.

sudo systemd-nspawn --system-call-filter='@keyring bpf' --network-veth -b -D /var/lib/machines/docker-container

Teraz instalacja Dockera wewnątrz kontenera nspawn przebiega tak samo, jak na rzeczywistym systemie. Jedyną różnicą jest konieczność uruchomienia na początku usługi systemowej odpowiedzialnej za sieć wirtualną.

systemctl enable --now systemd-networkd systemd-resolved

apt-get install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

apt-get update

apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Na rysunku nr 1 przedstawiam prosty test z uruchomienia polecenia docker run hello-world w kontenerze nspawn.

Rys. 1. Działający Docker w kontenerze nspawn.
Rys. 1. Działający Docker w kontenerze nspawn.

Kolejnym problemem, który napotkałem, było uruchomienie kontenera jako usługi systemowej. W poprzednim artykule wykorzystaliśmy metodę za pomocą systemd:

sudo systemctl enable --now [email protected]

Ponieważ nasz kontener wymaga specyficznej konfiguracji, powyższe polecenie uruchomi kontener, ale usługa wewnętrzna, taka jak Docker, przestanie działać. Aby rozwiązać ten problem, skopiowałem plik [email protected] i zmodyfikowałem jego konfigurację:

sudo cp /usr/lib/systemd/system/[email protected] /usr/lib/systemd/system/docker-container.service

W nim zamieniłem linię:

ExecStart=systemd-nspawn --quiet --keep-unit --boot --link-journal=try-guest --network-veth -U --settings=override --machine=%i

na:

ExecStart=systemd-nspawn --quiet --keep-unit --boot --link-journal=try-guest --network-veth --system-call-filter='add_key bpf keyctl' --machine=docker-container

W powyższej linii dodałem wcześniej wspomnianą flagę --system-call-filter oraz usunąłem flagę -U, zgodnie z opisem tego błędu. Następnie wykonałem polecenia:

systemctl daemon-reload
systemctl start docker-container

Ostatnią kwestią, którą chciałbym poruszyć w tym artykule, jest konfiguracja reverse proxy, które pozwala na udostępnienie usługi Docker w sieci lokalnej. Sama konfiguracja jest bardzo prosta i sprawdza się do wykorzystania nazwy kontenera docker-contener jako nazwy. Dzięki temu, że nspawn świetnie integruje się z systemem, rozwiązywanie nazw odbywa się za pomocą nazw kontenerów. Na rysunku nr 2 przedstawiony jest przykład wykorzystania polecenia ping.

Rys. 2. Wykorzystanie polecenia ping do rozwiązania adresu kontenera docker-container.
Rys 2. Wykorzystanie polecenia ping do rozwiązania adresu kontenera docker-container.

Najpierw przygotujmy usługę wewnątrz naszego kontenera nspawn:

echo “Hello this is nginx from docker in nspawn container” > index.html
docker run -v /root:/usr/share/nginx/html:ro -p 8080:80 nginx:stable-alpine-slim

Kiedy mamy już skonfigurowaną naszą usługę Docker, na systemie gospodarza instalujemy pakiet nginx za pomocą polecenia:

sudo apt install nginx

oraz modyfikujemy plik /etc/nginx/sites-available/default na:

server {
    listen [::]:80;
    listen 80;

    server_name _;

    location ~ (/.*) {
        proxy_pass http://docker-container:80;
    }
}

Na końcu wykonujemy restart usługi nginx:

sudo systemctl restart nginx

Bibliografia

  1. https://www.docker.com/
  2. https://wiki.archlinux.org/title/systemd-nspawn
  3. https://mwalkowski.com/post/introduction-to-systemd-nspawn-containers-chroot-on-steroids/