Как стать автором
Обновить

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

Время на прочтение9 мин
Количество просмотров54K

Ниже предоставлен готовый набор окружения веб сервера на базе контейнеров 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.

Теги:
Хабы:
Всего голосов 18: ↑8 и ↓10-1
Комментарии21

Публикации

Истории

Работа

DevOps инженер
52 вакансии

Ближайшие события

Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область