Запуск в продакшн с помощью Docker

С тех пор, как я перешёл на разработку в Docker, моя программистская жизнь не будет прежней. Настолько это оказалось удобно, что словами не передать!

Любые версии PHP, любые версии Node, локальная отладка отправленных писем и т.д. Подключить можно какие угодно службы, для всяких разных нужд.

Естественно, мне захотелось перенести этот опыт и в боевой режим на сервере, чтобы мои контейнеры так же замечательно работали в продакшене.

Как оказалось, сделать это совсем несложно. Нужен только VPS, куда вы сможете установить Docker.

Конфигурация

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

Возможно вы не обращали внимание, но в в репозитории VespShop уже лежат 2 конфига:

  • docker/docker-compose.yml - для разработки
  • docker/docker-production.yml - для продакшена

Вы можете просто сравнить эти 2 файла и заметить небольшие различия:

  • продакшн контейнеры должны перезапускаться при ошибках
  • контенеры PHP используют разные конфиги и команды для запуска
  • в боевом режиме нет контейнера Mailhog для отлова почты
  • порты Node недоступны снаружи

Внутри конфигов PHP еще меньше различий - только установка XDebug.

Дальше проверяем работу новой конфигурации, указывая её при запуске:

docker compose -f ./docker-production.yml up -d --build

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

Сервис systemd

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

В качестве хостовой машины я использую свежую Ubuntu Server LTS, со встроенным systemd - это такой системный демон, который как раз отвечает за работу с разными сервисами, включая пользовательские.

Так что создаём ему новый конфиг в файле /etc/systemd/system/vesp-shop.service:

[Unit]
Description=VespShop
After=docker.service
Requires=docker.service

[Service]
WorkingDirectory=/home/user/vesp-shop/
ExecStart=docker compose -f /home/user/vesp-shop/docker/docker-production.yml up --build
ExecStartPost=service nginx reload
ExecStop=docker compose -f /home/user/vesp-shop/docker/docker-production.yml stop
Restart=on-failure

[Install]
WantedBy=multi-user.target

Обратите внимание, здесь нет флага -d, который означает запуск в фоне (т. е. detach) - потому что systemd должен следить за запущенным сервисом, читать его состояние и перезапускать при необходимости.

Дальше нужно активировать наш сервис и перезапустить демон.

systemctl enable vesp-shop
systemctl daemon-reload

Это создаст символическую ссылку /etc/systemd/system/multi-user.target.wants/vesp-shop.service и тогда systemd будет требовать запуск наших контейнеров сразу после загрузки сервера.

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

systemctl start vesp-shop

Следить за ходом событий можно двумя вариантами

  • через systemctl status vesp-shop
  • или перейти в docker директорию проекта и там использовать docker compose logs -f --tail=10

Первый вариант показывает вам статус сервиса вместе с последними строчками логов. А второй вариант позволяет следить за логами в прямом эфире и с подстветкой

Глобальный Nginx

Несмотря на то, что в наших контейнерах уже есть Nginx, напрямую выводить наружу я его не хочу. Меня вполне устраивает его роль прокси-сервера для доступа в контейнеры Docker.

А для доступа снаружи я устанавливаю Nginx на хостовую машину и прописываю вот такой конфиг:

server {
    listen 80;
    listen 443 ssl http2;
    server_name shop.vesp.pro;

    ssl_certificate /etc/letsencrypt/live/shop.vesp.pro/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shop.vesp.pro/privkey.pem;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    if ($scheme = 'http') {
        return 307 https://$host$request_uri;
    }

    location / {
        proxy_redirect                      off;
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-Host   $server_name;
        proxy_set_header X-Real-IP          $remote_addr;
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header X-Scheme           $scheme;
        proxy_read_timeout                  1m;
        proxy_connect_timeout               1m;
        proxy_pass http://127.0.0.1:10004;
    }
}

С получением сертификатов разберётесь сами (а если нет - спросите в комментариях, расскажу про certbot), но вот откуда взялся локальный порт 10004?

А очень просто - я его прописал в настройках vesp-shop/docker/.env:

NGINX_PORT=10004

Не зря же у нас там есть эта настройка, правильно?

При локальной работе она равна 8080, но локально я запускаю только один проект за раз. А на боевом сервере их обычно больше.

Поэтому каждому проекту я прописываю свой локальный порт, с которым будет работать глобальный Nginx.

Все запросы снаружи на домен shop.vesp.pro полетят на порт 10004, где их получит контейнер Nginx в Docker и переправит в нужных контейнер.

Возможно, это небольшой оверхэд, но как это всё удобно и классно работает!

Серверный рендер

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

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

Меняем команду на запуск контейнера Node таким образом:

command: sh -c 'yarn && yarn generate:admin && yarn build:site && yarn start:site'

То есть:

  1. Установка пакетов из npm
  2. Генерация статичной админки
  3. Сборка публичного сайта
  4. Запуск сервера Nuxt для публичного сайта

Из-за того, что последняя команда не завершается, контейнер не прекращает работу и systemd следит за ним. Нам даже не нужен PM2, потому что Docker вместе с systemd становятся менеджером задач для контейнера и перезапустят его в случае ошибки!

Теперь нужно только указать Nginx обращаться не в статическую директорию, а на порт 4100 - как прописано в команде start:site нашего package.json.

    location / {
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_redirect off;
        proxy_read_timeout 240s;
        proxy_pass http://127.0.0.1:4100;
    }

И всё!

В зависимости от запрашиваемого адреса Nginx отправит нас или в серверный рендер Nuxt, или покажет статические файлы админки.

Остаётся только перезапустить контейнеры.

Права пользователя

В своих проектах я использую контнейнер php-fpm, собранный на образе Alpine. Его основной процесс запускается от юзера www-data, а не root.

Соответственно, все файлы, создаваемые процессом PHP получают владельца сuid = 82 - потому что именно такой uid у юзера www-data в Alpine.

А в хостовой машине Ubuntu такого юзера просто нет, и получаются разнообразные ошибки доступа, например при попытке записать временный файл в директорию /vesp/tmp, созданую не юзером c uid = 82.

Я столкнулся с этим сам, когда переносил bezumkin.ru в Docker на сервере. Просто перестала отправляться почта, потому что Fenom не мог сохранить скомпилированный шаблон.

Вариантов решения несколько:

  • назначить широкие права на директорию с проектом - 0777
  • использовать php-fpm на базе Debian - там у www-data должен быть тот же uid, что и в Ubuntu (я не проверял)
  • создать юзера с uid 82, который будет работать с Docker.

Именно так я и поступил - поместил все проекты в директорию своему юзеру, а потом поменял ему id в системных файлах:

  • /etc/passwd
  • /etc/group
  • /etc/shadow

Ну и обновил владельца всех файлов и директорию юзера. Теперь user id создаваемых файлов на хосте и в PHP совпадают, проблем нет. Не знаю, насколько это костыльно, но работает уже давно.

Обновление проекта

Лично я для личных проектов давно использую Gitlab, благодаря удобной настройке CI/CD с помощью конфига .gitlab-ci.yml в проекте. Как-то раз вот разобрался, с тех пор и использую.

И возник вопрос, а как обновлять проекты, работающие в Docker? Раньше я их собирал на сервисе, выгружал на сервер и там перезапускал процессы.

Оказалось, что и тут всё стало проще - надо только выгрузить исходники и перезапустить сервис. Они же всё сами делают при запуске!

Вопрос только в невозможности ввода пароля для запуска сервиса при автоматической выгрузке. Так что прописываем такую возможность своему пользователю в /etc/sudoers:

user ALL=NOPASSWD: /bin/systemctl

Теперь он делает sudo systemctl restart vesp-shop без пароля.

Для примера мой .gitlab-ci.yml :

image: alpine:3

before_script:
  - which ssh-agent || ( apk update -q && apk add openssh-client -q )
  - which rsync || ( apk update -q && apk add rsync -q )
  - mkdir -p ~/.ssh
  - ssh-keyscan -t rsa $DEPLOYMENT_HOST > ~/.ssh/known_hosts
  - eval $(ssh-agent -s)
  - ssh-add <(echo "$DEPLOYMENT_KEY")

stage_deploy:
  only:
    - master
  script:
    - ssh $DEPLOYMENT_USER@$DEPLOYMENT_HOST "mkdir -p $WWW_PATH"
    - ssh $DEPLOYMENT_USER@$DEPLOYMENT_HOST "mkdir -p $FRONTEND_PATH"
    - ssh $DEPLOYMENT_USER@$DEPLOYMENT_HOST "mkdir -p $CORE_PATH"
    - rsync -r --del --force www/* $DEPLOYMENT_USER@$DEPLOYMENT_HOST:$WWW_PATH
    - rsync -r --del --force frontend/* $DEPLOYMENT_USER@$DEPLOYMENT_HOST:$FRONTEND_PATH
    - rsync -r --del --force core/* $DEPLOYMENT_USER@$DEPLOYMENT_HOST:$CORE_PATH
    - rsync -r --del --force composer.* $DEPLOYMENT_USER@$DEPLOYMENT_HOST:$BASE_PATH
    - ssh $DEPLOYMENT_USER@$DEPLOYMENT_HOST "sudo systemctl restart webstartpage"

Даже не надо переделывать для других проектов - все отличия будут в конфигах Docker.

Заключение

На этом я рассказал и показал всё, что хотел.

Мы очень сильно прокачали VespShop, перенесли в него данные из miniShop2 и запустили проект в продакшн.

Думаю, на основе получившегося проекта и описания, как это всё и почему работает, вы сможете добавить любой нужный функционал. И доставку, и скидки, и чёрта в ступе - было бы желание.

Самое главное, что вы ни от кого не зависите, ничего не ждёте, и ничем не ограничены - вы всё пишете своими руками и головой. Такое положение сильно отличается от работы с дополнением для старой CMS.

Надеюсь, вам было интересно. Итоговый коммит, как обычно, в репозитории.

← Предыдущая заметка
Личный кабинет пользователя
Комментарии (20)
bezumkinВасилий Наумкин
14.09.2023 23:59

Очень рад, что тебе понравилось!

bezumkinВасилий Наумкин
01.10.2023 13:37

Из этого скрина непонятно, надо логи докера смотреть.

Для этого зайди в docker директорию проекта и там набери:

docker-compose logs --tail=20

Это выведет записи по всем контенерам и в них уже можно найти ошибки.

bezumkinВасилий Наумкин
01.10.2023 22:52

Module not found - наверное, чего-то не хватает?

Скорее всего, с Nginx это никак не связано, попробуй удалить node_modules и запустить контейнер - он должен установить всё заново при запуске.

bezumkinВасилий Наумкин
02.10.2023 08:22

На здоровье!

bezumkinВасилий Наумкин
22.12.2023 08:41

Мне кажется, ты забыл создать файл prod.dockerfile внутри docker/php. Проверь, пожалуйста.

bezumkinВасилий Наумкин
22.12.2023 17:01

Не знаю, что сказать.

Как обычно, наверное, ты где-то поторопился.

bezumkinВасилий Наумкин
24.12.2023 05:50

Сразу закинул туда docker-production.yml и prod.dockerfile .

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

Так нельзя.

Если в твоём dev конфиге не монтируется файл composer.lock, то не надо его монтировать и в конфиге для продакшена.

Или я просто "побежал впереди паровоза" и чистый VESP пока не доработан под "Докер в продакшене".

Я для того и написал эту заметку, чтобы объяснить принципы запуска в продакшн. Тебе нужно взять свой dev конфиг и убрать всё ненужное.

Не копировать мой конфиг из другого проекта, а сделать свой - основывая на информации из этой заметки.

bezumkinВасилий Наумкин
24.12.2023 09:13

А можно взять c github твой готовый проект vesp-shop и запустить его в локальном Докере?

Да, конечно.

ты же потом в итоге сделаешь обычный установочный VESP с конфигурацией production?

Наверняка, но сроков назвать не могу.

bezumkinВасилий Наумкин
24.12.2023 11:26

Спасибо!

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...