Запуск в продакшн с помощью 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 vesp-shop.bezumkin.ru;

    ssl_certificate /etc/letsencrypt/live/vesp-shop.bezumkin.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/vesp-shop.bezumkin.ru/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.
Все запросы снаружи на домен vesp-shop.bezumkin.ru полетят на порт 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 комментариев

Курс супер! Все темы, которые писал в пожеланиях, были рассмотрены. И даже более того. Спасибо за такой фундаментальный труд!
Василий Наумкин
Очень рад, что тебе понравилось!
Почему может выдавать такую ошибку? В какую сторону копать?
Василий Наумкин
Из этого скрина непонятно, надо логи докера смотреть.
Для этого зайди в docker директорию проекта и там набери:
docker-compose logs --tail=20
Это выведет записи по всем контенерам и в них уже можно найти ошибки.
От этой ошибки удалось избавиться, docker-compose старый был. Обновил версию, и она ушла. Появилась новая:
возникла после того, как обновил конфигурацию для nginx в файле default.conf.template
Василий Наумкин
Module not found - наверное, чего-то не хватает?
Скорее всего, с Nginx это никак не связано, попробуй удалить node_modules и запустить контейнер - он должен установить всё заново при запуске.
Заработало, спасибо. Но появилась ошибка Connection refused while connecting to upstream. upstream: "http://127.0.0.1:4100/"
Всё ок, моя ошибка) Просто нужно было подождать, пока контейнеры запустятся) Ещё раз спасибо
Василий Наумкин
На здоровье!
Дальше проверяем работу новой конфигурации, указывая её при запуске:
docker-compose -f ./docker-production.yml up -d --build
А эта проверка выполняется еще на локалке или уже на рабочем сервере? Я использую обычный VESP (не vesp-shop из этого курса). На локалке слепил тестовый сайт, засунул туда docker-production.yml из репозитория vesp-shop. И запустил:
docker-compose -f ./docker-production.yml up -d --build
. В итоге выдается ошибка -
failed to solve: failed to read dockerfile: open /var/lib/docker/tmp/buildkit-mount292105791/prod.dockerfile: no such file or directory
Как понял система ругается на то, что на локалке не существует серверной директории /var/lib/docker/tmp/ .
Василий Наумкин
Мне кажется, ты забыл создать файл prod.dockerfile внутри docker/php. Проверь, пожалуйста.
Не то что забыл) Не знал про это. Скопировал prod.dockerfile с репозитория vesp-shop и запустил конфигурацию production. Теперь запустилось, но работает косячно:
  1. На фронте нет вывода моих страниц (картинка + заголовок);
  2. А в админку не пускает с ошибкой "Неправильное имя или пароль". Без разницы как авторизоваться: admin/admin или user/user .
Василий Наумкин
Не знаю, что сказать.
Как обычно, наверное, ты где-то поторопился.
Да тут и ошибиться вроде негде. Сайт и админка работают в Докере, если запускать не в конфигурации production. Далее я скопировал 2 файла с репозитория vesp-shop и положил себе: docker/docker-production.yml и docker/php-fpm/prod.dockerfile . И далее запускаю docker-compose -f ./docker-production.yml up -d --build . Ранее я спрашивал у тебя, можно ли обычный VESP (не vesp-shop) запустить на продакшене. Ты сказал что можно закинуть туда docker-production.yml и должно работать. Про prod.dockerfile я и не знал ничего. Закинул его тоже - выдает ошибку, требует composer.lock. Скопировал его также с vesp-shop и добавил - работает косячно. Может просто чистый VESP еще чем-то отличается от vesp-shop и поэтому на нем не запускается конфигурации production?
Снес свои поделки с локального Докера и поставил опять чистый VESP. Сразу закинул туда docker-production.yml и prod.dockerfile . Потом запустил, засеял базу и попробовал запустить конфигурацию production. Там все довольно долго грузилось, потом запустилось. Но в админку попасть не смог. И контейнер vesp-php-fpm-1 постоянно перегружается. Посмотрел логи контейнера vesp-php-fpm-1 - он ругается на отсутствие файла composer.lock .
Тут два варианта, как понял теперь у меня:
  1. Разобраться как генерируется composer.lock, создать его и, возможно, после этого все наладится.
  2. Или я просто "побежал впереди паровоза" и чистый VESP пока не доработан под "Докер в продакшене". Тогда буду ждать "официального релиза".) Мне просто сама идея "из Докера в Докер" понравилась, но vesp-shop сложно для меня. Вот я и уперся в чистый VESP с docker-production.yml.
Василий Наумкин
Сразу закинул туда docker-production.yml и prod.dockerfile .
То есть, ты просто бездумно копируешь конфиги другого проекте себе и удивляешься, что оно не работает?
Так нельзя.
Если в твоём dev конфиге не монтируется файл composer.lock, то не надо его монтировать и в конфиге для продакшена.
Или я просто "побежал впереди паровоза" и чистый VESP пока не доработан под "Докер в продакшене".
Я для того и написал эту заметку, чтобы объяснить принципы запуска в продакшн. Тебе нужно взять свой dev конфиг и убрать всё ненужное.
Не копировать мой конфиг из другого проекта, а сделать свой - основывая на информации из этой заметки.
Спасибо большое за помощь! Буду изучать вопрос. Я вообще-то хотел закинуть сайт на сервер в Докер, чисто "в научных целях" и на этом пока успокоиться, подучить базу. Но, оказалось, что для это уже нужно немало навыков иметь.))
А можно взять c github твой готовый проект vesp-shop и запустить его в локальном Докере? Т.е. не разрабатывать с нуля. Будет это работать? И на нем посмотрю как там production работает. Там главное, видимо, не запутаться с миграциями после установки и базу MODX импортировать.
И еще вопрос - но ты же потом в итоге сделаешь обычный установочный VESP с конфигурацией production?
Василий Наумкин
А можно взять c github твой готовый проект vesp-shop и запустить его в локальном Докере?
Да, конечно.
ты же потом в итоге сделаешь обычный установочный VESP с конфигурацией production?
Наверняка, но сроков назвать не могу.
С Наступающим тебя и твоих ближних! Отдохни хорошенько от трудов своих))
Василий Наумкин
Спасибо!
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
03.12.2024, 13:13:34
Генерация - это создание статичный файлов, для их работы потом pm2 не нужен, только правильная настр...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.