Привет, Хабр! В данной статье я хочу поделиться опытом разворачивания тестового сервера для команды разработчиков. Вкратце суть проблемы — есть команда разработки и несколько проектов на php. Пока нас было мало и проект был по сути один, то использовался 1 тестовый сервер и чтобы показать задачу заказчику — разработчик «столбил» сервер на определенное время. Если «окон» по времени не было, то приходилось ждать. Со временем рос коллектив и сложность задач, соответственно увеличивалось время проверки и занятость тестового сервера, что негативно влияло на сроки выполнения и премию. Поэтому пришлось искать решение и оно под катом.

Вводная


Что было:

  1. Один тестовый сервер
  2. Gitlab и redmine на другом сервере
  3. Желание разобраться в проблеме

Все сервера находятся в нашей локальной сети, тестовый сервер недоступен извне.

Что требовалось:

  1. Возможность тестировать несколько проектов/веток одновременно
  2. Разработчик может зайти на сервер, до настроить его и при этом не сломать ничего у других
  3. Все должно быть максимально удобно и делаться по 1 кнопке желательно из gitlab (CI/CD).

Варианты решений


1. Один сервер, много хостов


Самый простой вариант. Используем тот же тестовый сервер, только разработчику нужно создавать хост под каждую ветку/проект и вносить его в конфигурацию nginx/apache2.

Плюсы:

  1. Быстро и всем понятно
  2. Можно автоматизировать

Минусы:

  1. Не выполняется п.2 из требований — разработчик может запустить обновление бд и при некотором стечении обстоятельств положить все (Привет Андрей!)
  2. Довольно сложная автоматизация с кучей конфигурационных файлов

2. Каждому разработчику по серверу!


Выделяем каждому по серверу и разработчик сам отвечает за свое хозяйство.

Плюсы:

  1. Разработчик может полностью настроить сервер под свой проект

Минусы:

  1. п.2 требований так и не выполняется
  2. Дорого и ресурсы могут просто простаивать пока идет разработка, а не тестирование
  3. Автоматизация еще сложней чем в п.1 из-за разных серверов

3. Контейнеризация — docker, kubernetes


Данная технология все больше проникает в нашу жизнь. Дома я уже давно использую для своих проектов docker.
Docker — программное обеспечение для автоматизации развёртывания и управления приложениями в среде виртуализации на уровне операционной системы. Позволяет «упаковать» приложение со всем его окружением и зависимостями в контейнер, который может быть перенесён на любую Linux-систему с поддержкой cgroups в ядре, а также предоставляет среду по управлению контейнерами.
Плюсы:

  1. Используется один сервер
  2. Выполняются все пункты требований

Минусы:

  1. Образы и контейнеры порой отнимают довольно много места, приходится кроном чистить уже устаревшие для освобождения места.

Внедрение docker


При использовании gitlab очень часто попадались на глаза настройки AutoDevOps, kubernetes. Плюс бородатые дядьки на различных meetup рассказывают как у них круто все работает с kubernetes. Поэтому было принято решение попробовать развернуть кластер на своих мощностях, был выпрошен сервер (а тестовый трогать нельзя, там люди тестируют) и понеслась!

Так как опыта у меня с kubernetes 0, делось все по мануалу с попыткой понять как все эти кластера работают. Спустя некоторое время мне удалось поднять кластер, но потом пошли проблемы с сертификатами, ключами, да и вообще с трудностью развертывания. Мне же нужно было решение проще, чтобы научить своих коллег с этим работать (например, тот же отпуск не хочется проводить сидящим в скайпе и помогающим с настройкой). Поэтому kubernetes был оставлен в покое. Оставался сам docker и нужно было найти решение для маршрутизации контейнеров. Так как их можно было поднять на разных портах, то можно было бы использовать тот же nginx для внутреннего перенаправления. Называется это обратный прокси сервер.
Обратный прокси-сервер — тип прокси-сервера, который ретранслирует запросы клиентов из внешней сети на один или несколько серверов, логически расположенных во внутренней сети. При этом для клиента это выглядит так, будто запрашиваемые ресурсы находятся непосредственно на прокси-сервере.

Обратный прокси-сервер


Чтобы не изобретать велосипед, я начал искать готовые решения. И оно нашлось — это traefik.

Tr?fik — это современный обратный прокси HTTP и балансировщик нагрузки, который упрощает развертывание микросервисов. Tr?fik интегрируется с существующими инфраструктурными компонентами ( Docker, Swarm mode, Kubernetes, Marathon, Consul, Etcd, Rancher, Amazon ECS, ...) и настраивается автоматически и динамически. Для работы с docker достаточно указать его сокет и все, дальше Tr?fik сам находит все контейнеры и маршрутизацию до них (подробнее в «Упаковываем приложения в docker»).

Конфигурация контейнера Tr?fik
Запускаю его через docker-compose.yml

version: '3'

services:
  traefik:
    image: traefik:latest # The official Traefik docker image
    command: --api --docker # Enables the web UI and tells Tr?fik to listen to docker
    ports:
      - 443:443
      - 80:80     # The HTTP port
      - 8080:8080 # The Web UI (enabled by --api)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock # So that Traefik can listen to the Docker events
      - /opt/traefik/traefik.toml:/traefik.toml
      - /opt/traefik/certs/:/certs/
    networks:
    - proxy
    container_name: traefik
    restart: always
networks:
  proxy:
    external: true


Здесь мы сообщаем прокси, что нужно слушать порты 80,443 и 8080 (веб морда прокси), монтируем сокет докера, файл конфигурации и папку с сертификатами. Для удобства именования тестовых сайтов, мы решили сделать локальную доменную зону *.test. При обращении к любому сайту на ней, пользователь попадает на наш тестовый сервер. Поэтому сертификаты в папке traefik самоподписаные, но он так поддерживает Let's Encrypt.

Генерация сертификатов

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout domain.key -out domain.crt

Перед стартом нужно создать в докере сеть proxy (можете назвать по своему).

docker network create proxy

Это будет сеть для связи traefik с контейнерами php сайтов. Поэтому указываем ее в параметре networks сервиса и в networks всего файла указав в параметре external: true.

Файл traefik.toml
debug = false

logLevel = "DEBUG"
defaultEntryPoints = ["https","http"] #точки входа
insecureSkipVerify = true  #принимать самоподписаные сертификаты

[entryPoints]
  [entryPoints.http]
  address = ":80"
  [entryPoints.https]
  address = ":443"
  [entryPoints.https.tls]

[docker]
endpoint = "unix:///var/run/docker.sock"
domain = "docker.localhost"
watch = true
exposedbydefault = false


Тут все довольно просто — указываем точки входа http и https трафика, не забудьте поставить insecureSkipVerify = true если сертификаты локальные. В секции entryPoints.https.tls можно не указывать сертификаты, тогда traefik подставит свой сертификат.

Можно запустить сервис

docker-compose up -d

Если перейти по адресу site.test, то выдаст ошибку 404, так как этот домен не привязан ни к какому контейнеру.

Упаковываем приложения в docker


Теперь нужно настроить контейнер с приложением, а именно:

1. указать в сетях сеть proxy
2. добавить labels с конфигурацией traefik

Ниже приведена конфигурация одного из приложений

docker-compose.yml приложения
version: '3'
services:
  app:
    build: data/docker/php    #кастомная сборка сервера
    restart: always
    working_dir: /var/www/html/public
    volumes:
    - ./:/var/www/html   #монтирование папки с сайтом
    - /home/develop/site-files/f:/var/www/html/public/f #монтирование папки с загрузками для экономии места
    links:
    - mailcatcher
    - memcached
    - mysql
    labels:
    - traefik.enabled=true
    - traefik.frontend.rule=Host:TEST_DOMAIN,crm.TEST_DOMAIN,bonus.TEST_DOMAIN
    - traefik.docker.network=proxy
    - traefik.port=443
    - traefik.protocol=https
    networks:
    - proxy
    - default

  mailcatcher:
    image: schickling/mailcatcher:latest
    restart: always

  memcached:
    image: memcached
    restart: always

  mysql:
    image: mysql:5.7
    restart: always
    command: --max_allowed_packet=902505856 --sql-mode=""
    environment:
      MYSQL_ROOT_PASSWORD: 12345
      MYSQL_DATABASE: site
    volumes:
    - ./data/cache/mysql-db:/var/lib/mysql # сохранение файлов БД на хосте

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    restart: always
    links:
    - mysql
    environment:
      MYSQL_USERNAME: root
      MYSQL_ROOT_PASSWORD: 12345
      PMA_ARBITRARY: 1
      PMA_HOST: mysql_1
    labels:
    - traefik.enabled=true
    - traefik.frontend.rule=Host:pma.TEST_DOMAIN
    - traefik.docker.network=proxy
    - traefik.port=80
    - traefik.default.protocol=http
    networks:
    - proxy
    - default
networks:
  proxy:
    external: true


В сервисе app, в секции сети нужно указать proxy и default, это значит что он будет доступен в двух сетях, как видно из конфигурации я не пробрасываю порты наружу, все идет внутри сети.

Далее конфигурируем labels

    - traefik.enabled=true   #включение traefik для данного сервиса
    - traefik.frontend.rule=Host:TEST_DOMAIN,crm.TEST_DOMAIN,bonus.TEST_DOMAIN #перечисление доменов для которых traefik будет перенаправлять запросы сюда
    - traefik.docker.network=proxy   #сеть для связи
    - traefik.port=443                      #порт, если у вас нет ssl то укажите 80 и ниже http
    - traefik.protocol=https    #используемый протокол 
    #в секции phpmyadmin приведен пример http подключения

В общей секции networks нужно указать external: true

Константу TEST_DOMAIN нужно заменить на домен, например, site.test

Запускаем приложение

docker-compose up -d

Теперь если зайти на домены site.test, crm.site.test, bonus.site.test можно увидеть рабочий сайт. А на домене pma.site.test будет phpmyadmin для удобной работы с бд.

Настройка GitLab


Создаем обработчик заданий, для этого запускаем

gitlab-runner register

Указываем url gitlab, токен и через что будет выполняться задание (executors). Так как у меня тестовый и gitlab находятся на разных серверах, то выбираю ssh executor. Нужно будет указать адрес сервера и логин/пароль для подключения по ssh.

Runner можно сделать прикрепленным к одному или нескольким проектам. Так как у меня логика работы везде одинаковая, поэтому был создан shared runner (общий для всех проектов).
И последний штрих это создать файл конфигурации CI

.gitlab-ci.yml
stages:
- build
- clear

#Конфигурация для develop
build_develop:
  stage: build    #относим к этапу build
  tags:               #если нужно можно указать теги
  - ssh-develop
  environment:   #настройки окружения, после разворачивания они выведутся в Операции - Среды проекта
    name: review/$CI_BUILD_REF_NAME  #название проекта
    url: https://site$CI_PIPELINE_ID.test  #url для доступа к нему
    on_stop: clear
  when: manual
  script:
  - cd ../ && cp -r $CI_PROJECT_NAME $CI_PIPELINE_ID && cd $CI_PIPELINE_ID  #копирование проекта в отдельную папку
  - cp -r /home/develop/site-files/.ssh  data/docker/php/.ssh  #ключи для ssh
  - sed -i -e "s/TEST_DOMAIN/site$CI_PIPELINE_ID.test/g" docker-compose.yml    #Замена имени домена
  - docker-compose down  #на случай ребилда
  - docker-compose up -d --build  #билд образов
  - script -q -c "docker exec -it ${CI_PIPELINE_ID}_app_1 bash -c \"cd ../ && php composer.phar install --prefer-dist \"" #установка пакетов компосера
  - script -q -c "docker exec -it ${CI_PIPELINE_ID}_app_1 bash -c \"cd ../ && php composer.phar first-install $CI_PIPELINE_ID\"" #запуск скрипта первичной настройки приложения

#конфигурация для production
build_prod:
  stage: build
  tags:
  - ssh-develop
  environment:
    name: review/$CI_BUILD_REF_NAME
    url: https://site$CI_PIPELINE_ID.test
    on_stop: clear
  when: manual
  script:
  - cd ../ && cp -r $CI_PROJECT_NAME $CI_PIPELINE_ID && cd $CI_PIPELINE_ID
  - cp -r /home/develop/site-files/.ssh  data/docker/php/.ssh  #ключи для ssh
  - docker-compose down
  - docker-compose up -d --build
  - script -q -c "docker exec -it ${CI_PIPELINE_ID}_app_1 bash -c \"cd ../ && php composer.phar install --prefer-dist --no-dev\""
  - script -q -c "docker exec -it ${CI_PIPELINE_ID}_app_1 bash -c \"cd ../ && php composer.phar first-install $CI_PIPELINE_ID\""

clear:
  stage: clear
  tags:
  - ssh-develop
  environment:
    name: review/$CI_BUILD_REF_NAME
    action: stop
  script:
  - cd ../ && cd $CI_PIPELINE_ID && docker-compose down && cd ../ && echo password | sudo -S rm -rf $CI_PIPELINE_ID  #Остановка контейнеров и удаление папки с проектом
  when: manual


В данной конфигурации описаны 2 этапа — build и clear. Этап build имеет 2 варианта выполнения — build_develop и build_prod



Gitlab строит понятную диаграмму выполнения процесса. В моем примере все процессы стартуют вручную (параметр when: manual). Сделано это для того, чтобы разработчик после разворачивания тестового сайта, мог делать pull своих правок в контейнер без пересборки всего контейнера. Еще одна причина это наименование доменов — site$CI_PIPELINE_ID.test, где CI_PIPELINE_ID — номер процесса запустившего сборку. То есть отдали на проверку сайт с доменом site123.test и чтобы внести горячие правки, сразу заливаются изменения в контейнер самим разработчиком.

Небольшая особенность работы ssh executor. При подключении к серверу создается папка вида

/home/пользователь/builds/хеш_runner/0/Группа_проекта/Название_проекта

Поэтому была добавлена строчка

cd ../ && cp -r $CI_PROJECT_NAME $CI_PIPELINE_ID && cd $CI_PIPELINE_ID

В ней мы поднимаемся на папку выше и копируем проект в папку с номером процесса. Так можно разворачивать несколько веток одного проекта. Но в настройках обработчика нужно поставить галку Lock to current projects, так обработчик не будет пытаться развернуть несколько веток одновременно.

Этап clear останавливает контейнеры и удаляет папку, могут понадобиться права root, поэтому используем команду echo password | sudo -S rm, где password ваш пароль.

Уборка мусора


Время от времени нужно удалять не используемые контейнеры, чтобы не занимать место, для этого в кроне висит скрипт с таким содержанием

#!/bin/bash
# удаление завершенных контейнеров:
docker ps --filter status=dead --filter status=exited -aq | xargs -r docker rm -v
# удаление неиспользуемых контейнеров:
yes | docker container prune
# удаление не используемых образов:
yes | docker image prune
# удаление не используемых томов:
yes | docker volume prune

выполняется раз в день.

Заключение


Данное решение помогло нам существенно оптимизировать тестирование и выпуск новых фич. Готов ответить на вопросы, конструктивная критика принимается.

Бонус


Для того чтобы не собирать каждый раз образы из Dockerfile, можно хранить их локальном реестре докера.

Файл docker-compose.yml
registry:
  restart: always
  image: registry:2
  ports:
    - 5000:5000
  volumes:
    - /opt/docker-registry/data:/var/lib/registry #монтирование папки для хранения образов


В данном варианте не используется аутентификация, это не безопасный способ (!!!), но для хранения не критичных образов нам подходит.

Можно настроить gitlab для просмотра

 gitlab_rails['registry_enabled'] = true
 gitlab_rails['registry_host'] = "registry.test"
 gitlab_rails['registry_port'] = "5000"

После этого в gitlab появляется список образов

Комментарии (3)


  1. gudster
    21.10.2018 22:16

    echo password | sudo -S rm,

    Решая подобную задачу, меня напрягло решение запускать Ранер с правами рута или давать ему их через sudo. Так-же возня с копированием проекта — работает пока проект мал.
    Для себя выбрал использование: dind (Docker in Docker) — gitlab-ci поддерживает его.

    Заметил в скрипте ci:
    - script -q -c "docker e.....
    для чего так сделано? именно: script -q -c?


    1. Afinogen Автор
      21.10.2018 22:23

      У меня подключается через обычного пользователя, но повышение прав нужно для удаление некоторых файлов из проекта, которые появляются в процессе работы (не во всех проектах нужно sudo). Какие варианты есть если не копирование, чтобы сделать независимую папку с проектом?
      Пробовал dind меня смутил как раз таки фактор запуска контейнера внутри контейнера, плюс запуск в привилегированном режиме.
      script -q -c нужен для того, чтобы не было ошибки «the input device is not a TTY»


      1. gudster
        21.10.2018 22:57

        script -q -c нужен для того, чтобы не было ошибки «the input device is not a TTY»

        убрать параметр -it, для ci он не нужен