Все системы работают штатно 6 офшорных регионов Оформление без KYC
Hands-on Полевое руководство

Логи сервера на VPS: данные, которые вы решили не хранить

Вы перенесли машину в офшор, заплатили в Monero и спрятали всё за туннелем. А потом nginx записал строку. Каждый запрос, на который отвечает ваш сервер, оставляет датированную запись о том, кто спрашивал, откуда и о чём, — и на образе по умолчанию эта запись переживёт две недели в /var/log, месяц — в базе fail2ban, и столько, сколько хранят ваши бэкапы, а это обычно дольше обоих сроков. Это руководство смотрит не на сеть, а на саму машину: что записывает ваш собственный сервер, усечение адреса ещё до того, как строка сформирована, а не чистка задним числом, и хранение ровно того, что нужно, чтобы заблокировать атакующего и отладить 500-ю ошибку, не накапливая историю посетителей, которую вы предпочли бы не хранить.

Обновлено 2026-09-15 · 15 мин чтения · Операции с парком серверов
На этой странице
  1. Запрос, на который вы ответили, — теперь запись, которую храните вы
  2. Шесть логов, и два из них называют людей
  3. Усекайте при записи, а не при ротации
  4. Что ломает усечение, и три задачи, которые лог решает на самом деле
  5. journald, auth.log и след, который ведёт обратно к вам
  6. Ваш срок хранения — это тот срок, который назначили ваши бэкапы
  7. Копии, которые вам не подконтрольны
  8. Минимизация по проекту, а не удаление по требованию
  9. Шаг за шагом
SP·01

Запрос, на который вы ответили, — теперь запись, которую храните вы

Всё остальное в этой серии смотрит наружу: скрыть исходный сервер, зашифровать диск, выбрать юрисдикцию, не дать кому-то ещё вести на вас лог. А это руководство смотрит на саму машину: сервер, который отвечает на запросы, по умолчанию записывает, кто их сделал, — причём сразу в нескольких местах и со сроком хранения, который никто не выбирал.

Посмотрите, что скопилось на образе по умолчанию через две недели после развёртывания. В /var/log/nginx/access.log — по строке на каждый запрос: адрес, метка времени, путь, referrer, user agent; ротация ежедневно, хранение две недели. В /var/log/auth.log — каждая SSH-сессия, включая исходный адрес каждой вашей. В журнале — те же события ещё раз, плюс всё, что ваши сервисы напечатали в stderr. Если запущен Docker, у каждого контейнера свой JSON-лог, у которого из коробки вообще нет ограничения по размеру. Если запущен fail2ban, у него есть лог каждого адреса, который он когда-либо банил, и база SQLite с теми же данными, — и адреса в обоих местах полные.

Ничего из этого не злонамеренно, и большая часть по-настоящему полезна — примерно час после того, как что-то сломалось. Проблема — в форме: полная детализация, долгий срок хранения, случайно, а не по решению. Про каждый из этих файлов стоит спрашивать не "это чувствительные данные?", а "что я реально сделаю со строкой, которую записал три недели назад?" Для подавляющего большинства строк на подавляющем большинстве серверов ответ — ничего, а запись, которую вы никогда не прочтёте, — чистый риск: при компрометации, при бэкапе, который переживёт саму машину, при появлении того, кто однажды её запросит.

Формальное название этого решения — минимизация данных, и это самая бесспорная идея во всей защите данных: собирайте то, что нужно для дела, храните, пока это нужно, а потом прекращайте. Дальше это применяется к машине, которой вы реально управляете, — при условии, что вы уже прошли первый час после развёртывания и на машине действительно есть что защищать.

SP·02

Шесть логов, и два из них называют людей

Прежде чем что-то менять, составьте опись. Небольшой VPS на Debian или Ubuntu, обслуживающий веб-сервис, обычно пишет шесть потоков данных, и они пересекаются сильнее, чем кажется, — одно и то же событие нередко попадает сразу в три файла с тремя разными сроками хранения.

Access-лог nginx — тот, что называет ваших посетителей поимённо. По строке на запрос: адрес клиента, точный путь, referrer и строка user agent, достаточно подробная, чтобы самой по себе служить слабым отпечатком. Error-лог nginx — тот, про который забывают: он записывает client: 203.0.113.9 при каждом таймауте апстрима, каждой 403-й и каждом некорректном запросе, — и, в отличие от access-лога, его формат фиксирован и не поддаётся шаблонизации.

Лог аутентификации/var/log/auth.log в семействе Debian, /var/log/secure в семействе RHEL — называет поимённо вас. Каждая строка о принятом входе по publickey несёт ваш исходный адрес и отпечаток ключа, который открыл сессию. Тот, кто прочитает месяц этого лога, узнает, из каких сетей вы администрируете, в какие часы и сколько у вас разных ключей. На анонимно оформленной машине этот файл нередко раскрывает больше, чем всё, что породили ваши посетители.

Журнал хранит копию почти всего перечисленного выше, плюс stdout и stderr каждого юнита, и при установке по умолчанию ему разрешено расти до 10% файловой системы, но не больше 4 ГБ, — после чего он начинает вытеснять самые старые записи. На любом диске от 40 ГБ этот потолок — это полные 4 ГБ, а при объёмах, которые выдаёт небольшой сервер, это многие месяцы.

Логи приложения — джокер в этой колоде. Фреймворк в режиме отладки записывает полные URL вместе с query-строками, а query-строки то и дело несут в себе токены сессий, ссылки для сброса пароля и поисковые запросы. PHP-FPM можно настроить так, чтобы он вёл собственный access-лог, дублирующий строку запроса, которую nginx уже записал, — причём в файле, которого настройки nginx вообще не касаются.

Логи контейнеров — самые тихие. У драйвера json-file, который Docker использует по умолчанию, нет ротации, пока вы её не настроите, так что /var/lib/docker/containers/*/*-json.log хранит всё, что контейнер когда-либо сказал с момента создания. Это частый способ обнаружить, что политика хранения "14 дней" на самом деле держит одиннадцать месяцев, — и это тот же класс сюрпризов, что и правила firewall, которые Docker прописывает в обход ufw.

Из шести потоков два несут идентификаторы людей, которые стоит сознательно минимизировать: access-лог nginx (ваши посетители) и лог аутентификации (вы). Остальным по большей части нужны только потолок по размеру и более короткий срок хранения.

SP·03

Усекайте при записи, а не при ротации

Первый инстинкт — логировать как обычно, а чистить потом: ночная cron-задача, которая переписывает вчерашний файл, вырезая из него адреса. Не стройте так. Задача-чистильщик означает, что необработанные адреса реально лежали на диске — до суток, — и за эти сутки они успели попасть в снапшот вашего бэкапа, возможно, в блочный снапшот хостинга, и осесть в том, что файловая система сделала со старыми блоками. Хуже того, такая задача — лишняя подвижная часть: она молча падает именно в ту неделю, когда диск заполнился, и ничто не подскажет вам, что вчерашний файл по-прежнему хранит адреса целиком.

Единственное сокращение данных, на которое реально можно положиться, — то, что происходит ещё до записи строки. В nginx это блок map, вычисляемый в момент логирования: он переписывает адрес в новую переменную, а log_format использует эту новую переменную вместо $remote_addr. Полный адрес никогда не сериализуется. После этого нечего чистить, нечего планировать по расписанию и нечего сломать в тот день, когда вы не следите.

# /etc/nginx/conf.d/00-privacy-log.conf  --  http context, loaded before the sites
map $remote_addr $ip_trunc {
    # IPv4: keep the /24, zero the host part
    ~(?<v4>\d+\.\d+\.\d+)\.     "${v4}.0";
    # IPv6: keep the first two groups, drop the rest
    ~(?<v6>[^:]+:[^:]+):          "${v6}::";
    # anything the two patterns cannot parse -- including compressed forms
    # like ::1 -- falls through here, i.e. fails closed rather than open
    default                       "0.0.0.0";
}

log_format privacy '$ip_trunc - - [$time_local] "$request" $status '
                   '$body_bytes_sent "$http_referer" "$http_user_agent" '
                   'rid=$request_id rt=$request_time';

Три детали решают, заработает ли это на самом деле. Первое: блок map обязан жить в контексте http — внутри блока server nginx откажется стартовать. Проще всего гарантировать это, положив файл в conf.d/ с именем, которое сортируется одним из первых.

Второе: если вы работаете за Cloudflare или любым другим edge, проверьте, что на самом деле содержит $remote_addr. Модуль realip подменяет его на настоящий адрес клиента ещё в начале обработки запроса, а адрес, с которым установлено соединение, сохраняет в $realip_remote_addr. Этот порядок вам на руку: map выполняется в момент логирования, поэтому усекается адрес настоящего посетителя, а не edge-узла. Но это же означает, что если вы не настроили realip, то усекаете адрес Cloudflare и не узнаёте вообще ничего, пока настоящий адрес лежит в заголовке.

Третье: проверьте всю остальную строку формата. Усечение $remote_addr не даёт ровным счётом ничего, если строка всё равно заканчивается на "$http_x_forwarded_for" или несёт $http_cf_connecting_ip, — а очень многие стандартные форматы и форматы из панелей управления включают одно из двух. Полный адрес лежит в заголовках запроса, и он не попадёт в файл только в том случае, если вы уберёте из формата каждый заголовок, который его несёт.

Обратите внимание, что встало на его место: $request_id — случайная 32-символьная hex-строка, которую nginx генерирует на каждый запрос. Это и есть поворотная точка для следующего раздела.

SP·04

Что ломает усечение, и три задачи, которые лог решает на самом деле

Всегда находится тот, кто возражает: анонимизированные логи бесполезны, — и он наполовину прав, потому что "лог" — это три не связанные друг с другом задачи под одним именем файла, и адрес нужен только для одной из них.

Задача первая: заблокировать того, кто долбит вас прямо сейчас. Для этого нужен полный адрес, и нужен он в течение секунд. Завтра он уже не нужен. Обычный инструмент для этого — fail2ban, и он в буквальном смысле не может работать с усечённым файлом: бан 203.0.113.0 банит один ни в чём не повинный хост, а атакующий остаётся на связи. Но для этой задачи достаточно файла, который живёт один день, — или вообще без файла: собственные limit_req и limit_conn у nginx хранят состояние в разделяемой памяти, реагируют за микросекунды вместо того, чтобы ждать опроса от fail2ban, и ничего не пишут на диск.

Задача вторая: разобраться, почему этот запрос вернул 500-ю. Здесь нужна корреляция, а не идентификация. ID запроса, протянутый из nginx в приложение, связывает строку access-лога, ошибку апстрима и трассировку стека приложения в одно событие, — а именно это вы и пытались получить, хватаясь за адрес. На практике ID даже лучше: он переживает клиента в мобильной сети, у которого адрес меняется посреди сессии, и не устаревает, когда четверо посетителей делят один адрес CGNAT.

Задача третья: понимать трафик в динамике. Объём, смесь кодов состояния, какие пути популярны, не сорвался ли краулер с цепи. Здесь усечённого адреса вполне достаточно, и уцелевшей после усечения /24 хватает, чтобы увидеть, что одна сеть даёт 40% ваших запросов.

Так что суть не в том, чтобы "логировать меньше", а в том, чтобы разделить по времени жизни: поток полной детализации живёт один день и питает инструменты блокировки, а усечённый поток живёт столько, сколько вам нужно для статистики. Оба пишет nginx в один и тот же момент, так что между ними нет промежуточной обработки и нет окна, в котором не то лежало бы на диске дольше, чем задумано.

# inside the server block, or in a snippet included by it
access_log  /var/log/nginx/access.log       privacy;   # truncated, keep for weeks
access_log  /var/log/nginx/security.log     secip;     # full address, keep for a day

# static assets are pure noise in a privacy log -- drop them entirely
location ~* \.(?:css|js|svg|png|jpe?g|webp|woff2?|ico)$ {
    access_log off;
    expires 30d;
}

Формат secip рядом с другим — однострочник: $remote_addr, метка времени, запрос и статус, больше ничего. Это единственный файл на машине, где допускается держать полный адрес посетителя, и поэтому его срок хранения — это одно решение в одном месте, а не свойство, которое приходится держать в уме сразу для шести файлов.

SP·05

journald, auth.log и след, который ведёт обратно к вам

О приватности посетителей пишут охотно. А вот след администратора важен именно на машине, весь смысл которой в том, что ваше имя к ней не привязано, и почти весь этот след лежит всего в двух местах.

/var/log/auth.log пишет строку Accepted publickey на каждую открытую вами сессию — с вашим исходным адресом и отпечатком ключа. За месяц это готовое расписание того, когда вы работаете, и список сетей, которыми вы пользуетесь. Если вы всегда подключаетесь через один и тот же туннель, это один и тот же повторяющийся адрес, и в общем-то скучно. Если вы подключаетесь откуда придётся, это уже история поездок.

Журнал хранит те же события, плюс всё, что напечатали ваши юниты, и настройки по умолчанию у него щедрые: SystemMaxUse= — это 10% файловой системы, а MaxRetentionSec= не задан вовсе, то есть никакого ограничения по времени нет — только по размеру. На тихом сервере эта комбинация хранит данные месяцами.

Здесь есть настоящий компромисс, и его стоит проговорить прямо, а не отмахнуться. Логи, которые описывают вас, — это те же логи, которые расскажут, как кто-то проник внутрь. Задайте Storage=volatile — и журнал будет жить только в оперативной памяти, исчезая при перезагрузке: по-настоящему приватно и по-настоящему бесполезно тем утром, когда вы обнаружите процесс, который не запускали сами, — потому что первая же перезагрузка, устроенная атакующим, стёрла улики. Для большинства разумная середина — это постоянное хранение с жёстким потолком и коротким сроком: достаточно долгим, чтобы расследовать инцидент, замеченный в течение недели, и достаточно коротким, чтобы файл не превращался в дневник.

Две практические тонкости, на которых обычно спотыкаются. Образы Debian и Ubuntu расходятся в том, установлен ли rsyslog; если на вашей машине есть /var/log/auth.log, значит его пишет rsyslog, и лимиты journald на этот файл вообще не действуют — это работа logrotate. А ForwardToSyslog= — это то, что передаёт данные из журнала в rsyslog, так что отключение этой опции на машине, где стоят оба, избавляет вас от хранения двух копий одного и того же с двумя разными сроками хранения.

SP·06

Ваш срок хранения — это тот срок, который назначили ваши бэкапы

Вот что сводит на нет всю аккуратную работу, описанную выше, — и это незаметно, пока вы сами не пойдёте искать.

Допустим, logrotate хранит логи nginx четырнадцать дней, и вас это устраивает. Теперь добавьте бэкап вне сервера, который вы совершенно правильно завели: ежедневный прогон Borg или restic с хранением семи дневных, четырёх недельных и шести месячных архивов. Каждый из этих архивов содержит /var/log таким, каким он был в день прогона. Самому старому месячному архиву — полгода, и в нём лежат те четырнадцать дней логов, что были актуальны тогда. Ваш реальный срок хранения логов — не четырнадцать дней. Это полгода, в зашифрованном репозитории, который нельзя прогрепать без восстановления, на второй машине, в другой стране.

Есть ровно два честных решения. Исключить каталоги логов из бэкапа — это едва ли не единственное на сервере, что почти всегда можно пересобрать заново или обойтись без этого, и восстановление без /var/log не становится от этого хуже. Либо включить их осознанно и признать, что ваш реальный срок хранения — это срок хранения репозитория, и в таком случае сказать об этом прямо в публикуемой политике, потому что альтернатива — заявленная политика, которой противоречит ваша же инфраструктура.

# exclude logs from the backup, and prove it took
borg create --stats                       \
    --exclude '/var/log'                  \
    --exclude '/var/lib/docker/containers' \
    ::'{hostname}-{now:%Y-%m-%d}' /

# the check that matters: can you still find an address in the newest archive?
borg list ::"$(borg list --short --last 1)" | grep -c '^var/log/' || echo "clean"

Раз уж вы в этом настрое — два соседних варианта той же проблемы. Снапшоты вашего хостинга — не ваши. Снапшот на уровне гипервизора фиксирует диск таким, каким он был, включая логи, которые вы с тех пор уже сротировали прочь, и живёт он на хранилище провайдера по правилам хранения провайдера, — и это ещё один довод в пользу того, что адреса вообще не стоило записывать полностью, а не повод сделать что-то хитрое задним числом.

И не хватайтесь за shred на VPS. Перезапись файла на виртуальном диске с динамическим выделением места, поверх файловой системы с copy-on-write, поверх SSD, который переназначает блоки ради выравнивания износа, не гарантирует перезапись физических ячеек, где эти данные лежали. Безопасное удаление на арендованном, виртуализированном хранилище — это театр. Работает только то сокращение данных, которое вы сделали в момент записи; всё, что после, — это попытка, которую нельзя проверить. Шифрование тома меняет это уравнение, — но меняет его до того, как данные записаны, и это тот же урок ещё раз.

SP·07

Копии, которые вам не подконтрольны

Минимизация на вашей собственной машине — это только один слой из нескольких, и ясное понимание остальных как раз и не даёт этому превратиться в ложное чувство полноты защиты.

Ваша хостинг-сеть видит запись о потоке. Источник, назначение, порты, объём, тайминги — по каждому соединению внутрь машины и наружу, независимо от того, ведёте вы хоть какой-то лог или нет. Никакая настройка на сервере этого не меняет. Во многом именно поэтому юрисдикция, в которой стоит машина, — реальная переменная, а не маркетинговый ход: что именно закон требует от сети — очень сильно зависит от страны.

Ваш CDN или edge логирует на своей стороне, ещё на периметре. Если Cloudflare терминирует TLS за вас, у него есть строка запроса и адрес клиента ещё до того, как в дело вступает ваш сервер, — по собственному графику хранения и в ответ на собственные запросы от правоохранителей. Усечение вашего origin-лога не дотягивается назад сквозь это. Запустить собственный edge — это версия того же самого, которую вы реально можете настроить, — и правила логирования из этого руководства в первую очередь относятся именно к edge-машине, потому что именно туда приходят адреса без усечения.

Трекеры ошибок и аналитика сами увозят это с машины за вас. Sentry и большинство похожих на него сервисов по умолчанию прикрепляют IP клиента к каждому событию; настройка обычно называется примерно как send_default_pii, и её стоит проверить, а не считать чем-то само собой разумеющимся. Любая облачная аналитика по своей конструкции — это сторонняя копия того самого access-лога, который вы только что потратили целый вечер на усечение.

Почта протекает сильнее всего остального. Если хоть что-то на машине отправляет письма, заголовки несут хост и адрес отправителя, а каждый узел-ретранслятор на этом пути хранит копию конверта с отметками времени. Форма обратной связи, которая шлёт вам письмо, — это лог, которым управляете не вы.

Всё это не делает работу с локальными логами бессмысленной — именно локальная копия попадёт в чужие руки вместе с машиной при изъятии, утечёт при взломе или будет передана вами самими. Это просто тот единственный слой, который вы контролируете полностью, — и ошибка в том, чтобы принимать его за всю картину целиком.

SP·08

Минимизация по проекту, а не удаление по требованию

Здесь стоит быть точным, потому что эти два понятия постоянно путают, а разница между ними — это разница между стандартной инженерной практикой и тем, чего делать не следует.

Заранее решить, что именно ваш сервис собирает и как долго это хранит, — обычная, задокументированная и всячески поощряемая практика. В GDPR это два из базовых принципов — минимизация данных и ограничение срока хранения, — и более короткий срок хранения логов — это как раз то, чего аудиторы просят, а не то, против чего они возражают. У оператора сайта или клиента хостинга в ЕС нет общей обязанности хранить логи трафика; директива о повальном хранении данных, которая раньше как будто это подразумевала, была признана недействительной Судом Европейского союза в 2014 году, а уцелевшие национальные законы в основном касаются операторов электросвязи, а не тех, кто просто держит веб-сервер. В США тоже нет общего требования о хранении данных для операторов сайтов.

Уничтожение конкретных записей уже после того, как вас о них официально уведомили, — совершенно другое действие. Официальный запрос на сохранение данных, требование сохранить доказательства для суда, решение суда или запрос полиции меняют то, что вам можно делать с данными, существующими на этот момент, и "это удалила моя политика хранения" не работает как оправдание, если вы ускорили удаление именно из-за уведомления. Ничего из этого руководства к такой ситуации не относится. Политику хранения устанавливают в спокойный вторник и потом оставляют в покое; если её укорачивают только тогда, когда что-то уже случилось, это была не политика.

Та же граница проходит через весь остальной сайт: есть настоящая черта между инженерией приватности и позицией "bulletproof", которая продаёт себя как неуязвимость. Сервис, спроектированный так, что он никогда не накапливает историю посетителей, уверенно стоит по правильную сторону этой черты — в одном ряду с шифрованием дисков и отказом спрашивать у пользователей email, который вам не нужен.

Отсюда два практических следствия. Если вы публикуете политику конфиденциальности, приведите указанные в ней сроки хранения в соответствие с тем, что реально лежит на диске: заявленные четырнадцать дней при реальных шести месяцах в репозитории бэкапов — это тот разрыв, что на бумаге превращает добросовестного оператора в недобросовестного. И запишите это решение для себя — хотя бы комментарием в начале файла logrotate, — потому что объяснять эти цифры через полтора года придётся именно вам, а вы не вспомните, почему стояла семёрка. Ничто из вышесказанного не является юридической консультацией; если вы работаете там, где действуют отраслевые требования к срокам хранения, сверьтесь с ними применительно к своей ситуации, прежде чем что-либо сокращать.

SP·09

Шаг за шагом

  1. 01

    Узнайте, что машина уже успела накопить

    Не настраивайте ничего, пока не измерили текущее состояние. Смысл этого прохода — найти файл, о котором вы забыли, а на большинстве машин это либо лог контейнера, либо лог приложения, в который никто не заглядывал со времён деплоя.

    # biggest log files anywhere on the box, largest last
    sudo du -ah /var/log /var/lib/docker/containers 2>/dev/null \
      | sort -h | tail -20
    
    # how much disk the journal holds, and how far back it goes
    journalctl --disk-usage
    journalctl --output=short-iso | head -1
    
    # how old is the oldest nginx line still on disk?
    zcat -f /var/log/nginx/access.log* 2>/dev/null | head -1

    Затем посмотрите на одну строку из каждого файла и спросите себя, что именно она идентифицирует. Команда ниже считает, сколько разных полных адресов сейчас можно достать из ваших веб-логов, — обычно именно это число и служит лучшим доводом в пользу всего остального в этом руководстве.

    zcat -f /var/log/nginx/access.log* 2>/dev/null \
      | grep -oE '^([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u | wc -l

    Запишите, что у вас получилось. В конце вы прогоните те же самые команды, чтобы доказать, что изменения подействовали.

  2. 02

    Усекайте адрес клиента ещё до того, как nginx запишет строку

    Создайте map и оба формата в файле, который подключается в контекст http. В Debian и Ubuntu /etc/nginx/conf.d/ подключается из nginx.conf раньше конфигов сайтов, — то есть это как раз то самое место.

    sudo tee /etc/nginx/conf.d/00-privacy-log.conf > /dev/null <<'EOF'
    # The client address is reduced here, at log time, and never written in full
    # to the long-retention file. $realip_remote_addr still holds the connecting
    # address if you need it while debugging a proxy problem.
    map $remote_addr $ip_trunc {
        ~(?<v4>\d+\.\d+\.\d+)\.     "${v4}.0";
        ~(?<v6>[^:]+:[^:]+):          "${v6}::";
        # anything the two patterns cannot parse -- including compressed forms
        # like ::1 -- falls through here, i.e. fails closed rather than open
        default                       "0.0.0.0";
    }
    
    # Long retention: no full address, no forwarded-for header, request id instead.
    log_format privacy '$ip_trunc - - [$time_local] "$request" $status '
                       '$body_bytes_sent "$http_referer" "$http_user_agent" '
                       'rid=$request_id rt=$request_time';
    
    # Short retention: the one file allowed to hold a complete address.
    log_format secip   '$remote_addr [$time_local] "$request" $status';
    EOF
    
    sudo nginx -t && sudo systemctl reload nginx

    Теперь направьте сайт на них. В своём блоке server замените существующую строку access_log этой парой и заодно отключите логирование для статических файлов.

    access_log  /var/log/nginx/access.log    privacy;
    access_log  /var/log/nginx/security.log  secip;

    Перезагрузите nginx, откройте страницу и посмотрите на результат. Первое поле должно заканчиваться на .0, а в строке должно быть значение rid=.

    sudo nginx -t && sudo systemctl reload nginx
    curl -s -o /dev/null https://your-domain.example/
    sudo tail -1 /var/log/nginx/access.log

    Если адрес всё ещё полный, значит где-то ниже блок server перекрывает формат заново, — grep -rn access_log /etc/nginx/ найдёт ту строку, которая побеждает.

  3. 03

    Держите полные адреса только там, где на них что-то реагирует

    Если у вас запущен fail2ban, сейчас он читает тот самый файл, который вы только что усекли. Перенаправьте его на лог безопасности и дайте этому логу жить один день, чтобы полные адреса истекали сами собой.

    sudo tee /etc/fail2ban/jail.d/nginx-privacy.local > /dev/null <<'EOF'
    [nginx-http-auth]
    enabled  = true
    logpath  = /var/log/nginx/error.log
    
    [nginx-botsearch]
    enabled  = true
    logpath  = /var/log/nginx/security.log
    maxretry = 6
    findtime = 10m
    bantime  = 1h
    EOF
    
    sudo fail2ban-client reload

    Затем разберитесь с собственной памятью fail2ban, до которой большинство вообще не доходит руками: он ведёт свой лог банов с настройкой по умолчанию rotate 4 weekly, а также базу SQLite со всеми выданными банами. dbpurgeage — это то, что удаляет устаревшие строки из этой базы; выставьте значение, близкое к вашему самому долгому бану, а не оставляйте значение по умолчанию.

    sudo tee /etc/fail2ban/fail2ban.d/retention.local > /dev/null <<'EOF'
    [Definition]
    dbpurgeage = 2d
    loglevel   = NOTICE
    EOF
    
    sudo systemctl restart fail2ban

    Ещё лучше — для простых флудов делать эту работу прямо в nginx, где вообще ничего не записывается. limit_req держит свои счётчики в разделяемой памяти, реагирует за микросекунды вместо ожидания опроса и не оставляет никакой записи о том, кого он придержал, — за расчётом размера зон под реальной нагрузкой обращайтесь к runbook на первый час DDoS-атаки.

    # http context: keyed on the truncated address, so nothing complete is
    # held in memory either. 10m of shared state is plenty for a small site.
    limit_req_zone $ip_trunc zone=perip:10m rate=20r/s;
    
    # in the location you want protected
    limit_req zone=perip burst=40 nodelay;

    Ключевание зоны по $ip_trunc вместо $binary_remote_addr — это осознанный компромисс: теперь лимит действует сразу на весь /24, так что загруженный офис за одним блоком делит один общий бюджет. Для небольшого сайта это обычно нормально, а иногда даже к лучшему; если нет — ключуйте зону по полному адресу: состояние ограничителя запросов живёт в разделяемой памяти и никогда не пишется на диск, так что оно вообще не входит в то, что вы здесь минимизируете.

  4. 04

    Ограничьте журнал и избавьтесь от второй копии

    journald поддерживает drop-in-файлы, которые переживают обновления пакета — в отличие от прямого редактирования journald.conf. Значения ниже хранят данные около недели — этого достаточно, чтобы расследовать в понедельник то, что началось в пятницу, — внутри жёсткого потолка в 200 МБ.

    sudo mkdir -p /etc/systemd/journald.conf.d
    sudo tee /etc/systemd/journald.conf.d/retention.conf > /dev/null <<'EOF'
    [Journal]
    Storage=persistent
    SystemMaxUse=200M
    SystemMaxFileSize=20M
    MaxRetentionSec=7day
    MaxFileSec=1day
    ForwardToSyslog=no
    EOF
    
    sudo systemctl restart systemd-journald
    journalctl --disk-usage

    Перезапуск сразу применяет ограничения по размеру; срок хранения начинает действовать по мере ротации новых файлов, так что уже разросшийся журнал сожмётся при следующей ротации, а не мгновенно. journalctl --vacuum-time=7d заставит это произойти прямо сейчас, если вам нужно освободить диск сегодня же.

    ForwardToSyslog=no имеет значение на любом образе, где есть rsyslog: без этой настройки каждая запись журнала дополнительно дублируется в /var/log/syslog — уже по расписанию logrotate, а не journald, — и у вас оказывается две копии с двумя разными сроками жизни. Проверьте, какая ситуация у вас, прежде чем строить предположения:

    systemctl is-active rsyslog 2>/dev/null || echo "rsyslog not running"
    ls -la /var/log/auth.log /var/log/syslog 2>/dev/null

    Если эти файлы существуют, ими управляет rsyslog, и их срок хранения задаётся на шаге шестом. Если их нет, журнал — единственная копия, и вы её только что ограничили.

  5. 05

    Не дайте приложению заново залогировать то, что вы убрали

    Всё, что было выше, не касается вашего приложения, а приложение в неправильном режиме с радостью запишет полный адрес, полный URL и токен сессии в собственный файл. Есть три вещи, которые стоит проверить.

    У PHP-FPM в конфиге пула есть директива access.log, по умолчанию закомментированная, но включённая в очень многих панелях управления. Если она включена, это вторая копия каждой строки запроса, — из файла, которого вся ваша работа с nginx никогда не касалась.

    grep -rn '^access.log' /etc/php/*/fpm/pool.d/ || echo "fpm access log off"

    Уровень логирования вашего фреймворка решает, попадут ли на диск query-строки и тела запросов. Режим отладки в большинстве фреймворков логирует полный URL, а ссылка для сброса пароля — это и есть полный URL. Установите продакшен-уровень и убедитесь, что загружен именно он, а не тот, что лежит в файле, который вы считаете актуальным.

    Драйвер Docker по умолчанию никогда не ротирует логи. Исправьте это на уровне демона, чтобы ограничение унаследовал каждый будущий контейнер. Учтите: это касается только контейнеров, созданных после перезапуска, — существующие держат свой текущий, ничем не ограниченный файл, пока их не пересоздадут.

    sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "10m", "max-file": "3" }
    }
    EOF
    
    sudo systemctl restart docker
    docker inspect --format '{{.HostConfig.LogConfig}}' $(docker ps -q) 2>/dev/null

    Если у контейнера уже накопился большой файл, реально обрезает историю только его пересоздание командой docker compose up -d --force-recreate, — обычный перезапуск оставляет тот же самый лог-файл.

  6. 06

    Назначайте срок хранения осознанно — по одному месту на файл

    logrotate — это то место, где живут все сроки для всего, что пишут rsyslog и nginx. Редактируйте готовую секцию вместо того, чтобы добавлять вторую: две секции с одним и тем же путём заставляют logrotate упасть с ошибкой дублирующейся записи и вообще перестать ротировать этот файл, а это самый частый способ, которым изменение срока хранения тихо превращается в его отсутствие.

    # check for duplicates BEFORE editing, then again after
    sudo logrotate --debug /etc/logrotate.conf 2>&1 | grep -i 'duplicate\|error'

    Для nginx двум файлам нужны разные сроки: усечённый может жить неделями, а тот, что хранит полные адреса, не должен пережить день. Добавьте отдельную секцию для лога безопасности — с другим путём, так что дублирования не будет, — и сократите срок у готовой секции.

    sudo tee /etc/logrotate.d/nginx-security > /dev/null <<'EOF'
    # The only file on this box that holds complete client addresses.
    # One day, uncompressed so fail2ban can read it. Do not lengthen without
    # a reason you would be happy to write down here.
    /var/log/nginx/security.log {
        daily
        rotate 1
        maxage 1
        missingok
        notifempty
        nocompress
        create 0640 www-data adm
        sharedscripts
        postrotate
            [ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
        endscript
    }
    EOF
    
    sudo sed -i 's/^\trotate 14$/\trotate 7/' /etc/logrotate.d/nginx
    sudo logrotate --debug /etc/logrotate.d/nginx-security

    Проделайте ту же арифметику для /etc/logrotate.d/rsyslog, если rsyslog установлен, — по умолчанию он хранит четыре недели auth.log, а это четыре недели ваших собственных SSH-сессий. И запустите debug-проход ещё раз, напоследок: он печатает, какие именно файлы будут ротированы и удалены, и это единственный способ убедиться, что только что введённые вами цифры — это и есть действующие цифры.

  7. 07

    Докажите это, включая проверку через бэкап

    Прогоните заново измерения из первого шага. Количество разных полных адресов в логе с долгим сроком хранения теперь должно перестать расти, а после одного цикла ротации — стать нулевым.

    # should print 0 once the pre-change files have rotated out
    zcat -f /var/log/nginx/access.log* 2>/dev/null \
      | grep -oE '^([0-9]{1,3}\.){3}[0-9]{1,3}' \
      | grep -v '\.0$' | sort -u | wc -l
    
    # and the journal should now be bounded
    journalctl --disk-usage

    Затем — проверка, которую почти никто не делает: загляните внутрь бэкапа. Репозиторий, который всё ещё несёт в себе /var/log, хранит ту самую детализацию и тот самый срок, на устранение которых вы только что потратили вечер, — и будет хранить их, пока жив ваш самый старый архив.

    borg list | head -3          # borg lists oldest first
    borg list ::"$(borg list --short | head -1)" 2>/dev/null \
      | grep -c '^var/log/' || echo "no logs in oldest archive"

    Если счётчик не нулевой, либо добавьте исключение, описанное раньше, и дайте старым архивам устареть самим, либо вычистите их вручную. Пока не произойдёт ни то, ни другое, ваш реальный срок хранения — это срок хранения репозитория, — и это, пожалуй, самая полезная фраза во всём этом руководстве, и её же проще всего забыть.

    И напоследок — запишите цифры там, где их найдёт следующий человек: выбранный срок хранения, причину и дату. Комментария в начале файла logrotate достаточно. Всё, что настроено выше, — это решение, а решение, которое никто не сможет восстановить через год, снова становится дефолтом.

SP·10 — FAQ

Быстрые ответы

Обнуление последнего октета IP-адреса — это действительно анонимизация?

Нет, и здесь стоит быть точным, а не утешительным. Усечение до /24 — это псевдонимизация: оно сужает посетителя до блока в пределах 256 адресов вместо одного, а это у домашнего провайдера — целый район, а в сети небольшой компании — всё ещё может быть одна-единственная организация. Сложите /24 с точной меткой времени и подробной строкой user agent — и реидентификация часто оказывается возможна для того, кто настроен серьёзно.

Что это действительно делает — убирает то самое поле, из-за которого лог тривиально сопоставляется с любым другим набором данных в мире, причём убирает его ещё до записи строки: точное значение вообще никогда не существовало на вашем диске, чтобы его можно было изъять, слить или запросить по повестке. Это большое, реальное снижение риска. Но это не анонимность, и любой инструмент, который называет это анонимностью, — преувеличивает.

Будет ли работать fail2ban, если я усеку access-лог?

С усечённым файлом — нет: захват <HOST> совпадёт с 203.0.113.0 и забанит один-единственный адрес, который к вам вообще никогда не подключался, а настоящий источник останется нетронутым. Любой, кто анонимизировал свои логи и не заметил этого, молча выключил себе fail2ban.

Решение — то самое разделение, что описано выше: nginx пишет второй, короткоживущий файл с полными адресами, и fail2ban читает именно его. Учтите, что /var/log/nginx/error.log тоже несёт полные адреса и не поддаётся переформатированию, так что jail'ы, завязанные на него (обычно это те, что ловят неудачные попытки входа), продолжают работать в любом случае. А для объёмных атак, а не подбора учётных данных, limit_req у nginx лучше обоих вариантов: он действует прямо внутри процесса, за микросекунды, и вообще ничего не записывает.

Как отладить 500-ю ошибку на проде без адреса клиента?

С помощью ID запроса, который обычно даже полезнее, чем был адрес. nginx генерирует $request_id на каждый запрос; включите его в формат лога, прокиньте выше по цепочке через proxy_set_header X-Request-ID $request_id; и логируйте его в приложении рядом с трассировкой стека. Теперь одна строка связывает воедино строку access-лога, ошибку апстрима и исключение.

Он превосходит адрес по трём пунктам: он уникален для каждого запроса, а не для каждого клиента, так что два одновременных сбоя не сливаются в один; он переживает мобильного клиента, у которого адрес меняется посреди сессии; и его безопасно показывать пользователю на странице ошибки, так что репорт о баге приходит сразу с нужным вам точным идентификатором. А для редкого случая, когда адрес действительно нужен — скажем, при целевой атаке, — он на сутки остаётся в логе безопасности с коротким сроком хранения.

Обязан ли я по закону хранить логи сервера?

Для оператора сайта или клиента хостинга в ЕС — как правило, нет. Режим повального хранения, который многие смутно помнят, происходит из директивы ЕС о хранении данных 2006 года, признанной недействительной Судом Европейского союза в 2014 году; уцелевшие национальные законы в основном касаются операторов электросвязи — телекомов и интернет-провайдеров, — а не тех, кто просто держит веб-сервер. GDPR толкает в обратную сторону: хранить меньше и не так долго. В США тоже нет общего требования о хранении для операторов сайтов, хотя запрос на сохранение данных может прийти и в этот момент изменить то, что вам можно делать с уже существующими данными.

Конкретика начинается там, где есть отрасль и роль: обработка платежей, регулируемые финансы, здравоохранение и статус настоящего интернет-провайдера — у всех свои обязанности, а некоторые юрисдикции накладывают требования на операторов публичного Wi-Fi или платформ. В какой стране стоит машина — это существенно меняет ответ. Это не юридическая консультация: если вы относитесь к одной из этих категорий, сверьтесь со своим положением, прежде чем что-либо сокращать.

А как насчёт логов, которые хранит Cloudflare или мой CDN?

Они существуют независимо от того, что вы делаете на origin-сервере, и усечение вашего собственного файла не дотягивается назад сквозь edge. Если Cloudflare терминирует TLS за вас, он видит полный запрос и настоящий адрес клиента ещё до того, как в дело вступает ваш сервер, хранит это по собственному графику и отвечает на собственные юридические запросы. То же самое касается любого облачного WAF или сервиса фильтрации DDoS.

У вас есть два рычага. Проверить, что и как долго хранит ваш edge-провайдер, и урезать всё, что он позволяет урезать. Либо запустить edge под собственным контролем — небольшой VPS, который терминирует TLS и проксирует обратно через туннель, — и тогда всё из этого руководства в первую очередь относится именно к этой машине, ведь это она получает полные адреса на самом деле.

Стоит ли просто полностью отключить access-логи?

Обычно нет, и причина тут практическая, а не философская. Без access-лога вы не сможете ответить на жалобу об abuse, не отличите краулера от атаки, не подберёте размер машины и не заметите, что час назад деплой начал отдавать 404. Вы также теряете возможность доказать, что произошло на самом деле, если кто-то заявит, будто ваш сервер сделал то, чего он не делал.

Середина — это как раз то, что строит это руководство: оставить строку, убрать идентификатор, добавить ID для корреляции и дать единственному файлу с полными адресами истечь через день. Единственное место, где access_log off однозначно уместен, — это статика: картинки, CSS, шрифты, — чистый объём, не сообщающий ничего, чего нельзя получить из предшествовавшего ему HTML-запроса. И не забывайте, что error-лог в любом случае продолжает записывать адреса клиентов при сбоях, а это ещё один довод дать ему короткий срок хранения, а не тянуться к выключателю.

Применить на практике

VPS в онлайне за 15 min, выделенный передаётся за 2–12 h. Пополните от $30.00 в крипте — без привязки личности.

Развернуть VPS