Bezpieczne urządzenie w domyślnej konfiguracji to takie wyłączone urządzenie. A najlepiej także odłączone od prądu. Bezpieczna aplikacja to wyłączona aplikacja, a najlepiej odinstalowana. Tak, trochę za dużo chyba ostatnio z “bezpiecznikami” rozmawiam… Niemniej tych zasad nie uda nam się spełniać w systemach IT, musimy zatem zadbać o odpowiednie mechanizmy bezpieczeństwa. Nie inaczej jest z Dockerem. W poprzednim swoim artykule zaznaczałem, że Docker w swojej domyślnej instalacji może się co najwyżej nadawać do domowego odizolowanego laboratorium. Nawet w systemach testowych czy developerskich powinniśmy wdrożyć mechanizm zwany namespace. Jego aktywacja powoduje jednak problemy z dostępem do gniazda (socket), służącego do lokalnej komunikacji z demonem Dockera.
PID, użytkownik i namespace
Jedną z zalecanych zmian w celu poprawy bezpieczeństwa Dockera jest izolacja kontenerów w przestrzeni nazw użytkownika (namespace), która została wprowadzona w Docker Engine 1.10. Przestrzenie nazw sprawiają, że proces uruchamiany na hoście uważa, że ma własny dostęp do niektórych globalnych zasobów, takich jak PID. Przestrzenie nazw użytkowników zapewniają mechanizm odwzorowywania zasobów kontenera na zasoby hosta, ograniczając dostęp kontenera do systemu hosta.
W niektórych przypadkach proces działający w kontenerze może potrzebować bezpośredniego dostępu do zasobów hosta. Takim zasobem może być gniazdo procesu Dockera. Z taką sytuacją będziemy mieli miejsce, gdy uruchamiamy Docker wewnątrz kontenera Docker. Inny przykład to działające w kontenerach narzędzie, które będzie zarządzać naszymi hostami Dockera lub klastrem Docker Swarm. Na czym polega problem, gdy używamy namespace? Gniazdo w systemie hosta jest własnością użytkownika root, natomiast root PID z kontenera jest mapowany na PID użytkownika innego niż root na hoście. Procesy uruchomione w kontenerze działają tak, jakby były uruchomione z prawami root-a. Natomiast z punktu widzenia hosta, działają one z uprawnieniami dedykowanego, “zmapowanego” użytkownika o PID bez uprawnień root-a. Sprawia to, że gniazdo hosta jest niedostępne dla procesów wewnątrz kontenera z powodu braku odpowiednich uprawnień. Istnieje jednak łatwe obejście tego problemu.
Uprawnienia gniazda
Gniazdo (socket) w systemach rodziny Unix to specjalny moduł obsługi, który umożliwia aplikacjom komunikację. To swego rodzaju interfejs API. Aby uprościć, w tym przypadku pomyśl o tym, jak o połączeniach TCP, ale zamiast połączenia z IP i portem podłączasz się do gniazda na hoście. Aby jeszcze bardziej uprościć myślenie o gnieździe jako o pliku w systemie plików, z którego czytasz i zapisujesz sekwencje bajtów. Właścicielem gniazda jest proces jego tworzenia.
Jak już wspomniałem, korzystając z przestrzeni nazw użytkownika, mamy do czynienia z sytuacją, w której proces dockerd (silnik Docker) jest uruchamiany jako root, podczas gdy same kontenery używają PID innego niż root. W takim przypadku uprawnienia do gniazda Docker powinny tak wyglądać.
srw-rw---- 1 root docker 0 Jan 16 22:52 docker.sock
Proces działający w kontenerze z perspektywy hosta jest procesem działającym z PID innym niż root, jeśli korzystamy z przestrzeni nazw użytkownika.
231072 512 0.0 1.4 20888 13856 ? Ss Jan16 0:16 /usr/bin/python2 /usr/bin/supervisord
Pierwsza kolumna to identyfikator użytkownika – w moim systemie jest to 231072. Jeśli powiążemy gniazdo Dockera z kontenerem, nie będzie ono dostępne z powodu niedopasowania właściciela i uprawnień.
Narzędzie socat przychodzi z pomocą
Rozwiązaniem naszego problemu jest mała aplikacja – socat. Jest domyślnie dostępna w większości dystrybucji Linuksa. Tworzy one dwa dwukierunkowe strumienie bajtów, czyli potoki (pipelines) i przesyła dane między nimi. Punktami końcowymi tych potoków mogą być na przykład typ gniazda SOCK_STREAM. Drugim końcem może być też inne gniazdo, które ma przypisanego zupełnie innego właściciela.
srw-rw---- 1 root docker 0 Jan 16 22:52 docker.sock srw-rw---- 1 231072 231072 0 Jan 16 22:52 docker-userns.sock
To drugie gniazdo może być łatwo przypisane i dostępne dla procesów wewnątrz kontenera.
Uruchamiając socat, musimy określić oba punkty końcowe – najpierw nowy, a następnie istniejące gniazdo root.
# /usr/bin/socat UNIX-LISTEN:/var/run/docker-userns.sock,user=231072,group=231072,mode=0660,fork UNIX-CLIENT:/var/run/docker.sock
W ten prosty sposób omijamy ograniczenia nakładane przez przestrzenie użytkownika. U mnie socat uruchamiany jest jako usługa systemowa, gdy tylko Docker zostanie uruchomiony. Pamiętajmy jednak by nie wdrażać tej funkcjonalności na każdym hoście z Dockerem, jedynie tam, gdzie skonteneryzowana aplikacja tego wymaga.