Евгений Горяев aka floor12
разработка сложных веб-проектов

Yii2 приложение в docker +codeception в gitlab pipelines

Обложка для статьи Yii2 приложение в docker +codeception в gitlab pipelines

Встала передо мною сегодня такая задача. как запуск одного из сайтов. находящихся у меня на поддержке, в докере при помощи docker-compose. Запустить это Yii приложение в докере я его хочу потому, что сейчас у меня плавно идет миграция всех проектов на систему работы с docker-compose и ci от гитлаба. А у проекта, о котором сегодня речь, возникла необходимость обмена файлами с 1С сервером. Предоставить ограниченный доступ sftp серверу только до одной папки проекта - это же идеальный вариант использовать для этого докер. В общем, пакуя сегодня этот проект в докер и перенося его на другой сервер я убиваю трех зайцев: настраиваю sftp сервер для обмена данными с 1С, переношу этот проект на другой сервер и начинаю его деплоить и тестировать через ci гитлаба, и заодно пишу эту статью.

Сейчас этот проект висит в проде по стандартной олд-скул схеме: общий для пары десятков сайтов сервер mysql, установленный на хостовой машине, + пхп как модуль для апача + нгинкс. Такой вот ретро-подход без fpm.

Для того, чтобы обеспечить нормальную работу проекта с использованием docker-compose и выгрузкой его на прод нам необходимо будет сделать несколько несложных вещей:

  • составить docker-compose файл, причем в git репозиторий я предлагаю добавить две версии файла: dev (она же будет использоваться в тестах) и prod-версию;
  • поправить некоторые файлы проекта для получения параметров работы приложения из переменных окружения контейнера;
  • составить .gitlab-ci.yml файл для запуска тестов и публикации приложения;
  • протестировать работу gitlab runner на локальной машине (в моем случае - мак).

Запуск в docker

Для запуска проекта в минимальной конфигурации в докере нам потребуется использовать 3 контейнера: nginx, mysql и php-fpm. Для запуска acceptance тестирования в браузере нам понадобится также контейнер с сервером selenium и chrom (в нашем случае ограничимся прогону приемочных тестов в хроме). В некоторых других случаях, помимо трех основных контейнеров, могут еще понадобится контейнейры sphinx, memcache или redis - их можно добавить в docker-compose и настроить в конфиге приложения по аналогии с mysql.

Итак, в моем случае dev-версия конфига для basic приложения будет выглядеть так:

version: '2'
services:
 selenium:
   image: selenium/standalone-chrome
 mysql:
   ports:
     - '127.0.0.1:3305:3306'
   image: mysql:5.7
   volumes:
     - ./docker/mysql/data:/var/lib/mysql
    - ./docker/mysql/config/my.cnf:/etc/mysql/my.cnf
   environment:
     MYSQL_ROOT_PASSWORD: root
     MYSQL_DATABASE: database
     MYSQL_USER: yii2
     MYSQL_PASSWORD: database
 nginx:
   image: nginx
   volumes:
     - ./:/app:delegated
     - ./docker/nginx/:/etc/nginx/conf.d/
   ports:
     - '8888:80'
 php:
       build:
         context: ./
         dockerfile: Dockerfile
   environment:
     YII_DEBUG: 'true'
     YII_ENV: 'dev'
     YII_MAIL_FILE_TRANSPORT: 'true'
   volumes:
     - ./docker/php/php.ini:/usr/local/etc/php/conf.d/php.ini
     - ./:/app:delegated

Несколько пояснений по поводу конфига:

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

Для контейнера mysql пробрасываем на улицу папку для хранения файлов базы, а также конфиг my.conf для того, чтобы иметь возможность хранить конфигурацию сервера базы данных прямо в репозитории нашего приложения.

В секции контейнера nginx мы экспозим 80й порт контейнера на 8888 порт локальной машины, причем, хочу обратить внимание, что в случае с версией docker-compose файла для разработки, а не продакшена, экспоузить лучше не указывая локальный хост “127.0.0.1:8888:80”, а вешать на все интерфейсы “8888:80”, включая публичный, чтобы иметь возможность проверить работу сайта с телефонов и других устройств локальной сети, введя ip и порт нашей машины.

В секции контейнера php мы указываем несколько переменных окружения, которые мы будем слушать в приложении. Кроме того, мы не указываем имя образа php:7.3-fpm, а собираемся собирать свой образ на его основе из Dockerfile. Это потому, что стандартный образ php-fpm не содержит огромного количества дополнительных php модулей и debian пакетов, которые необходимы для работы и нашего приложения, и даже стандартного yii2-basic-app. Доставлять к стандартному образу мы будем следующие модули php:

  • gd (причем обязательно с поддержкой webp - так как мой файловый модуль конвертирует картинки в этот формат)
  • imap
  • zip
  • intl
  • pdo_mysql
  • xdebug (который по дефолту будет выключен)

А также в доставляем composer.phar и zip, git, и ffmpeg для кодировки видео-файлов (мой модуль для работы с файлами для yii2 умеет конвертировать видео-файлы в mp4 приемлемого разрешения). В итоге Dockerfile выглядит как-то так:

FROM php:7.3-fpm

RUN apt-get update
RUN apt-get install -y \
           git \
           libzip-dev \
           libc-client-dev \
           libkrb5-dev \
           libpng-dev \
           libjpeg-dev \
           libwebp-dev \
           libfreetype6-dev \
           libkrb5-dev \
           libicu-dev \
           zlib1g-dev \
           zip \
           ffmpeg

RUN docker-php-ext-configure gd \
   --with-webp-dir=/usr/include/ \
   --with-freetype-dir=/usr/include/ \
   --with-jpeg-dir=/usr/include/
RUN docker-php-ext-install gd

RUN docker-php-ext-configure imap \
   --with-kerberos \
   --with-imap-ssl
RUN docker-php-ext-install imap

RUN docker-php-ext-configure zip \
   --with-libzip
RUN docker-php-ext-install zip

RUN docker-php-ext-configure intl
RUN docker-php-ext-install intl

RUN docker-php-ext-install pdo
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install exif

RUN pecl install xdebug
RUN docker-php-ext-enable  xdebug

RUN echo "#zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20180731/xdebug.so" > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

RUN curl --silent --show-error https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

WORKDIR /app

Теперь в корне проекта создадим папку docker со следующей структурой, для хранения конфигов и данных контейнеров:

  • docker
    • php
      • php.ini
    • mysql
      • conf
        • my.conf
      • data
    • nginx
      • app.conf
    • .gitignore

Если вам не надо никак менять конфигурацию mysql, то my.conf можно оставить пустым на данном этапе. Конфиг app.conf для nginx у меня выглядит так:

server {
   listen  80;
   error_log  /var/log/nginx/error.log;
   access_log /var/log/nginx/access.log;
   root /app/web/;
   index index.php index.html;
   client_max_body_size 5000m;

   location / {
           try_files $uri $uri/ /index.php$is_args$args;
   }

   location ~ \.php$ {
           fastcgi_split_path_info ^(.+?\.php)(/.*)$;
           fastcgi_pass php:9000;
           fastcgi_index index.php;
           include fastcgi_params;
           fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
           fastcgi_param PATH_INFO $fastcgi_path_info;
       }
}

Собственно тут все просто: биндимся на 80 порт, увеличиваем размер возможного загружаемого файла до 5гб, для всех запросов кроме .php отправляем всех в папку проекта, для остальных - отправляем на хост php на 9000 порт где у нас висит контейнер c php-fpm.

В php.ini добавляем несколько необходимых нам параметров:

upload_max_filesize = 5000M
post_max_size = 5000M
date.timezone = Europe/Moscow

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

Добавляем наши новые файлы и папки в репозиторий и запускаем сборку, а по ее окончанию - запускаем группу.

$ git add docker-compose.dev.yml 
$ git add docker
$ git add Dockerfile
$ cp docker-compose.dev.yml docker-compose.yml 
$ docker-compose build
$ docker-compose up -d

Все должно запуститься. Проверить запущенные контейнеры можно командой $docker-compose ps, вывод которой покажет список контейнеров группы а также их проброшенные папки и порты.

Если пакеты composer еще не установлены в папке vendor, то можно выполнить команду, которая запустить их установку внутри контейнера (я предпочитаю с ключом -vv для более подробного вывода):

$ docker-compose exec php composer install -vv

Теперь, если мы обратимся на локальный хост и порт 127.0.0.1:8888 мы увидим ошибку фреймворка Yii2 о Database Exception - Connection Error, ведь мы еще не отредактировали код нашего приложения для работы с докером.

Конфиг базы данных у нас хранится в /config/db.php и шарится между web и консольным приложением. У меня он не был добавлен в репозиторий, а копировался с /config/db.example.php и правился под конкретный случай (разработка или прод). Теперь можно добавить /config/db.php, указав там постоянные параметры доступа к базе докера, а /config/db.example.php удалить. При этом, активировать enableSchemaCache для случая, когда система на продакшене. По итогу, /config/db.php выглядит так:

<?php

return [
   'class' => 'yii\db\Connection',
   'dsn' => 'mysql:host=mysql;dbname=database',
   'username' => 'yii2',
   'password' => 'database',
   'charset' => 'utf8',
   'enableSchemaCache' => YII_ENV == 'prod',
   'schemaCacheDuration' => 6000,
   'schemaCache' => 'cache',
];

Еще в секции конфига components[‘mailer’] приводим все вот к такому виду, давая возможность сконфигурировать параметры сервера для отправки почты, а также активировать file tranport для тестирования и разработки с помощью переменных окружения, которые мы зададим для этого контейнера в docker-compose.yml файле.

'mailer' => [
   'class' => 'yii\swiftmailer\Mailer',
   'useFileTransport' => getenv('YII_MAIL_FILE_TRANSPORT'),
   'transport' => [
       'class' => 'Swift_SmtpTransport',
       'host' => getenv('SMTP_HOST'),
       'username' => getenv('SMTP_LOGIN'),
       'password' => getenv('SMTP_PASSWORD'),
       'port' => getenv('SMTP_PORT'),
       'encryption' => 'ssl',
   ],
],

Кроме того, нам необходимо поправить входной скрипт web/index.php так, чтобы он забирал из переменных окружения режим работы приложения, заменяем первые строчки этими:

defined('YII_DEBUG') or define('YII_DEBUG', getenv('YII_DEBUG'));
defined('YII_ENV') or define('YII_ENV', getenv('YII_ENV'));

Теперь все готово чтобы выполнить миграции и проверить работу приложения в браузере.

$ docker-compose exec php ./yii migrate

После выполнения миграций, у меня успешно и без всяких ошибок открылся мой проект на локальном хосте 127.0.0.1:8888.

Тестирование с selenium в docker

Ранее, до того, как мы добавили докер в наше приложение, мы использовали разные конфиги приложения и базы для тестирования. Сейчас это не обязательно, проще и надежнее тестировать приложение с реальными конфигами, по необходимости немного их корректируя если приложение находится в режиме тестирования.

Главный конфиг codeception.yml я привожу к такому виду, чтобы он брал за основу /config/web.php вместо /config/test.php, как это было раньше:

actor: Tester
paths:
 tests: tests
 log: tests/_output
 data: tests/_data
 helpers: tests/_support
settings:
 bootstrap: _bootstrap.php
 memory_limit: 1024M
 colors: true
modules:
 config:
   Yii2:
     configFile: 'config/web.php'

Конкретно в этом приложении используется только acceptance тестирования, так как все его компоненты у меня во внешних библиотеках и тестируются там юнит-тестами отдельно. В этом конкретном случае, я хочу перед публикацией изменений приложения запустить acceptance тестирование, прокликать там основной функционал, и если никаких ошибок нет, отправить изменения в продакшн.

Когда мы писали docker-compose.dev.yml файл, мы добавили в него образ selenium/standalone-chrome, так что нам надо указать его в конфиге /tests/acceptance.suite.yml, а также указать, что обращаться к приложению нужно по адресу http://nginx. Этот конфиг у меня выглядит так:

class_name: AcceptanceTester
modules:
 enabled:
   - WebDriver:
       url: http://nginx
       host: selenium
       browser: chrome
   - Yii2:
       part: [orm, email, fixtures]
       cleanup: false

После этого можно запускать наши тесты:

$ docker-compose exec php vendor/bin/codecept run

Хочется отметить, что при тестировании в докере, мы не видим на нашем компьютере запущенного браузера, в котором пробегают тесты. Поэтому всю информацию придется брать из скриншотов и html-дампов в папке /tests/_output.

Помимо выполнения миграций, для проверки работы приемочных тестов, я предпочитаю подкинуть в приложение реальный дамп базы данных, поэтому возьму из бекапа любой дамп за последнее время и закину его в корень приложения, чтобы загружать его при запуске тестов в gitlab ci. Можно добавить его в репозиторий, что не очень уж красиво, можно брать его откуда-то в процессе выполнения паплайна в гитлабе. Я не буду выпендриваться, и добавлю в корень приложения облегченный и очищенный от лишних данных дамп с продакшена, и добавлю его в git.

Запуск тестов в gitlab-runner

Для начала, нам необходимо поставить себе локально gitlab runner. Поставить можно по инструкции для мака отсюда, или найти на этом сайте мануал для своей операционной системы.

Для того, чтобы у нас заработали gitlab piplines, необходимо в корень проекта добавить файл .gitlab-ci.yml, в котором описать инструкции для gitlab piplines. Кроме того, для того чтобы перед запуском тестов загружать в чистую базу данных наш снапшот боевой базы данных, я написал примитивный контроллер. Нужно это потому, что в нашем контейнере нет консольного mysql клиента, и загрузить этот дамп в базу консольной командой не получится. Вот код моего консольного контроллера для этих целей:

<?php

namespace app\commands;

use Yii;
use yii\console\Controller;
use yii\db\Exception;

/**
* Class DbController
* @package app\commands
*/
class DbController extends Controller
{
   /**
    * Загружаем базу для теста
    * @throws Exception
    */
   public function actionLoad()
   {
       $sql = file_get_contents('/app/test.dump.sql');
       Yii::$app->db->createCommand($sql)->execute();
   }
}

Теперь можно составить непосредственно файл инструкций на gitlab runner .gitlab-ci.yml, у меня он выглядит так:

codeception:
 image: floor13/compose
 services:
   - docker:dind
 variables:
   DOCKER_DRIVER: overlay
   DOCKER_HOST: tcp://docker:2375
 artifacts:
   expire_in: 1 week
   when: on_failure
   paths:
     - tests/_output/*
 script:
   - cp docker-compose.dev.yml docker-compose.yml
   - docker-compose up -d
   - docker-compose exec -T php composer install -vv
   - docker-compose exec -T php ./yii db/load
   - docker-compose exec -T php vendor/bin/codecept run

Что же за образ такой floor13/compose? Это обычный alpine образ в котором уже есть docker-compose утилита. Сервис docker:dind - это специальный сервис, который позволяет нам запустить как бы докер в докере, ведь сам gitlab runner запускается в докере, а нам необходимо поднять в нем через docker-compose свои контейнеры. Собственно говоря, это и происходит далее в инструкциях: копирование конфига docker-compose.dev.yml, поднятие всей группы контейнеров, установка пакетов composer, загрузка тестовой базы и запуск codeception.

Проверяем работу конфиг файла запуском локального гитлаб раннера. Заходим в папку проекта и выполняем команду:

$ gitlab-runner exec docker --docker-privileged codeception  

где codeception - это название нашей единственной работы, описанной в сценарии для гитлаба. Видим, что все выполняется хорошо, после чего коммитим и пушим изменения в гитлаб. Там, гитлаб обнаружив в корне проекта файл .gitlab-ci.yml, приступает к выполнению инструкций, описанных в нем.

Для обновления кода на проде я добавил в корень приложения небольшой баш-скрипт updade.sh, который забирает изменения с гитлаба, обновляет зависимости и накатывает миграции:

#!/bin/bash
git checkout -f master
git pull
docker-compose exec -T php  composer install -vv
docker-compose exec -T php ./yii migrate --interactive=0

После этого, добавил соответствующую секцию в .gitlab-ci.yml, которая выполняет следующую инструкцию: подключается к продакшн серверу по ссш через ключ, который мы сохранили в переменной окружения в настройках CI репозитория в гитлабе, а после этого выполняет скрипт ./update.sh. Теперь .gitlab-ci.yml с двумя “работами” выглядит так:

codeception:
 image: floor13/compose
 services:
   - docker:dind
 variables:
   DOCKER_DRIVER: overlay
   DOCKER_HOST: tcp://docker:2375
 artifacts:
   expire_in: 1 week
   when: on_failure
   paths:
     - tests/_output/*
 script:
   - cp docker-compose.dev.yml docker-compose.yml
   - docker-compose up -d
   - docker-compose exec -T php composer install -vv
   - docker-compose exec -T php ./yii db/load
   - docker-compose exec -T php vendor/bin/codecept run


prod_deploy:
 stage: deploy
 image: kroniak/ssh-client
 only:
   - master
 before_script:
   - mkdir -p ~/.ssh
   - eval $(ssh-agent -s)
   - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
 script:
   - ssh-add <(echo "$PROD_PRIVATE_KEY" | base64 -d)
   - ssh -p22 floor12@lg.ttit.pro "cd /home/floor12/youstore/ && ./update"

Таким образом, после того, как в браузере “прокликались” все тесты, раннер гитлаба подключится к продакшн серверу, и выполнит скрипт обновления, который накатит обновления из репозитория, обновит пакеты и накатит миграции.