Непрерывная интеграция/внедрение приложения Symfony с помощью docker-compose и GitLab CI

  • Tutorial

В статье я поделюсь своим опытом автоматизации всего процесса разработки приложения Symfony с нуля от настройки инфраструктуры до деплоя в production. От development- и до production-окружения для запуска приложения будет использоваться docker-compose, а все процедуры непрерывной интеграции/внедрения будут запускаться через GitLab CI/CD Pipelines в docker-контейнерах.


Подразумевается, что вы знакомы с docker и docker-compose. Если нет или вы не знаете как его установить, я подготовил инструкцию по подготовке локального окружения разработчика. Фактически, для работы над приложением потребуется только Docker, VirtualBox и, опционально, Yarn.


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


Я подготовил скелет приложения и выложил его на GitHub. Всё написанное ниже относится к приложениям, созданным на основе этого шаблона и к инфраструктуре, необходимой для запуска такого приложения.


Чтобы запустить приложение локально, нужно выполнить следущие команды:


git clone git@github.com:covex-nn/docker-workflow-symfony.git
cd docker-workflow-symfony
docker-compose up -d
docker-compose exec php phing

Сайт будет доступен по адресу http://docker.local/, добавлять app_dev.php/ к адресу не нужно. Будет запущено 4 контейнера: nginx, php, mysql и phpmyadmin (последний запускается только в development-окружении).


docker.local нужно прописать в файл hosts. Для Linux ip-адрес сайта будет 127.0.0.1, а под Windows его можно узнать в результате работы команды docker-machine env (всё таки см. инструкцию).


composer в контейнере php настроен таким образом, что папка vendor находится внутри контейнера, а не на хосте, и не оказывает влияние на быстродействие в локальном окружении разработчика.


Подготовка и настройка инфраструктуры


В боевых условиях для работы системы потребуется три сервера: GitLab — сервер для управления репозиториями Git и Container Registry, Docker для production — сервер для production-сайтов и Docker для разработки — сервер для pre-production и тестовых сайтов разработчиков.


GitLab

Настройка сервера с GitLab и Container Registry


С инструкциями по установке GitLab и Container Registry можно ознакомиться на сайте gitlab.com.


По умолчанию для GitLab Container Registry требуется настройка SSL сертификатов. Мы будем использовать один и тот же сертификат и для Container Registry, и для Web-интерфейса GitLab. Создать SSL-сертификат можно с помощью сервиса LetsEncrypt.


Подключить SSL-сертификат можно в файле /etc/gitlab/gitlab.rb. Также нужно настроить возможность автоматического обновления сертификата:


nginx['ssl_certificate'] = "/etc/letsencrypt/live/gitlab.site.ru/fullchain.pem"
nginx['ssl_certificate_key'] = "/etc/letsencrypt/live/gitlab.site.ru/privkey.pem"
registry_nginx['ssl_certificate'] = "/etc/letsencrypt/live/gitlab.site.ru/fullchain.pem"
registry_nginx['ssl_certificate_key'] = "/etc/letsencrypt/live/gitlab.site.ru/privkey.pem"
nginx['custom_gitlab_server_config'] = "location ^~ /.well-known { \n allow all;\n alias /var/lib/letsencrypt/.well-known/;\n default_type \"text/plain\";\n try_files $uri =404;\n }\n"

После изменения в файле gitlab.rb нужно перегрузить GitLab через gitlab-ctl restart и настроить crontab для обновления сертификатов:


41 0 * * * /root/certbot-auto renew --no-self-upgrade --webroot -w /var/lib/letsencrypt --renew-hook "service nginx reload"

Docker для production

Настройка сервера с Docker для production


С инструкцией по установке Docker можно ознакомиться на сайте docs.docker.com.


Дополнительно нужно создать локальную сеть для назначения контейнерам внутренних IP адресов:


docker network create graynetwork --gateway 192.168.10.1 --subnet 192.168.10.0/24

Кроме Docker на сервер нужно установить nginx и certbot-auto от LetsEncrypt.


Nginx будет проксировать запросы к веб-серверам в контейнерах Docker. С инструкцией по установке Nginx можно ознакомиться на сайте nginx.org.


Обновление будущих SSL-сертификатов должно быть настроено сразу же так, как с на сервере с GitLab:


41 0 * * * /root/certbot-auto renew --no-self-upgrade --webroot -w /var/lib/letsencrypt --renew-hook "service nginx reload"

Docker для разработки

Настройка сервера с Docker для разработки


Нужно выполнить все пункты установки Docker для production и дополнительно на сервер нужно установить GitLab CI Runner.


С инструкцией по установке GitLab CI Runner можно ознакомиться на сайте docs.gitlab.com.


Запуск GitLab Runner:


gitlab-ci-multi-runner verify --delete
printf "concurrent = 10\ncheck_interval = 0\n\n" > /etc/gitlab-runner/config.toml
gitlab-ci-multi-runner register -n \
   --url https://gitlab-server.ru/ \
   --registration-token <token> \
   --tag-list "executor-docker,docker-in-docker" \
   --executor docker \
   --description "docker-dev" \
   --docker-image "docker:latest" \
   --docker-volumes "/composer/home/cache" \
   --docker-volumes "/root/.composer/cache" \
   --docker-volumes "/var/run/docker.sock:/var/run/docker.sock"

Токен <token> нужно скопировать из Web-интерфейсе GitLab в разделе Admin Area --> Runners.


Над проектом будут работать несколько разработчиков, им нужно выдать доступ так, чтобы они ничего не сломали и не мешали друг другу.


Настройка доступа

Создание Master-пользователя


  • На сервере Docker для production нужно создать пользователя master и добавить в группу docker:


    adduser master
    usermod -aG docker master

  • Далее нужно зайти под новым пользователем и создать id_rsa ключ без passphrase:


    ssh-keygen -t rsa -b 4096 -C "master@docker-server-prod.ru"
    cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys

    Этот ключ будет использоваться для SSH-доступа на сервер и для доступа в git-репозитории разработчиков.


  • В GitLab создать пользователя master и добавить ему SSH-ключ. Этот пользователь будет чисто техническим. В дальнейшем под ним не нужно будет заходить и выполнять какие-либо операции.

Создание пользователя-разработчика


  • На сервере Docker для разработки нужно создать пользователя dev1 (имя может быть любым):


    adduser dev1
    usermod -aG docker dev1

  • Далее нужно зайти под новым пользователем и создать id_rsa ключ без passphrase:


    ssh-keygen -t rsa -b 4096 -C "dev1@docker-server-dev.ru"
    cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
    chmod 400 ~/.ssh/id_rsa ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys 

    Этот ключ будет использоваться для SSH-доступа на сервер, он не должен быть известен кому-либо из разработчиков.


  • В GitLab создать пользователя dev1, запретив ему создавать свои репозитории и группы. SSH-ключ настраивать не нужно — разработчик сам себе его настроит.


  • В GitLab создать группу dev1-projects и добавить в группу пользователя master с ролью Master. В этой группе будут находиться все репозитории данного разработчика.

У проекта будет один основной репозиторий и по одному репозиторию на каждого разработчика. Основной репозиторий будет источником для production- и staging-сайтов, репозиторий разработчика – для тестового сайта именно этого разработчика. Процессы деплоя для каждого из сайтов будут совпадать. Отличия будут только в конфигурации приложения и настройках доступа к серверу c Docker. Конфигурация и настройки будут храниться в GitLab, в разделе Settings -- CI/CD Pipelines: в основном репозитории – для production- и staging-сайтов, а в репозитории разработчика – для тестового сайта этого разработчика.


Создание и настройка основного репозитория

Основной репозиторий проекта можно поместить в произвольную группу.


В разделе Settings --> Pipelines нужно выбрать git clone в качестве Git strategy for pipelines и добавить переменные:


Переменная Значение
COMPOSER_GITHUB_TOKEN Создать токен на странице https://github.com/settings/tokens
SSH_PRIVATE_KEY заполнить её содержимым файла id_rsa пользователя master
NETWORK_NAME_MASTER graynetwork
SERVER_NAME_MASTER site-staging.ru
NETWORK_IP_MASTER выбрать свободный IP в подсети graynetwork
NETWORK_NAME_PRODUCTION graynetwork
SERVER_NAME_PRODUCTION site-production.ru
NETWORK_IP_PRODUCTION выбрать свободный IP в подсети graynetwork
DEPLOY_USER_MASTER master
DEPLOY_HOST_MASTER docker-server-prod.ru
DEPLOY_DIRECTORY_MASTER /home/master/site-staging.ru
DEPLOY_USER_PRODUCTION master
DEPLOY_HOST_PRODUCTION docker-server-prod.ru
DEPLOY_DIRECTORY_PRODUCTION /home/master/site-production.ru
PROJECT_FORKS <оставить пустым>

Для деплоя скелета приложения на staging нужно залить ветку master в репозиторий через git push origin master.


Создание и настройка репозитория разработчика

Репозиторий разработчика должен находиться в группе проектов разработчика. Для пользователя dev1 — это dev1-projects. Репозиторий разработчика создаётся путём создания Fork администратором из основного репозитория. Это важно.


  • Вместе с созданием fork появится возможность создавать Merge Request из репозитория разработчика в основной
  • А создание fork именно администратором необходимо для обеспечения стабильности работы системы и сохранения в секрете id_rsa ключа для доступа на сервер.

В разделе Settings --> Pipelines нужно выбрать git clone в качестве Git strategy for pipelines, скрыть Public pipelines и добавить переменные:


Переменная Значение
COMPOSER_GITHUB_TOKEN Создать токен на странице https://github.com/settings/tokens
SSH_PRIVATE_KEY заполнить её содержимым файла id_rsa пользователя dev1
NETWORK_NAME_MASTER graynetwork
SERVER_NAME_MASTER site-dev1.ru
NETWORK_IP_MASTER выбрать свободный IP в подсети graynetwork
DEPLOY_USER_MASTER dev
DEPLOY_HOST_MASTER docker-server-dev.ru
DEPLOY_DIRECTORY_MASTER /home/dev1/site-dev1.ru
PROJECT_FORKS <оставить пустым>

Перед деплоем на тестовый сайт, нужно создать ветку stable, указывающую в тот же коммит, что и ветка master. Ветка stable будет соответствовать состоянию staging-сайта, в этой ветке будет находиться только проверенный и принятый код.


В процессе работы разработчик должен, с одной стороны, иметь возможность объединять коммиты и переписывать историю через git push -f origin master. А с другой стороны, он не должен иметь возможность смещать ветку stable и создавать тэги, чтобы не нарушить работу всей остальной системы.


Для этого в разделе Settings --> Repository нужно снять защиту с ветки master и защить ветку stable и все тэги.


Для деплоя приложения на тестовый сайт разработчика нужно запустить Pipeline для ветки master. После этого нужно выдать роль Developer пользователю dev1 в разделе Settings --> Members.


В конце нужно донастроить основной репозиторий. Нужно добавить строку с адресом репозитория разработчика в переменную PROJECT_FORKS для синхронизации ветки stableв новом репозитории. И выдать роль Reporter пользователю dev1 в основном репозитории.


Последний шаг до начала работы – настройка Nginx на серверах с Docker. Этот Nginx будет настраиваться вручную, и все HTTP/HTTPS-запросы к приложениям Symfony будут им проксироваться в выбранный IP-адрес во внутренней, ранее созданной, подсети Docker (см. переменные NETWORK_NAME_... и NETWORK_IP_...).


Настройка внешнего Nginx

Создание конфигурационного файла


Пример конфигурация для домена site-dev1.ru. Здесь 192.168.10.10 — содержимое переменной NETWORK_IP_MASTER из настроек репозитория разработчика dev1.


server {
    listen 80;
#    listen 443 ssl;

    server_name site-dev1.ru;
#    ssl_certificate /etc/letsencrypt/live/site-dev1.ru/fullchain.pem;
#    ssl_certificate_key /etc/letsencrypt/live/site-dev1.ru/privkey.pem;
#    if ($ssl_protocol = "") {
#        rewrite ^/(.*) https://$server_name/$1 permanent;
#    }

    location / {
        proxy_pass http://192.168.10.10;
        include proxy_params;
    }

    location ~ /.well-known {
        allow all;
        alias /var/lib/letsencrypt/.well-known;
    }
}

Создание SSL-сертификата


/root/certbot-auto certonly \
  --no-self-upgrade \
  --webroot \
  -d site-dev1.ru \
  -w /var/lib/letsencrypt

Для переключения сайта с HTTP на HTTPS нужно раскоментировать строки в конфигурации HTTP-домена и перегрузить Nginx.


nginx -t
service nginx reload

Процесс разработки


На данном этапе у разработчика есть доступ в свой собственный репозиторий. В своём репозитории он имеет роль Developer и может делать практически всё что угодно. В репозитории разработчика ветка master соответствует состоянию его тестового сайта. Protected-ветка stable — состоянию сайта staging.


Как выглядит процесс разработки для разработчика

Каждое новое задание должно начинаться с создания ветки задачи, указывающей на тот же коммит, что и ветка stable.


git fetch --all --prune
git checkout origin/stable
git checkout -b feature-qwerty
git push origin feature-qwerty

Затем, на каком-то этапе, когда нужно выложить свои изменения в на тестовый сайт, можно залить изменения в репозиторий в ветку master — и изменения будут выложены в течении 2-5 минут.


Слияние изменений из репозитория разработчика в основной должно происходить из ветки задачи, в примере — это feature-qwerty, в ветку master основного репозитория через создания соответствующего Merge Request в Web-интерфейсе GitLab.


Перед принятием Merge Request администратор должен убедиться, что коммиты в ветке разработчика идут строго после текущего положения ветки master основного репозитория. Автоматически в GitLab CE это сделать не получится, фича доступна только в GitLab EE.


Для выкатки изменений на рабочий сайт, нужно создать тэг release-... в Web-интерфейсе GitLab.


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


Настройка параметров Symfony

Локальное окружение разработчика


Дефолтная конфигурация хранится в файле .env в корне проекта. Этот файл — один на всех разработчиков и является частью репозитория:


ENV_hwi_facebook_client_id=1234
ENV_hwi_facebook_client_secret=4567

Файл загружается при запуске docker-compose up -d, значения попадают в контейнер через блок environment в описании сервиса php:


services:
    php:
        environment:
            ENV_hwi_facebook_client_id: "${ENV_hwi_facebook_client_id}"
            ENV_hwi_facebook_client_secret: "${ENV_hwi_facebook_client_secret}"

Внутри Symfony эти значения попадают через файл app/config/parameters.yml (он также является частью приложения):


parameters:
    hwi_facebook_client_id: "%env(ENV_hwi_facebook_client_id)%"
    env(ENV_hwi_facebook_client_id): ~
    hwi_facebook_client_secret: "%env(ENV_hwi_facebook_client_secret)"
    env(ENV_hwi_facebook_client_secret): ~

Для внедрения новых папаметров, нужно перезагрузить docker-compose:


docker-compose stop
docker-compose up -d

Тестовый сайт разработчика


Перед выкаткой изменений на тестовый сайт разработчика, администратор должен добавить значения переменных для этого сайта в разделе Settings --> Pipelines. К именам переменных должен быть добавлен суффикс _MASTER


ENV_hwi_facebook_client_id_MASTER
ENV_hwi_facebook_client_secret_MASTER

Если переменные не будут созданы, значения для них будут браться из файла .env.


Staging


До принятия Merge Request в основном репозиторий нужно добавить переменные с суффиксом _MASTER, как это было сделано для тестового сайта разработчика.


После принятия Merge Request и внедрения изменений на staging нужно добавить переменные во все остальные репозитории разработчиков.


Production


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


Также для разработчика в development-окружении доступно расширение xdebug, а управление CSS и Javascript файлами происходит при помощи Webpack Encore.


CI/CD изнутри


Процесс непрерывной интеграции/внедрения описан в файле .gitlab-ci.yml в корне репозитория, он состоит из 4 стадий: загрузка зависимостей, phpunit-тестирование, сборка, развёртывание.


Загрузка зависимостей


На данном этапе производится попытка установить все зависимости приложения через composer.


Этап deps stage в .gitlab-ci.yml
deps:php-composer:
    stage: deps
    image: covex/php7.1-fpm:1.0
    script:
      - echo '{"github-oauth":{"github.com":"'"$COMPOSER_GITHUB_TOKEN"'"}}' > ./auth.json
      - composer install --prefer-dist --no-scripts --no-autoloader --no-interaction
    tags:
      - executor-docker

Результатом работы данного этапа будет наполнение папки /composer/home/cache. Эта папка сохраняется в volume у gitlab-ci-multi-runner и кэш composer будет доступен при выполнении всех последующих задач (как в текущей pipeline, так и в последующих).


PHPUnit-тестирование


Перед запуском собственно phpunit, создаются переменные окружения для работы приложения Symfony. Если какие-то значения переменных в testing-окружении должны отличаться значений во всех остальных окружениях — нужно создать такие переменные в настройках репозитория GitLab с суффиксом _TEST (например, ENV_hwi_facebook_client_id_TEST). Тогда её значение перекроет дефолтное из файла .env.


Этап test в .gitlab-ci.yml
.template-suffix-vars: &suffix-vars
    before_script:
      - cat .env | grep ENV_ > .build-env
      - sed -i 's/^/export /' .build-env
      - for name in `env | awk -F= '{if($1 ~ /'"$ENV_SUFFIX"'$/) print $1}'`; do
          echo 'export '`echo $name|awk -F''"$ENV_SUFFIX"'$' '{print $1}'`'='`printenv $name`'' >> .build-env;
        done

test:phpunit:
    stage: test
    image: covex/php7.1-fpm:1.0
    <<: *suffix-vars
    variables:
        ENV_SUFFIX: "_TEST"
    script:
      - eval $(cat .build-env)
      - echo '{"github-oauth":{"github.com":"'"$COMPOSER_GITHUB_TOKEN"'"}}' > ./auth.json
      - composer require phpunit/phpunit:* --dev
      - phpunit
    dependencies: []
    tags:
      - executor-docker

Сборка


Здесь сборка для php-проекта — это создание docker-образов для контейнеров nginx и php, и выкладывание подготовленных образов в GitLab Container Registry.


Этап build в .gitlab-ci.yml
.template-docker-nginx-image: &docker-nginx-image
    stage: build
    image: docker:latest
    <<: *suffix-vars
    script:
      - eval $(cat .build-env)
      - docker build --tag $CI_NGINX_IMAGE_WITH_TAG --build-arg server_name=$SERVER_NAME --build-arg server_upstream=prod --build-arg app_php=app ./docker/nginx
      - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
      - docker push $CI_NGINX_IMAGE_WITH_TAG
      - docker logout $CI_REGISTRY
    tags:
      - executor-docker
      - docker-in-docker

.template-docker-app-image: &docker-app-image
    stage: build
    image: docker:latest
    <<: *suffix-vars
    script:
      - eval $(cat .build-env)
      - echo '{"github-oauth":{"github.com":"'"$COMPOSER_GITHUB_TOKEN"'"}}' > ./auth.json
      - docker build --tag $CI_APP_IMAGE_WITH_TAG .
      - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
      - docker push $CI_APP_IMAGE_WITH_TAG
      - docker logout $CI_REGISTRY
    dependencies:
      - deps:php-composer
    tags:
      - executor-docker
      - docker-in-docker

.template-docker-compose: &docker-compose
    stage: build
    image: covex/docker-compose:1.0
    <<: *suffix-vars
    script:
      - eval $(cat .build-env)
      - mkdir build
      - docker-compose -f docker-compose-deploy.yml config > build/docker-compose.yml
      - sed -i 's/\/builds\/'"$CI_PROJECT_NAMESPACE"'\/'"$CI_PROJECT_NAME"'/\./g' build/docker-compose.yml
    artifacts:
        untracked: true
        name: "$CI_COMMIT_REF_NAME"
        paths:
          - build/
    tags:
      - executor-docker
    dependencies: []

build:docker-nginx-image-master:
    <<: *docker-nginx-image
    variables:
        ENV_SUFFIX: "_MASTER"
    only:
      - master
    except:
      - tags

build:docker-nginx-image-production:
    <<: *docker-nginx-image
    variables:
        ENV_SUFFIX: "_PRODUCTION"
    only:
      - /^release-.*$/
    except:
      - branches

build:docker-app-image-master:
    <<: *docker-app-image
    variables:
        ENV_SUFFIX: "_MASTER"
    only:
      - master
    except:
      - tags

build:docker-app-image-production:
    <<: *docker-app-image
    variables:
        ENV_SUFFIX: "_PRODUCTION"
    only:
      - /^release-.*$/
    except:
      - branches

build:docker-compose-master:
    <<: *docker-compose
    variables:
        ENV_SUFFIX: "_MASTER"
    only:
      - master
    except:
      - tags

build:docker-compose-production:
    <<: *docker-compose
    variables:
        ENV_SUFFIX: "_PRODUCTION"
    only:
      - /^release-.*$/
    except:
      - branches

Здесь, задача build:docker-app-image-master создаёт образы PHP-приложения для staging-сайта (и для тестового сайта разработчика); а задача build:docker-app-image-production — для production-сайта. Для каждой задачи значения переменных из настроек pipeline с суффиксом _MASTER или _PRODUCTION перекрывают дефолтные значения из файла .env. Аналогичным образом описаны задачи по сборке образов nginx (см. задачи build:docker-nginx-image-master и build:docker-nginx-image-production).


Также на этом этапе создаётся файл docker-compose.yml, который на следующем этапе будет скопирован на удалённый сервер (см. задачи build:docker-compose-master и build:docker-compose-production). Сформированный файл docker-compose.yml содержит все переменные окружения, необходимые для запуска приложения. В секции services все контейнеры будут создаваться только на основе готовых образов docker.


Пример сформированного файла docker-compose.yml
networks:
  nw_external:
    external:
      name: graynetwork
  nw_internal: {}
services:
  mysql:
    environment:
      MYSQL_DATABASE: project
      MYSQL_PASSWORD: project
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: project
    expose:
    - '3306'
    image: covex/mysql:5.7
    networks:
      nw_internal: null
    restart: always
    volumes:
    - database:/var/lib/mysql:rw
  nginx:
    depends_on:
      mysql:
        condition: service_healthy
    image: gitlab.site.ru:5005/dev1-projects/symfony-workflow2/nginx:master
    networks:
      nw_external:
        ipv4_address: 192.168.10.13
      nw_internal: null
    ports:
    - 80/tcp
    restart: always
    volumes:
    - assets:/srv/a:ro
    - assets:/srv/b:ro
    - assets:/srv/storage:ro
  php:
    environment:
      ENV_database_host: mysql
      ENV_database_mysql_version: '5.7'
      ENV_database_name: project
      ENV_database_password: project
      ENV_database_port: '3306'
      ENV_database_user: project
      ENV_mailer_from: andrey@mindubaev.ru
      ENV_mailer_host: 127.0.0.1
      ENV_mailer_password: 'null'
      ENV_mailer_transport: smtp
      ENV_mailer_user: 'null'
      ENV_secret: ThisTokenIsNotSoSecretChangeIt
    image: gitlab.site.ru:5005/dev1-projects/symfony-workflow2:master
    networks:
      nw_internal: null
    restart: always
    volumes:
    - assets:/srv/a:rw
    - assets:/srv/b:rw
    - assets:/srv/storage:rw
  spare:
    environment:
      ENV_database_host: mysql
      ENV_database_mysql_version: '5.7'
      ENV_database_name: project
      ENV_database_password: project
      ENV_database_port: '3306'
      ENV_database_user: project
      ENV_mailer_from: andrey@mindubaev.ru
      ENV_mailer_host: 127.0.0.1
      ENV_mailer_password: 'null'
      ENV_mailer_transport: smtp
      ENV_mailer_user: 'null'
      ENV_secret: ThisTokenIsNotSoSecretChangeIt
    image: gitlab.site.ru:5005/dev1-projects/symfony-workflow2:master
    networks:
      nw_internal: null
    restart: always
    volumes:
    - assets:/srv/a:rw
    - assets:/srv/b:rw
    - assets:/srv/storage:rw
version: '2.1'
volumes:
  assets: {}
  database: {}

Развёртывание


На данном этапе docker-образы приложения готовы и загружены в Container Registry. Осталось обновить приложения.


На удалённых серверах ceрвис phpmyadmin отсутствует; дополнительно к сервису php добавлен абсолютно такой же сервис spare; а в конфигурации nginx вместо одного сервера в upstream прописано два. Использование двух одинаковых сервисов позволило добиться практически нулевого deployment downtime.


Этап deploy в .gitlab-ci.yml
.template-secure-copy: &secure-copy
    stage: deploy
    image: covex/alpine-git:1.0
    before_script:
      - eval $(ssh-agent -s)
      - ssh-add <(echo "$SSH_PRIVATE_KEY")
    script:
      - eval $(cat .build-env)
      - ssh -p 22 $DEPLOY_USER@$DEPLOY_HOST 'set -e ;
          rm -rf '"$DEPLOY_DIRECTORY"'_tmp ;
          mkdir -p '"$DEPLOY_DIRECTORY"'_tmp'
      - scp -P 22 -r build/* ''"$DEPLOY_USER"'@'"$DEPLOY_HOST"':'"$DEPLOY_DIRECTORY"'_tmp'
      - ssh -p 22 $DEPLOY_USER@$DEPLOY_HOST 'set -e ;
          if [ -d '"$DEPLOY_DIRECTORY"' ]; then rm -rf '"$DEPLOY_DIRECTORY"'; fi ;
          mv '"$DEPLOY_DIRECTORY"'_tmp '"$DEPLOY_DIRECTORY"' ;
          cd '"$DEPLOY_DIRECTORY"' ;
          docker login -u gitlab-ci-token -p '"$CI_JOB_TOKEN"' '"$CI_REGISTRY"' ;
          docker-compose pull ;
          docker-compose up -d --no-recreate ;
          docker-compose up -d --force-recreate --no-deps spare ;
          docker-compose exec -T spare sh -c "cd /srv && rm -rf b/* && cp -a web/. b/ && rm -rf a/* && cp -a web/. a/" ;
          docker-compose exec -T spare phing storage-prepare database-deploy ;
          docker-compose up -d --force-recreate --no-deps php'
      - ssh -p 22 $DEPLOY_USER@$DEPLOY_HOST 'set -e ;
          cd '"$DEPLOY_DIRECTORY"' ;
          echo "[$(date -R)] web-server is down" ;
          docker-compose stop nginx ;
          docker-compose up -d nginx ;
          echo "[$(date -R)] web-server is up"'
    tags:
      - executor-docker

deploy:secure-copy-master:
    <<: *secure-copy
    only:
      - master
    except:
      - tags
    environment:
        name: staging
    dependencies:
      - build:docker-compose-master

deploy:secure-copy-production:
    <<: *secure-copy
    only:
      - /^release-.*$/
    except:
      - branches
    environment:
        name: production
    dependencies:
      - build:docker-compose-production

Алгоритм развёртывания следующий:


  • Копируем сформированный на этапе build файл docker-compose.yml
  • Загружаем новые образы из Container Registry
  • Обновляем контейнер spare
  • Обновляем статичные файлы для nginx, производим миграцию БД
  • Обновляем контейнер php
  • Обновляем контейнеры nginx и mysql (в боевых условиях — это не обязательно)

Во время обновления контейнеров spare или php, nginx через несколько секунд недоступности одного из них переключается на следующий доступный в upstream. Т.е. приложение работает правильно для 100% HTTP-запросов, но иногда с задержкой.


Во время выполнения миграции БД первая половина HTTP-запросов идёт в контейнер php, который может работать со старой структурой БД, а вторая половина — в контейнер spare, который может работать с только с новой структурой. Т.е. в обоих контейнерах возможны сбои в работе во время миграции БД. Но если допустить, что внесение изменений в структуру БД не такое уж и частое явление, то можно считать это вполне приемлемым.


Во время обновления контейнеров nginx и mysql, сайт недоступен вообще. Эти сервисы обновляются очень редко, обновление вообще можно производить вручную "ночью". Проверка возможности обновлений для этих контейнеров длится около 5 секунд, что примерно 80-90% от всего deployment downtime.


Заключение


GitLab Continuous Integration & Deployment и docker-compose — замечательные инструменты. Вместе с ними мы наконец-то смогли отказаться от использования vagrant в процессе разработки. Сайт проекта, запущенный локально, стал работать гораздо быстрее, даже с большим количеством библиотек, подключенных через composer.json. Development-окружение стало не просто схожим — теперь оно абсолютно такое же, как в production, исчезли ограничения использования технологий помимо Linux + Apache + PHP + MySQL. Параллельные изменения в коде разными разработчиками не конфликтуют друг с другом, а новая процедура деплоя позволяет выкладывать изменения гораздо чаще, чем мы могли бы себе позволить ранее.


Следующий шаг — использование docker swarm, или kubernetes, или оставим всё как есть. Пока не ясно, время покажет.

Метки:
Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 39
  • +1
    Поправте плз текст ссылки

    С инструкцией по установке GitLab CI Runner можно ознакомиться на сайте docs.docker.com.
    • 0
      Исправил. Спасибо
    • +1
      Актуальная тема! Спасибо!

      Скажите, используете ли вы проверку кода (CodeSniffer)? Как вы считаете, уместно ли проверять код именно тут. Ведь на сколько я понимаю, если код не прошел проверку, то его просто вообще нельзя запушить на сервер.
      • 0
        Мы используем, но у нас это не автоматизировано: есть свой coding style, основанный на PSR-2, всем «рекомендуется» настраивать свою IDE так чтобы оно подсвечвало неправильно оформленный код. Ну и на этом всё, да и не прижилась у нас такая проверка.

        Проверку на coding style можно было бы запускать до этапа build и после этапа deps. Параллельно с phpunit, например, было бы самое оно. Ну и этап можно было бы переименовать: вместо test сделать qa, например. Вообще ещё много чего можно добавить в pipeline! Например, этапа lint нет. Ещё нужно проверять а запустятся ли контейнеры после сборки до того, как их выложим на удалённый сервер и как они будут работать (вот тут я не знаю каким инструментом можно воспользоваться, кстати). Может быть ещё что-нибудь.

        Ещё я не согласен с тем, что непрошедший проверку на coding style код, не может быть запушен на сервер! Если такую проверку и вводить, то этот код таки будет запушен, но результат работы не дойдёт до тестового сайта разработчика (не говоря уже про staging и production), что не позволит сдать результат работы. И если такая проверка (как и phpunit-тестирование, кстати) не позволяет выкладывать код на удалённый сервер, разработчик должен будет исправить недочёты, объединить коммиты и запушить --force свой код ещё раз.
      • 0
        мне кажется, что лучше на job-ах deps:php-composer и test:phpunit лучше использовать реальный php контейнер. Ведь если мы добавим в composer.json зависимость, которому будет необходим какой то экстеншн то тесты на зависимость уже не пройдут. Та же ситуация с phpunit, но тут еще и настройки php добавляются.
        • 0
          и возможно, такой pipeline возможно тогда будет лучше

          build — создаем контейнеры и пушим в Registry
          deps — используем созданные контейнеры из Registry
          test — используем созданные контейнеры из Registry

          Кстати, ведь для тестов вероятно нужны будут зависимости, но так как это другая джоба, то зависимостей не этапе теста нет. Тут наверное нужно объеденить deps и test в одну джобу?
          • 0

            На самом деле в скелете приложения задача deps:php-composer не особо и нужна. Она заполняет кэш composer, а использует этот кэш только задача test:phpunit. Задача test:phpunit запускает composer require phpunit/phpunit:* --dev что, также как и в deps, заполняет кэш composer. Я оставил deps в шаблоне "на всякий пожарный", чтобы в случае, когда параллельно с phpunit будут выполнятся какие-то другие задачи, — этот кэш не надо было наполнять в конкурентной борьбе компоузеров.


            И если останется только одна задача на этапе test, то deps и test можно будет объединить. А если появится третья, например, phpcs — то весь кэш нужно будет загружать в deps так, чтобы он был доступен и для phpunit и для phpcs, т.е. объединять не нужно. Пусть пока будут разъединёнными — а потом либо надо будет удалить deps, либо на "потенциальном" этапе qa что-нибудь появится такое, что будет полезным всем.

            • +1
              На сколько я знаю, кеширование папок делается вот так
              cache:
                paths:
                  - vendor/
              

              А каким образом это происходит у вас? ведь наполненый кеш композера и заполненая папка vendors это все таки разные вещи. И для исполнения скриптов нужно чтоб были фалы в vendors, а не в кеше композера.
              • 0

                Таким образом можно закэшировать только директории внутри рабочей директории.


                В Dockerfile образа для php домашняя директория composer установлена в /composer/home. Кэш composer находится в /composer/home/cache. Эта директория находится вне рабочей директории, с которой может работать GitLab Runner.


                И поэтому я сначала формирую кэш composer, чтобы много раз запускать composer install или composer require.

                • 0
                  О, теперь ясно, спасибо! Но ИМХО, это не правильно, базовый образ «критично» связан с gitlab-ci. И это не явно. Можно легко все нечаянно сломать
                  • 0

                    Весь процесс будет работать и без кэширования этой директории. Но с кэшированием через gitlab ci runner оно просто быстрее работает. Но, вообще, я согласен про возможность "нечаянно сломать".

                    • 0
                      у себя я это сделал через кеширование папки vendors и работает оно быстро, ведь нужно только сравнить composer.lock и папку vendors
          • 0
            А сейчас так и есть: на всех этапах в качастве основы уже используется один и тот же образ для php: covex/php7.1-fpm:1.0. См. в репозитории файлы: Dockerfile (для образа приложения для удалённых серверах), Dockerfile-dev (для контейнера в локальном окружении), .gitlab-ci.yml — описание задач в pipeline. Для «скелета приложения» образ — именно такой. А для реальных задач мой образ всё равно будет не очень безопасно использовать — нужно будет делать свой, копируя и изменяя Dockerfile моего образа для контейнера с php.
            • 0
              Не уверен что я правильно вас понял.
              Вы описали так джоб, где композер инсталирует зависимости
              deps:php-composer:
                  stage: deps
                  image: covex/php7.1-fpm:1.0
                  script:
                    - echo ...
                    - composer install --prefer-dist --no-scripts --no-autoloader --no-interaction
              

              Но это не боевой контейнер, а «covex/php7.1-fpm:1.0», вы ведь когда будете изменять php docker image (тут) образы могут быть разные. Вы тестируете не тем образом что будет в продакшене
              • 0

                Это не инсталирование зависимостей, а заполнение кэша composer, чтобы этот кэш использовали следущие задачи. Кэш composer хранится в volume у gitlab-ci-runner и доступен для всех задач, которые запускаются этим runner-ом.


                Окружение на этапе test, когда работает phpunit, совпадает с тем, что в production — там один и тот же образ. Но в production (да и везде внутри контейнеров, запущенных через docker-compose) — есть база данных, а на этапе test, для phpunit этой БД ещё нет.


                Для юнит-тестов этой группы подключение БД не нужно, а юнит-тестов, использующих БД ещё и не существует. Наверное, эту задачу нужно будет сделать вместе с "интеграционными тестами". Например: (1) запускаем docker-compose внутри docker (2) проверяем запущены ли все контейнеры (3) наполняем БД данными для тестирования (4) запускаем тесты, использующие БД.

                • +1
                  Андрей. спасибо за такие подробные ответы, но я хотел бы уточнить.

                  Вы тестируете образом «covex/php7.1-fpm:1.0», а на продакшене образ от этого файла который наследуется от «covex/php7.1-fpm:1.0».

                  Если вы будете менять образ для прода, вы ведь в этот же файл планируете вносить изминения, а не в базовый образ? Если так, то получается два разных образа.
                  • 0

                    Если вдруг будет нужно установить новое расширение или ещё какое-нибудь ПО внутрь образа с php, я сделаю новый образ на основе Dockerfile для образа covex/php7.1-fpm:1.0 и буду использовать новый образ как "базовый" для проекта на всех стадиях.

                    • 0
                      А какой тогда смысл в локальном php Dockerfile, если вы изминения будете вносить в базовый образ?
                      • 0

                        Расширения php для образа на основе alpine очень долго устанавливаются. Для создания образа я использую репозиторий на gitlab.com — там сборка образа занимает 3-4 минуты. Локально у меня на ноуте это длится в разы дольше. Если очень часто запускать docker-compose down -v и затем docker-compose up -d то, можно весь день только этим и заниматься =)

                        • 0
                          =) но вопрос остался тем же, зачем локальный Dockerfile если мы фактически вручную вносим изминения в родительский образ? Зачем тогда вообще что то собирать? Сделали docker pull чего нам надо, и юзаем
                          • 0

                            Локально используется файл Dockerfile-dev — это "базовый образ" плюс включение расширения xdebug.


                            Но, видимо, тут какое-то недопонимание есть. Локально весь код находится на хосте. Внутри контейнера код доступен в директории /srv. Плюс локально нужно вручную запускать phing после запуска docker-compose up


                            На удалённом сервере создаётся новый образ. Это "базовый образ" плюс копирование кода в внутрь контейнера, также в папку /srv; плюс запускается composer install; плюс запускается phing (для вызова cache:warmup и assets:install).


                            Таким образом "базовый образ" — это основа для всех окружений. От проекта к проекту основа может отличаться. Расширениями, например.


                            "Базовый образ" для скелета приложения — дефолтный covex/php7.1-fpm:1.0. Для другого проекта — он может быть другим, и я его сначала подготовлю, а потом буду использовать также как дефолтный.

          • 0
            правильно ли я понял, что мы не можем тут провести тесты, которым требуется DataBase?
            • 0

              На этапе test базы данных не существует — там только php. БД появляется в docker-compose после этапа build.


              Основной репозиторий "скелета приложения" хранится у нас внутри GitLab. Репозиторий на GitHub — это его копия. И для репозитория на GitHub я дополнительно подключил travis-ci для тестирования "как система будет запускаться с нуля". Вот файл .travis.yml, и вот задача внутри travis.


              И вот в Travis CI тесты запускатся внутри docker-compose! Таким же самым образом можно сделать "тестирование-с-базой-данных" на этапе между build и deploy. Но это сейчас не реализовано, да.

            • +2
              Не могли бы вы детальнее расказать, для чего для каждого разработчика свой репозиторий? Что заставило прийти к такому подходу?
              • 0

                Чтобы иметь возможность безболезненно менять историю коммитов. Ну и чтобы изменения одного разработчика никак не влияли на результат работы другого.


                До docker у нас был один репозиторий, доступный для всех разработчиков, на проект. Для деплоя на тестовый сайт проекта, мы используем ещё один "служебный и скрытый ото всех" репозиторий, в который jenkins заливает изменения после push в ветку задачи разработчика. Вобщем, у нас Система. И эта система ломается, когда кто-то из добрых и хороших побуждений решает сделать git push --force — приходится чистить и приводить репозитории к работоспособному виду.


                А иногда разработчики правят один и тот же файл в одном и том же месте! Тут опять приходится вмешиваться, потому что и тут система оказывается сломаной =) И хорошо ещё, что репозитории находятся внутри GitLab (он у нас какой-то там совсем старой версии), так что ветки master и production у нас защищены от изменений.


                Ещё иногда случается, что локально без изменения коллеги всё работает, а на тестовом сайте, со слитыми изменениями всех разработчиков, что-то идёт не так.


                Сейчас же у разработчика есть и свобода действий с историей коммитов с одной стороны, и строгие рамки в виде ветки origin/stable — все изменения должны идти строго после неё.

                • 0

                  Перемудрено сильно, по-моему. Три ветки (у нас было мастер, препрод и дев) защищенные по полной, для каждой фичи или разработчика отдельная ветка, которая средствами CI/CD гитлаба и докера разворачивается на отдельный домен типа cool-feature-branch.dev.example.com, ветка препрод идёт на preprod.example.com, а мастер на example.com. Локально смотреть можно на cool-feature-branch.example.com.localhost (по дефолту, а в принципе через env переменные передаётся).

                  • 0
                    Я исходил из имеющихся у меня инструментов. Например, я не знаю как сделать новый домен, выделить свободный IP в подсети docker, и плюс ещё настроить «внешний» nginx чтобы тот проксировал HTTP-запрос в нужный внутренний ip:port. Наверное поэтому у меня так и получилось: операции с настройкой внешнего nginx — вручную, а всё остальное — автоматом. А про «локально» я не понял.
                    • +2

                      Локально — на машине разработчика разворачивается система, по возможности без необходимости ребилдить при каждом изменении исходников через монтирование volumes


                      Автоматическая привязка создается примерно так:


                      • контейнер с прокси (nginx, haproxy, ...), привязывается к 80/443/… порту хоста, слушает события докера и при наличии у стартующего контейнера специальных меток (имя домена, порт, протокол и т. п.) реконфигурирует прокси для нового контейнера. Есть минимум два готовіх решения — docker-flow и nginx-proxy. Я первое выбрал, поскольку в целом swarm используем.
                      • внешний днс сервер настраивается на вилд-кард для домена, типа *.dev.example.com, резолвя его на хост с контейнером прокси.
                      • +1
                        А может быть вы напишете тут, на хабре, про контейнер-с-прокси, про docker-flow и docker swarm?
                        • 0
                          Я кстати зарезолвил всю зону *.dev на 127.0.0.1 через dnsmasq для локальной разработки
                          • 0

                            Использовал подобное решение для резолвинга *.localhost, пока dnsmasq был штатным для ubuntu.

                          • +1

                            Сейчас начал писать пост о разработке микросервисной системы, в основе большинства сервисов которой на Symfony 3+ с DDD, ES и т. п… Не знаю успею ли закончить до 1 сентября, но постараюсь и этот момент отразить. Если не успею, то не знаю когда закончить смогу.


                            Основная проблема, внезапно, сначала придумать, а потом реализовать предметную область. Вроде придумал одну, но как-то она полезла в теорию графов внезапно. Вот сижу думаю стоит ли зарываться, реализовать "в лоб", а-ля MVP или переключиться на классические "бложики-тудушечки".

                    • 0
                      На сколько я понял этот подход ветвления называется forking-workflow и в atlassian считают ее очень хорошим. Правда они приводят другие преимущества.
                      • 0
                        Я вспомнил, что был на этой странице, когда изучал Git и выдумывал нашу предыдущую «систему». Видимо, тогда мне этот подход понравился, но со старым GitLab и Jenkins не получилось сделать удобный и понятный workflow, а тут — оно как-то само вспомнилось и я возомнил, что я это сам придумал =) Я стремился сделать чтобы было похоже на GitHub, где есть один общедоступный репозиторий и много contributors. В итоге так и получилось, но со своей спецификой: это всё таки не для open source проектов.
                  • 0
                    before_script:
                          - eval $(ssh-agent -s)
                          - ssh-add <(echo "$SSH_PRIVATE_KEY")
                    


                    ssh-add <
                    очепятка?
                    • 0

                      Это работает как нужно. И я скопировал это из документации на docs.gitlab.com

                      • 0
                        Возможно из-за того что я ипользую docker:git версию, но у меня не работало. И не только у меня
                        • 0

                          Да, непонятно. Мой образ — это alpine + bash + openssh-client + git + настройка ssh и git. Практически такой же как docker:git только без докер =)

                      • 0
                        мой рабочий вариант такой
                        deploy_dev:
                          stage: deploy
                          script:
                            - eval $(ssh-agent -s)
                            - echo "$SSH_PRIVATE_KEY" > /tmp/id_rsa && chmod 600 /tmp/id_rsa && ssh-add /tmp/id_rsa
                            - ssh -o StrictHostKeyChecking=no -p $SSH_PORT $SSH_USER@$SSH_HOST
                          environment:
                            name: dev
                          only:
                            - dev

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