GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

Тази статия ще бъде интересна както на тестьорите, така и на разработчиците, но е насочена най-вече към автоматизаторите, които са се сблъскали с проблема за настройка на GitLab CI/CD за извършване на интеграционно тестване при недостатъчни инфраструктурни ресурси и/или отсъствие на платформа за оркестрация на контейнери. Ще обясня как да настроите разгръщането на тестваните среди с помощта на docker compose на един единствен GitLab shell ранър, така че разгръщаните услуги да не си пречат помежду си.


Съдържание

Предпоследите

  1. В моята практика често се налагаше да "ремонтирам" интеграционното тестване по проекти. И често първата и най-съществена проблем е CI pipeline, в който интеграционното тестване на разработвания сервис(и) се провежда в dev/stage среда. Това създава не малко проблеми:

    • Заради дефекти в някой от сървисите в процеса на интеграционно тестване тестовият контур може да бъде развален от повредени данни. Имало е случаи, когато изпращането на заявка с повреждан JSON формат е спирало сървиса, което води до пълно неработоспособно състояние на стенда.
    • Забавяне на работата на тестовия контур с нарастването на тестовите данни. Мисля, че не е необходимо да описвам пример с почистване/връщане на база данни.
    • Риск от нарушаване работоспособността на тестовия контур при тестване на общите настройки на системата. Например, user/group/password/application policy.
    • Тестовите данни от автоматичните тестове пречат на ръчните тестировачи.

    Някой ще каже, че добрите автотестове трябва да почистват данните след себе си. Имам аргументи против:

    • Динамичните стендове са изключително удобни за използване.
    • Не всеки обект може да бъде изтрит от системата чрез API. Например, извикването за изтриване на обект не е реализирано, тъй като противоречи на бизнес логиката.
    • При създаване на обект чрез API може да се генерира огромно количество метаданни, които са трудни за изтриване.
    • Ако тестовете имат зависимости помежду си, процесът на почистване на данни след изпълнение на тестовете става истинска болка в главата.
    • Допълнителни (и, според мен, неоправдани) извиквания към API.
    • И основният аргумент: когато тестовите данни започнат да се почистват директно от БД. Това се превръща в истински цирк с PK/FK! От разработчиците се чува: „Аз само добавих/изтрих/преименувах таблица, защо 100500 интеграционни теста счупиха?“

    Според мен най-оптималното решение е динамична среда.

  2. Много хора използват docker-compose за стартиране на тестова среда, но малко хора използват docker-compose при провеждане на интеграционни тестове в CI/CD. И тук не взимам предвид kubernetes, swarm и други платформи за оркестрация на контейнери. Не всяка компания има такива. Би било добре ако docker-compose.yml беше универсален.
  3. Ако дори имаме собствен QA раннер, как да направим така, че услугите, стартирани чрез docker-compose, да не пречат една на друга?
  4. Как да събираме логовете на тестваните услуги?
  5. Как да чистим раннера?

Имам собствен GitLab раннер за проектите си и с тези въпроси се сблъсках при разработката Java клиент за TestRail. По-точно при стартиране на интеграционните тестове. По-нататък ще решаваме тези въпроси с примери от този проект.

К съдържанието

GitLab Shell Runner

За раннера препоръчвам линукс виртуална машина с 4 vCPU, 4 GB RAM, 50 GB HDD.
В интернет има много информация за настройка на gitlab-runner, затова накратко:

  • Влизаме на машината по SSH
  • Ако имате по-малко от 8 GB RAM, препоръчвам да направите swap 10 GB, за да не дойде OOM killer и да не убива задачите ни поради недостатъчен RAM. Това може да се случи, когато се стартират едновременно повече от 5 задачи. Задачите ще преминават по-бавно, но стабилно.

    Пример с OOM killer

    Ако в логовете на задачата видите bash: line 82: 26474 Killed, просто изпълнете на раннера sudo dmesg | grep 26474

    [26474]  1002 26474  1061935   123806     339        0             0 java
    Out of memory: Убий процес 26474 (java) score 127 или жертвай дете
    Убит процес 26474 (java) total-vm:4247740kB, anon-rss:495224kB, file-rss:0kB, shmem-rss:0kB

    И ако картината изглежда приблизително така, то или добавете swap, или увеличете RAM.

  • Инсталираме gitlab-runner, docker, docker-compose, make.
  • Добавяме потребителя gitlab-runner в групата docker
    sudo groupadd docker
    sudo usermod -aG docker gitlab-runner
  • Регистрираме gitlab-runner.
  • Отваряме за редактиране /etc/gitlab-runner/config.toml и добавяме

    concurrent=20
    [[runners]]
      request_concurrency = 10

    Това ще позволи стартиране на паралелни задачи на един раннер. Повече подробности за четене тук.
    Ако разполагате с по-мощна машина, например с 8 vCPU и 16 GB RAM, тези цифри могат да бъдат увеличени поне двойно. Но всичко зависи от това какво точно ще се стартира на този ранър и в какво количество.

Това е достатъчно.

К съдържанието

Подготовка на docker-compose.yml

Основната задача е универсален docker-compose.yml, който разработчици/тестери могат да използват както локално, така и в CI pipeline.

На първо място, правим уникални имена на услугите за CI. Една от уникалните променливи в GitLab CI е променливата CI_JOB_ID. Ако посочим container_name со значением "service-${CI_JOB_ID:-local}", то в случай на:

  • ако CI_JOB_ID не е определена в променливите на средата,
    името на услугата ще бъде service-local
  • ако CI_JOB_ID определена в променливите на средата (например 123),
    името на услугата ще бъде service-123

На второ място, правим обща мрежа за стартираните услуги. Това ни осигурява изолация на мрежово ниво при стартиране на няколко тестови среди.

networks:
  default:
    external:
      name: service-network-${CI_JOB_ID:-local}

Същност, това е първата стъпка към успеха =)

Пример за моя docker-compose.yml с коментари

version: "3"

# За правилната работа на web (php) и fmt е нужно, 
# контейнерите да имат общо изпълнимо съдържание.
# В нашия случай, това е директорията /var/www/testrail
volumes:
  static-content:

# Изолираме средата на ниво мрежа
networks:
  default:
    external:
      name: testrail-network-${CI_JOB_ID:-local}

services:
  db:
    image: mysql:5.7.22
    # Всеки container_name съдържа ${CI_JOB_ID:-local}
    container_name: "testrail-mysql-${CI_JOB_ID:-local}"
    environment:
      MYSQL_HOST: db
      MYSQL_DATABASE: mydb
      MYSQL_ROOT_PASSWORD: 1234
      SKIP_GRANT_TABLES: 1
      SKIP_NETWORKING: 1
      SERVICE_TAGS: dev
      SERVICE_NAME: mysql
    networks:
    - default

  migration:
    image: registry.gitlab.com/touchbit/image/testrail/migration:latest
    container_name: "testrail-migration-${CI_JOB_ID:-local}"
    links:
    - db
    depends_on:
    - db
    networks:
    - default

  fpm:
    image: registry.gitlab.com/touchbit/image/testrail/fpm:latest
    container_name: "testrail-fpm-${CI_JOB_ID:-local}"
    volumes:
    - static-content:/var/www/testrail
    links:
    - db
    networks:
    - default

  web:
    image: registry.gitlab.com/touchbit/image/testrail/web:latest
    container_name: "testrail-web-${CI_JOB_ID:-local}"
    # Ако променливите TR_HTTP_PORT или TR_HTTPS_PORTS не са определени,
    # услугата се стартира на порт 80 и 443 съответно.
    ports:
      - ${TR_HTTP_PORT:-80}:80
      - ${TR_HTTPS_PORT:-443}:443
    volumes:
      - static-content:/var/www/testrail
    links:
      - db
      - fpm
    networks:
      - default

Пример за локално стартиране

docker-compose -f docker-compose.yml up -d
Starting   testrail-mysql-local     ... done
Starting   testrail-migration-local ... done
Starting   testrail-fpm-local       ... done
Recreating testrail-web-local       ... done

Но не всичко е толкова просто при стартиране в CI.

К съдържанието

Подготовка на Makefile

Използвам Makefile, тъй като е много удобно както за локално управление на средата, така и в CI. Следват коментари в инлайн

# У меня в проектах все вспомогательные вещи лежат в директории `.indirect`,
# в том числе и `docker-compose.yml`

# Использовать bash с опцией pipefail 
# pipefail - фейлит выполнение пайпа, если команда выполнилась с ошибкой
SHELL=/bin/bash -o pipefail

# Останавливаем контейнеры и удаляем сеть
docker-kill:
    docker-compose -f $${CI_JOB_ID:-.indirect}/docker-compose.yml kill
    docker network rm network-$${CI_JOB_ID:-testrail} || true

# Предварительно выполняем docker-kill 
docker-up: docker-kill
    # Создаем сеть для окружения 
    docker network create network-$${CI_JOB_ID:-testrail}
    # Забираем последние образы из docker-registry
    docker-compose -f $${CI_JOB_ID:-.indirect}/docker-compose.yml pull
    # Запускаем окружение
    # force-recreate - принудительное пересоздание контейнеров
    # renew-anon-volumes - не использовать volumes предыдущих контейнеров
    docker-compose -f $${CI_JOB_ID:-.indirect}/docker-compose.yml up --force-recreate --renew-anon-volumes -d
    # Ну и, на всякий случай, вывести что там у нас в принципе запущено на машинке
    docker ps

# Коллектим логи сервисов
docker-logs:
    mkdir ./logs || true
    docker logs testrail-web-$${CI_JOB_ID:-local}       >& logs/testrail-web.log
    docker logs testrail-fpm-$${CI_JOB_ID:-local}       >& logs/testrail-fpm.log
    docker logs testrail-migration-$${CI_JOB_ID:-local} >& logs/testrail-migration.log
    docker logs testrail-mysql-$${CI_JOB_ID:-local}     >& logs/testrail-mysql.log

# Очистка раннера
docker-clean:
    @echo Останавливаем все testrail-контейнеры
    docker kill $$(docker ps --filter=name=testrail -q) || true
    @echo Очистка докер контейнеров
    docker rm -f $$(docker ps -a -f --filter=name=testrail status=exited -q) || true
    @echo Очистка dangling образов
    docker rmi -f $$(docker images -f "dangling=true" -q) || true
    @echo Очистка testrail образов
    docker rmi -f $$(docker images --filter=reference='registry.gitlab.com/touchbit/image/testrail/*' -q) || true
    @echo Очистка всех неиспользуемых volume
    docker volume rm -f $$(docker volume ls -q) || true
    @echo Очистка всех testrail сетей
    docker network rm $(docker network ls --filter=name=testrail -q) || true
    docker ps

Проверяваме.

make docker-up

$ make docker-up 
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml kill
Убивам testrail-web-local   ... готово
Убивам testrail-fpm-local   ... готово
Убивам testrail-mysql-local ... готово
docker network rm network-${CI_JOB_ID:-testrail} || true
network-testrail
docker network create network-${CI_JOB_ID:-testrail}
d2ec063324081c8bbc1b08fd92242c2ea59d70cf4025fab8efcbc5c6360f083f
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml pull
Изтеглям db        ... готово
Изтеглям миграция ... готово
Изтеглям fpm       ... готово
Изтеглям уеб       ... готово
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml up --force-recreate --renew-anon-volumes -d
Създавам отново testrail-mysql-local ... готово
Създавам отново testrail-fpm-local       ... готово
Създавам отново testrail-migration-local ... готово
Създавам отново testrail-web-local       ... готово
docker ps
CONTAINER ID  PORTS                                     NAMES
a845d3cb0e5a  0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp  testrail-web-local
19d8ef001398  9000/tcp                                  testrail-fpm-local
e28840a2369c  3306/tcp, 33060/tcp                       testrail-migration-local
0e7900c23f37  3306/tcp                                  testrail-mysql-local

make docker-logs

$ make docker-logs
mkdir ./logs || true
mkdir: не може да се създаде директория ‘./logs’: Директорията съществува
docker logs testrail-web-${CI_JOB_ID:-local}       > logs/testrail-web.log
docker logs testrail-fpm-${CI_JOB_ID:-local}       > logs/testrail-fpm.log
docker logs testrail-migration-${CI_JOB_ID:-local} > logs/testrail-migration.log
docker logs testrail-mysql-${CI_JOB_ID:-local}     > logs/testrail-mysql.log

GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

К съдържанието

Подготовка на .gitlab-ci.yml

Стартиране на интеграционни тестове

Интеграция:
  етап: тест
  етикети:
    - my-shell-runner
  before_script:
    # Аутентифицираме се в registry
    - docker login -u gitlab-ci-token -p ${CI_JOB_TOKEN} ${CI_REGISTRY}
    # Генерираме псевдоуникални TR_HTTP_PORT и TR_HTTPS_PORT
    - export TR_HTTP_PORT=$(shuf -i10000-60000 -n1)
    - export TR_HTTPS_PORT=$(shuf -i10000-60000 -n1)
    # създаваме директория с идентификатора на задачата
    - mkdir ${CI_JOB_ID}
    # копираме в създадената директория нашия docker-compose.yml
    # за да бъде контекстът различен за всяка задача
    - cp .indirect/docker-compose.yml ${CI_JOB_ID}/docker-compose.yml
  script:
    # стартираме нашата среда
    - make docker-up
    # пускаме тестовете с изпълняем jar (така го правя аз)
    - java -jar itest.jar --http-port ${TR_HTTP_PORT} --https-port ${TR_HTTPS_PORT}
    # или в контейнера
    - docker run --network=testrail-network-${CI_JOB_ID:-local} --rm itest
  after_script:
    # събираме логове
    - make docker-logs
    # спираме средата
    - make docker-kill
  артефакти:
    # запазваме логовете
    когато: винаги
    пътища:
      - logs
    изтичане: 30 дни

В резултат на стартирането на такава задача, в артефактите папката logs ще съдържа логове на услугите и тестовете. Което е много удобно в случай на грешки. Всеки тест на паралелно изпълнение записва своя лог, но за това ще говоря отделно.

GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

К съдържанието

Почистване на раннера

Задачата ще се изпълнява само по график.

етапи:
- почистване
- изграждане
- тест

Почистващ изпълнител:
  етап: почистване
  само:
    - графици
  етикети:
    - my-shell-runner
  скрипт:
    - make docker-clean

Следваме в нашия проект GitLab -> CI/CD -> Разписания -> Ново разписание и добавяме ново разписание

GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

К съдържанието

Резултат

Стартираме 4 задачи в GitLab CI
GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

В логовете на последната задача с интеграционните тестове виждаме контейнери от различни задачи

CONTAINER ID  NAMES
c6b76f9135ed  testrail-web-204645172
01d303262d8e  testrail-fpm-204645172
2cdab1edbf6a  testrail-migration-204645172
826aaf7c0a29  testrail-mysql-204645172
6dbb3fae0322  testrail-web-204645084
3540f8d448ce  testrail-fpm-204645084
70fea72aa10d  testrail-mysql-204645084
d8aa24b2892d  testrail-web-204644881
6d4ccd910fad  testrail-fpm-204644881
685d8023a3ec  testrail-mysql-204644881
1cdfc692003a  testrail-web-204644793
6f26dfb2683e  testrail-fpm-204644793
029e16b26201  testrail-mysql-204644793
c10443222ac6  testrail-web-204567103
04339229397e  testrail-fpm-204567103
6ae0accab28d  testrail-mysql-204567103
b66b60d79e43  testrail-web-204553690
033b1f46afa9  testrail-fpm-204553690
a8879c5ef941  testrail-mysql-204553690
069954ba6010  testrail-web-204553539
ed6b17d911a5  testrail-fpm-204553539
1a1eed057ea0  testrail-mysql-204553539

По-подробен лог

$ docker login -u gitlab-ci-token -p ${CI_JOB_TOKEN} ${CI_REGISTRY}
ПРЕДУПРЕЖДЕНИЕ! Използването на --password през CLI е небезопасно. Използвайте --password-stdin.
ПРЕДУПРЕЖДЕНИЕ! Вашата парола ще бъде съхранена нешифрована в /home/gitlab-runner/.docker/config.json.
Конфигурирайте помощна програма за удостоверяване, за да премахнете това предупреждение. Вижте
https://docs.docker.com/engine/reference/commandline/login/#credentials-store
Входът е успешен
$ export TR_HTTP_PORT=$(shuf -i10000-60000 -n1)
$ export TR_HTTPS_PORT=$(shuf -i10000-60000 -n1)
$ mkdir ${CI_JOB_ID}
$ cp .indirect/docker-compose.yml ${CI_JOB_ID}/docker-compose.yml
$ make docker-up
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml kill
docker network rm testrail-network-${CI_JOB_ID:-local} || true
Грешка: Не съществува такова мрежа: testrail-network-204645172
docker network create testrail-network-${CI_JOB_ID:-local}
0a59552b4464b8ab484de6ae5054f3d5752902910bacb0a7b5eca698766d0331
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml pull
Изтегляне на web       ... завършено
Изтегляне на fpm       ... завършено
Изтегляне на migration ... завършено
Изтегляне на db        ... завършено
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml up --force-recreate --renew-anon-volumes -d
Създаване на том "204645172_static-content" с подразбиране драйвер
Създаване на testrail-mysql-204645172 ... 
Създаване на testrail-mysql-204645172 ... завършено
Създаване на testrail-migration-204645172 ... завършено
Създаване на testrail-fpm-204645172       ... завършено
Създаване на testrail-web-204645172       ... завършено
docker ps
CONTAINER ID        IMAGE                                                          COMMAND                  CREATED              STATUS              PORTS                                           NAMES
c6b76f9135ed        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   13 seconds ago       Up 1 second         0.0.0.0:51148->80/tcp, 0.0.0.0:25426->443/tcp   testrail-web-204645172
01d303262d8e        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   16 seconds ago       Up 13 seconds       9000/tcp                                        testrail-fpm-204645172
2cdab1edbf6a        registry.gitlab.com/touchbit/image/testrail/migration:latest   "docker-entrypoint.s…"   16 seconds ago       Up 13 seconds       3306/tcp, 33060/tcp                             testrail-migration-204645172
826aaf7c0a29        mysql:5.7.22                                                   "docker-entrypoint.s…"   18 seconds ago       Up 16 seconds       3306/tcp                                        testrail-mysql-204645172
6dbb3fae0322        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   36 seconds ago       Up 22 seconds       0.0.0.0:44202->80/tcp, 0.0.0.0:20151->443/tcp   testrail-web-204645084
3540f8d448ce        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   38 seconds ago       Up 35 seconds       9000/tcp                                        testrail-fpm-204645084
70fea72aa10d        mysql:5.7.22                                                   "docker-entrypoint.s…"   40 seconds ago       Up 37 seconds       3306/tcp                                        testrail-mysql-204645084
d8aa24b2892d        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   About a minute ago   Up 53 seconds       0.0.0.0:31103->80/tcp, 0.0.0.0:43872->443/tcp   testrail-web-204644881
6d4ccd910fad        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   About a minute ago   Up About a minute   9000/tcp                                        testrail-fpm-204644881
685d8023a3ec        mysql:5.7.22                                                   "docker-entrypoint.s…"   About a minute ago   Up About a minute   3306/tcp                                        testrail-mysql-204644881
1cdfc692003a        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   About a minute ago   Up About a minute   0.0.0.0:44752->80/tcp, 0.0.0.0:23540->443/tcp   testrail-web-204644793
6f26dfb2683e        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   About a minute ago   Up About a minute   9000/tcp                                        testrail-fpm-204644793
029e16b26201        mysql:5.7.22                                                   "docker-entrypoint.s…"   About a minute ago   Up About a minute   3306/tcp                                        testrail-mysql-204644793
c10443222ac6        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   5 hours ago          Up 5 hours          0.0.0.0:57123->80/tcp, 0.0.0.0:31657->443/tcp   testrail-web-204567103
04339229397e        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   5 hours ago          Up 5 hours          9000/tcp                                        testrail-fpm-204567103
6ae0accab28d        mysql:5.7.22                                                   "docker-entrypoint.s…"   5 hours ago          Up 5 hours          3306/tcp                                        testrail-mysql-204567103
b66b60d79e43        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   5 hours ago          Up 5 hours          0.0.0.0:56321->80/tcp, 0.0.0.0:58749->443/tcp   testrail-web-204553690
033b1f46afa9        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   5 hours ago          Up 5 hours          9000/tcp                                        testrail-fpm-204553690
a8879c5ef941        mysql:5.7.22                                                   "docker-entrypoint.s…"   5 hours ago          Up 5 hours          3306/tcp                                        testrail-mysql-204553690
069954ba6010        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   5 hours ago          Up 5 hours          0.0.0.0:32869->80/tcp, 0.0.0.0:16066->443/tcp   testrail-web-204553539
ed6b17d911a5        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   5 hours ago          Up 5 hours          9000/tcp                                        testrail-fpm-204553539
1a1eed057ea0        mysql:5.7.22                                                   "docker-entrypoint.s…"   5 hours ago          Up 5 hours          3306/tcp                                        testrail-mysql-204553539

Всички задачи са завършени успешно.

Артефактите на задачата съдържат логове на услугите и тестовете.
GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

Изглежда всичко е наред, но има нюанс. Pipeline може да бъде принудително отменен по време на изпълнението на интеграционните тестове и в този случай стартираните контейнери няма да бъдат спрени. От време на време е необходимо да се почисти runner-ът. За съжаление, задачата за доработка в GitLab CE все още е в статус. Open

Но ние добавихме стартиране на задачата по график и никой не ни пречи да я стартираме ръчно.
Прехвърляме се в нашия проект -> CI/CD -> Графици и стартираме задачата. Почистване на runner-а.

GitLab Shell Runner. Конкурентно изпълнение на тествани услуги с помощта на Docker Compose

В обобщение:

  • Имаме един shell runner.
  • Нямаме конфликти между задачите и средата.
  • Имаме паралелно стартиране на задачи с интеграционни тестове.
  • Интеграционните тестове могат да се стартират както локално, така и в контейнер.
  • Логовете на услугите и тестовете се събират и прикачват към pipeline задачата.
  • Има възможност за почистване на runner-а от стари docker образи.

Време за настройка — ~2 часа.
Ето, всъщност, това е всичко. Ще се радвам на обратна връзка.

К съдържанието

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster