Ниже предоставлен готовый набор окружения веб сервера на базе контейнеров Docker. Включает в себя MySQL, PHP, NGINX, composer, SSL сертификаты и механизм резервного копирования в облако.
Код доступен на 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 && docker-compose kill -s SIGHUP webserver docker system prune -af
На этом этапе сайт доступен, и с ним можно продолжать работы.
Пошаговый процесс установки и описание переменных доступны на github.
