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.
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='add_key bpf keyctl' --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.
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.
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