Предисловие
Так сложилось, что мне приходится работать над большим количеством сайтов, задачи решать так же разные - от настроек сервера до "сверстать форму". И вот на одном из проектов возникла задача - обновиться до актуальной версии 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.
