Docker compose i proxy NGINX

W ciągu ostatnich kilku dni podjąłem się przeniesienia aplikacji portalu sędziów PLQ z hostingu sloppy.io na mój własny serwer ze względu na chęć obniżenia kosztów i zwiększenia dostępnych zasobów.

W tym celu musiałem się zapoznać z systemem docker-compose i skonfigurować reverse proxy, bo w przyszłości na tym serwerze usiądzie więcej moich projektów.

Postanowiłem stworzyć post, który będzie niejako bazą wiedzy do robienia podobnych konfiguracji w przyszłości.

TL:DR; Mam projekt z trzema usługami w kontenerach, z których aplikacja WWW jest połączona ze światem zewnętrznym przy pomocy reverse proxy NGINX, a do tego całość działa z HTTPS i certyfikatem od Let’s Encrypt.

Co to docker

Docker jest systemem zarządzania kontenerami, które same w sobie są zapewniane przez jądro systemu operacyjnego. Obecnie zarówno Linux jak i Windows pozwalają na tworzenie kontenerów.

Aplikacja żyjąca w kontenerze to taka aplikacja, która działa na tym samym jądrze - czyli sercu naszego systemu, co nasz główny system i aplikacje, ale ma specyficznie dobrany system plików - organy, który jest z nią związany - tworzą razem obraz. Ten obraz możemy przenosić między komputerami i na każdym z nich aplikacja powinna działać tak samo. Jądro zapewnia izolację kontenera i musimy użyć dodatkowych mechanizmów, aby móc komunikować się między tymi izolowanymi środowiskami.

Docker jest jednym z tych mechanizmów - pozwala na tworzenie obrazów i kontenerów, tworzenie sieci, którymi będą one połączone, i wykonywanie poleceń wewnątrz kontenera.

Instalacja (Ubuntu)

Poniżej krótki skrypt instalacji na Ubuntu (lub pochodnym Linux Mint). W celu instalacji na innej dystrybucji należy odnieść się do instrukcji z dokumentacji.

# instalacja zależności
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common

# dodanie repozytorium dockera
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - sudo apt-key fingerprint 0EBFCD88
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

# instalacja dockera
sudo apt-get update
sudo apt-get install docker-ce

Następnie będziemy chcieli dodać się do grupy docker aby nie musieć uruchamiać poleceń dockera jako root.

sudo usermod -a -G docker <user>

Używanie dockera

W tym poście nie będę pisał o tym jak używać dockera. Napisałem o tym kiedyś post, przy czym obecnie część jego zawartości się przeterminowała. Jedyne polecenie którego czasem użyłem to

docker inspect <container>

które dostarcza nam szczegółowych informacji, o uruchomionym kontenerze (m.in. adres IP).

Docker compose - określa ustawienia dockera

System dockera jest spoko i bardzo łatwo się go używa do jednorazowego uruchomienia jednego obrazu. Ale czasem tworzymy aplikacje, które są bardziej zaawansowane. Wtedy chcemy mieć prosty system konfiguracji wielu kontenerów.

I to nam właśnie dostarcza docker-compose. Zapisujemy konfigurację systemu w pliku YAML, wywołujemy jedno polecenie i system tworzy wszystko co potrzebujemy za nas.

Instalacja

Musimy mieć zainstalowany docker.

sudo curl -L "https://github.com/docker/compose/releases/download/1.22.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

Podejście projektowe

Możemy podzielić wszystkie kontenery w naszym systemie na projekty.

Np. portal sędziów PLQ to projekt o nazwie plqref, który zawiera w sobie trzy usługi:

  • baza danych MySQL
  • aplikacja ASP.NET Core
  • phpmyadmin - do zarządzania danymi bazy MySQL

Każdy projekt ma swój oddzielny plik docker-compose.yml, który określa jego konfigurację.

Podział aplikacji na usługi

Moglibyśmy całą naszą aplikację, wraz z bazą danych i innymi potrzebnymi elementami umieścić w jednym kontenerze. Ale wtedy przy dowolnej aktualizacji jednej z usług musimy zatrzymywać cały system, robić przebudowę obrazu, co jest czasochłonne.

Dlatego dzielimy ją na usługi, które komunikują się przez sieć.

Co siedzi w pliku docker-compose.yml?

Polecam zapoznać się z dokumentację tego pliku, ale wytłumaczenie na przykładzie na pewno pomoże.

# wersja opisuje schemat konfiguracji
version: '3'
services:
   # na tym poziomie wymyślamy nazwę usługi
   database:
      # moje usługi bazują na gotowych obrazach
      # ale mogą też na bieżąco być budowane
      image: mysql:5.6

      # exponujemy port do innych kontenerów w sieci
      # ale nie dla hosta
      expose: ["3306"]

      # ustalamy zmienne środowiskowe
      environment:
              MYSQL_ROOT_PASSWORD: "root"

      # podpinamy <folder hosta>:<folder kontenera>
      # żeby po usunięciu kontenera dane zostały zachowane
      volumes:
         - ./volumes/database:/var/lib/mysql

      # określamy do jakich sieci należy kontener
      networks:
         - plqlocal
   dbadmin:
      image: phpmyadmin/phpmyadmin:latest

      # udostępniamy port z wewnątrz kontenera do hosta
      ports: ["300:80"] # host:container

      # inny sposób zapisu zmiennych środowiskowych
      environment:
         - PMA_HOST=mysql
         - MYSQL_ROOT_PASSWORD=root

      # nadajemy alias sieciowy usłudze
      links:
         - database:mysql  # service:alias
      networks:
         - plqlocal
   app:
     image: manio143/plqref:latest
     ports: ["500:443"]

     # żeby podpiąć pojedynczy plik do naszego systemu
     # musi on mieć absolutną ścieżkę
     volumes:
        - ${PWD}/volumes/app/config.json:/var/app/appsettings.json

     links:
        - database:mysql
     networks:
        - proxy_global
        - plqlocal
# definiujemy sieci dla projektu
networks:
   # ta sieć odwołuje się do zewnętrznego projektu
   # projekt proxy <- sieć o nazwie global
   proxy_global:
     external: true
   plqlocal:

Łączność między usługami

Usługi wewnątrz jednego projektu są łączone przy użyciu domyślnie tworzonej sieci, jeśli sami jej nie zadeklarujemy.

Możemy tworzyć dowolnie wiele aliasów dyrektywą links:, aby ułatwić sobie konfigurację. Dodatkowo jeśli mamy zewnętrzną sieć to możemy też użyć dyrektywy external_links: i wskazać konkretny kontener (np. plqref_app_1, czyli <project>_<service>_<instance>).

Musimy pamiętać, aby upublicznić odpowiednie porty, których używają nasze aplikacje.

Bramka na świat - reverse proxy

Więc mam teraz kilka projektów, z których część będzie potrzebowała dostępu z zewnątrz. Mógłbym upublicznić je na różnych portach, ale to nie jest wygodne. Wobec tego chcę postawić proxy przed aplikacjami, które będzie działało na publicznych portach http:80 i https:443, a następnie przekierowywało lokalnie żądania do odpowiednich aplikacji.

Czym te żądania się będą różnić? Parametrem Host protokołu HTTP, czyli innymi słowy domeną.

Więc utworzyłem nowy projekt docker-compose, w którym uruchomię usługę NGINX

version: '3'
services:
   nginx:
      image: nginx
      volumes:
        # folder na pliki konfiguracji
        - ./conf:/etc/nginx
        # folder na certyfikaty SSL
        - /etc/letsencrypt/:/etc/certs
      ports:
        - "80:80"
        - "443:443"
      external_links:
        - plqref_app_1:plqref
      networks:
        - global
networks:
   global:

Widzimy zadeklarowaną sieć global, do której należy moja aplikacja w projekcie plqref. Dzięki temu jestem w stanie się z nią przez to proxy komunikować.

Konfiguracja NGINX

W folderze conf, który mapuje się na folder /etc/nginx tworzę plik nginx.conf

worker_processes 1;
events { worker_connections 1024; }

http {
      # określamy serwery zewnętrzne
    upstream plqref {
        server plqref:443;
    }
      # port 80 przekierowywuje na HTTPS
    server {
        listen         80;
        return 301 https://$host$request_uri;
    }
      # robimy po jednym wpisie server na aplikację
    server {
          # nasłuchujemy na porcie 443, używając ssl i jeśli się da to http2
        listen 443 ssl http2;
          # domena dla tej aplikacji
        server_name ref.polskaligaquidditcha.pl;
          # certyfikaty
        ssl_certificate /path/to/fullchain.pem;
        ssl_certificate_key /path/to/privkey.pem;
          # nie weryfikuj certyfikatu aplikacji (może być self signed)
        ssl_verify_client off;
        location / {
            proxy_pass         https://plqref;  # tu podajemy nazwę upstream
            proxy_set_header   Host $host;      # przekaż informację o domenie
              # oraz dodaj informacje o żądaniu
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }
    }
}

Let’s Encrypt

Żeby nasza strona była bezpieczna przed wieloma różnymi atakami, musimy mieć włączone HTTPS. Ale żeby to działało to potrzebny nam jest certyfikat, który przeglądarka zaakceptuje.

Na szczęście za nami już czasy, kiedy za certyfikat trzeba płacić, a jego instalacja jest skomplikowana.

Let’s Encrypt pozwala jednym poleceniem zdobyć certyfikat, a także utworzyć zadanie automatycznego odnawiania certyfikatu, który ważny jest przez 3 miesiące.

Instalacja

Let’s Encrypt używa pythona i virtualenv, więc jeśli możliwe, że będziemy musieli dopisać universe do naszych źródeł apt-get, co zostało opisane tutaj.

Sprawdzimy czy nasza domena wskazuje na nasz serwer (musimy zedytować wpis DNS i włączyć przekierowanie portów jeśli serwer stoi za routerem), bo Let’s Encrypt wykonuje walidację czy my posiadamy domenę dla której chcemy dostać certyfikat.

Należy się jeszcze upewnić, że port 80 jest wolny (zatrzymać nasze proxy).

Następnie wykonamy

git clone https://github.com/letsencrypt/letsencrypt
cd letsencrypt
./letsencrypt-auto certonly --standalone --email <email> -d <domena>

Zostaną zainstalowane potrzebne pakiety i zainstalowany w systemie certyfikat.

Podpinamy certyfikat

Zostało nam wtedy wpisanie właściwej ścieżki do konfiguracji NGINX, dlatego w jego docker-compose.yml jest dodany wolumen /etc/letsencrypt, gdzie w folderze live/<domena> znajduje się certyfikat.