⬆️ ⬇️

Continuous integration / deployment of a symfony application using docker-compose and GitLab CI

In this article, I will share my experience in automating the entire process of developing a symfony application from scratch, from infrastructure setup to deployment in production. From development to production, the docker-compose will be used to launch the application, and all the continuous integration / deployment procedures will be run via GitLab CI / CD Pipelines in docker containers.



The implication is that you are familiar with docker and docker-compose. If not, or you don’t know how to install it, I prepared instructions on how to prepare the developer’s local environment . In fact, to work on the application, only Docker, VirtualBox and, optionally, Yarn will be required.



Run application locally



I prepared the skeleton application and laid it out on github . Everything written below refers to applications created on the basis of this template and to the infrastructure necessary to run such an application.



To run the application locally, you need to run the following commands:



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


The site will be available at http: //docker.local/ , you do not need to add app_dev.php/ to the address. 4 containers will be launched: nginx , php , mysql and phpmyadmin (the latter is run only in the development environment).



docker.local needs to be registered in the hosts . For Linux, the ip-address of the site will be 127.0.0.1 , and under Windows you can find it out as a result of the work of the docker-machine env (after all, see the instructions ).



composer in the php container is configured in such a way that the vendor folder is inside the container and not on the host, and does not affect the performance in the local developer's environment.



Preparing and setting up the infrastructure



In combat, the system will require three servers: GitLab — the server for managing the Git and Container Registry repositories, Docker production — the server for production sites, and Docker — the server for pre-production and developer test sites.



Gitlab

Setting up a server with GitLab and Container Registry



GitLab and Container Registry installation instructions are available at gitlab.com.



By default, the GitLab Container Registry requires setting SSL certificates. We will use the same certificate for both the Container Registry and the GitLab web interface. You can create an SSL certificate using the LetsEncrypt service.



You can connect an SSL certificate in the /etc/gitlab/gitlab.rb file. You also need to configure the ability to automatically renew the certificate:



 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" 


After the change in the gitlab.rb file, gitlab.rb need to reload GitLab via gitlab-ctl restart and configure crontab to update the certificates:



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


Docker for production

Setting up a server with Docker for production



Docker installation instructions are available on docs.docker.com .



Additionally, you need to create a local network to assign internal IP addresses to the containers:



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


In addition to the Docker server, you must install nginx and certbot-auto from LetsEncrypt .



Nginx will proxy requests to web servers in Docker containers. Nginx installation instructions are available on the nginx.org website.



Updating future SSL certificates should be configured immediately, as with on the server with GitLab:



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


Docker for development

Setting up a server with Docker for development



You need to complete all the Docker production installation points Docker production and additionally install GitLab CI Runner on the server.



GitLab CI Runner installation GitLab CI Runner are available on docs.gitlab.com .



Running 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" 


The <token> must be copied from the GitLab web interface in the Admin Area --> Runners section.



Several developers will work on the project, they need to give access so that they do not break anything and do not interfere with each other.



Access setting

Creating a Master User



  • On the Docker production server Docker production you need to create a master user and add it to the docker group:



     adduser master usermod -aG docker master 


  • Next, you need to log in as a new user and create an id_rsa key without passphrase:



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


    This key will be used for SSH access to the server and for access to the git repository of developers.



  • In GitLab, create a user master and add an SSH key to it. This user will be purely technical. In the future, under it will not need to go and perform any operations.


Creating a developer user



  • On the Docker server Docker you need to create a user dev1 Docker (the name can be any):



     adduser dev1 usermod -aG docker dev1 


  • Next, you need to log in as a new user and create an id_rsa key without 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 


    This key will be used for SSH access to the server, it should not be known to any of the developers.



  • In GitLab create user dev1 , forbidding him to create their own repositories and groups. You do not need to configure the SSH key - the developer will set it up for himself.



  • In GitLab, create the dev1-projects group and add the master user with the Master role to the group. This group will contain all the repositories of this developer.


The project will have one main repository and one repository for each developer. The main repository will be the source for production- and staging-sites, the developer repository for the test site of this particular developer. Deploy processes for each of the sites will be the same. The differences will be only in the application configuration and access settings to the server with Docker. Configuration and settings will be stored in GitLab , in the Settings -- CI/CD Pipelines section Settings -- CI/CD Pipelines : mainly the repositories are for production and staging sites, and in the developer repository are for the developer’s test site.



Create and configure the main repository

The main project repository can be placed in an arbitrary group.



In the Settings --> Pipelines section, you need to select git clone as your Git strategy for pipelines and add variables:



VariableValue
COMPOSER_GITHUB_TOKENCreate a token at https://github.com/settings/tokens
SSH_PRIVATE_KEYfill it with the contents of the master user id_rsa file
NETWORK_NAME_MASTERgraynetwork
SERVER_NAME_MASTERsite-staging.ru
NETWORK_IP_MASTERchoose free IP in the graynetwork subnet
NETWORK_NAME_PRODUCTIONgraynetwork
SERVER_NAME_PRODUCTIONsite-production.ru
NETWORK_IP_PRODUCTIONchoose free IP in the graynetwork subnet
DEPLOY_USER_MASTERmaster
DEPLOY_HOST_MASTERdocker-server-prod.ru
DEPLOY_DIRECTORY_MASTER/home/master/site-staging.ru
DEPLOY_USER_PRODUCTIONmaster
DEPLOY_HOST_PRODUCTIONdocker-server-prod.ru
DEPLOY_DIRECTORY_PRODUCTION/home/master/site-production.ru
PROJECT_FORKS<leave empty>


To deploy the skeleton of an application to staging, you need to upload the master branch to the repository via git push origin master .



Creating and setting up a developer repository

The developer repository must be in the developer’s project group For the user, dev1 is dev1-projects . The developer repository is created by creating an administrator from the main repository for Fork . It is important.



  • Together with the creation of a fork, it will be possible to create a Merge Request from the developer’s repository to the main
  • And the creation of a fork by the administrator is necessary to ensure the stability of the system and to keep the key secret for the id_rsa to access the server.


In the Settings --> Pipelines section, you need to select git clone as your Git strategy for pipelines , hide the Public pipelines and add variables:



VariableValue
COMPOSER_GITHUB_TOKENCreate a token at https://github.com/settings/tokens
SSH_PRIVATE_KEYfill it with the contents of the dev1 user dev1
NETWORK_NAME_MASTERgraynetwork
SERVER_NAME_MASTERsite-dev1.ru
NETWORK_IP_MASTERchoose free IP in the graynetwork subnet
DEPLOY_USER_MASTERdev
DEPLOY_HOST_MASTERdocker-server-dev.ru
DEPLOY_DIRECTORY_MASTER/home/dev1/site-dev1.ru
PROJECT_FORKS<leave empty>


Before deploying to the test site, you need to create a stable branch that points to the same commit as the master branch. The stable branch will correspond to the state of the staging site; only verified and accepted code will be located in this branch.



In the process of work, the developer should, on the one hand, be able to merge commits and rewrite history via git push -f origin master . On the other hand, it should not be able to displace the stable branch and create tags in order not to disrupt the rest of the system.



To do this, in the Settings --> Repository section, you need to unprotect the master branch and secure the stable branch and all tags.



To deploy an application to the developer’s test site, you need to run Pipeline for the master branch. After that, you need to issue the Developer role to the user dev1 in the Settings --> Members section.



At the end you need to tune the main repository. You need to add a line with the address of the developer's repository to the PROJECT_FORKS variable to synchronize the stable branch in the new repository. And to issue the role Reporter user dev1 in the main repository.



The last step before starting work is to configure Nginx on servers with Docker. This Nginx will be configured manually, and all HTTP / HTTPS requests to Symfony applications will be proxied to the selected IP address in the internal, previously created, Docker subnet (see the NETWORK_NAME_... and NETWORK_IP_... variables).



Setting up an external Nginx

Creating a configuration file



Sample configuration for the site-dev1.ru domain. Here 192.168.10.10 is the content of the NETWORK_IP_MASTER variable from the dev1 developer repository settings.



 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; } } 


Create SSL Certificate



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


To switch the site from HTTP to HTTPS, uncomment the lines in the HTTP domain configuration and reboot Nginx.



 nginx -t service nginx reload 


Development process



At this stage, the developer has access to his own repository. In its repository, it has the role of Developer and can do almost anything . In the developer's repository, the master branch corresponds to the state of its test site. Protected- stable branch - as a staging site.



What is the development process for a developer?

Each new task must begin with the creation of a task branch pointing to the same commit as the stable branch.



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


Then, at some stage, when you need to put your changes in to the test site, you can upload the changes to the repository in the master branch - and the changes will be posted in 2-5 minutes.



The merging of changes from the developer's repository to the main one should occur from the task branch, in the example, this is feature-qwerty , to the master branch of the main repository through creating the corresponding Merge Request in the GitLab web interface.



Before accepting a Merge Request, the administrator must make sure that the commits in the developer branch go strictly after the current position of the master branch of the main repository. This will not happen automatically in GitLab CE, the feature is available only in GitLab EE .



To roll out changes to the working site, you need to create a release-... tag in the GitLab web interface.



Along with the change in the project code, the developer can add new values ​​to the application parameters. The values ​​of these parameters may vary depending on the environment.



Configuring symfony settings

Local Developer Environment



The default configuration is stored in the .env file in the project root. This file is one for all developers and is part of the repository:



 ENV_hwi_facebook_client_id=1234 ENV_hwi_facebook_client_secret=4567 


The file is loaded when docker-compose up -d started, the values ​​get into the container through the environment block in the php service description:



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


Inside symfony, these values ​​get through the file app/config/parameters.yml (it is also part of the application):



 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): ~ 


To implement new papameters, you need to restart docker-compose :



 docker-compose stop docker-compose up -d 


Developer Test Site



Before rolling out the changes to the developer’s test site, the administrator must add the variable values ​​for this site in the Settings --> Pipelines section. _MASTER suffix should be added to variable names.



 ENV_hwi_facebook_client_id_MASTER ENV_hwi_facebook_client_secret_MASTER 


If variables are not created, values ​​for them will be taken from the .env file.



Staging



Before accepting a Merge Request, in the main repository you need to add variables with the _MASTER suffix, as was done for the developer’s test site.



After accepting the Merge Request and implementing changes to staging you need to add variables to all other developer repositories.



Production



In the main repository, you need to add variables with the suffix _PRODUCTION , as was done for staging.



Also for the developer in the development environment, the xdebug extension is xdebug , and CSS and Javascript files are managed using the Webpack Encore .



CI / CD inside



The process of continuous integration / deployment is described in the file .gitlab-ci.yml in the root of the repository, it consists of 4 stages: dependency loading, phpunit testing, building, deployment.



Download dependencies



At this stage, an attempt is made to install all the dependencies of the application through the composer .



Stage deps stage in .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 


The result of this stage will be filling the folder /composer/home/cache . This folder is saved in the volume of gitlab-ci-multi-runner and the composer cache will be available for all subsequent tasks (both in the current pipeline and in subsequent ones).



PHPUnit testing



Before running phpunit , environment variables are created for the operation of the symfony application. If any values ​​of variables in the testing environment should differ in values ​​in all other environments - you need to create such variables in the settings of the GitLab repository with the _TEST suffix (for example, ENV_hwi_facebook_client_id_TEST ). Then its value will .env default from the .env file.



Test phase in .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 


Assembly



Here the build for the php project is the creation of docker images for nginx and php containers, and putting the prepared images in GitLab Container Registry.



Build stage in .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 


Here, the build:docker-app-image-master task build:docker-app-image-master creates PHP application images for the staging site (and for the developer’s test site); and the build:docker-app-image-production task build:docker-app-image-production is for a production site. For each task, the values ​​of variables from the pipeline settings with the suffix _MASTER or _PRODUCTION override the default values ​​from the .env file. The tasks for building nginx images are described in a similar way (see the build:docker-nginx-image-master tasks build:docker-nginx-image-master and build:docker-nginx-image-production ).



Also at this stage, the docker-compose.yml file is created, which in the next stage will be copied to the remote server (see build:docker-compose-master and build:docker-compose-production tasks). The generated docker-compose.yml contains all the environment variables needed to run the application. In the services section all containers will be created only on the basis of ready docker images.



Sample docker-compose.yml file generated
 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: {} 


Deployment



At this stage, the docker images of the application are ready and uploaded to the Container Registry. It remains to update the application.



There is no phpmyadmin server on remote servers; in addition to the php service, absolutely the same spare service has been added; and in the nginx configuration, instead of one server, two are upstream . The use of two identical services allowed achieving almost zero deployment downtime .



Stage deploy to .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 


The deployment algorithm is as follows:





During the update of the spare or php , nginx containers after a few seconds the inaccessibility of one of them switches to the next available in upstream . Those. The application works correctly for 100% HTTP requests, but sometimes with a delay.



HTTP- php , , — spare , . Those. . , , .



nginx mysql , . , "". 5 , 80-90% deployment downtime.



Conclusion



GitLab Continuous Integration & Deployment docker-compose — . - vagrant . , , , , composer.json . Development- — , production, Linux + Apache + PHP + MySQL. , , .



— docker swarm , kubernetes , . , .



')

Source: https://habr.com/ru/post/335564/



All Articles