Docker: собираем веб сервер

от автора

Предисловие

Так сложилось, что мне приходится работать над большим количеством сайтов, задачи решать так же разные — от настроек сервера до «сверстать форму». И вот на одном из проектов возникла задача — обновиться до актуальной версии php (8.1 на момент написания), обновить до актуальной версии CMS (1C Bitrix), ну и в целом, «довести до ума».
Поскольку проект оброс значительным количеством функционала, не связанного с сайтом напрямую (инкрементальные и полные бэкапы по расписанию с выгрузкой в облако, составление словарей, синхронизации с разными поставщиками), а работы ведутся в 3 окружениях (локально, тестовая площадка и продакшн сайт), то я решил, что это будет хорошей возможностью перенести всю инфраструктуру на контейнеры Docker.
Поскольку технология уже устоявшаяся, то ожидалось, что найдется готовый шаблон сервера «из коробки», который подойдет под наши нужды. Но поискав, не удалось найти полноценного решения — везде были какие-то нюансы, из-за которых решение не подходило. В результате был собран собственный сервер для сайта на 1С Битрикс. После чего из сервера было вырезано все, что связано с этой CMS и теперь он может использоваться под другие проекты без ограничений.

Код доступен на github.

Компоненты сервера

Для полноценной работы сервера нам нужны следующие компоненты:

  • база данных (MySQL);

  • PHP;

  • NGINX;

  • прокси для отправки почты (msmtp);

  • composer;

  • letsencrypt сертификаты;

  • резервное копирование и восстановление;

  • опционально — облако для хранения бэкапов.

Так же нам нужно по расписанию запускать разные действия. Для этого будет использоваться crontab на хосте, а не в контейнерах.

Перед началом работ

На сервере нам понадобится docker-compose. Инструкции:

Так же нам нужны будут доступы к smtp почтового сервиса и s3 хранилища для бэкапов (опционально).

По поводу gmail smtp

Google сообщил, что с июня 2022 года приостанавливает доступ небезопасных приложений (с авторизацией только по паролю аккаунта). Чтобы получить возможность использовать gmail smtp, надо в настройках аккаунта включить двухфакторную авторизацию, создать отдельный пароль авторизации для нашего сайта и использовать его. Подробных инструкций достаточно.

Сервисы и окружения

Для гибкости в настройке сервера создаем 4 отдельных файла compose.yml:

  • compose-app.yml — основные сервисы нашего приложения (база данных, php, nginx, composer);

  • compose-https.yml — для работы сайта по протоколу https. Включает в себя certbot, а так же правила перенаправления с http на https для nginx;

  • compose-cloud.yml — для хранения бэкапов в холодном хранилище;

  • compose-production.yml — переопределяет правила рестарта для всех контейнеров.

compose-app.yml
version: '3' services:   db:     image: mysql     container_name: database     restart: unless-stopped     tty: true     environment:       MYSQL_DATABASE: ${DB_DATABASE}       MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}       MYSQL_USER: ${DB_USER}       MYSQL_PASSWORD: ${DB_USER_PASSWORD}     volumes:       - ./.backups:/var/www/.backups       - ./.docker/mysql/my.cnf:/etc/mysql/my.cnf       - database:/var/lib/mysql     networks:       - backend    app:     image: php:8.1-fpm     container_name: application     build:       context: .       dockerfile: Dockerfile       args:         GID: ${SYSTEM_GROUP_ID}         UID: ${SYSTEM_USER_ID}         SMTP_HOST: ${MAIL_SMTP_HOST}         SMTP_PORT: ${MAIL_SMTP_PORT}         SMTP_EMAIL: ${MAIL_SMTP_USER}         SMTP_PASSWORD: ${MAIL_SMTP_PASSWORD}     restart: unless-stopped     tty: true     working_dir: /var/www/app     volumes:       - ./app:/var/www/app       - ./log:/var/www/log       - ./.docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini     networks:       - backend     links:       - "webserver:${APP_NAME}"    composer:     build:       context: .     image: composer     container_name: composer     working_dir: /var/www/app     command: "composer install"     restart: "no"     depends_on:       - app     volumes:       - ./app:/var/www/app    webserver:     image: nginx:stable-alpine     container_name: webserver     restart: unless-stopped     tty: true     ports:       - "80:80"       - "443:443"     volumes:       - ./app/public:/var/www/app/public       - ./log:/var/www/log       - ./.docker/nginx/default.conf:/etc/nginx/includes/default.conf       - ./.docker/nginx/templates/http.conf.template:/etc/nginx/templates/website.conf.template     environment:       - APP_NAME=${APP_NAME}     networks:       - frontend       - backend  networks:   frontend:     driver: bridge   backend:     driver: bridge  volumes:   database: 

compose-https.yml
version: '3' services:   webserver:     volumes:       - ./.docker/certbot/conf:/etc/letsencrypt       - ./.docker/certbot/www:/var/www/.docker/certbot/www       - ./.docker/nginx/templates/https.conf.template:/etc/nginx/templates/website.conf.template    certbot:     image: certbot/certbot     container_name: certbot     restart: "no"     volumes:       - ./log/letsencrypt:/var/www/log/letsencrypt       - ./.docker/certbot/conf:/etc/letsencrypt       - ./.docker/certbot/www:/var/www/.docker/certbot/www 

compose-cloud.yml
version: '3' services:   cloudStorage:     image: efrecon/s3fs     container_name: cloudStorage     restart: unless-stopped     cap_add:       - SYS_ADMIN     security_opt:       - 'apparmor:unconfined'     devices:       - /dev/fuse     environment:       AWS_S3_BUCKET: ${AWS_S3_BUCKET}       AWS_S3_ACCESS_KEY_ID: ${AWS_S3_ACCESS_KEY_ID}       AWS_S3_SECRET_ACCESS_KEY: ${AWS_S3_SECRET_ACCESS_KEY}       AWS_S3_URL: ${AWS_S3_URL}       AWS_S3_MOUNT: '/opt/s3fs/bucket'       S3FS_ARGS: -o use_path_request_style       GID: ${SYSTEM_GROUP_ID}       UID: ${SYSTEM_USER_ID}     volumes:       - ${AWS_S3_LOCAL_MOUNT_POINT}:/opt/s3fs/bucket:rshared 

compose-production.yml
version: '3' services:   db:     restart: always    app:     restart: always    webserver:     restart: always    cloudStorage:     restart: always

И определяем настройки окружения в файле .env

.env
COMPOSE_FILE=compose-app.yml:compose-cloud.yml:compose-https.yml:compose-production.yml SYSTEM_GROUP_ID=1000 SYSTEM_USER_ID=1000  APP_NAME=example.local ADMINISTRATOR_EMAIL=example@gmail.com  DB_HOST=db DB_DATABASE=example_db DB_USER=example DB_USER_PASSWORD=example DB_ROOT_PASSWORD=example  AWS_S3_URL=http://storage.example.net AWS_S3_BUCKET=storage AWS_S3_ACCESS_KEY_ID=#YOU_KEY# AWS_S3_SECRET_ACCESS_KEY=#YOU_KEY_SECRET# AWS_S3_LOCAL_MOUNT_POINT=/mnt/s3backups  MAIL_SMTP_HOST=smtp.gmail.com MAIL_SMTP_PORT=587 MAIL_SMTP_USER=example@gmail.com MAIL_SMTP_PASSWORD=example 

В зависимости от того, какой набор сервисов нужен нам в конкретном окружении — указываем в переменной COMPOSE_FILE набор compose-*.yml файлов

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

  • Для nginx мы используем файл с правилами .docker/nginx/default.conf и два шаблона (.docker/nginx/templates/http.conf.template и .docker/nginx/templates/https.conf.template). В зависимости от того, по какому протоколу работаем — будут использованы соответствующие настройки nginx. О шаблонах подробно сказано на странице образа nginx;

  • Для msmtp в файле .docker/msmtp/msmtp мы указываем заплатки вида #PASSWORD#, которые будут заменены при построении образа.

.docker/msmtp/msmtprc
# Set default values for all following accounts. defaults auth           on tls            on logfile        /var/www/log/msmtp/msmtp.log timeout 5  account        docker host           #HOST# port           #PORT# from           #EMAIL# user           #EMAIL# password       #PASSWORD#  # Set a default account account default : docker 

Создаем файл Dockerfile, в котором укажем особенности сборки и, как говорилось ранее, для msmtp задаем параметры подключения из переменных окружения:

Dockerfile
FROM php:8.1-fpm  ARG GID ARG UID ARG SMTP_HOST ARG SMTP_PORT ARG SMTP_EMAIL ARG SMTP_PASSWORD  USER root  WORKDIR /var/www  RUN apt-get update -y \     && apt-get autoremove -y \     && apt-get -y --no-install-recommends \     msmtp \     zip \     unzip \     && rm -rf /var/lib/apt/lists/*  COPY ./.docker/msmtp/msmtprc /etc/msmtprc  RUN sed -i "s/#HOST#/$SMTP_HOST/" /etc/msmtprc \         && sed -i "s/#PORT#/$SMTP_PORT/" /etc/msmtprc \         && sed -i "s/#EMAIL#/$SMTP_EMAIL/" /etc/msmtprc \         && sed -i "s/#PASSWORD#/$SMTP_PASSWORD/" /etc/msmtprc  COPY --from=composer /usr/bin/composer /usr/bin/composer  RUN getent group www || groupadd -g $GID www \     && getent passwd $UID || useradd -u $UID -m -s /bin/bash -g www www  USER www  EXPOSE 9000  CMD ["php-fpm"] 

Резервное копирование

Бэкап состоит из двух частей: архив с файлами и дамп базы данных. Хранить их мы можем локально, либо отправлять в облако. Для формирования используем скрипт cgi-bin/create-backup.sh.
Для восстановления — cgi-bin/restore-backup.sh соответственно. Если у нас подключено облачное хранилище — то предложим восстанавливать из него:

create-backup.sh
#!/bin/bash  BASEDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../" &> /dev/null && pwd)  source "$BASEDIR/.env"  cd "$BASEDIR/"  # If run script with --local, then don't send backup to remote storage moveToCloud="Y" while [ $# -gt 0 ] ; do     case $1 in         --local) moveToCloud="N";;     esac     shift done  # If backups storage is not mounted, then anyway store backups local if ! [[ $COMPOSE_FILE == *"compose-cloud.yml"* ]]; then     moveToCloud="N" fi  # Current date, 2022-01-25_16-10 timestamp=`date +"%Y-%m-%d_%H-%M"` backups_local_folder="$BASEDIR/.backups/local" backups_cloud_folder="$AWS_S3_LOCAL_MOUNT_POINT"  # Creating local folder for backups mkdir -p "$backups_local_folder"  # Creating backup of application tar \ --exclude='vendor' \     -czvf $backups_local_folder/"$timestamp"_app.tar.gz \ -C $BASEDIR "app"  # Creating backup of database docker exec database sh -c "exec mysqldump -u root -h $DB_HOST -p$DB_ROOT_PASSWORD $DB_DATABASE" > $backups_local_folder/"$timestamp"_database.sql gzip $backups_local_folder/"$timestamp"_database.sql  # If required, then move current backup to cloud storage if [ $moveToCloud == "Y" ]; then     mv $backups_local_folder/"$timestamp"_database.sql.gz $backups_cloud_folder/"$timestamp"_database.sql.gz     mv $backups_local_folder/"$timestamp"_app.tar.gz $backups_cloud_folder/"$timestamp"_app.tar.gz fi  # If we already moved backup to cloud, then remove old backups (older than 30 days) from cloud storage if [ $moveToCloud == "Y" ]; then     /usr/bin/find $backups_cloud_folder/ -type f -mtime +30 -exec rm {} \; fi 

restore-backup.sh
#!/bin/bash  BASEDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../" &> /dev/null && pwd)  source "$BASEDIR/.env"  cd "$BASEDIR/"  backupsDestination="$BASEDIR/.backups/local"  # If backups storage is mounted, ask, from where will restore backups if [[ $COMPOSE_FILE == *"compose-cloud.yml"* ]]; then     while true     do         reset         echo "Select backups destination:"         echo "1. Local;"         echo "2. Cloud;"         echo "---------"         echo "0. Exit"          read -r choice          case $choice in             "0")                 exit                 ;;             "1")                 break                 ;;             "2")                 backupsDestination="$AWS_S3_LOCAL_MOUNT_POINT"                 break                 ;;             *)                 ;;         esac     done fi reset  # Select backup for restore echo "Available backups:" find "$backupsDestination"/*.gz  -printf "%f\n" echo "------------" echo "Enter backup path:"  read -i "$backupsDestination"/ -e backup_name  if ! [ -f "$backup_name" ]; then     echo "Wrong backup path."     exit 1 fi   backup_mode="unknown" if [[ $backup_name == *"app.tar.gz"* ]]; then     backup_mode="app" elif [[ $backup_name == *"database.sql.gz"* ]]; then     backup_mode="database" fi  if [ $backup_mode == "unknown" ]; then     echo "Unknown backup type"     exit 1 fi  reset  if [ $backup_mode == "app" ]; then     mkdir -p "$BASEDIR"/.backups/tmp     cp "$backup_name" "$BASEDIR"/.backups/tmp/app_tmp.tar.gz      tar -xvf "$BASEDIR"/.backups/tmp/app_tmp.tar.gz -C "$BASEDIR"      rm -rf "$BASEDIR"/.backups/tmp fi  if [ $backup_mode == "database" ]; then     mkdir -p "$BASEDIR"/.backups/tmp     cp "$backup_name" "$BASEDIR"/.backups/tmp/database_tmp.sql.gz      gunzip "$BASEDIR"/.backups/tmp/database_tmp.sql.gz      if ! [ -f "$BASEDIR"/.backups/tmp/database_tmp.sql ]; then         echo "Error in database unpack process"         exit 1     fi      docker-compose exec db bash -c "exec mysql -u root -p$DB_ROOT_PASSWORD $DB_DATABASE < /var/www/.backups/tmp/database_tmp.sql"      rm -rf "$BASEDIR"/.backups/tmp fi 

Crontab

Запуск по расписанию делаем на стороне хоста. Для инициализации используется файл cgi-bin/prepare-crontab.sh. В ходе выполнения скрипт собирает все файлы из каталога .crontab, заменяет в них путь к приложению #APP_PATH# на актуальный, и вносит их в crontab на хосте.

prepare-crontab.sh
#!/bin/bash  BASEDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../" &> /dev/null && pwd)  # Load environment variables source "$BASEDIR"/.env  # Create temporary directory mkdir -p "$BASEDIR"/.crontab_tmp/  # Copy all crontab files to temporary directory cp "$BASEDIR"/.crontab/* "$BASEDIR"/.crontab_tmp/  # Set actual app path in crontab files find "$BASEDIR"/.crontab_tmp/ -name "*.cron" -exec sed -i "s|#APP_PATH#|$BASEDIR|g" {} +  # Set crontab if [[ $COMPOSE_FILE == *"compose-https.yml"* ]]; then     find "$BASEDIR"/.crontab_tmp/ -name '*.cron' -exec cat {} \; | crontab - else     find "$BASEDIR"/.crontab_tmp/ -name '*.cron' -not -name 'certbot-renew.cron' -exec cat {} \; | crontab - fi  # Remove temporary directory rm -rf "$BASEDIR"/.crontab_tmp/ 

Certbot

Если https в рамках данного окружения не нужен — то этот шаг пропускаем.
Для получения ssl сертификатов используем certbot. Но тут есть одна особенность — для подтверждения владения доменом нам нужно запустить nginx, но без сертификатов он не запустится. Получается замкнутый круг. Для решения используем скрипт cgi-bin/prepare-certbot.sh, который создает сертификаты-заглушки, запускает nginx, запрашивает актуальные сертификаты, устанавливает их и перезапускает nginx.
Для обновления сертификатов создадим файл cgi-bin/certbot-renew.sh, который будем запускать по расписанию.

prepare-certbot.sh
#!/bin/bash  BASEDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../" &> /dev/null && pwd)  source "$BASEDIR/.env"  cd "$BASEDIR/"  if ! [ -x "$(command -v docker-compose)" ]; then   echo 'Error: docker-compose is not installed.' >&2   exit 1 fi  domains=($APP_NAME www.$APP_NAME) rsa_key_size=4096 data_path="$BASEDIR/.docker/certbot" email=$ADMINISTRATOR_EMAIL staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits  if [ -d "$data_path" ]; then   read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision   if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then     exit   fi fi   if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then   echo "### Downloading recommended TLS parameters ..."   mkdir -p "$data_path/conf"   curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"   curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"   echo fi  echo "### Creating dummy certificate for $domains ..." path="/etc/letsencrypt/live/$domains" mkdir -p "$data_path/conf/live/$domains" docker-compose run --rm --entrypoint "\   openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\     -keyout '$path/privkey.pem' \     -out '$path/fullchain.pem' \     -subj '/CN=localhost'" certbot echo   echo "### Starting nginx ..." docker-compose up --force-recreate -d webserver echo  echo "### Deleting dummy certificate for $domains ..." docker-compose run --rm --entrypoint "\   rm -Rf /etc/letsencrypt/live/$domains && \   rm -Rf /etc/letsencrypt/archive/$domains && \   rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot echo   echo "### Requesting Let's Encrypt certificate for $domains ..." domain_args="" for domain in "${domains[@]}"; do   domain_args="$domain_args -d $domain" done  case "$email" in   "") email_arg="--register-unsafely-without-email" ;;   *) email_arg="--email $email" ;; esac  if [ $staging != "0" ]; then staging_arg="--staging"; fi  docker-compose run --rm --entrypoint "\   certbot certonly --webroot -w /var/www/.docker/certbot/www \     $staging_arg \     $email_arg \     $domain_args \     --rsa-key-size $rsa_key_size \     --agree-tos \     --force-renewal" certbot echo  echo "### Reloading nginx ..." docker-compose exec webserver nginx -s reload 

certbot-renew.sh
#!/bin/bash  BASEDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../" &> /dev/null && pwd)  cd "$BASEDIR/"  docker-compose run --rm certbot renew && ocker-compose kill -s SIGHUP webserver docker system prune -af 

На этом этапе сайт доступен, и с ним можно продолжать работы.

Пошаговый процесс установки и описание переменных доступны на github.


ссылка на оригинал статьи https://habr.com/ru/post/670938/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *