0

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

Ниже предоставлен готовый набор окружения веб сервера на базе контейнеров Docker. Включает в себя MySQL, PHP, NGINX, composer, SSL сертификаты и механизм резервного копирования в облако.

Код доступен на 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 && docker-compose kill -s SIGHUP webserver
docker system prune -af

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

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

Облачная платформа

Свежие комментарии

Подписка

Лучшие статьи

WordPress › Ошибка

На сайте возникла критическая ошибка.

Узнайте больше про решение проблем с WordPress.