Wdrażanie aplikacji z pomocą Docker Swarm

Nasza internetowa rekomendacyjna system wideo, nad którym pracujemy, jest zamkniętym projektem komercyjnym i technicznie stanowi wielokomponentowy klaster z własnych oraz open source'owych komponentów. Celem napisania tego artykułu jest opisanie implementacji systemu klasteryzacji Docker Swarm dla środowiska stagingowego, nie naruszając ustalonego workflow naszych procesów w warunkach ograniczonego czasu. Przedstawiona narracja dzieli się na dwie części. Pierwsza część opisuje CI/CD przed zastosowaniem Docker Swarm, natomiast druga — proces jego wdrażania. Osoby, które nie są zainteresowane czytaniem pierwszej części, mogą śmiało przejść do drugiej.

Część I

W odległym roku potrzebne było jak najszybsze skonfigurowanie procesu CI/CD. Jednym z warunków było nieużywanie Dockera do wdrażania opracowywanych komponentów z kilku powodów:

  • aby komponenty w produkcji działały bezpieczniej i stabilniej (czyli w zasadzie wymaganie, aby nie stosować wirtualizacji)
  • główni programiści nie chcieli pracować z Dockerem (dziwne, ale tak właśnie było)
  • z powodów ideowych kierownictwa R&D

Infrastruktura, stos technologiczny i przybliżone wymagania początkowe dla MVP przedstawiały się następująco:

  • 4 serwery Intel® X5650 z Debianem (jedna mocniejsza maszyna całkowicie przeznaczona do programowania)
  • Rozwój własnych komponentów dostosowanych prowadzi się w C++, Python3
  • Najważniejsze zewnętrzne narzędzia: Kafka, Clickhouse, Airflow, Redis, Grafana, PostgreSQL, MySQL, …
  • Pipelines do budowy i testowania komponentów osobno dla debug i release

Jednym z pierwszych pytań, które należy rozwiązać na początkowym etapie, jest sposób wdrażania dostosowanych komponentów w danym środowisku (CI/CD).

Zewnętrzne komponenty postanowiono instalować i aktualizować systemowo. Natomiast aplikacje niestandardowe, rozwijane w C++ lub Pythonie, można wdrażać na kilka sposobów. Wśród nich jest na przykład: tworzenie pakietów systemowych, przesyłanie ich do repozytoriów zbudowanych obrazów, a następnie instalacja na serwerach. Z nieznanych powodów wybrano inny sposób, tzn. za pomocą CI kompilowane są pliki wykonywalne aplikacji, tworzone jest wirtualne środowisko projektu, instalowane są moduły py z requirements.txt, a wszystkie te artefakty są przesyłane razem z konfiguracjami, skryptami i towarzyszącym środowiskiem aplikacji na serwery. Następnie aplikacje uruchamiane są z konta wirtualnego użytkownika bez uprawnień administratora.

Jako system CI/CD wybrano Gitlab-CI. Ostateczny pipeline wyglądał mniej więcej tak:

Wdrażanie aplikacji z pomocą Docker Swarm
Struktura pliku gitlab-ci.yml wyglądała następująco

---
variables:
  # minimalna wersja CPU na serwerach, gdzie uruchamiany jest klaster
  CMAKE_CPUTYPE: "westmere"

  DEBIAN: "MYREGISTRY:5000/debian:latest"

before_script:
  - eval $(ssh-agent -s)
  - ssh-add  ~/.ssh/config

stages:
  - build
  - testing
  - deploy

debug.debian:
  stage: build
  image: $DEBIAN
  script:
    - cd builds/release && ./build.sh
    paths:
      - bin/
      - builds/release/bin/
    when: always
release.debian:
  stage: build
  image: $DEBIAN
  script:
    - cd builds/release && ./build.sh
    paths:
      - bin/
      - builds/release/bin/
    when: always

## etap testowania
tests.codestyle:
  stage: testing
  image: $DEBIAN
  dependencies:
    - release.debian
  script:
    - /bin/bash run_tests.sh -t codestyle -b "${CI_COMMIT_REF_NAME}_codestyle"
tests.debug.debian:
  stage: testing
  image: $DEBIAN
  dependencies:
    - debug.debian
  script:
    - /bin/bash run_tests.sh -e codestyle/test_pylint.py -b "${CI_COMMIT_REF_NAME}_debian_debug"
  artifacts:
    paths:
      - run_tests/username/
    when: always
    expire_in: 1 week
tests.release.debian:
  stage: testing
  image: $DEBIAN
  dependencies:
    - release.debian
  script:
    - /bin/bash run_tests.sh -e codestyle/test_pylint.py -b "${CI_COMMIT_REF_NAME}_debian_release"
  artifacts:
    paths:
      - run_tests/username/
    when: always
    expire_in: 1 week

## etap wdrażania
deploy_staging:
  stage: deploy
  environment: staging
  image: $DEBIAN
  dependencies:
    - release.debian
  script:
    - cd scripts/deploy/ &&
        python3 createconfig.py -s $CI_ENVIRONMENT_NAME &&
        /bin/bash install_venv.sh -d -r ../../requirements.txt &&
        python3 prepare_init.d.py &&
        python3 deploy.py -s $CI_ENVIRONMENT_NAME
  when: manual

Warto zauważyć, że budowa i testowanie odbywa się na własnym obrazie, na którym już zainstalowane są wszystkie niezbędne pakiety systemowe i wykonano inne ustawienia.

Chociaż każdy z tych skryptów w zadaniach jest interesujący na swój sposób, nie będę oczywiście opisywał każdego z nich, ponieważ zajmie to znaczną ilość czasu i nie jest celem tego artykułu. Zwracam tylko uwagę, że etap wdrożenia składa się z sekwencji wywołań skryptów:

  1. createconfig.py — tworzy plik settings.ini z ustawieniami komponentów w różnych środowiskach do późniejszego wdrożenia (Preproduction, Production, Testing, …)
  2. install_venv.sh — tworzy wirtualne środowisko dla komponentów py w określonym katalogu i kopiuje je na zdalne serwery
  3. prepare_init.d.py — przygotowuje skrypty startu i zatrzymania komponentów na podstawie szablonu
  4. deploy.py — rozmieszcza i ponownie uruchamia nowe komponenty

Czas mijał. Etap staging zastąpiono preproduction i production. Dodano wsparcie dla produktu w jeszcze jednej dystrybucji (CentOS). Przybyło także 5 potężnych serwerów fizycznych i kilka wirtualnych. A deweloperzy i testerzy mieli coraz większe trudności w testowaniu swoich zadań w środowisku bardziej zbliżonym do stanu operacyjnego. W tym momencie stało się jasne, że nie można się bez niego obejść...

Część II

Wdrażanie aplikacji z pomocą Docker Swarm

Nasz klaster to imponujący system składający się z kilku dziesięciu oddzielnych komponentów, które nie są opisane w plikach Dockerfile. Można go skonfigurować do wdrożenia w określonym środowisku tylko w całości. Naszym zadaniem jest wdrożenie klastra w środowisku staging w celu przetestowania go przed testami przed wydaniem.

Teoretycznie może być kilka jednocześnie działających klastrów: tyle, ile zadań w stanie zakończonym lub bliskim zakończenia. Możliwości serwerów, które mamy do dyspozycji, pozwalają na uruchomienie kilku klastrów na każdym serwerze. Każdy klaster staging powinien być izolowany (nie powinno być przecięć portów, katalogów itp.).

Najcenniejszym zasobem jest nasz czas, a mieliśmy go niewiele.

Aby szybciej zacząć, wybraliśmy Docker Swarm ze względu na jego prostotę i elastyczność architektury. Pierwszą rzeczą, którą zrobiliśmy, było utworzenie menedżera i kilku węzłów na zdalnych serwerach:

$ docker node ls
ID                            HOSTNAME            STATUS              AVAILABILITY        MANAGER STATUS      ENGINE VERSION
kilqc94pi2upzvabttikrfr5d     nop-test-1     Gotowy               Aktywny                                  19.03.2
jilwe56pl2zvabupryuosdj78     nop-test-2     Gotowy               Aktywny                                  19.03.2
j5a4yz1kr2xke6b1ohoqlnbq5 *   nop-test-3     Gotowy               Aktywny              Lider              19.03.2

Następnie utworzyliśmy sieć:


$ docker network create --driver overlay --subnet 10.10.10.0/24 nw_swarm

Dalej połączyliśmy Gitlab-CI i węzły Swarm w zakresie zdalnego zarządzania węzłami z CI: instalacja certyfikatów, konfiguracja zmiennych sekretów oraz konfiguracja usługi Docker na serwerze zarządzającym. Oto ta artykuł znacznie zaoszczędził nam czas.

Potem dodaliśmy joby do tworzenia i niszczenia stosu w .gitlab-ci .yml.

W .gitlab-ci .yml dodano jeszcze kilka jobów.

## staging stage
deploy_staging:
  stage: testing
  before_script:
    - echo "override global 'before_script'"
  image: "REGISTRY:5000/docker:latest"
  environment: staging
  dependencies: []
  variables:
    DOCKER_CERT_PATH: "/certs"
    DOCKER_HOST: tcp://10.50.173.107:2376
    DOCKER_TLS_VERIFY: 1
    CI_BIN_DEPENDENCIES_JOB: "release.centos.7"
  script:
    - mkdir -p $DOCKER_CERT_PATH
    - echo "$TLSCACERT" > $DOCKER_CERT_PATH/ca.pem
    - echo "$TLSCERT" > $DOCKER_CERT_PATH/cert.pem
    - echo "$TLSKEY" > $DOCKER_CERT_PATH/key.pem
    - docker stack deploy -c docker-compose.yml ${CI_ENVIRONMENT_NAME}_${CI_COMMIT_REF_NAME} --with-registry-auth
    - rm -rf $DOCKER_CERT_PATH
  when: manual

## stop staging stage
stop_staging:
  stage: testing
  before_script:
    - echo "override global 'before_script'"
  image: "REGISTRY:5000/docker:latest"
  environment: staging
  dependencies: []
  variables:
    DOCKER_CERT_PATH: "/certs"
    DOCKER_HOST: tcp://10.50.173.107:2376
    DOCKER_TLS_VERIFY: 1
  script:
    - mkdir -p $DOCKER_CERT_PATH
    - echo "$TLSCACERT" > $DOCKER_CERT_PATH/ca.pem
    - echo "$TLSCERT" > $DOCKER_CERT_PATH/cert.pem
    - echo "$TLSKEY" > $DOCKER_CERT_PATH/key.pem
    - docker stack rm ${CI_ENVIRONMENT_NAME}_${CI_COMMIT_REF_NAME}
    # TODO: need check that stopped
  when: manual

Z powyższego fragmentu kodu widać, że w Pipelines dodano dwa przyciski (deploy_staging, stop_staging), które wymagają ręcznego działania.

Wdrażanie aplikacji z pomocą Docker Swarm
Nazwa stosu odpowiada nazwie gałęzi i ta unikalność powinna być wystarczająca. Usługi w stosie otrzymują unikalne adresy IP, a porty, katalogi itp. będą izolowane, ale identyczne od stosu do stosu (ponieważ plik konfiguracyjny jest taki sam dla wszystkich stosów) — to, co chcieliśmy osiągnąć. Stos (klaster) rozwijamy za pomocą docker-compose.yml, w którym opisano nasz klaster.

docker-compose.yml

---
version: '3'

services:
  userprop:
    image: redis:alpine
    deploy:
      replicas: 1
      placement:
        constraints: [node.id == kilqc94pi2upzvabttikrfr5d]
      restart_policy:
        condition: none
    networks:
      nw_swarm:
  celery_bcd:
    image: redis:alpine
    deploy:
      replicas: 1
      placement:
        constraints: [node.id == kilqc94pi2upzvabttikrfr5d]
      restart_policy:
        condition: none
    networks:
      nw_swarm:

  schedulerdb:
    image: mariadb:latest
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
      MYSQL_DATABASE: schedulerdb
      MYSQL_USER: ****
      MYSQL_PASSWORD: ****
    command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci', '--explicit_defaults_for_timestamp=1']
    deploy:
      replicas: 1
      placement:
        constraints: [node.id == kilqc94pi2upzvabttikrfr5d]
      restart_policy:
        condition: none
    networks:
      nw_swarm:

  celerydb:
    image: mariadb:latest
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
      MYSQL_DATABASE: celerydb
      MYSQL_USER: ****
      MYSQL_PASSWORD: ****
    deploy:
      replicas: 1
      placement:
        constraints: [node.id == kilqc94pi2upzvabttikrfr5d]
      restart_policy:
        condition: none
    networks:
      nw_swarm:

  cluster:
    image: $CENTOS7
    environment:
      - CENTOS
      - CI_ENVIRONMENT_NAME
      - CI_API_V4_URL
      - CI_REPOSITORY_URL
      - CI_PROJECT_ID
      - CI_PROJECT_URL
      - CI_PROJECT_PATH
      - CI_PROJECT_NAME
      - CI_COMMIT_REF_NAME
      - CI_BIN_DEPENDENCIES_JOB
    command: >
      sudo -u myusername -H /bin/bash -c ". /etc/profile &&
        mkdir -p /storage1/$CI_COMMIT_REF_NAME/$CI_PROJECT_NAME &&
        cd /storage1/$CI_COMMIT_REF_NAME/$CI_PROJECT_NAME &&
            git clone -b $CI_COMMIT_REF_NAME $CI_REPOSITORY_URL . &&
            curl $CI_API_V4_URL/projects/$CI_PROJECT_ID/jobs/artifacts/$CI_COMMIT_REF_NAME/download?job=$CI_BIN_DEPENDENCIES_JOB -o artifacts.zip &&
            unzip artifacts.zip ;
        cd /storage1/$CI_COMMIT_REF_NAME/$CI_PROJECT_NAME/scripts/deploy/ &&
            python3 createconfig.py -s $CI_ENVIRONMENT_NAME &&
            /bin/bash install_venv.sh -d -r ../../requirements.txt &&
            python3 prepare_init.d.py &&
            python3 deploy.py -s $CI_ENVIRONMENT_NAME"
    deploy:
      replicas: 1
      placement:
        constraints: [node.id == kilqc94pi2upzvabttikrfr5d]
      restart_policy:
        condition: none
    tty: true
    stdin_open: true
    networks:
      nw_swarm:

networks:
  nw_swarm:
    external: true

Tutaj widać, że komponenty są połączone jedną siecią (nw_swarm) i są dostępne nawzajem.

Komponenty systemowe (oparte na redis, mysql) są oddzielone od ogólnego zbioru komponentów dostosowanych (w planach również podzielić je jako usługi). Faza wdrożenia naszego klastra wygląda jak przesyłanie CMD do naszego jednego, dużego skonfigurowanego obrazu i w zasadzie nie różni się od wdrożenia opisanego w Części I. Podkreślam różnice:

  • git clone … — otrzymujemy pliki potrzebne do przeprowadzenia wdrożenia (createconfig.py, install_venv.sh itd.)
  • curl… && unzip … — pobieramy i rozpakowujemy artefakty budowy (skompilowane narzędzia)

Został tylko jeden jeszcze nieopisany problem: komponenty, które mają interfejs webowy, nie są dostępne z przeglądarek deweloperów. Rozwiązujemy ten problem za pomocą reverse proxy w następujący sposób:

W .gitlab-ci.yml po wdrożeniu stosu klastra dodajemy linię wdrożenia load balancera (który przy commitach tylko aktualizuje swoją konfigurację (tworzy nowe pliki konfiguracyjne nginx według szablonu: /etc/nginx/conf.d/${CI_COMMIT_REF_NAME}.conf) — zob. kod docker-compose-nginx.yml)

    - docker stack deploy -c docker-compose-nginx.yml ${CI_ENVIRONMENT_NAME} --with-registry-auth

docker-compose-nginx.yml

---
version: '3'

services:
  nginx:
    image: nginx:latest
    environment:
      CI_COMMIT_REF_NAME: ${CI_COMMIT_REF_NAME}
      NGINX_CONFIG: |-
            server {
                listen 8080;
                server_name staging_${CI_COMMIT_REF_NAME}_cluster.dev;

                location / {
                    proxy_pass http://staging_${CI_COMMIT_REF_NAME}_cluster:8080;
                }
            }
            server {
                listen 5555;
                server_name staging_${CI_COMMIT_REF_NAME}_cluster.dev;

                location / {
                    proxy_pass http://staging_${CI_COMMIT_REF_NAME}_cluster:5555;
                }
            }
    volumes:
      - /tmp/staging/nginx:/etc/nginx/conf.d
    command:
      /bin/bash -c "echo -e "$$NGINX_CONFIG" > /etc/nginx/conf.d/${CI_COMMIT_REF_NAME}.conf;
        nginx -g "daemon off;";
        /etc/init.d/nginx reload"
    ports:
      - 8080:8080
      - 5555:5555
      - 3000:3000
      - 443:443
      - 80:80
    deploy:
      replicas: 1
      placement:
        constraints: [node.id == kilqc94pi2upzvabttikrfr5d]
      restart_policy:
        condition: none
    networks:
      nw_swarm:

networks:
  nw_swarm:
    external: true

Na komputerach deweloperów aktualizujemy /etc/hosts; wpisujemy adres URL do nginx:

10.50.173.106 staging_BRANCH-1831_cluster.dev

Zatem wdrożenie izolowanych klastrów stagingowych zostało zrealizowane, a deweloperzy mogą teraz uruchamiać je w dowolnej ilości, wystarczającej do testowania ich zadań.

Plany na przyszłość:

  • Podzielić nasze komponenty na usługi
  • Stworzyć dla każdego Dockerfile
  • Automatycznie identyfikować mniej obciążone węzły w stosie
  • Ustawianie węzłów według wzoru nazwy (a nie używanie identyfikatora jak w artykule)
  • Dodanie sprawdzenia, czy stos został zniszczony

Osobne podziękowania za artykuł.

Źródło: habr.com

Kup solidny hosting dla stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting dla stron z ochroną przed DDoS, serwery VPS VDS | ProHoster