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:

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:
- createconfig.py — tworzy plik settings.ini z ustawieniami komponentów w różnych środowiskach do późniejszego wdrożenia (Preproduction, Production, Testing, …)
- install_venv.sh — tworzy wirtualne środowisko dla komponentów py w określonym katalogu i kopiuje je na zdalne serwery
- prepare_init.d.py — przygotowuje skrypty startu i zatrzymania komponentów na podstawie szablonu
- 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

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 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.

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 .
Źródło: habr.com
