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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Предисловие

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


Интересные статьи

Интересные статьи

Эта статья о том как настроить Wireguard VPN в локальной сети, если вам достался роутер от провайдера без возможности его прошивки и полноценной настройки. Для настройки VPN поставим в локальной сети ...
Это руководство посвящено настройке защиты приложений с помощью TLS-аутентификации. При таком подходе возможность работы пользователей с приложением зависит от имеющихся у них сертификато...
Меня зовут Леонид, я разработчик сайта Поиск VPS. Как видно из названия, проект специализируется на помощи в поиске виртуальных серверов, поэтому я стараюсь следить за всеми новшества...
На прошлой неделе было опубликовано интересное исследование об атаках на Unix-подобные системы. В нем описано создание ханипота из Docker-контейнера (новость, исходная статья Akamai). Docker было...
В этой статье описываются некоторые методы ускорения загрузки фронтенд-приложений, чтобы реализовать отзывчивый, быстрый пользовательский интерфейс. Мы обсудим общую архитектуру фронтенда, как...