Online'owy system rekomendacji treści wideo, nad którym pracujemy, jest zamkniętym projektem komercyjnym i technicznie stanowi wieloskładnikowy klaster z własnych i komponentów typu open source. Celem napisania tego artykułu jest opis wdrożenia systemu klastrowania docker swarm na etapie staging, nie naruszając ustalonego workflow naszych procesów w warunkach ograniczonego czasu. Przedstawiona narracja podzielona jest na dwie części. Pierwsza część opisuje CI/CD przed użyciem docker swarm, a druga — proces jego wdrożenia. Kto nie jest zainteresowany czytaniem pierwszej części, może przejść od razu do drugiej.
Część I
W odległym roku wymagano jak najszybszego skonfigurowania procesu CI/CD. Jednym z warunków było nieużywanie Dockera do wdrożenia opracowywanych komponentów z kilku powodów:
- dla bardziej niezawodnej i stabilnej pracy komponentów w produkcji (tj. w zasadzie wymóg nieużywania wirtualizacji)
- główni programiści nie chcieli pracować z Dockerem (dziwne, ale tak było)
- z powodów ideologicznych kierownictwa R&D
Infrastruktura, stos i wstępne wymagania dla MVP przedstawiały się następująco:
- 4 serwery Intel® X5650 z Debianem (jedna bardziej wydajna maszyna całkowicie pod rozwój)
- Rozwój własnych dostosowanych komponentów odbywa się w C++, Python3
- Główne wykorzystywane narzędzia 3rdparty: Kafka, Clickhouse, Airflow, Redis, Grafana, Postgresql, Mysql, …
- Pipelines budowy i testowania komponentów osobno dla debug i release
Jednym z pierwszych pytań, które należy rozwiązać na wczesnym etapie, jest sposób, w jaki będą wdrażane dostosowane komponenty w jakimś środowisku (CI/CD).
Zewnętrzne komponenty postanowiono instalować i aktualizować systemowo. Aplikacje niestandardowe, rozwijane w C++ lub Pythonie, można wdrażać na kilka sposobów. Wśród nich, na przykład: tworzenie pakietów systemowych, wysyłanie ich do repozytoriów zbudowanych obrazów i ich późniejsza instalacja na serwerach. Z nieznanych już powodów wybrano inny sposób, a mianowicie: za pomocą CI kompilowane są pliki wykonywalne aplikacji, tworzona jest wirtualna przestrzeń robocza projektu, instalowane są moduły py z pliku requirements.txt, a wszystkie te artefakty są wysyłane razem z konfiguracjami, skryptami i towarzyszącym środowiskiem aplikacji na serwery. Następnie uruchamiane są aplikacje z konta wirtualnego użytkownika bez uprawnień administratora.
Jako system CI/CD wybrano Gitlab-CI. Otrzymany pipeline wyglądał mniej więcej tak:

Strukturalnie gitlab-ci.yml wyglądał następująco
---
variables:
# minimalna wersja CPU na serwerach, na których wdrażany 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
## testing stage
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
## staging stage
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 kompilacja i testowanie odbywa się na własnym obrazie, na którym są już zainstalowane wszystkie niezbędne pakiety systemowe i dokonane inne konfiguracje.
Chociaż każdy z tych skryptów w zadaniach jest interesujący na swój sposób, nie będę o nich opowiadać, ponieważ opis każdego z nich zajmie znaczną ilość czasu, a to nie jest celem artykułu. Zwrócę jedynie uwagę, że etap wdrażania składa się z sekwencji wywoływania skryptów:
- createconfig.py — tworzy plik settings.ini z konfiguracjami 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 uruchamiania i zatrzymywania komponentów na podstawie szablonu
- deploy.py — rozkłada i restartuje nowe komponenty
Czas mijał. Etap staging został zastąpiony preproduction i production. Dodano wsparcie produktu na jeszcze jednym dystrybucie (CentOS). Dodano pięć potężnych fizycznych serwerów i kilkanaście wirtualnych. Deweloperom i testerom coraz trudniej było testować swoje zadania w środowisku bardziej zbliżonym do stanu roboczego. W tym czasie stało się jasne, że nie można się bez niego obejść…
Część II

Nasz klaster to spektakl, system składający się z kilku dziesiątek oddzielnych komponentów, które nie są opisane w Dockerfile. Skonfigurowanie go do wdrożenia w określonym środowisku można zrobić tylko w całości. Naszym zadaniem jest wdrożenie klastra w środowisku staging, aby przetestować go przed testowaniem przedpremierowym.
Teoretycznie może być kilka równocześnie działających klastrów: tyle, ile zadań jest w zakończonym stanie lub bliskim zakończeniu. Moce dostępnych nam serwerów pozwalają na uruchamianie kilku klastrów na każdym serwerze. Każdy klaster staging powinien być izolowany (nie może być nakładania się portów, katalogów itp.).
Najcenniejszym zasobem jest nasz czas, a mieliśmy go niewiele.
Aby szybciej wystartować, wybraliśmy Docker Swarm ze względu na jego prostotę i elastyczność architektury. Pierwszą rzeczą, którą zrobiliśmy, to stworzenie na zdalnych serwerach menedżera i kilku węzłów:
$ 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
Następnie połączyliśmy Gitlab-CI i węzły Swarm w zakresie zdalnego zarządzania węzłami z CI: instalacja certyfikatów, ustawienia zmiennych sekretów oraz konfiguracja usługi Docker na serwerze zarządzającym. To bardzo zaoszczędziło nam czas.
Następnie 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ęcznej interwencji.

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ą odizolowane, ale identyczne od stosu do stosu (ponieważ plik konfiguracyjny jest taki sam dla wszystkich stosów) — co chcieliśmy osiągnąć. Stos (klaster) wdrażamy 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
Tu można zobaczyć, że komponenty są połączone jedną siecią (nw_swarm) i są dla siebie dostępne.
Komponenty systemowe (oparte na redis, mysql) są oddzielone od ogólnej puli komponentów dostosowanych (plany obejmują również podział na usługi). Etap wdrażania naszego klastra wygląda jak przekazywanie CMD do naszego jednego dużego skonfigurowanego obrazu i praktycznie nie różni się od opisanego w Części I. Podkreślam różnice:
- git clone … — otrzymujemy pliki niezbędne do przeprowadzenia wdrożenia (createconfig.py, install_venv.sh itp.)
- curl… && unzip … — pobieramy i rozpakowujemy artefakty kompilacji (skompilowane narzędzia)
Pozostał tylko jeden nieopisany problem: komponenty, które mają interfejs internetowy, nie są dostępne z przeglądarek programistów. Rozwiązujemy ten problem za pomocą reverse proxy, w ten sposób:
W .gitlab-ci.yml po wdrożeniu stosu klastra dodajemy linię wdrożenia load balancera (który przy commitach, jedynie 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 programistów aktualizujemy /etc/hosts; wpisujemy URL do nginx:
10.50.173.106 staging_BRANCH-1831_cluster.dev
Zatem wdrożenie izolowanych klastrów stagingowych zostało zrealizowane i programiści mogą teraz uruchamiać je w dowolnej liczbie wystarczającej do testowania ich zadań.
Kolejne plany:
- Podzielić nasze komponenty na usługi
- Utworzyć dla każdego Dockerfile
- Automatycznie określać mniej obciążone węzły w stosie
- Przypisywać węzły według wzoru nazwy (a nie używać id jak w artykule)
- Dodać sprawdzenie, że stos został zniszczony
- …
Osobne podziękowania za .
Źródło: habr.com
