- Опубликовано: 12 июл 2026
- 21
Несколько сайтов на одном VPS: делим ресурсы без драки
Часть 8 из 14 · Серия «Сервер в бою»
Сюжет, который проживает почти каждый владелец VPS. Сначала на сервере живёт один сайт. Потом появляется «ну давай и мой блог сюда же, чего серверу простаивать». Потом — сайт клиента, staging-версия, чей-то WordPress «на недельку». Через год на VPS живёт коммуналка из пяти сайтов, и заселялись они все по одному сценарию: скопировали конфиг nginx, поменяли домен, готово.
Работает? Работает. До первого из двух инцидентов. Инцидент первый, про ресурсы: блог соседа попал в подборку, поймал наплыв трафика — и уронил ваш магазин, потому что съел все PHP-воркеры. Инцидент второй, про безопасность, он хуже: в чужом WordPress нашлась дырка в плагине, злоумышленник получил исполнение кода — и внезапно читает конфиги всех сайтов на сервере, включая пароли от всех баз. Потому что в коммуналке, заселённой копипастом, все жильцы работают под одним пользователем и все двери открыты.
Оба инцидента лечатся на этапе заселения, тремя уровнями изоляции: nginx разводит запросы по сайтам, отдельные пользователи разводят права, отдельные пулы PHP-FPM разводят ресурсы. Разберём все три — а в конце проверим изоляцию руками, как настоящий взломщик.
Уровень 1. Nginx: один швейцар на все подъезды
Начнём с механики, которая отвечает на вопрос «как один сервер обслуживает пять доменов». Все домены указывают на один IP; nginx смотрит в заголовок Host входящего запроса и по директиве server_name решает, какому виртуальному хосту его отдать. Каждый сайт — отдельный файл в /etc/nginx/sites-available/, включается симлинком в sites-enabled/:
# /etc/nginx/sites-available/site1.ru
server {
listen 443 ssl;
server_name site1.ru www.site1.ru;
root /var/www/site1/public;
index index.php;
# у каждого сайта — СВОИ логи, это важно
access_log /var/log/nginx/site1.access.log;
error_log /var/log/nginx/site1.error.log;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# свой сокет — к нему вернёмся в уровне 3
fastcgi_pass unix:/run/php/site1.sock;
}
}
ln -s /etc/nginx/sites-available/site1.ru /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Привычка nginx -t перед каждым reload — из тех, что окупаются пожизненно: опечатка в одном конфиге валит все сайты сразу, а -t ловит её до того, как reload это сделает.
Два неочевидных момента, которые превращают «скопировал конфиг» в «настроил осознанно»:
Раздельные логи — не эстетика, а диагностика. Помните практикум про пять минут до причины 502? С общим на всех access.log вы будете искать проблему сайта в шуме четырёх соседей. С раздельными — сразу видно, кого долбят боты и чей трафик вырос.
Сервер по умолчанию. Вопрос на засыпку: что ответит nginx на запрос с доменом, которого нет ни в одном server_name? (А такие запросы будут: сканеры ходят по IP, чужие домены иногда ошибочно указывают на ваш адрес.) Ответ: отдаст его... первому по алфавиту виртуальному хосту. Сюрприз: ваш магазин открывается по чужому домену, со всеми последствиями для SEO и здравого смысла. Лечится явной заглушкой:
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on; # для незнакомых доменов по https — просто отказ
return 444; # фирменный код nginx: закрыть соединение молча
}
Теперь незваные запросы получают тишину, а не случайный сайт.
Уровень 2. Пользователи: у каждого жильца — своя квартира
Вот здесь коммуналка по умолчанию устроена хуже всего. Стандартная схема «все файлы принадлежат www-data, PHP работает от www-data» означает: код сайта №3, будучи взломанным, имеет ровно те же права, что и код сайтов №1, №2, №4 и №5. Прочитать wp-config.php соседа с паролем от его базы? Пожалуйста. Дописать майнер в чужой index.php? Без проблем — тот же пользователь.
Правильная схема проста как квартплата: один сайт — один системный пользователь.
adduser --disabled-password --gecos "" site1
adduser --disabled-password --gecos "" site2
Файлы каждого сайта живут в домашней папке владельца и принадлежат ему:
mkdir -p /var/www/site1/public
chown -R site1:site1 /var/www/site1
Осталась одна загвоздка: nginx (он работает от www-data) должен уметь читать статику сайтов — картинки, css. Решается членством в группе и аккуратными правами:
usermod -aG site1 www-data # пускаем www-data в группу сайта
chmod 750 /var/www/site1 # владелец всё, группа — читать и заходить, остальные — ничего
И правило на стену: рабочие права — это 750 на каталоги и 640 на файлы (владелец читает-пишет, группа читает, остальные не видят ничего). Никогда — 777. Каждый chmod 777 в интернет-инструкции «чтобы заработало» — это предложение снять дверь с петель, потому что заедает замок. «Остальные» в нашей коммуналке — это в том числе взломанный сосед: ради защиты от него всё и затевалось.
Заодно та же логика — на этаж ниже, в MySQL: у каждого сайта — своя база и свой пользователь с правами только на неё (GRANT ALL ON site1_db.* TO 'site1'@'localhost'). Утёкший пароль от базы блога не должен открывать базу магазина. Три строчки при создании базы — и этот класс проблем закрыт.
Уровень 3. PHP-FPM пулы: отдельный счётчик на каждую квартиру
Теперь ресурсы и — снова — права, потому что пулы решают обе задачи разом. По умолчанию весь PHP на сервере исполняется одним пулом www от имени www-data: общий пользователь (см. инцидент второй) и общая очередь воркеров (см. инцидент первый — наплыв на блоге съедает воркеры магазина).
Заводим каждому сайту свой пул — файл /etc/php/8.3/fpm/pool.d/site1.conf:
[site1]
; исполняем код от пользователя сайта — ядро всей изоляции
user = site1
group = site1
; свой сокет — тот самый, что указан в nginx
listen = /run/php/site1.sock
listen.owner = www-data
listen.group = www-data
; персональный лимит воркеров — бюджет квартиры
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
; и персональный лог медленных запросов — пригодится в следующей статье
slowlog = /var/log/php/site1.slow.log
request_slowlog_timeout = 5s
Копируем под каждый сайт, меняя имя, пользователя и сокет; systemctl restart php8.3-fpm — и коммуналка превратилась в нормальный дом. Что мы получили каждой строчкой:
user = site1 — PHP-код сайта работает от его собственного пользователя. Взломанный плагин WordPress теперь заперт в правах site1: файлы соседей для него — «Permission denied» (мы же выставили 750). Радиус поражения взлома сжался с «весь сервер» до «одна квартира».
pm.max_children = 8 — потолок одновременных PHP-процессов этого сайта. Наплыв трафика на блоге упрётся в его собственные 8 воркеров — посетители блога подождут, а магазин продолжит работать как ни в чём не бывало. Это и есть «делим ресурсы без драки»: не приоритеты и не магия, а простые персональные лимиты.
Как выбрать цифры — бюджетная арифметика: один PHP-воркер среднего сайта ест 40–80 МБ (посмотрите своё: ps aux | grep php-fpm, колонка RSS). Сумма max_children × размер_воркера по всем пулам плюс аппетиты MySQL и nginx должна помещаться в оперативку VPS — иначе однажды придёт наш старый знакомый OOM killer из первой статьи. На VPS с 4 ГБ и тремя сайтами разумный старт: важному сайту 10–12 воркеров, остальным по 4–6, и смотреть по факту. Тонкой настройке pm-параметров (и тому, что делать, когда воркеров не хватает честно) посвящена следующая статья — здесь важен принцип: лимиты назначаются по важности сайта, а не поровну.
Проверяем изоляцию руками
Настроили — не верим на слово, а проверяем. Побудем взломщиком: представим, что код site2 скомпрометирован, и попробуем от его имени дотянуться до соседа:
sudo -u site2 cat /var/www/site1/public/wp-config.php
cat: /var/www/site1/public/wp-config.php: Permission denied
Музыка. Теперь ресурсную изоляцию: устройте одному пулу нагрузку (хоть простым ab/wrk по его страницам) и смотрите в соседнем окне systemctl status php8.3-fpm и статистику пулов — воркеры нагруженного сайта упрутся в свой потолок, остальные пулы даже не заметят. И контрольный смотр всей картины:
ps aux | grep php-fpm
В выводе должна быть видна вся социология дома: мастер-процесс от root и группки воркеров от site1, site2, site3 — каждый под своим именем. Если все воркеры до сих пор от www-data — какой-то пул не подхватился, идём в journalctl -u php8.3-fpm выяснять.
Грабли коммунального хозяйства
| Грабля | Чем стреляет | Профилактика |
|---|---|---|
| Всё от www-data | Один взлом = все сайты | Пользователь + пул на сайт |
chmod 777 «чтобы заработало» |
То же, только добровольно | 750/640; если «не работает» — чинить владельца, а не открывать всем |
| Нет default_server | Ваш сайт отвечает по чужим доменам | Заглушка с return 444 |
| Общий access.log | Диагностика вслепую | Свои логи каждому (и не забыть их в logrotate — статья 5) |
| Сумма max_children не считана | OOM среди ночи | Бюджет: Σ(children × RAM воркера) + MySQL < RAM |
| Один пользователь MySQL на всё | Утёк один пароль — утекли все базы | База + пользователь на сайт |
| Правка «живого» конфига без nginx -t | Опечатка кладёт все сайты разом | nginx -t && reload, всегда |
| Сертификаты вручную по одному | Просроченный SSL у сайта, о котором забыли | certbot --nginx на каждый хост, авто-продление под присмотром (статья 12) |
Отдельной строкой — деплой в новую схему: заливать файлы сайта надо от его пользователя (site1), а не от root, иначе владельцы файлов поплывут и начнётся вечная чехарда с chown. Как устроить деплой красиво — одной командой и без FTP — тема тринадцатой статьи.
Что в итоге
Три уровня — nginx разводит трафик, пользователи разводят права, пулы разводят ресурсы — и «коммуналка» превращается в нормальный многоквартирный дом: у каждого сайта свои стены, свой счётчик и свой почтовый ящик с логами. Взлом соседа больше не ваша проблема, его хабраэффект — тоже. Цена вопроса — по 15 минут на сайт при заселении, и заметьте: ни одна из мер не потребовала ни докера, ни панелей управления — только штатные механизмы, которые уже были в системе.
Но изоляция отвечает лишь за то, чтобы сайты не мешали друг другу. А если сайт тормозит сам по себе — в одиночестве, без соседей и наплывов? В следующей статье займёмся производительностью всерьёз: pm.max_children по науке, opcache, gzip и brotli, keepalive — разберём, какие крутилки nginx и PHP-FPM реально ускоряют сайт, а какие переписываются из статьи в статью как карго-культ. Спойлер: тот slowlog, что мы прописали в пуле сегодня, завтра станет главным свидетелем обвинения.
- 1 Сервер упал в три ночи: разбор полётов без паники
- 2 Логи, которые говорят: учимся читать journalctl, nginx и syslog
- 3 Fail2ban и брутфорс: как перестать кормить ботов
- 4 Мониторинг для одного сервера: Uptime Kuma, Netdata и здравый смысл
- 5 Диск закончился внезапно: куда деваются гигабайты на VPS
- 6 Обновления без страха: apt upgrade, который ничего не сломает
- 7 Свой VPN на VPS за полчаса: WireGuard без магии
- 8 Несколько сайтов на одном VPS: делим ресурсы без драки вы здесь
- 9 Тормозит сайт — виноват не хостинг: тюним nginx и PHP-FPM скоро
- 10 MySQL на маленьком VPS: почему база съедает всю память скоро
- 11 Кэширование на пальцах: Redis, FastCGI cache и когда хватит просто заголовков скоро
- 12 Cron, systemd-таймеры и скрипты: автоматизируем рутину сервера скоро
- 13 Деплой без FTP в 2026-м: git pull, хуки и простой CI/CD на своём VPS скоро
- 14 Переезд на другой VPS без даунтайма: чек-лист миграции скоро
Была статья полезной: