Задача клиента: один взлом — и 200 сайтов лежат
В декабре 2025 года к нам в АйТи Фреш обратился «ВебХаус» — хостинг-провайдер из Тулы, обслуживающий около 200 клиентских сайтов на shared-хостинге. Проблема была острой: один взломанный WordPress-сайт с устаревшим плагином стал точкой входа для атаки, которая положила весь сервер целиком.
Злоумышленник через уязвимость в плагине WP File Manager получил web-shell, эскалировал привилегии через уязвимость в ядре и запустил криптомайнер, который загрузил все 32 ядра CPU на 100%. Все 200 сайтов на сервере стали недоступны на 14 часов, пока администратор вручную вычислял источник проблемы.
«Один клиент не обновлял WordPress три года. Из-за него мы потеряли выручку за сутки и получили шквал жалоб от остальных 199 клиентов. Мы поняли, что shared-хостинг в классическом виде — это русская рулетка» — основатель «ВебХаус».
Аудит текущей архитектуры shared-хостинга
Наши инженеры подключились к серверу и зафиксировали архитектуру:
- 1 физический сервер: Intel Xeon E-2288G (8C/16T), 64 ГБ RAM, 2×1 ТБ NVMe в RAID 1
- ISPmanager как панель управления хостингом
- Apache + PHP-FPM (5 версий PHP: 7.4, 8.0, 8.1, 8.2, 8.3)
- MySQL 8.0 — общий инстанс для всех клиентов
- Изоляция: только
open_basedir и разделение по uid/gid
# Проверяем, как изолированы клиенты
ls -la /var/www/clients/ | head -10
# drwxr-x--- 12 web1 client1 4096 Dec 10 web1/
# drwxr-x--- 15 web2 client2 4096 Dec 10 web2/
# drwxr-x--- 8 web3 client3 4096 Dec 10 web3/
# ... всего 200 директорий
# Проверяем изоляцию PHP
grep open_basedir /etc/php/8.2/fpm/pool.d/web47.conf
# php_admin_value[open_basedir] = /var/www/clients/web47:/tmp
# Но PHP — не единственный вектор!
# Пользователь web47 может видеть процессы других пользователей
ps aux | grep php-fpm | wc -l
# 312 — все PHP-FPM процессы видны всем
# И читать /proc
ls /proc/*/cmdline 2>/dev/null | wc -l
# 847 — полный доступ к /proc
Shared-хостинг полагался на open_basedir и UNIX-права для изоляции. Но open_basedir — это ограничение PHP, а не ядра. Любая уязвимость, дающая shell-доступ, полностью обходит эту защиту. Фактической изоляции между клиентами не было.
Выбор технологии: LXC vs Docker vs KVM
Перед миграцией нужно было выбрать технологию изоляции. 200 сайтов — это серьёзная плотность, поэтому overhead имеет значение:
| Технология | Изоляция | Overhead | Плотность | Управление |
|---|
| KVM (полные ВМ) | Полная (своё ядро) | Высокий (512+ МБ/ВМ) | ~30–40 ВМ на сервер | Proxmox GUI |
| Docker | Хорошая (namespaces) | Минимальный | Высокая | Не подходит для «классических» сайтов |
| LXC | Хорошая (namespaces + AppArmor) | Минимальный (20–50 МБ) | 100–200+ контейнеров | Proxmox GUI + CLI |
LXC выиграл — это полноценная изоляция на уровне ядра (namespaces, cgroups, AppArmor), но с overhead в 10–20 раз меньше, чем у KVM. Каждый контейнер — это полноценная «виртуальная машина» с точки зрения пользователя (свой init, свой /etc, свои пользователи), но без накладных расходов на эмуляцию оборудования. Docker не подходил: для хостинга нужна полная ОС в контейнере, а не один процесс.
Установка Proxmox и подготовка хранилища ZFS
Мы закупили для «ВебХаус» новый сервер (AMD EPYC 7443P, 128 ГБ ECC RAM, 4×2 ТБ NVMe) и установили Proxmox VE 8.1 — гипервизор с нативной поддержкой LXC и KVM, веб-интерфейсом и API.
Установка Proxmox VE и настройка ZFS
Proxmox устанавливается как полноценная ОС (на базе Debian 12). Для хранилища мы выбрали ZFS — файловую систему с встроенными снапшотами, компрессией и контролем целостности:
# После установки Proxmox — создаём ZFS pool
# 4 диска NVMe в RAID-Z1 (аналог RAID5)
zpool create -f -o ashift=12 \
-O compression=lz4 \
-O atime=off \
-O xattr=sa \
-O dnodesize=auto \
tank raidz1 \
/dev/nvme0n1 /dev/nvme1n1 /dev/nvme2n1 /dev/nvme3n1
# Проверяем пул
zpool status tank
# NAME STATE READ WRITE CKSUM
# tank ONLINE 0 0 0
# raidz1-0 ONLINE 0 0 0
# nvme0n1 ONLINE 0 0 0
# nvme1n1 ONLINE 0 0 0
# nvme2n1 ONLINE 0 0 0
# nvme3n1 ONLINE 0 0 0
# Общая ёмкость: ~5.5 ТБ (usable)
zpool list tank
# NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP HEALTH
# tank 7.27T 124K 7.27T - - 0% 0% ONLINE
# Создаём отдельные datasets для контейнеров
zfs create tank/containers
zfs create tank/backups
zfs create tank/templates
# Настраиваем квоты
zfs set quota=4T tank/containers
zfs set quota=1.5T tank/backups
# Добавляем ZFS storage в Proxmox
pvesm add zfspool local-zfs -pool tank/containers -content rootdir,images
pvesm add dir local-backups -path /tank/backups -content backup,vztmpl
ZFS даёт нам ключевое преимущество: datasets с квотами для каждого контейнера. Если клиент заполнит свой диск, это не повлияет на остальных. Плюс мгновенные снапшоты для бекапов.
Загрузка шаблонов и создание базового контейнера
Proxmox поддерживает готовые шаблоны LXC. Мы скачали шаблон Ubuntu 22.04 и создали «золотой образ» — базовый контейнер со всем необходимым для хостинга:
# Скачиваем шаблон
pveam update
pveam download local debian-12-standard_12.2-1_amd64.tar.zst
# Создаём базовый контейнер (будет шаблоном)
pct create 9000 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \
--hostname template-web \
--memory 1024 \
--swap 512 \
--cores 2 \
--rootfs local-zfs:10 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 1 \
--features nesting=1 \
--ostype debian \
--password
# Запускаем и заходим
pct start 9000
pct enter 9000
# Внутри контейнера устанавливаем стек хостинга
apt update && apt upgrade -y
apt install -y nginx php8.2-fpm php8.2-mysql php8.2-gd php8.2-xml \
php8.2-mbstring php8.2-curl php8.2-zip php8.2-intl php8.2-bcmath \
mariadb-client curl wget unzip cron logrotate
# Настраиваем nginx + PHP-FPM для одного сайта
# (стандартная конфигурация, специфичная для каждого клиента)
# Останавливаем и конвертируем в шаблон
pct stop 9000
pct template 9000
# Теперь можем клонировать шаблон для каждого клиента
pct clone 9000 101 --hostname client-site-01 --full
Конвертация в шаблон делает контейнер read-only. Клонирование из шаблона занимает 3–5 секунд благодаря ZFS (copy-on-write). Это позволило нам быстро развернуть 200 контейнеров.
Конфигурация LXC: ресурсы, сеть, безопасность
Каждый контейнер клиента получал индивидуальные лимиты ресурсов, изолированную сеть и профиль безопасности. Это ключевое отличие от shared-хостинга — теперь один клиент физически не может повлиять на другого.
Лимиты CPU, RAM и дискового пространства
Мы разработали три тарифных плана с разными лимитами:
# Конфигурация контейнера — /etc/pve/lxc/101.conf
# Тариф "Стартовый"
arch: amd64
cores: 1
memory: 512
swap: 256
hostname: client-site-01
unprivileged: 1
onboot: 1
features: nesting=1
# Storage с квотой 5 ГБ
rootfs: local-zfs:subvol-101-disk-0,size=5G
# Сеть
net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:01:01:01,ip=10.10.1.101/24,gw=10.10.1.1
# CPU лимиты (cgroups v2)
lxc.cgroup2.cpu.max: 100000 100000
# Ограничение I/O
lxc.cgroup2.io.max: 253:0 rbps=52428800 wbps=26214400
# --------------------------------------------------
# Тариф "Бизнес" (пример для контейнера 150)
cores: 2
memory: 2048
swap: 1024
rootfs: local-zfs:subvol-150-disk-0,size=20G
# --------------------------------------------------
# Тариф "Премиум" (пример для контейнера 180)
cores: 4
memory: 4096
swap: 2048
rootfs: local-zfs:subvol-180-disk-0,size=50G
Квота на ZFS-dataset гарантирует, что клиент не может занять больше выделенного пространства. Параметр lxc.cgroup2.cpu.max ограничивает использование CPU — даже если клиент запустит криптомайнер, он получит не более выделенного количества ядер.
# Применение лимитов к работающему контейнеру
pct set 101 --memory 1024 --cores 2 --swap 512
# Изменение квоты диска
zfs set quota=10G tank/containers/subvol-101-disk-0
# Мониторинг ресурсов контейнера
pct status 101 --verbose
# Status: running
# CPU: 0.03 (1 cores)
# Memory: 287.42 MB / 512.00 MB
# Swap: 0 B / 256.00 MB
# Disk: 1.82 GB / 5.00 GB
Сетевая изоляция: veth, bridge и firewall
Каждый контейнер получал свой виртуальный интерфейс (veth pair), подключённый к мосту. Мы настроили несколько уровней сетевой изоляции:
# Структура сети:
# vmbr0 — публичный мост (внешние IP, NAT)
# vmbr1 — приватный мост (межконтейнерная связь, DB)
# Создаём приватный мост для базы данных
auto vmbr1
iface vmbr1 inet static
address 10.10.2.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
post-up echo 1 > /proc/sys/net/ipv4/ip_forward
# Firewall-правила на уровне Proxmox
# /etc/pve/firewall/cluster.fw
[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT
[RULES]
# Разрешаем HTTP/HTTPS извне
IN ACCEPT -p tcp -dport 80
IN ACCEPT -p tcp -dport 443
# Запрещаем связь между контейнерами
# Каждый контейнер видит только свой IP и сервер БД
# Firewall для каждого контейнера
# /etc/pve/firewall/101.fw
[OPTIONS]
enable: 1
ipfilter: 1
[RULES]
IN ACCEPT -source 10.10.2.200 -p tcp -dport 3306 -log nolog
# Контейнер 101 может подключиться только к серверу БД
Параметр ipfilter: 1 предотвращает IP-spoofing — контейнер не может подделать свой IP-адрес. Межконтейнерный трафик блокируется, каждый сайт изолирован от соседей на сетевом уровне.
Безопасность: AppArmor, seccomp и unprivileged containers
Три уровня защиты делают LXC-контейнеры безопасной средой для ненадёжного кода клиентов:
# 1. Unprivileged containers — UID mapping
# Контейнер работает от непривилегированного пользователя хоста
# UID 0 (root) в контейнере = UID 100000 на хосте
grep 'lxc.idmap' /etc/pve/lxc/101.conf
# lxc.idmap: u 0 100000 65536
# lxc.idmap: g 0 100000 65536
# Даже если атакующий получит root в контейнере,
# на хосте он будет пользователем 100000 — без привилегий
# 2. AppArmor профиль
# Proxmox применяет AppArmor-профиль по умолчанию
aa-status | grep lxc
# /usr/bin/lxc-start (enforce)
# lxc-container-default-cgns (enforce)
# Профиль запрещает:
# - mount файловых систем (кроме разрешённых)
# - доступ к /proc/sys (запись)
# - загрузку модулей ядра
# - изменение системного времени
# 3. Seccomp фильтр — блокируем опасные системные вызовы
cat /usr/share/lxc/config/common.seccomp
# 2
# blacklist
# [all]
# kexec_load errno 1
# open_by_handle_at errno 1
# init_module errno 1
# finit_module errno 1
# delete_module errno 1
# ...
# Кастомный seccomp-профиль для хостинговых контейнеров
cat /etc/pve/lxc/seccomp-hosting.conf
# 2
# blacklist
# [all]
# kexec_load errno 1
# open_by_handle_at errno 1
# init_module errno 1
# finit_module errno 1
# delete_module errno 1
# pivot_root errno 1
# swapon errno 1
# swapoff errno 1
# syslog errno 1
# mount errno 1 [1,!=tmpfs]
# umount2 errno 1
Unprivileged containers — самый важный элемент. Root внутри контейнера не имеет никаких привилегий на хосте. Даже при обнаружении уязвимости в ядре (container escape), атакующий окажется под UID 100000 — обычным пользователем без прав.
Миграция 200 сайтов и автоматизация
Миграция 200 сайтов — масштабная операция. Мы разработали скрипт автоматизации, который создавал контейнер, переносил файлы и базу данных, настраивал nginx и DNS.
Скрипт автоматической миграции
#!/bin/bash
# migrate-site.sh — Миграция одного сайта в LXC-контейнер
# Использование: ./migrate-site.sh client_name domain ctid
CLIENT=$1
DOMAIN=$2
CTID=$3
TEMPLATE_ID=9000
STORAGE="local-zfs"
BRIDGE="vmbr0"
GATEWAY="10.10.1.1"
IP="10.10.1.${CTID}"
DB_SERVER="10.10.2.200"
echo "[*] Создаём контейнер ${CTID} для ${DOMAIN}..."
pct clone ${TEMPLATE_ID} ${CTID} \
--hostname "${CLIENT}" \
--storage ${STORAGE} \
--full
# Настраиваем ресурсы (тариф "Стартовый")
pct set ${CTID} \
--memory 512 \
--swap 256 \
--cores 1 \
--net0 name=eth0,bridge=${BRIDGE},ip=${IP}/24,gw=${GATEWAY} \
--rootfs ${STORAGE}:5
pct start ${CTID}
sleep 5
echo "[*] Копируем файлы сайта..."
rsync -avz /var/www/clients/${CLIENT}/ \
/tank/containers/subvol-${CTID}-disk-0/var/www/html/
echo "[*] Экспортируем базу данных..."
mysqldump -u root -p"${MYSQL_PASS}" ${CLIENT}_db | \
pct exec ${CTID} -- mysql -u root ${CLIENT}_db
echo "[*] Настраиваем nginx..."
pct exec ${CTID} -- bash -c "cat > /etc/nginx/sites-available/${DOMAIN} << 'NGINX'
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
root /var/www/html;
index index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
include fastcgi_params;
}
}
NGINX"
pct exec ${CTID} -- ln -s /etc/nginx/sites-available/${DOMAIN} /etc/nginx/sites-enabled/
pct exec ${CTID} -- nginx -t && pct exec ${CTID} -- systemctl reload nginx
echo "[+] Контейнер ${CTID} для ${DOMAIN} готов: ${IP}"
Скрипт мигрировал один сайт за 2–3 минуты. За два вечера (с 23:00 до 05:00) мы перенесли все 200 сайтов, параллельно запуская по 10 миграций. DNS переключался последними — с TTL 300 секунд даунтайм составил не более 5 минут на сайт.
Бекапы и восстановление через Proxmox
Proxmox предоставляет встроенную систему бекапов с поддержкой ZFS-снапшотов:
# Бекап одного контейнера (snapshot mode — мгновенный)
vzdump 101 --storage local-backups --mode snapshot --compress zstd
# Расписание бекапов через Proxmox
# Ежедневно в 03:00, хранить 7 последних
cat /etc/pve/jobs.cfg
# vzdump: backup-daily
# enabled 1
# schedule 0 3 * * *
# storage local-backups
# mode snapshot
# compress zstd
# prune-backups keep-daily=7,keep-weekly=4,keep-monthly=3
# all 1
# Восстановление контейнера из бекапа
pct restore 101 /tank/backups/dump/vzdump-lxc-101-2026_01_15-03_00_01.tar.zst \
--storage local-zfs
# ZFS-снапшоты для мгновенного отката
zfs snapshot tank/containers/subvol-101-disk-0@before-update
# Откат:
zfs rollback tank/containers/subvol-101-disk-0@before-update
# Размер снапшота — только дельта изменений
zfs list -t snapshot -r tank/containers/subvol-101-disk-0
# NAME USED AVAIL REFER
# ...@before-update 12.4M - 1.82G
Snapshot-бекап всех 200 контейнеров занимает 15 минут без остановки сервисов. Для сравнения, на старом сервере бекап через tar занимал 4 часа и требовал maintenance window.
Мониторинг и обслуживание контейнеров
200 контейнеров — это 200 отдельных серверов, каждый из которых нужно обновлять, мониторить и обслуживать. Мы автоматизировали рутинные операции через скрипты и Proxmox API.
Массовое обновление контейнеров
Обслуживание 200 контейнеров вручную невозможно. Мы написали набор скриптов для массовых операций — обновления, перезапуска и диагностики:
#!/bin/bash
# update-all-containers.sh — обновление пакетов во всех контейнерах
# Получаем список работающих контейнеров
CONTAINERS=$(pct list | awk '/running/ {print $1}')
TOTAL=$(echo $CONTAINERS | wc -w)
COUNT=0
for CTID in $CONTAINERS; do
COUNT=$((COUNT + 1))
echo "[${COUNT}/${TOTAL}] Обновляем контейнер ${CTID}..."
pct exec ${CTID} -- bash -c "apt update -qq && apt upgrade -y -qq" &
# Не более 10 параллельных обновлений
while [ $(jobs -r | wc -l) -ge 10 ]; do
sleep 1
done
done
wait
echo "[+] Все ${TOTAL} контейнеров обновлены"
# Результат: обновление 200 контейнеров за ~20 минут
# (вместо ручного обновления одного за другим)
# Скрипт проверки состояния всех контейнеров
# health-check.sh
for CTID in $(pct list | awk '/running/ {print $1}'); do
HOSTNAME=$(pct config $CTID | grep hostname | awk '{print $2}')
MEM=$(pct status $CTID | grep mem | awk '{print $2}')
DISK=$(pct exec $CTID -- df -h / | awk 'NR==2{print $5}')
NGINX=$(pct exec $CTID -- systemctl is-active nginx 2>/dev/null)
PHP=$(pct exec $CTID -- systemctl is-active php8.2-fpm 2>/dev/null)
echo "${CTID} ${HOSTNAME}: mem=${MEM}, disk=${DISK}, nginx=${NGINX}, php=${PHP}"
done
Скрипт health-check выполняется ежедневно через cron и отправляет отчёт администратору. Контейнеры с использованием диска выше 85% или упавшими сервисами попадают в алерт.
Мониторинг через Proxmox API и Prometheus
Proxmox API позволяет получать метрики всех контейнеров. Мы настроили Prometheus + Grafana для визуализации:
# Prometheus exporter для Proxmox
# pve-exporter — собирает метрики через API
pip3 install prometheus-pve-exporter
# Конфигурация /etc/pve-exporter/pve.yml
default:
user: monitoring@pve
password: MonitorPass2026!
verify_ssl: false
# Запуск
pve_exporter --config.file /etc/pve-exporter/pve.yml
# Prometheus config
# /etc/prometheus/prometheus.yml
scrape_configs:
- job_name: 'proxmox'
static_configs:
- targets: ['10.10.1.1:9221']
metrics_path: /pve
params:
module: [default]
target: ['10.10.1.1']
# Полезные метрики для мониторинга хостинга:
# pve_lxc_cpu_usage_ratio — CPU контейнера
# pve_lxc_memory_usage_bytes — RAM контейнера
# pve_lxc_disk_usage_bytes — Диск контейнера
# pve_lxc_network_transmit_bytes — Исходящий трафик
# Alertmanager — оповещение при превышении 90% ресурсов
groups:
- name: lxc-alerts
rules:
- alert: LXC_HighMemory
expr: pve_lxc_memory_usage_bytes / pve_lxc_memory_size_bytes > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "Контейнер {{ $labels.id }} использует >90% RAM"
Результаты внедрения: цифры и выводы
Миграция с shared-хостинга на LXC-контейнеры заняла 3 недели, включая тестирование и постепенный перенос клиентов.
Ключевые метрики
| Метрика | Shared-хостинг (до) | LXC-контейнеры (после) |
|---|
| Изоляция между клиентами | open_basedir (обходится) | Ядро: namespaces, cgroups, AppArmor |
| Последствия взлома одного сайта | Падение всего сервера | Только один контейнер |
| Время развёртывания нового клиента | 15 минут (ручная настройка) | 3 секунды (клон из шаблона) |
| Время полного бекапа | 4 часа (tar) | 15 минут (ZFS snapshot) |
| Overhead памяти на 200 сайтов | ~48 ГБ (один монолит) | ~32 ГБ (200 контейнеров по 150 МБ avg) |
| Инцидентов безопасности (за 3 месяца) | 3 взлома → общий downtime | 2 взлома → 0 влияние на соседей |
| Время восстановления после взлома | 4–14 часов | 30 секунд (ZFS rollback) |
Уроки и рекомендации
Опыт «ВебХаус» подтвердил несколько важных принципов:
- Unprivileged LXC — обязательно. Privileged-контейнеры сводят на нет всю безопасность. Все 200 контейнеров работают в unprivileged-режиме.
- ZFS — идеальная пара для LXC. Снапшоты, квоты, компрессия, контроль целостности — всё это бесплатно и мгновенно.
- Автоматизация — ключ к масштабу. 200 контейнеров невозможно обслуживать вручную. Скрипты обновления, мониторинг и алерты обязательны.
- Сетевая изоляция не менее важна, чем FS-изоляция. Без firewall между контейнерами взломанный сайт может атаковать соседей по сети.
LXC-контейнеры в Proxmox — оптимальное решение для хостинг-провайдеров: изоляция уровня виртуальных машин при overhead уровня обычных процессов.