Предисловие
Так сложилось, что мне приходится работать над большим количеством сайтов, задачи решать так же разные — от настроек сервера до «сверстать форму». И вот на одном из проектов возникла задача — обновиться до актуальной версии php (8.1 на момент написания), обновить до актуальной версии CMS (1C Bitrix), ну и в целом, «довести до ума».
Поскольку проект оброс значительным количеством функционала, не связанного с сайтом напрямую (инкрементальные и полные бэкапы по расписанию с выгрузкой в облако, составление словарей, синхронизации с разными поставщиками), а работы ведутся в 3 окружениях (локально, тестовая площадка и продакшн сайт), то я решил, что это будет хорошей возможностью перенести всю инфраструктуру на контейнеры Docker.
Поскольку технология уже устоявшаяся, то ожидалось, что найдется готовый шаблон сервера «из коробки», который подойдет под наши нужды. Но поискав, не удалось найти полноценного решения — везде были какие-то нюансы, из-за которых решение не подходило. В результате был собран собственный сервер для сайта на 1С Битрикс. После чего из сервера было вырезано все, что связано с этой CMS и теперь он может использоваться под другие проекты без ограничений.
Код доступен на github.
Компоненты сервера
Для полноценной работы сервера нам нужны следующие компоненты:
-
база данных (MySQL);
-
PHP;
-
NGINX;
-
прокси для отправки почты (msmtp);
-
composer;
-
letsencrypt сертификаты;
-
резервное копирование и восстановление;
-
опционально — облако для хранения бэкапов.
Так же нам нужно по расписанию запускать разные действия. Для этого будет использоваться crontab на хосте, а не в контейнерах.
Перед началом работ
На сервере нам понадобится docker-compose. Инструкции:
-
подготовка сервера;
-
установка docker;
-
установка 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/
Добавить комментарий