Compare commits

...

10 Commits

Author SHA1 Message Date
astronit 7be5dd910b feat: включение комментариев Comentario (partial, global showComments) 2026-07-24 13:09:59 +00:00
astronit 07bf07f78e feat: Замена файла фоновой анимации 2026-07-21 14:43:23 +00:00
astronit 132f194432 feat: Установлена светлая тема по умолчанию. Заменены имена файлов с логотипами 2026-07-21 10:49:19 +00:00
astronit 9138b45459 feat: Настройка дизайна сайта. Удаление картинок-превью. 2026-07-21 10:35:22 +00:00
astronit 5f56173ae9 feat: опубликована статья: Ubuntu 24.04: базовая настройка и hardening для production 2026-07-07 12:38:47 +00:00
astronit 0f6bdfd02b fix: переделал Категории. Тэги привел в однообразное состояние 2026-06-28 12:35:40 +00:00
astronit b8594dff65 fix: К статье добавлен slug: mailserver-part-4-tls-dkim-spf-dmarc 2026-06-24 18:18:11 +00:00
astronit e7ddb7bc82 feat: НС: Диск забит на 100% для Debian + замена вывода статей на СПИСОК 2026-06-24 13:35:25 +00:00
astronit 40e42fd8c6 feat: опубликовал статью Debian 12: настройка Docker-хоста для production 2026-06-09 13:19:41 +00:00
astronit 05e5ffdad4 feat: Добавлена новая статья Debian 12: настройка Docker-хоста для production и скорректирована старая Debian 12: базовая настройка и hardening для production. Обновлены ссылки на них 2026-06-09 12:17:44 +00:00
40 changed files with 3378 additions and 1495 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+37 -36
View File
@@ -6,12 +6,12 @@
# https://blowfish.page/docs/configuration/#theme-parameters # https://blowfish.page/docs/configuration/#theme-parameters
colorScheme = "blowfish" colorScheme = "blowfish"
defaultAppearance = "dark" # valid options: light or dark defaultAppearance = "light" # valid options: light or dark
autoSwitchAppearance = false autoSwitchAppearance = false
enableA11y = false enableA11y = true
enableSearch = true enableSearch = true
enableCodeCopy = false enableCodeCopy = true
enableStructuredBreadcrumbs = false enableStructuredBreadcrumbs = false
# enableStyledScrollbar = true # disable to use native scrollbar style (defaults to true) # enableStyledScrollbar = true # disable to use native scrollbar style (defaults to true)
@@ -20,18 +20,19 @@ replyByEmail = false
# mainSections = ["section1", "section2"] # mainSections = ["section1", "section2"]
# robots = "" # robots = ""
disableImageOptimization = false disableImageZoom = true
disableImageOptimizationMD = false disableImageOptimization = true
# disableImageOptimizationMD = false
disableTextInHeader = true disableTextInHeader = true
# backgroundImageWidth = 1200 # backgroundImageWidth = 1200
defaultBackgroundImage = "/img/background.png" # used as default for background images defaultBackgroundImage = "/img/background.svg" # used as default for background images
defaultFeaturedImage = "/img/background.png" # used as default for featured images in all articles # defaultFeaturedImage = "/img/background.png" # used as default for featured images in all articles
# defaultSocialImage = "/android-chrome-512x512.png" # used as default for social media sharing (Open Graph and Twitter) # defaultSocialImage = "/android-chrome-512x512.png" # used as default for social media sharing (Open Graph and Twitter)
hotlinkFeatureImage = false hotlinkFeatureImage = false
# imagePosition = "50% 50%" # imagePosition = "50% 50%"
# highlightCurrentMenuArea = true highlightCurrentMenuArea = true
# smartTOC = true # smartTOC = true
# smartTOCHideUnfocusedChildren = true # smartTOCHideUnfocusedChildren = true
@@ -54,65 +55,65 @@ forgejoDefaultServer = "https://v11.next.forgejo.org"
layout = "background" # valid options: page, profile, hero, card, background, custom layout = "background" # valid options: page, profile, hero, card, background, custom
#homepageImage = "IMAGE.jpg" # used in: hero, and card #homepageImage = "IMAGE.jpg" # used in: hero, and card
showRecent = true showRecent = true
showRecentItems = 18 showRecentItems = 5
showMoreLink = true showMoreLink = true
showMoreLinkDest = "/posts" showMoreLinkDest = "/posts"
cardView = true cardView = false
cardViewScreenWidth = false cardViewScreenWidth = false
layoutBackgroundBlur = true # only used when layout equals background layoutBackgroundBlur = true # only used when layout equals background
disableHeroImageFilter = false # only used when layout equals hero disableHeroImageFilter = true # only used when layout equals hero
[article] [article]
showDate = true showDate = true
showViews = false # showViews = false
showLikes = false # showLikes = false
showDateOnlyInArticle = false # showDateOnlyInArticle = false
showDateUpdated = false # showDateUpdated = false
showAuthor = true showAuthor = true
# showAuthorBottom = false # showAuthorBottom = false
showHero = true showHero = true
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
showBreadcrumbs = false #showBreadcrumbs = false
showDraftLabel = true showDraftLabel = true
showEdit = false #showEdit = false
# editURL = "https://github.com/username/repo/" # editURL = "https://github.com/username/repo/"
editAppendPath = true # editAppendPath = true
seriesOpened = true seriesOpened = true
showHeadingAnchors = true showHeadingAnchors = true
showPagination = true showPagination = true
invertPagination = false # invertPagination = false
showReadingTime = true showReadingTime = true
showTableOfContents = true showTableOfContents = true
showRelatedContent = true showRelatedContent = true
relatedContentLimit = 6 relatedContentLimit = 3
showTaxonomies = true # use showTaxonomies OR showCategoryOnly, not both # showTaxonomies = true # use showTaxonomies OR showCategoryOnly, not both
showCategoryOnly = false # use showTaxonomies OR showCategoryOnly, not both showCategoryOnly = true # use showTaxonomies OR showCategoryOnly, not both
showAuthorsBadges = false showAuthorsBadges = false
showWordCount = false # showWordCount = false
# sharingLinks = [ "linkedin", "twitter", "bluesky", "mastodon", "reddit", "pinterest", "facebook", "email", "whatsapp", "telegram", "line"] # sharingLinks = [ "linkedin", "twitter", "bluesky", "mastodon", "reddit", "pinterest", "facebook", "email", "whatsapp", "telegram", "line"]
sharingLinks = [ "reddit", "email", "telegram"] sharingLinks = [ "reddit", "email", "telegram"]
showZenMode = false # showZenMode = false
# externalLinkForceNewTab = false # disable to allow external links in the same tab (defaults to true) # externalLinkForceNewTab = false # disable to allow external links in the same tab (defaults to true)
# showComments = true showComments = true
[list] [list]
showHero = true showHero = true
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
showBreadcrumbs = false # showBreadcrumbs = false
showSummary = false showSummary = true
showViews = false # showViews = false
showLikes = false # showLikes = false
showTableOfContents = false # showTableOfContents = false
showCards = false showCards = true
orderByWeight = false # orderByWeight = false
groupByYear = false # groupByYear = false
cardView = false # cardView = false
cardViewScreenWidth = false # cardViewScreenWidth = false
constrainItemsWidth = false # constrainItemsWidth = false
[sitemap] [sitemap]
excludedKinds = ["taxonomy", "term"] excludedKinds = ["taxonomy", "term"]
@@ -7,6 +7,7 @@ slug: "shortcodes"
tags: ["shortcodes", "mermaid", "icon", "lead", "docs"] tags: ["shortcodes", "mermaid", "icon", "lead", "docs"]
series: ["Документация"] series: ["Документация"]
series_order: 8 series_order: 8
showComments: true
--- ---
Hugo даёт [стандартные shortcodes](https://gohugo.io/content-management/shortcodes/). Blowfish добавляет свои — для типичных задач блога. Hugo даёт [стандартные shortcodes](https://gohugo.io/content-management/shortcodes/). Blowfish добавляет свои — для типичных задач блога.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2026-01-03
draft: false draft: false
description: "Переезд с Jekyll на Hugo: выбор темы Blowfish, настройка K3s кластера и два окружения с нуля. Старт цикла о production блоге на Open Source стеке." description: "Переезд с Jekyll на Hugo: выбор темы Blowfish, настройка K3s кластера и два окружения с нуля. Старт цикла о production блоге на Open Source стеке."
tags: ["hugo", "blowfish", "gitea", "homelab", "devops"] tags: ["hugo", "blowfish", "gitea", "homelab", "devops"]
categories: ["Веб-разработка"] categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"] series: ["Блог на Hugo в K3s"]
series_order: 1 series_order: 1
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -4,7 +4,7 @@ date: 2026-01-08
draft: false draft: false
description: "Деплой Hugo в K3s: NFS хранилище, Hugo Builder с webhook, Nginx с Prometheus exporter и автоматический SSL от Let's Encrypt." description: "Деплой Hugo в K3s: NFS хранилище, Hugo Builder с webhook, Nginx с Prometheus exporter и автоматический SSL от Let's Encrypt."
tags: ["hugo", "k3s", "kubernetes", "nfs", "traefik", "cert-manager"] tags: ["hugo", "k3s", "kubernetes", "nfs", "traefik", "cert-manager"]
categories: ["Kubernetes", "Веб-разработка"] categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"] series: ["Блог на Hugo в K3s"]
series_order: 2 series_order: 2
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2026-01-15
draft: false draft: false
description: "Dev окружение для Hugo в K3s: отдельный Hugo Builder, Nginx и Basic Auth через Traefik. Проверяем статьи до публикации в production." description: "Dev окружение для Hugo в K3s: отдельный Hugo Builder, Nginx и Basic Auth через Traefik. Проверяем статьи до публикации в production."
tags: ["hugo", "k3s", "kubernetes", "traefik", "basic-auth"] tags: ["hugo", "k3s", "kubernetes", "traefik", "basic-auth"]
categories: ["Веб-разработка", "DevOps практики"] categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"] series: ["Блог на Hugo в K3s"]
series_order: 3 series_order: 3
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -3,8 +3,9 @@ title: "Блог на Hugo в K3s: часть 4 - выбор Git workflow"
date: 2026-02-16 date: 2026-02-16
draft: false draft: false
description: "Одна папка или две для dev и production? Разбираем Git workflow для Hugo блога в K3s — переключение веток против отдельных директорий." description: "Одна папка или две для dev и production? Разбираем Git workflow для Hugo блога в K3s — переключение веток против отдельных директорий."
summary: "Одна папка или две для dev и production? Разбираем Git workflow для Hugo блога в K3s — переключение веток против отдельных директорий."
tags: ["git", "workflow", "devops", "hugo"] tags: ["git", "workflow", "devops", "hugo"]
categories: ["Веб-разработка", "DevOps практики"] categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"] series: ["Блог на Hugo в K3s"]
series_order: 4 series_order: 4
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+1 -1
View File
@@ -4,7 +4,7 @@ date: 2026-02-17
draft: false draft: false
description: "Алгоритм диагностики Hugo блога в K3s за 5 минут: от DNS до пода. Сайт отдаёт 503 — находим причину без паники." description: "Алгоритм диагностики Hugo блога в K3s за 5 минут: от DNS до пода. Сайт отдаёт 503 — находим причину без паники."
tags: ["kubernetes", "k3s", "traefik", "debugging", "nginx", "troubleshooting"] tags: ["kubernetes", "k3s", "traefik", "debugging", "nginx", "troubleshooting"]
categories: ["Kubernetes", "DevOps практики"] categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"] series: ["Блог на Hugo в K3s"]
series_order: 5 series_order: 5
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2026-02-18
draft: false draft: false
description: "Переносим сервисы между namespace в Kubernetes: экспорт манифестов, миграция NFS данных, новый SSL за 32 секунды. Реальный пример с Gitea." description: "Переносим сервисы между namespace в Kubernetes: экспорт манифестов, миграция NFS данных, новый SSL за 32 секунды. Реальный пример с Gitea."
tags: ["kubernetes", "k3s", "gitea", "devops", "homelab", "namespace"] tags: ["kubernetes", "k3s", "gitea", "devops", "homelab", "namespace"]
categories: ["Kubernetes", "DevOps практики"] categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"] series: ["Блог на Hugo в K3s"]
series_order: 6 series_order: 6
--- ---
@@ -0,0 +1,838 @@
---
title: "Debian 12: базовая настройка и hardening для production"
date: 2026-05-22
draft: false
description: "Пошаговая настройка чистого Debian 12 (Bookworm) для production: SSH, firewall, fail2ban, swap, sysctl, автообновления, логи, бэкап конфигов."
summary: "Пошаговая настройка чистого Debian 12 (Bookworm) для production: SSH, firewall, fail2ban, swap, sysctl, автообновления, логи, бэкап конфигов."
tags: ["debian-12", "security", "hardening", "ssh", "ufw", "fail2ban", "sysctl", "production", "linux"]
categories: ["Безопасность"]
series: ["Linux Hardening"]
series_order: 1
---
# Debian 12: базовая настройка и hardening для production
Поставил чистый Debian 12 и думаешь сразу накатывать приложения? Не торопись. Час на базовую настройку сейчас - это недели сэкономленного времени на разгребание последствий взлома или падения.
Статья покрывает всё что нужно для production-сервера без контейнеров. Пройдёшь по шагам и получишь сервер с закрытым SSH, firewall, защитой от брутфорса, ограничением ресурсов ядра и автоматическими обновлениями безопасности. Docker - в следующей статье серии.
## Исходные данные
- Чистый Debian 12 (Bookworm)
- Root доступ по SSH
- Статический IP
Проверь версию:
```bash
lsb_release -a
```
Ожидаемый вывод:
```
Distributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookworm
```
Все команды выполняются от `root` если не указано иное.
## Обновление системы
```bash
apt update
apt upgrade -y
apt dist-upgrade -y
apt autoremove -y
```
Проверь нужна ли перезагрузка:
```bash
[ -f /var/run/reboot-required ] && echo "Reboot needed" || echo "No reboot needed"
```
Если нужна - перезагрузись:
```bash
reboot
```
## Базовые утилиты
```bash
apt install -y \
sudo curl wget git vim htop iotop iftop \
net-tools dnsutils tcpdump screen tmux rsync \
util-linux unzip ca-certificates gnupg2 lsb-release \
smartmontools
```
## Настройка системы
### Hostname
```bash
hostnamectl set-hostname srv01.example.com
```
Добавь в `/etc/hosts`:
```bash
nano /etc/hosts
```
```
127.0.0.1 localhost
203.0.113.10 srv01.example.com srv01
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
```
Замени `203.0.113.10` на реальный IP сервера, `srv01.example.com` - на своё имя.
Проверь FQDN:
```bash
hostname -f
# srv01.example.com
```
### Timezone
```bash
timedatectl set-timezone Europe/Moscow
```
Проверь:
```bash
timedatectl
```
### Локаль
```bash
apt install -y locales
dpkg-reconfigure locales
```
Выбери `en_US.UTF-8` как основную. Добавь `ru_RU.UTF-8` если нужна кириллица в системных сообщениях.
Проверь:
```bash
locale
# LANG=en_US.UTF-8
```
## Пользователь и SSH
### Создание пользователя
Root сессия - одна ошибка без возможности откатиться. `sudo` даёт контроль и лог действий. Создай непривилегированного пользователя:
```bash
adduser admin
```
Добавь в sudo:
```bash
usermod -aG sudo admin
```
Проверь:
```bash
groups admin
# admin : admin sudo
```
### SSH ключи
На своей рабочей машине сгенерируй ключ:
```bash
# Linux/macOS
ssh-keygen -t ed25519 -C "admin@srv01"
```
```powershell
# Windows
ssh-keygen -t ed25519
```
Скопируй публичный ключ на сервер:
```bash
# Linux/macOS
ssh-copy-id admin@203.0.113.10
```
```powershell
# Windows
type $env:userprofile\.ssh\id_ed25519.pub | ssh admin@203.0.113.10 "mkdir -m 700 -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
Проверь вход по ключу - не должен спрашивать пароль:
```bash
ssh admin@203.0.113.10
```
### SSH hardening
Залогинься под `admin` и открой конфиг:
```bash
sudo nano /etc/ssh/sshd_config
```
Замени содержимое (или найди и измени каждый параметр):
```
# Нестандартный порт - отсекает 90% ботов
Port 2222
# Только ключи
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
# Запретить root логин
PermitRootLogin no
# Только ключи, без паролей
PasswordAuthentication no
PermitEmptyPasswords no
KbdInteractiveAuthentication no
# Ограничения сессий
MaxAuthTries 3
MaxSessions 3
LoginGraceTime 30
# Таймауты - отключать неактивные сессии
ClientAliveInterval 300
ClientAliveCountMax 2
# Отключить лишнее
X11Forwarding no
PrintMotd no
UsePAM yes
AcceptEnv LANG LC_*
# Только IPv4 (убери если используешь IPv6)
AddressFamily inet
```
**КРИТИЧНО:** Перед перезапуском SSH открой вторую сессию и не закрывай её - страховка если что-то пойдёт не так.
Проверь конфиг на ошибки:
```bash
sudo sshd -t
```
Нет вывода - нет ошибок. Перезапусти:
```bash
sudo systemctl restart sshd
```
В новом терминале проверь подключение на новом порту:
```bash
ssh -p 2222 admin@203.0.113.10
```
Если работает - старые сессии можно закрывать.
### SSH banner (опционально)
Создай предупреждение при входе:
```bash
sudo nano /etc/ssh/banner
```
```
###############################################################################
# Доступ только для авторизованных пользователей. #
# Все действия логируются. #
# Несанкционированный доступ преследуется по закону. #
###############################################################################
```
Добавь в `sshd_config`:
```
Banner /etc/ssh/banner
```
Перезапусти SSH:
```bash
sudo systemctl restart sshd
```
## Firewall (UFW)
**Важно:** сначала настраиваем правила, потом включаем. Иначе заблокируешь себя.
```bash
sudo apt install -y ufw
```
Политика по умолчанию:
```bash
# Блокировать все входящие
sudo ufw default deny incoming
# Разрешить все исходящие
sudo ufw default allow outgoing
```
Разрешаем SSH на новом порту:
```bash
# Замени 2222 на свой порт если менял
sudo ufw allow 2222/tcp comment 'SSH'
```
Включаем firewall:
```bash
sudo ufw enable
```
Проверь статус:
```bash
sudo ufw status verbose
```
Ожидаемый вывод:
```
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
2222/tcp ALLOW IN Anywhere # SSH
2222/tcp (v6) ALLOW IN Anywhere (v6) # SSH
```
### Rate limiting на SSH
```bash
# Блокировать IP после 6 подключений за 30 секунд
sudo ufw limit 2222/tcp comment 'SSH rate limit'
```
### Открытие дополнительных портов
Когда будешь ставить сервисы - открывай только нужные порты:
```bash
# HTTP/HTTPS
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# PostgreSQL только с конкретного IP
sudo ufw allow from 192.168.1.10 to any port 5432 comment 'PostgreSQL'
```
Проверь что слушает на хосте:
```bash
ss -tulnp
```
Всё что слушает - должно быть либо закрыто firewall, либо намеренно открыто.
## Fail2ban
```bash
sudo apt install -y fail2ban
```
Создай локальный конфиг - не редактируй `jail.conf`, он перезаписывается при обновлении:
```bash
sudo nano /etc/fail2ban/jail.local
```
```ini
[DEFAULT]
# Бан на 1 час
bantime = 3600
# За 10 минут
findtime = 600
# После 3 неудачных попыток
maxretry = 3
# Не банить свои IP
ignoreip = 127.0.0.1/8 ::1
# Явно указываем backend - важно для совместимости в серии Ubuntu/Rocky
backend = systemd
[sshd]
enabled = true
port = 2222
filter = sshd
maxretry = 3
bantime = 3600
```
Замени `2222` на свой SSH порт.
Запусти:
```bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
```
Проверь статус:
```bash
sudo fail2ban-client status
```
```
Status
|- Number of jail: 1
`- Jail list: sshd
```
Проверь статус конкретного jail:
```bash
sudo fail2ban-client status sshd
```
## Swap
Swap в виде файла гибче раздела: можно увеличить или уменьшить без переразбивки диска.
### Создание swap файла
Проверь текущий swap:
```bash
free -h
sudo swapon --show
```
Создай файл (пример - 2GB):
```bash
# Создать файл нужного размера
sudo fallocate -l 2G /swapfile
```
Если `fallocate` не поддерживается файловой системой:
```bash
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 status=progress
```
Установи права - swap файл не должен читаться другими пользователями:
```bash
sudo chmod 600 /swapfile
```
Инициализируй и активируй:
```bash
# Разметить как swap
sudo mkswap /swapfile
# Включить
sudo swapon /swapfile
```
Проверь:
```bash
free -h
sudo swapon --show
```
Добавь в `/etc/fstab` для автомонтирования после перезагрузки:
```bash
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```
### Настройка swappiness
`swappiness` определяет агрессивность использования swap. Для production снижаем - swap только когда RAM заполнена на 90%+:
```bash
# Проверить текущее значение (по умолчанию 60)
cat /proc/sys/vm/swappiness
```
Установи постоянно:
```bash
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-swap.conf
sudo sysctl -p /etc/sysctl.d/99-swap.conf
```
### Изменение размера swap (когда потребуется)
```bash
# Отключить текущий swap
sudo swapoff /swapfile
# Изменить размер (пример - увеличить до 4GB)
sudo fallocate -l 4G /swapfile
# Переинициализировать
sudo mkswap /swapfile
# Включить
sudo swapon /swapfile
```
Проверь результат:
```bash
free -h
```
Запись в `/etc/fstab` менять не нужно - путь тот же.
### Удаление swap (если потребуется)
```bash
sudo swapoff /swapfile
sudo rm /swapfile
```
Удали строку из `/etc/fstab`:
```bash
sudo sed -i '/\/swapfile/d' /etc/fstab
```
## Sysctl - защита ядра
Создай отдельный файл - чище чем писать в `/etc/sysctl.conf`:
```bash
sudo nano /etc/sysctl.d/99-hardening.conf
```
```
# Защита от SYN flood
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
# Игнорировать ICMP redirects (защита от MITM)
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
# Не отправлять ICMP redirects
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Игнорировать source routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Защита от IP spoofing
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Логировать подозрительные пакеты
net.ipv4.conf.all.log_martians = 1
# Защита от time-wait assassination
net.ipv4.tcp_rfc1337 = 1
# Отключить IPv6 если не используешь
# net.ipv6.conf.all.disable_ipv6 = 1
# net.ipv6.conf.default.disable_ipv6 = 1
```
Примени:
```bash
sudo sysctl -p /etc/sysctl.d/99-hardening.conf
```
Проверь что применилось:
```bash
sudo sysctl net.ipv4.tcp_syncookies
# net.ipv4.tcp_syncookies = 1
```
## Автообновления безопасности
```bash
sudo apt install -y unattended-upgrades apt-listchanges
```
```bash
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
```
Раскомментируй/измени:
```
Unattended-Upgrade::Mail "root";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
```
`Automatic-Reboot "false"` - важно для production. Перезагрузку после обновления ядра делать вручную в удобное время.
Включи:
```bash
sudo dpkg-reconfigure -plow unattended-upgrades
```
Выбери `Yes`.
Настрой расписание:
```bash
sudo nano /etc/apt/apt.conf.d/10periodic
```
```
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
```
Проверь что сервис работает:
```bash
sudo systemctl status unattended-upgrades
```
## Отключение ненужных сервисов (при наличии)
Посмотри что запущено:
```bash
systemctl list-units --type=service --state=running
```
Стандартные кандидаты на отключение - проверь что не используешь перед отключением:
```bash
# Bluetooth - на сервере не нужен
sudo systemctl disable --now bluetooth.service
# avahi-daemon - mDNS/DNS-SD, нужен редко
sudo systemctl disable --now avahi-daemon.service
# ModemManager - управление модемами
sudo systemctl disable --now ModemManager.service
```
Проверь что слушает на сетевых портах:
```bash
ss -tulnp
```
Всё незнакомое - выясни что это и реши нужно ли.
## Логи и ротация
### Ограничение journald
```bash
sudo nano /etc/systemd/journald.conf
```
Раскомментируй/измени:
```
SystemMaxUse=500M
SystemMaxFileSize=100M
MaxRetentionSec=1month
```
Перезапусти:
```bash
sudo systemctl restart systemd-journald
```
Проверь размер:
```bash
sudo journalctl --disk-usage
```
На чистом Debian 12 rsyslog не установлен - логи пишутся только в journald. Настройки выше достаточно.
Если устанавливаешь rsyslog отдельно - создай /etc/logrotate.d/rsyslog с правилами ротации вручную.
## Мониторинг диска
На VPS имя устройства зависит от гипервизора - не всегда `/dev/sda`. Определи актуальное:
```bash
# Найти дисковые устройства
lsblk -d -o NAME,TYPE | grep disk
# NAME TYPE
# vda disk
#
# Результат зависит от гипервизора и провайдера:
# /dev/sda - KVM у некоторых провайдеров, Xen
# /dev/vda - KVM (наиболее распространённый вариант)
# /dev/xvda - Xen
```
Проверь состояние:
```bash
# Замени vda на своё устройство
sudo smartctl -H /dev/vda
```
Ожидаемый вывод:
```
# ATA диски:
# SMART overall-health self-assessment test result: PASSED
#
# SCSI/NVMe диски (типично для VPS):
# SMART Health Status: OK
#
# Оба варианта означают одно: диск здоров
```
Если `FAILED` - диск умирает. Срочно бэкап и замена.
Детали SMART:
```bash
sudo smartctl -a /dev/vda
```
Обращай внимание на `Reallocated_Sector_Ct`, `Pending_Sector_Count`, `Offline_Uncorrectable` - если ненулевые, диск проблемный.
Примечание:
На виртуальных дисках (VMware, KVM) SMART может быть недоступен: `SMART support is: Unavailable - device lacks SMART capability.`
В этом случае smartctl полезен только для физических серверов.
На VPS ориентируйся на мониторинг от провайдера.
## Бэкап конфигов
Конфиги на диске - не бэкап. Скрипт архивирует `/etc/` и SSH ключи.
```bash
nano ~/backup-configs.sh
```
```bash
#!/bin/bash
BACKUP_DIR="$HOME/backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/config_backup_$DATE.tar.gz"
mkdir -p "$BACKUP_DIR"
# Сохранить список установленных пакетов
dpkg --get-selections > "$HOME/installed-packages.txt"
# Архивировать конфиги и SSH ключи
sudo tar -czf "$BACKUP_FILE" \
/etc/ \
/root/.ssh/ \
/home/*/.ssh/ \
"$HOME/installed-packages.txt" \
2>/dev/null
echo "Backup: $BACKUP_FILE"
ls -lh "$BACKUP_FILE"
# Удалить бэкапы старше 30 дней
find "$BACKUP_DIR" -name "config_backup_*.tar.gz" -mtime +30 -delete
```
Права и первый запуск:
```bash
chmod +x ~/backup-configs.sh
~/backup-configs.sh
```
Ожидаемый вывод:
```
Backup: /home/admin/backups/config_backup_20260522_030000.tar.gz
-rw-r--r-- 1 root root 1.2M May 22 03:00 /home/admin/backups/config_backup_20260522_030000.tar.gz
```
Автоматизация через cron (каждое воскресенье в 3:00):
```bash
crontab -e
```
```
0 3 * * 0 /home/admin/backup-configs.sh >> /var/log/backup-configs.log 2>&1
```
### Восстановление
Восстановить конфиги:
```bash
sudo tar -xzf ~/backups/config_backup_ДАТА.tar.gz -C /
```
Восстановить список пакетов:
```bash
sudo dpkg --set-selections < ~/installed-packages.txt
sudo apt-get dselect-upgrade
```
## Финальная проверка
```bash
# Открытые порты
ss -tulnp
# Статус firewall
sudo ufw status verbose
# Статус fail2ban
sudo fail2ban-client status sshd
# Состояние диска
sudo smartctl -H /dev/vda
# Swap
free -h
```
## Что дальше
Сервер готов к установке приложений. База одинакова для любой нагрузки - веб, база данных, почта.
Если следующий шаг - контейнеры, читай продолжение серии: [Debian 12: настройка Docker-хоста для production](/posts/debian-12-docker-hardening/).
**Стек этой статьи:** Debian 12 (Bookworm) · UFW · Fail2ban · OpenSSH · unattended-upgrades · systemd · smartmontools
@@ -0,0 +1,870 @@
---
title: "Debian 12: настройка Docker-хоста для production"
date: 2026-05-22
draft: false
description: "Пошаговая настройка Debian 12 как Docker-хоста для production: установка Docker, настройка daemon, UFW + Docker интеграция, изоляция контейнеров, docker.sock, ресурсные ограничения."
summary: "Пошаговая настройка Debian 12 как Docker-хоста для production: установка Docker, настройка daemon, UFW + Docker интеграция, изоляция контейнеров, docker.sock, ресурсные ограничения."
tags: ["debian-12", "docker", "security", "hardening", "ufw", "production", "containers", "linux"]
categories: ["Безопасность"]
series: ["Linux Hardening"]
series_order: 2
---
# Debian 12: настройка Docker-хоста для production
Статья предполагает что базовая настройка уже выполнена: SSH на порту 2222, UFW включён, fail2ban настроен, sysctl применён.
Если нет - сначала:
{{< article link="/posts/debian-12-base-hardening/">}}
Здесь ставим Docker и закрываем типичные дыры: daemon без избыточных привилегий, UFW который не обходится Docker, изоляция контейнеров по сетям. Плюс разбираем docker.sock - самое опасное место на Docker-хосте.
## Исходные данные
- Debian 12 с выполненным базовым hardening
- Минимум 2GB RAM, 20GB диска
Проверь версию:
```bash
lsb_release -a
```
```
Distributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookworm
```
Все команды выполняются с `sudo` если не указано иное.
## Установка Docker
### Удаление старых версий
Если был установлен Docker из репозиториев Debian - удали. Версия там устаревшая:
```bash
sudo apt remove -y docker docker-engine docker.io containerd runc docker-compose docker-doc podman-docker
```
### Установка из официального репозитория
>Команды установки актуальны на момент написания статьи. Docker меняет процедуру добавления репозитория - перед установкой сверяйся с официальной документацией: docs.docker.com/engine/install/debian
```bash
# Установить зависимости
sudo apt update
sudo apt install -y ca-certificates curl
# Создать директорию для ключей
sudo install -m 0755 -d /etc/apt/keyrings
# Добавить GPG ключ Docker
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Добавить репозиторий
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
# Установить Docker
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
```
Проверь версию:
```bash
docker --version
```
```
Docker version 29.x.x, build xxxxxxx
```
Проверь что daemon запущен:
```bash
sudo systemctl status docker
```
### Добавление пользователя в группу docker
```bash
sudo usermod -aG docker $USER
# Активировать группу без перелогина
newgrp docker
```
**Важно:** членство в группе `docker` эквивалентно sudo без пароля - через docker.sock можно получить полный root на хосте. Добавляй только пользователей которым полностью доверяешь. Подробнее - в разделе про docker.sock ниже.
```bash
docker run --rm hello-world
```
Ожидаемый вывод: `Hello from Docker!`
## docker.sock - главный риск
Прежде чем настраивать daemon - разберёмся с самым опасным местом.
`/var/run/docker.sock` - Unix сокет Docker daemon. Любой процесс с доступом к нему имеет **полный root на хосте**. Не "почти root" - именно root. Через один вызов API можно запустить контейнер с bind mount `/:/host` и получить доступ ко всей файловой системе хоста:
```bash
# Пример атаки - одна команда и ты root на хосте (выполнение не требуется)
docker run --rm -v /:/host alpine chroot /host
```
Проверь кто имеет доступ к сокету:
```bash
# Права на сокет
ls -la /var/run/docker.sock
```
```
srw-rw---- 1 root docker 0 May 22 10:00 /var/run/docker.sock
```
Проверь кто в группе `docker`:
```bash
getent group docker
```
Только пользователи которым доверяешь полный root должны быть в этой группе.
## Настройка daemon.json
Создай файл:
```bash
sudo nano /etc/docker/daemon.json
```
```json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
},
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"storage-driver": "overlay2",
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 65536
}
}
}
```
Что и почему:
`log-driver: json-file` + `max-size/max-file` - ограничение логов контейнеров. Без этого `/var/lib/docker/containers/` забивает весь диск за несколько дней на активном сервисе. 50MB × 3 файла = 150MB максимум на контейнер.
`live-restore: true` - контейнеры продолжают работать пока daemon перезапускается. Без этого `systemctl restart docker` роняет все контейнеры.
`userland-proxy: false` - iptables вместо userland proxy для проброса портов. Быстрее и меньше процессов.
`no-new-privileges: true` - контейнеры не могут поднять привилегии через setuid/setgid бинарники.
`storage-driver: overlay2` - современный и быстрый драйвер. На Debian 12 используется по умолчанию, но лучше зафиксировать явно.
### Почему нет userns-remap
`userns-remap` - user namespace remapping. Root в контейнере (UID 0) маппится на непривилегированный UID на хосте (например 100000). Звучит безопасно.
На практике: ломает все bind mounts - файлы на хосте принадлежат UID 1000, а контейнер видит UID 100000 и получает `Permission denied`. Redis, PostgreSQL, nginx - у каждого свой UID, каждый нужно пересчитывать вручную через `chown -R 100000:100000`. Для production с несколькими сервисами это постоянная головная боль. Включай только если чётко понимаешь все последствия и готов управлять UID маппингом вручную.
### Почему нет icc:false
`icc: false` (inter-container communication) - глобальный запрет общения между контейнерами на уровне daemon.
Проблема: Docker Compose создаёт изолированную bridge сеть для каждого стека. Контейнеры одного стека (`app` + `postgres` + `redis`) общаются через эту сеть. С `icc: false` на уровне daemon они перестают видеть друг друга - стек перестаёт работать. Правильный подход - изоляция через сети Docker Compose. Описано ниже.
Примени конфиг:
```bash
sudo systemctl restart docker
```
Проверь что daemon запустился с нужными параметрами:
```bash
docker info | grep -E "Storage Driver|Logging Driver|Live Restore"
```
```
Storage Driver: overlay2
Logging Driver: json-file
Live Restore Enabled: true
```
## Sysctl для Docker-хоста
Docker требует дополнительных параметров ядра помимо базового hardening:
```bash
sudo nano /etc/sysctl.d/99-docker.conf
```
```
# IP forwarding - обязательно для работы сети контейнеров
# Docker включает это сам при старте, фиксируем явно чтобы не зависеть от порядка сервисов
net.ipv4.ip_forward = 1
# IPv6 forwarding - только если используешь IPv6 в контейнерах
# net.ipv6.conf.all.forwarding = 1
# Лимиты inotify - для контейнеров которые следят за файлами
fs.inotify.max_user_instances = 512
fs.inotify.max_user_watches = 524288
# Для Elasticsearch и других Java приложений в контейнерах
vm.max_map_count = 262144
```
Примени:
```bash
sudo sysctl -p /etc/sysctl.d/99-docker.conf
```
Проверь forwarding:
```bash
sudo sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 1
```
## UFW + Docker
>Этот раздел - справочный. Конкретные настройки применяются при разворачивании каждого сервиса.
Docker создаёт свои iptables правила и **обходит UFW**. Порт опубликованный через `-p 80:80` становится доступен снаружи даже если UFW его не разрешал. Это не баг - это архитектурное решение Docker.
Два подхода к решению:
### Подход 1 - рекомендуемый: порты на localhost
Публикуй порты только на localhost, снаружи открывай через reverse proxy (nginx, Traefik):
```bash
# Плохо - порт открыт на всех интерфейсах, обходит UFW
docker run -p 80:80 nginx
# Хорошо - порт только на localhost
docker run -p 127.0.0.1:80:80 nginx
```
В docker-compose.yml:
```yaml
ports:
- "127.0.0.1:8080:80"
```
Reverse proxy слушает на `0.0.0.0:443` и проксирует на `127.0.0.1:8080`. UFW разрешает только 443. Docker UFW не обходит - нечего обходить.
Для большинства production сетапов достаточно этого подхода - он проще и надёжнее.
### Подход 2: DOCKER-USER chain
Docker оставляет пустую цепочку `DOCKER-USER` для пользовательских правил. Правила в ней применяются до правил Docker. Нужен когда по каким-то причинам нельзя использовать reverse proxy:
```bash
sudo nano /etc/ufw/after.rules
```
Добавь в конец файла:
```
# BEGIN UFW AND DOCKER
*filter
:DOCKER-USER - [0:0]
# Разрешить трафик из локальных сетей
-A DOCKER-USER -s 10.0.0.0/8 -j RETURN
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
-A DOCKER-USER -s 192.168.0.0/16 -j RETURN
# Применить правила UFW к трафику идущему в контейнеры
-A DOCKER-USER -j ufw-user-forward
# Блокировать новые соединения снаружи к частным подсетям
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j RETURN
COMMIT
# END UFW AND DOCKER
```
Перезагрузи UFW:
```bash
sudo ufw reload
```
## Сетевая изоляция контейнеров
>Этот раздел - справочный. Сети создаются при разворачивании каждого сервиса.
Вместо глобального `icc: false` - изоляция через сети.
По умолчанию все контейнеры запущенные без явной сети попадают в `bridge` сеть и видят друг друга. Создавай отдельную сеть для каждого приложения:
```bash
# Создать изолированную сеть для приложения
docker network create --driver bridge app_network
# Контейнеры видят только соседей по той же сети
docker run -d --name app --network app_network myapp
docker run -d --name db --network app_network postgres
```
Контейнеры из разных сетей не могут общаться - это и есть правильная изоляция без глобального `icc: false`.
В docker-compose.yml сети создаются автоматически для каждого стека. Явно задавай сети если нужна дополнительная изоляция внутри стека.
## Безопасность образов
### Сканирование уязвимостей - Trivy
```bash
# Добавить репозиторий Trivy
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | \
gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] \
https://aquasecurity.github.io/trivy-repo/deb \
$(lsb_release -sc) main" | \
sudo tee /etc/apt/sources.list.d/trivy.list > /dev/null
sudo apt update
sudo apt install -y trivy
```
Проверь образ:
```bash
trivy image nginx:alpine
```
Вывод:
```bash
admin@vps01:~$ trivy image nginx:alpine
2026-06-09T12:22:41+03:00 INFO [vulndb] Need to update DB
2026-06-09T12:22:41+03:00 INFO [vulndb] Downloading vulnerability DB...
2026-06-09T12:22:41+03:00 INFO [vulndb] Downloading artifact... repo="mirror.gcr.io/aquasec/trivy-db:2"
95.58 MiB / 95.58 MiB [---------------------------------------------------------------------] 100.00% 763.08 KiB p/s 2m8s
2026-06-09T12:24:52+03:00 INFO [vulndb] Artifact successfully downloaded repo="mirror.gcr.io/aquasec/trivy-db:2"
2026-06-09T12:24:52+03:00 INFO [vuln] Vulnerability scanning is enabled
2026-06-09T12:24:52+03:00 INFO [secret] Secret scanning is enabled
2026-06-09T12:24:52+03:00 INFO [secret] If your scanning is slow, please try '--scanners vuln' to disable secret scanning
2026-06-09T12:24:52+03:00 INFO [secret] Please see https://trivy.dev/docs/v0.71/guide/scanner/secret#recommendation for faster secret detection
2026-06-09T12:25:11+03:00 INFO Detected OS family="alpine" version="3.23.4"
2026-06-09T12:25:11+03:00 INFO [alpine] Detecting vulnerabilities... os_version="3.23" repository="3.23" pkg_num=71
2026-06-09T12:25:11+03:00 INFO Number of language-specific files num=0
Report Summary
┌──────────────────────────────┬────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├──────────────────────────────┼────────┼─────────────────┼─────────┤
│ nginx:alpine (alpine 3.23.4) │ alpine │ 1 │ - │
└──────────────────────────────┴────────┴─────────────────┴─────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
nginx:alpine (alpine 3.23.4)
Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 0)
┌─────────┬───────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├─────────┼───────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────┤
│ libxml2 │ CVE-2026-6732 │ HIGH │ fixed │ 2.13.9-r0 │ 2.13.9-r1 │ libxml2: libxml2: Denial of Service via crafted │
│ │ │ │ │ │ │ XSD-validated document │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-6732 │
└─────────┴───────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────┘
```
>Status `fixed` - исправление уже есть, обнови образ
>Status `affected` - исправления нет, принимай решение осознанно
Правило для production: образы с CRITICAL уязвимостями не идут в production. HIGH - анализировать и принимать решение.
Сканировать перед каждым деплоем:
```bash
# Выйти с ненулевым кодом если есть CRITICAL/HIGH - удобно для CI
trivy image --exit-code 1 --severity CRITICAL,HIGH nginx:alpine
```
### Аудит событий Docker
Auditd фиксирует все обращения к Docker на уровне ядра: запуск контейнеров, изменения конфигурации, доступ к docker.sock. Когда что-то пойдёт не так - это первое место где искать что произошло и когда.
```bash
sudo apt install -y auditd
```
Создай правила:
```bash
sudo nano /etc/audit/rules.d/docker.rules
```
```
-w /usr/bin/docker -p rwxa -k docker
-w /var/lib/docker -p rwxa -k docker
-w /etc/docker -p rwxa -k docker
-w /var/run/docker.sock -p rwxa -k docker
```
```bash
sudo systemctl enable auditd
sudo systemctl restart auditd
```
Просматривай события:
```bash
sudo ausearch -k docker | tail -20
```
### Правила выбора образов
>Этот раздел - справочный. Применяй при выборе базового образа для своих Dockerfile.
Alpine-based или distroless - меньше attack surface:
```dockerfile
# 200MB лишних пакетов и уязвимостей
FROM ubuntu:24.04
# 5MB, минимум уязвимостей
FROM alpine:3.20
# Ещё лучше - distroless (нет shell, нет пакетного менеджера)
FROM gcr.io/distroless/static-debian12
```
Всегда указывай конкретный тег - никогда `latest`:
```bash
# Плохо - непредсказуемо, сломается при следующем релизе образа
docker pull nginx:latest
# Хорошо - предсказуемо
docker pull nginx:1.27-alpine
```
## Изоляция контейнеров
>Этот раздел - справочный.
### Capabilities
Контейнер по умолчанию имеет набор Linux capabilities которые ему не нужны. Убирай всё лишнее:
```bash
# Убрать все capabilities, добавить только нужные
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
```
Минимальный набор для большинства веб-сервисов: `NET_BIND_SERVICE` (порты < 1024). Если сервис не биндит привилегированные порты - вообще ничего добавлять не нужно, только `--cap-drop=ALL`.
### Read-only filesystem
```bash
# Контейнер не может писать в FS кроме явно указанных точек
docker run --read-only --tmpfs /tmp --tmpfs /var/run nginx
```
В docker-compose.yml:
```yaml
read_only: true
tmpfs:
- /tmp
- /var/run
```
Не все образы поддерживают read-only без дополнительной настройки - смотри документацию образа.
### Seccomp
Docker применяет seccomp профиль по умолчанию - блокирует ~44 опасных syscall. Проверь что профиль применяется:
```bash
docker info | grep seccomp
# Security Options: ... seccomp
```
### Не запускай контейнеры с --privileged
```bash
# НИКОГДА в production
docker run --privileged nginx
```
`--privileged` даёт контейнеру доступ ко всем устройствам хоста и отключает все механизмы изоляции. Если видишь `--privileged` в чужом docker-compose - это красный флаг.
## Ресурсные ограничения
>Этот раздел - справочный.
Без ограничений один контейнер может съесть всю память или CPU хоста и положить остальные сервисы.
>OOM (Out Of Memory) killer - механизм ядра Linux: когда процесс превышает лимит памяти, ядро принудительно его завершает. Код завершения контейнера 137 (128 + сигнал 9) означает что сработал OOM killer, а не штатное завершение.
```bash
# Максимум 512MB RAM - если превысит, OOM (Out Of Memory) killer завершит контейнер
docker run -m 512m --memory-reservation 256m nginx
# Максимум 0.5 ядра
docker run --cpus="0.5" nginx
# Защита от fork bomb - контейнер не создает бесконечное количество процессов
docker run --pids-limit 200 nginx
```
Если контейнер неожиданно останавливается - первым делом проверяй OOM (Out Of Memory):
```bash
dmesg | grep -i "oom\|killed process"
docker inspect CONTAINER --format='{{.State.OOMKilled}}'
```
## Docker Compose с настройками безопасности
>Этот раздел - справочный.
Шаблон `docker-compose.yml` для production: сети, лимиты, изоляция:
```yaml
services:
web:
image: nginx:1.27-alpine
container_name: web
restart: unless-stopped
# Порт только на localhost - reverse proxy снаружи
ports:
- "127.0.0.1:8080:80"
# Ресурсные ограничения
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
reservations:
memory: 128M
# Изоляция
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
security_opt:
- no-new-privileges:true
pids_limit: 100
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
- frontend
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- db_data:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- DAC_OVERRIDE
- CHOWN
security_opt:
- no-new-privileges:true
pids_limit: 200
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
# Только backend сеть - не доступен снаружи
networks:
- backend
app:
image: myapp:1.2.3
container_name: app
restart: unless-stopped
# Подключена к обеим сетям - мост между web и db
networks:
- frontend
- backend
depends_on:
- db
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 100
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # backend сеть без доступа к интернету
volumes:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txt
```
`internal: true` для backend сети - контейнеры в ней не имеют доступа к внешним сетям. База данных не должна ходить в интернет.
## Регулярные задачи
>Этот раздел - справочный.
### Проверка контейнеров с docker.sock
Проверь запущенные контейнеры у которых docker.sock смонтирован:
```bash
# Контейнеры с docker.sock = контейнеры с root-доступом к хосту
docker ps --format '{{.Names}}' | xargs -I {} docker inspect {} \
--format '{{.Name}}: {{range .Mounts}}{{if eq .Source "/var/run/docker.sock"}}DOCKER_SOCK_MOUNTED{{end}}{{end}}' \
2>/dev/null | grep DOCKER_SOCK_MOUNTED
```
Запускай после добавления каждого нового сервиса. Пустой вывод - норма.
Никогда не монтируй docker.sock в контейнер без явной необходимости. Если сервис требует docker.sock (Portainer, Watchtower, Traefik с автообнаружением) - осознавай что это сервис с root-доступом к хосту.
### Очистка
Docker накапливает мусор: остановленные контейнеры, неиспользуемые образы, dangling volumes:
```bash
# Показать что занимает место
docker system df
# Удалить всё неиспользуемое (остановленные контейнеры, dangling образы, неиспользуемые сети)
docker system prune -f
# Удалить всё включая неиспользуемые образы
docker system prune -a -f
# Удалить неиспользуемые volumes (ОСТОРОЖНО - данные удалятся безвозвратно)
docker volume prune -f
```
Автоматизируй через cron:
```bash
sudo crontab -e
```
```
# Еженедельная очистка - каждое воскресенье в 4:00
0 4 * * 0 docker system prune -f >> /var/log/docker-prune.log 2>&1
```
### Мониторинг ресурсов
```bash
# Статистика всех запущенных контейнеров в реальном времени
docker stats
# Один снимок без потока
docker stats --no-stream
# Конкретный контейнер
docker stats web --no-stream
```
## Troubleshooting
### Контейнер не видит DNS
**Симптом:** `curl: Could not resolve host: example.com` внутри контейнера.
**Причина:** DNS не передан контейнеру.
**Решение:** Добавь DNS в daemon.json:
```json
{
"dns": ["8.8.8.8", "8.8.4.4"]
}
```
```bash
sudo systemctl restart docker
```
---
### Docker обходит UFW
**Симптом:** Порт опубликованный через `-p 80:80` доступен снаружи несмотря на UFW deny.
**Причина:** Docker пишет напрямую в iptables, обходя UFW.
**Решение:** Использовать `127.0.0.1:80:80` вместо `80:80` - подход 1 из раздела UFW + Docker выше.
---
### Permission denied на bind mount
**Симптом:** Контейнер падает с `Permission denied` при обращении к примонтированной директории.
**Причина:** UID процесса в контейнере не совпадает с владельцем файлов на хосте.
**Решение:** Проверь под каким UID работает процесс в контейнере:
```bash
docker run --rm IMAGE id
```
Установи правильного владельца на хосте:
```bash
sudo chown -R UID:GID /path/to/mount
```
---
### OOM killer убивает контейнер
**Симптом:** Контейнер неожиданно останавливается, `docker ps` показывает `Exited (137)`.
**Причина:** Превышен memory limit, ядро завершило процесс.
**Диагностика:**
```bash
# Проверить OOM события
dmesg | grep -i "oom\|killed process"
# Статус контейнера
docker inspect CONTAINER --format='{{.State.OOMKilled}}'
```
**Решение:** Увеличить `memory` limit или оптимизировать приложение.
---
### Диск забит логами контейнеров
**Симптом:** `df -h` показывает 100% на `/var/lib/docker`.
**Диагностика:**
```bash
# Найти самые большие лог файлы
sudo find /var/lib/docker/containers/ -name "*.log" -exec du -sh {} \; | sort -rh | head -10
```
**Экстренное решение:**
```bash
# Обрезать лог конкретного контейнера (не удаляет файл)
sudo truncate -s 0 /var/lib/docker/containers/CONTAINER_ID/CONTAINER_ID-json.log
```
**Постоянное решение:** `max-size` и `max-file` в daemon.json - уже настроено выше.
## Финальная проверка
```bash
# Статус daemon
sudo systemctl status docker
# Конфигурация
docker info | grep -E "Storage Driver|Logging Driver|Live Restore|no-new-privileges"
# Открытые порты
ss -tulnp | grep docker
# Размер данных Docker
docker system df
# Контейнеры с docker.sock
docker ps -q | xargs docker inspect --format '{{.Name}}: {{range .Mounts}}{{if eq .Source "/var/run/docker.sock"}}SOCK{{end}}{{end}}' 2>/dev/null | grep SOCK
```
## Что дальше
Docker-хост готов к production нагрузке. Следующий шаг - разворачиваем сервисы поверх этой базы.
- Peertube на VPS - следующая статья серии: собственный видеохостинг на этом Docker-хосте
- Reverse proxy - Traefik или nginx перед контейнерами
- Мониторинг - Prometheus + cAdvisor + Grafana
- Централизованные логи - Loki + Promtail
**Стек этой статьи:** Debian 12 (Bookworm) · Docker CE · UFW · auditd · Trivy · docker-compose
@@ -0,0 +1,454 @@
---
title: "Диск забит на 100%: как Loki съел 24 ГБ логов, а df -i почти ввёл в заблуждение"
date: 2026-06-24
draft: false
description: "Расследование заполненного диска на Debian 12: от алерта Zabbix через df, du и docker system df до найденного виновника - логов Loki без лимита ротации. Чек-лист подозреваемых и настройка лимита логов Docker."
summary: "Расследование заполненного диска на Debian 12: от алерта Zabbix через df, du и docker system df до найденного виновника - логов Loki без лимита ротации."
tags: ["debian-12", "docker", "zabbix", "мониторинг", "диагностика", "логи", "xfs", "linux"]
categories: ["Файловые системы и хранилище"]
series: ["Linux: эксплуатация и обслуживание"]
series_order: 1
---
# Диск забит на 100%: как Loki съел 24 ГБ логов, а df -i почти ввёл в заблуждение
Zabbix присылает алерт: на проде закончилось место в корне файловой системы. Сервер - Debian 12, что на нём крутится и почему диск кончился - неизвестно, в тикете только хостнейм и факт.
Эта статья - не теория про docker logs и не абстрактный гайд по очистке диска. Это реальное расследование: от первого `df -h` до настроенного лимита логов, со всеми тупиками по дороге, включая два моих собственных, а не системы. Один из них почти увёл диагностику в сторону несуществующей проблемы с инодами.
Если у вас диск забился по другой причине - в конце статьи отдельный чек-лист по всем проверенным подозреваемым. Он работает как самостоятельный справочник, независимо от того, что нашлось в этом конкретном случае.
## Старт: два предела сразу
"Место кончилось" в Linux бывает по двум разным причинам - не хватило байтов или не хватило инодов. Алерт Zabbix не уточняет, какая именно, так что смотрим сразу на обе.
Занятость по байтам на всех точках монтирования:
```bash
df -h
```
```
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/vg01-root 29G 29G 16K 100% /
/dev/sda2 944M 147M 732M 17% /boot
/dev/mapper/vg02-opt 500G 22G 478G 5% /opt
```
Корень (`vg01-root`) забит под потолок - 29G из 29G, 16K свободно. `/opt` - отдельный том на 500G, занят на 5%, чист. Разделение на тома здесь сыграло на руку: не разведи их администратор раньше, искать причину было бы сложнее.
Та же проверка по инодам - те же 29G можно забить и десятком огромных файлов, и миллионом мелких, и метод диагностики для этих случаев разный:
```bash
df -i
```
```
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/mapper/vg01-root 67424 66740 684 99% /
/dev/mapper/vg02-opt 262141952 486209 261655743 1% /opt
```
Деталь, которая зацепила взгляд: всего 67424 инода на 29-гигабайтном томе - по привычке принял это за фиксированное значение, заданное при создании файловой системы, как это работает в ext4. При 99% занятости с таким скромным лимitom легко вылететь по инодам даже без аномального количества файлов. Отложил мысль на потом - и, как выяснится в финале статьи, отложил с неверной гипотезой в основе.
## Первая ошибка: du без -x
Чтобы понять, что внутри корня весит больше всего, логично пройтись `du` по верхним каталогам:
```bash
du -h --max-depth=1 / 2>/dev/null | sort -rh
```
```
23G /
19G /opt
2.2G /usr
1.1G /home
765M /var
147M /boot
```
Результат бессмысленный, и вот почему: без флага `-x` `du` пересекает точки монтирования. `/opt` и `/boot` - отдельные файловые системы, но `du` зашёл туда и насчитал их объём как часть `/`. Корень - это `vg01-root`, а в выводе сидят чужие 19G и 147M с других дисков.
Повторяем с `-x`, чтобы `du` не покидал текущую файловую систему:
```bash
du -hx --max-depth=1 / 2>/dev/null | sort -rh
```
```
4.0G /
2.2G /usr
1.1G /home
765M /var
```
Сумма едва дотягивает до 4G, а `df -h` показывает 29G использовано. Разрыв в 25G - и тут я слишком быстро потянулся к экзотической гипотезе: удалённый, но открытый файл, который процесс держит дескриптором, а в дереве каталогов его уже нет.
## Слепая зона: du без root
Гипотеза была преждевременной. Команда выполнялась без sudo, а `2>/dev/null` заглушил не только мусор, а заодно все "Permission denied". Если `du` не смог зайти в каталог с ограниченными правами, он молча посчитал его как 0 байт - и 4.0G превращаются в недостоверное число, а не в доказательство призрачного файла.
Сначала проверяем явно, что именно `du` не смог прочитать - без скрытия ошибок:
```bash
sudo du -hx --max-depth=1 / 2>&1 1>/dev/null | grep -i denied
```
Вывод пустой - но это ничего не доказывает: команда уже шла через sudo, то есть не воспроизводила условия первого, проблемного запуска. Сама проверка получилась нерелевантной собственной ошибке, которую должна была вскрыть.
Пересчитываем с правами root, без слепых зон:
```bash
sudo du -hx --max-depth=1 / 2>/dev/null | sort -rh
```
```
29G /
26G /var
2.2G /usr
1.1G /home
```
26G + 2.2G + 1.1G с мелочью почти точно сходится с 29G из `df -h`. Гипотеза про удалённый-но-открытый файл не подтвердилась - фиксирую как часть истории, а не прячу. Разрыв был не призраком, а банальной слепой зоной из-за прав доступа в моей же команде.
**Золотое правило:** диагностику диска без `sudo` лучше не начинать. Любая Permission denied молча обнулит реальный объём каталога, и вы будете гоняться за несуществующей проблемой.
## /var/lib: Docker номер один
Раскрываем `/var` на уровень глубже:
```bash
du -hx --max-depth=1 /var 2>/dev/null | sort -rh
```
```
26G /var/lib
242M /var/log
203M /var/cache
728K /var/backups
```
Весь вес - в `/var/lib`, остальное несущественно. `/var/lib` - общий контейнер для данных приложений, туда попадает и Docker, и базы данных, и системные служебные данные. Раскрываем ещё на уровень:
```bash
du -hx --max-depth=1 /var/lib 2>/dev/null | sort -rh
```
```
25G /var/lib/docker
283M /var/lib/apt
18M /var/lib/dpkg
17M /var/lib/plocate
```
`/var/lib/docker` - 25G из 26G `/var/lib`. Остальное суммарно тянет на 350M. Виновник почти найден.
## docker system df врёт - не считает логи
У Docker есть встроенная команда, дающая разбиение по образам, контейнерам, volumes и build cache сразу:
```bash
docker system df -v
```
```
Images space usage:
REPOSITORY TAG SIZE
grafana/grafana latest 485MB
grafana/loki 2.9.2 74.6MB
hello-world latest 13.3kB
Containers space usage:
CONTAINER IMAGE CREATED STATUS SIZE
loki grafana/loki:2.9.2 4 months ago Up 4 months 0B
grafana grafana/grafana:latest 4 months ago Up 4 months 0B
Local Volumes space usage: (нет volumes)
Build cache usage: 0B
```
Суммарно - около 560MB. А `/var/lib/docker` весит 25G. Разница больше 24G, и `docker system df` её просто не видит.
Это известная засада: **`docker system df` не считает логи контейнеров**. Команда показывает только образы, writable-слои, volumes и build cache - JSON-логи, которые Docker пишет по умолчанию без лимита, в эту статистику не попадают вообще.
Проверяем напрямую, сколько весят лог-файлы контейнеров:
```bash
du -h /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -rh
```
```
24G .../containers/<loki>/<loki>-json.log
257M .../containers/<grafana>/<grafana>-json.log
```
Оба контейнера работают по 4 месяца без перезапуска - времени накопить логи без ротации хватало обоим. Но разница в 93 раза говорит не просто об "отсутствии ротации" - что-то заставляет именно Loki писать аномально много.
## Почему именно Loki
Сначала смотрим характер записей - последние строки лога:
```bash
CID=$(docker ps -qf "name=loki")
tail -n 20 /var/lib/docker/containers/$CID/$CID-json.log
```
Все видимые строки - `level=info`, без единой ошибки. Но паттерн заметный: лог крутится по последовательным индексным таблицам компактора - `index_20407``index_20406``index_20405`, каждая обрабатывается за пару миллисекунд. Это не разовая фоновая задача, это что-то, что молотит постоянно и очень быстро.
Ищем настоящие ошибки, с ограничением по времени, чтобы не зависнуть на полном проходе по 24G:
```bash
timeout 15 grep -m 1 'level=error' /var/lib/docker/containers/$CID/$CID-json.log
```
Нашлась одна:
```
level=error ts=2026-01-26T12:36:53Z msg="error asking ring for who should run the compactor, will check again" err="at least 1 healthy replica required, could only find 0"
```
Классика для однонодового Loki: ring-based compactor рассчитан на кластер, а здесь единственный инстанс, и lifecycler не успел зарегистрироваться в ring при старте. Но дата - 26 января, почти 5 месяцев назад. Похоже на разовый сбой при старте, а не на текущую причину - нужно проверить, повторяется ли она сейчас.
Дальше - оценка темпа записи. Первая попытка была неудачной, признаю сразу: `timeout` после 10 секунд убил всю цепочку процессов сигналом, и `wc -l` не успел вывести даже ноль - результат недостоверен, метод сломан, аргумент в любую сторону строить на нём нельзя.
Первая попытка - через docker logs в реальном времени:
```bash
timeout 10 docker logs -f --tail 0 $CID | wc -l
```
Второй подход - без живого слежения, через временные метки в самом файле:
```bash
tail -n 2000 /var/lib/docker/containers/$CID/$CID-json.log | head -n 1
tail -n 1 /var/lib/docker/containers/$CID/$CID-json.log
```
Разница между 2000-й-с-конца записью и последней - 7.48 секунды. 2000 строк за 7.48с - это около 267 строк в секунду. При среднем объёме 24G за 4 месяца (~200MB в день, ~2.3KB/сек, то есть 10-15 строк/сек) пойманное значение в 20+ раз выше среднего - это всплеск компактора, а не стабильный фон. Экстраполировать всплеск на все 4 месяца было бы нечестно по отношению к методу.
Проверяем ошибки за недавний период без скана всех 24G - берём последние 500MB через seek по байтам:
```bash
tail -c 500M /var/lib/docker/containers/$CID/$CID-json.log | grep -c 'level=error'
```
```
0
```
Ноль ошибок в недавней истории. Гипотеза "контейнер сломан и спамит ошибками" закрыта - дело не в этом.
## Главная причина: лимит логов никто не настраивал
Раз дело не в ошибках, причина - в объёме обычного info-логирования без всякого предела. Смотрим, ограничен ли размер логов хоть на каком-то уровне.
Глобальная настройка драйвера логов:
```bash
cat /etc/docker/daemon.json
```
```json
{
"registry-mirrors": ["https://mirror.example.com"]
}
```
Настройка конкретного контейнера:
```bash
docker inspect --format '{{json .HostConfig.LogConfig}}' $CID
```
```json
{"Type":"json-file","Config":{}}
```
Оба пустые. Используется дефолт Docker - драйвер `json-file` без `max-size`/`max-file`. Компактор болтлив на уровне info, лимита нет, контейнер живёт 4 месяца - 24G неизбежны. Причина подтверждена документально, не догадкой.
## Остальные подозреваемые - для полноты
Виновник найден, но по уговору формата добиваем весь список систематически - это даёт читателю с другой причиной полную картину.
| Подозреваемый | Команда | Результат |
|---|---|---|
| journald | `journalctl --disk-usage` | 245.9M, чисто |
| Старые ядра | `dpkg -l 'linux-image-*' \| grep ^ii` | 3 версии, но на отдельном `/boot`, не влияет на `/` |
| Кэш apt | `du -sh /var/cache/apt/archives` | 118M, чисто |
| Удалённые, но открытые файлы | `lsof +L1` | пустой вывод, suspect закрыт окончательно |
Последний пункт стоит выделить отдельно: пустой `lsof +L1` - это не "цифры совпали", а прямое подтверждение, что ни один процесс не держит открытым файл с нулевым числом ссылок. Гипотеза про "призрачный файл" была отвергнута на двух независимых основаниях - арифметикой `du`/`df` раньше и прямым инструментом сейчас.
## Лечение: освобождаем место
Truncate файла "на горячую" безопасен для драйвера `json-file` - Docker продолжит дописывать в тот же inode, ничего не упадёт:
```bash
truncate -s 0 /var/lib/docker/containers/$CID/$CID-json.log
```
Проверяем результат:
```bash
df -h /
```
```
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/vg01-root 29G 5.1G 24G 18% /
```
Освободилось 23.9G - почти точное совпадение с размером усечённого файла. Это не просто "стало легче", а математическое подтверждение, что причина определена верно.
## Настройка лимита - и неожиданный нюанс
Чтобы это не повторилось ни с Loki, ни с любым другим контейнером, добавляем лимит в `daemon.json`:
```bash
cat > /etc/docker/daemon.json << 'EOF'
{
"registry-mirrors": ["https://mirror.example.com"],
"log-driver": "json-file",
"log-opts": {
"max-size": "20m",
"max-file": "5"
}
}
EOF
systemctl restart docker
```
Проверяем, применилось ли:
```bash
docker inspect --format '{{json .HostConfig.LogConfig}}' $CID
```
```json
{"Type":"json-file","Config":{}}
```
Всё ещё пусто. **Глобальный `log-opts` действует только для новых контейнеров**, созданных после изменения - не для существующих. `LogConfig` фиксируется в момент создания контейнера и не обновляется при `restart docker`, только при пересоздании.
Чтобы пересоздать контейнер без потери параметров, сначала смотрим, как он был запущен - через лейблы compose-проекта:
```bash
docker inspect --format '{{json .Config.Labels}}' $CID
```
В выводе - `com.docker.compose.project.working_dir` и `com.docker.compose.service: loki`. Контейнер управляется через compose, путь известен:
```bash
cd /home/deploy/docker
docker compose up -d --force-recreate loki
```
```
✔ Container loki Started
```
Проверяем лимит на пересозданном контейнере:
```bash
docker inspect --format '{{json .HostConfig.LogConfig}}' loki
```
```json
{"Type":"json-file","Config":{"max-file":"5","max-size":"20m"}}
```
Применилось. Тот же compose-проект поднимает и `grafana` - у неё на момент инцидента всего 257M логов, но без лимита она повторила бы путь Loki за более долгий срок. Раз инструмент уже в руках, закрываем сразу, не оставляя половину работы:
```bash
docker compose up -d --force-recreate grafana
```
## Побочный квест: ложная тревога с инодами
Возвращаемся к инодовой детали, отложенной в начале. Сверяем `df -i` сейчас с замером в начале расследования:
```bash
df -i /
```
```
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/mapper/vg01-root 15224832 66741 15158091 1% /
```
| Замер | Inodes всего | IUsed | IFree | IUse% |
|---|---|---|---|---|
| В начале | 67424 | 66740 | 684 | 99% |
| Сейчас | 15224832 | 66741 | 15158091 | 1% |
`IUsed` почти не изменился, а общее число инодов выросло в 225 раз. В ext4 количество инодов фиксируется в суперблоке при `mkfs` и не меняется без явного `resize2fs` - а размер тома не менялся вообще. Гипотеза "том создан с искусственно малым лимитом инодов" построена на ext4-логике, и она оказалась неверной - я строил её, не проверив тип файловой системы.
Смотрим напрямую в суперблок:
```bash
tune2fs -l /dev/mapper/vg01-root | grep -i inode
```
```
tune2fs: Bad magic number in super-block while trying to open /dev/mapper/vg01-root
```
Это не ошибка диагностики - это разоблачение неверной гипотезы. `tune2fs` понимает только ext2/ext3/ext4. Если суперблок не найден - файловая система другая.
Подтверждаем тип ФС напрямую:
```bash
findmnt -no FSTYPE /
```
```
xfs
```
Подтверждено: XFS. У XFS иноды выделяются динамически, а не фиксируются при создании. Столбец "Inodes" в `df -i` для XFS - это не константа, а оценка: текущие занятые иноды плюс расчёт, сколько ещё теоретически можно выделить исходя из свободного места на диске. При почти 100% занятости диска эта оценка падала почти до нуля, создавая видимость жёсткого лимита. После truncate освободилось 24G - и оценка свободных инодов резко выросла, потому что физически появилось место для их размещения.
Никакой аномалии не было - был артефакт интерпретации `df -i` на XFS при заполненном диске. Прячущая деталь: `IUsed` остался почти неизменным (66740 → 66741), и именно это надо было проверить раньше, чем строить гипотезу про лимит.
## Итог
Финальная сверка по диску:
```bash
df -h /
df -i /
```
```
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/vg01-root 29G 4.9G 25G 17% /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/mapper/vg01-root 15224832 66741 15158091 1% /
```
Было 29G из 29G, 100%. Стало 4.9G из 29G, 17%. Оба контейнера теперь ограничены лимитом логов - повторения с тем же сценарием не будет.
## Чек-лист: куда смотреть при заполненном диске
*Этот раздел - справочный.*
| Подозреваемый | Команда проверки | На что обратить внимание |
|---|---|---|
| Байты vs иноды | `df -h`, `df -i` | Какой именно лимит исчерпан; на XFS число инодов в `df -i` - оценка, а не константа |
| `du` без -x | `du -hx --max-depth=1 /` | Без `-x` команда пересечёт точки монтирования и даст мусорные числа |
| `du` без root | `sudo du -hx ...` | Без прав молча обнуляет недоступные каталоги |
| Docker логи контейнеров | `du -h /var/lib/docker/containers/*/*-json.log` | `docker system df` логи контейнеров не считает вообще |
| Лимит логов Docker | `docker inspect --format '{{json .HostConfig.LogConfig}}' <id>` | Пустой `Config{}` значит лимита нет; глобальный `daemon.json` не действует на уже созданные контейнеры |
| journald | `journalctl --disk-usage` | Может разрастись без `SystemMaxUse` в `journald.conf` |
| Старые ядра | `dpkg -l 'linux-image-*'` | Чистить через `apt autoremove --purge`, обычно живут в `/boot` отдельным томом |
| Кэш apt | `du -sh /var/cache/apt/archives` | `apt clean` освобождает мгновенно |
| Удалённые, но открытые файлы | `lsof +L1` | Процесс держит дескриптор - место не освободится без перезапуска процесса |
## Стек
Debian 12, XFS, Docker, Loki 2.9.2, Grafana, Docker Compose 2.29.7, Zabbix.
## Что дальше
Следующая статья серии - тот же сценарий для RHEL-подобных систем: dnf-кэш вместо apt, другие пути логов, и проверка, какие из подозреваемых из этого списка вообще не применимы на RHEL-like.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2025-10-14
draft: false draft: false
description: "K3s HA кластер для homelab: архитектура с 3 master нодами и embedded etcd. 50MB бинарник вместо 1.5GB зависимостей — почему это работает." description: "K3s HA кластер для homelab: архитектура с 3 master нодами и embedded etcd. 50MB бинарник вместо 1.5GB зависимостей — почему это работает."
tags: ["kubernetes", "k3s", "homelab", "proxmox", "architecture", "ha", "devops"] tags: ["kubernetes", "k3s", "homelab", "proxmox", "architecture", "ha", "devops"]
categories: ["Kubernetes", "Homelab"] categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"] series: ["K3s HA кластер для homelab"]
series_order: 1 series_order: 1
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2025-10-21
draft: false draft: false
description: "Готовим инфраструктуру для K3s в Proxmox: 5 VM с Debian 12, настройка сети, отключение swap. Пошаговая инструкция без сюрпризов." description: "Готовим инфраструктуру для K3s в Proxmox: 5 VM с Debian 12, настройка сети, отключение swap. Пошаговая инструкция без сюрпризов."
tags: ["kubernetes", "k3s", "homelab", "proxmox", "infrastructure", "debian", "devops"] tags: ["kubernetes", "k3s", "homelab", "proxmox", "infrastructure", "debian", "devops"]
categories: ["Kubernetes", "Homelab"] categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"] series: ["K3s HA кластер для homelab"]
series_order: 2 series_order: 2
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2025-11-02
draft: false draft: false
description: "Устанавливаем K3s HA кластер: 3 master и 2 worker ноды за 15 минут. Один curl-скрипт на ноду — и кластер готов к работе." description: "Устанавливаем K3s HA кластер: 3 master и 2 worker ноды за 15 минут. Один curl-скрипт на ноду — и кластер готов к работе."
tags: ["kubernetes", "k3s", "homelab", "installation", "ha", "etcd", "devops"] tags: ["kubernetes", "k3s", "homelab", "installation", "ha", "etcd", "devops"]
categories: ["Kubernetes", "Homelab", "DevOps практики"] categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"] series: ["K3s HA кластер для homelab"]
series_order: 3 series_order: 3
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 KiB

@@ -1,10 +1,11 @@
--- ---
title: "Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 1 - Начало" title: "Свой почтовый сервер на Debian 12 вместо Gmail: полное руководство. Часть 1 - Начало"
date: 2026-03-05 date: 2026-03-05
draft: false draft: false
description: "Зачем поднимать свой mail-сервер вместо Gmail. Анатомия почтового сервера: Postfix, Dovecot, антиспам, антивирус. Системные требования и чек-лист готовности." description: "Зачем поднимать свой mail-сервер вместо Gmail. Анатомия почтового сервера: Postfix, Dovecot, антиспам, антивирус. Системные требования и чек-лист готовности."
tags: ["postfix", "dovecot", "smtp", "imap", "Self-hosting"] summaty: "Зачем поднимать свой mail-сервер вместо Gmail. Анатомия почтового сервера: Postfix, Dovecot, антиспам, антивирус. Системные требования и чек-лист готовности."
categories: ["Системное администрирование", "Электронная почта"] tags: ["postfix", "dovecot", "smtp", "imap", "self-hosting"]
categories: ["Почтовые системы"]
series: ["Почтовый сервер на Debian 12"] series: ["Почтовый сервер на Debian 12"]
series_order: 1 series_order: 1
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

@@ -3,8 +3,9 @@ title: "Почтовый сервер на Debian 12: полное руково
date: 2026-03-10 date: 2026-03-10
draft: false draft: false
description: "Установка и настройка Postfix и Dovecot. SMTP/IMAP, виртуальные ящики, авторизация через SASL. Первые тесты отправки и получения почты." description: "Установка и настройка Postfix и Dovecot. SMTP/IMAP, виртуальные ящики, авторизация через SASL. Первые тесты отправки и получения почты."
tags: ["postfix", "dovecot", "smtp", "imap", "lmtp", "sasl", "Self-hosting"] summary: "Установка и настройка Postfix и Dovecot. SMTP/IMAP, виртуальные ящики, авторизация через SASL. Первые тесты отправки и получения почты."
categories: ["Системное администрирование", "Электронная почта"] tags: ["postfix", "dovecot", "smtp", "imap", "lmtp", "sasl", "self-hosting"]
categories: ["Почтовые системы"]
series: ["Почтовый сервер на Debian 12"] series: ["Почтовый сервер на Debian 12"]
series_order: 2 series_order: 2
--- ---
@@ -24,7 +25,7 @@ series_order: 2
> Если еще не настроил - сделай это сейчас: > Если еще не настроил - сделай это сейчас:
{{< article link="/posts/tips-debian-12-hardening/">}} {{< article link="/posts/debian-12-base-hardening/">}}
Дальше считаем, что база готова. Дальше считаем, что база готова.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

@@ -3,8 +3,9 @@ title: "Почтовый сервер на Debian 12: полное руково
date: 2026-03-16 date: 2026-03-16
draft: false draft: false
description: "Настройка Amavis, ClamAV, SpamAssassin и Postgrey для защиты от вирусов и спама. Fail2ban для защиты от брутфорса SMTP/IMAP. Обучение антиспама, белые списки, мониторинг." description: "Настройка Amavis, ClamAV, SpamAssassin и Postgrey для защиты от вирусов и спама. Fail2ban для защиты от брутфорса SMTP/IMAP. Обучение антиспама, белые списки, мониторинг."
summary: "Настройка Amavis, ClamAV, SpamAssassin и Postgrey для защиты от вирусов и спама. Fail2ban для защиты от брутфорса SMTP/IMAP. Обучение антиспама, белые списки, мониторинг."
tags: ["amavis", "clamav", "spamassassin", "postgrey", "fail2ban"] tags: ["amavis", "clamav", "spamassassin", "postgrey", "fail2ban"]
categories: ["Системное администрирование", "Электронная почта"] categories: ["Почтовые системы"]
series: ["Почтовый сервер на Debian 12"] series: ["Почтовый сервер на Debian 12"]
series_order: 3 series_order: 3
--- ---
@@ -729,7 +730,7 @@ SpamAssassin автоматически учится на письмах с че
Fail2ban должен быть установлен из статьи по базовой настройке сервера: Fail2ban должен быть установлен из статьи по базовой настройке сервера:
{{< article link="/posts/tips-debian-12-hardening/">}} {{< article link="/posts/debian-12-base-hardening/">}}
Проверь: Проверь:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

@@ -1,10 +1,12 @@
--- ---
title: "Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 4 - Шифрование и репутация отправителя" title: "Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 4 - Шифрование и репутация отправителя"
slug: mailserver-part-4-tls-dkim-spf-dmarc
date: 2026-04-19 date: 2026-04-19
draft: false draft: false
description: "Настройка TLS через Let's Encrypt, DKIM подписей, SPF и DMARC записей. Шифрование всех соединений SMTP/IMAP, повышение репутации отправителя для защиты от попадания в спам." description: "Настройка TLS через Let's Encrypt, DKIM подписей, SPF и DMARC записей. Шифрование всех соединений SMTP/IMAP, повышение репутации отправителя для защиты от попадания в спам."
tags: ["tls", "ssl", "lets encrypt", "certbot", "dkim", "opendkim", "spf", "dmarc", "swaks"] summary: "Настройка TLS через Let's Encrypt, DKIM подписей, SPF и DMARC записей. Шифрование всех соединений SMTP/IMAP, повышение репутации отправителя для защиты от попадания в спам."
categories: ["Системное администрирование", "Электронная почта"] tags: ["tls", "ssl", "lets-encrypt", "certbot", "dkim", "opendkim", "spf", "dmarc", "swaks"]
categories: ["Почтовые системы"]
series: ["Почтовый сервер на Debian 12"] series: ["Почтовый сервер на Debian 12"]
series_order: 4 series_order: 4
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -38,8 +38,8 @@ author:
byline_title: "Автор" byline_title: "Автор"
code: code:
copy: "Копировать" copy: "Копи."
copied: "Скопировано" copied: "ОК"
error: error:
404_title: "Страница не найдена: в замешательстве:" 404_title: "Страница не найдена: в замешательстве:"
+3 -14
View File
@@ -1,14 +1,3 @@
<!-- VK Comments Widget --> <!-- Comments Widget -->
<div class="vk-comments-wrap"> <script defer src="https://comments.oakazanin.ru/comentario.js"></script>
<div id="vk_comments"></div> <comentario-comments no-fonts="true" lang="ru" theme="light"></comentario-comments>
</div>
<script type="text/javascript" src="https://vk.com/js/api/openapi.js?169"></script>
<script type="text/javascript">
VK.init({apiId: 54459311, onlyWidgets: true});
VK.Widgets.Comments("vk_comments", {
limit: 10,
attach: "*",
autoPublish: 0,
pageUrl: "{{ .Permalink }}"
});
</script>