Compare commits

..

23 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
astronit c9bbffc3bf feat: замена ссылки на peertube сервис 2026-05-27 07:24:58 +00:00
astronit f6026656d0 Revert "feat: тест миграции на Proxmox стек"
This reverts commit 9cda70f120.
2026-05-08 12:14:59 +00:00
astronit 9cda70f120 feat: тест миграции на Proxmox стек 2026-05-08 12:08:18 +00:00
astronit 612222c222 Revert "feat: тест миграции на Proxmox стек"
This reverts commit 1147582f48.
2026-05-08 12:05:35 +00:00
astronit 1147582f48 feat: тест миграции на Proxmox стек 2026-05-08 11:58:47 +00:00
astronit f4076d1cc7 feat: добавлен валидатор Дзен 2026-04-27 14:29:34 +00:00
astronit 97a9aa1d45 feat: Добавлена инфа - Прокачка скилов 2026-04-21 08:08:15 +00:00
astronit c8e6459276 feat: добавлена ссылка на мой vk 2026-04-20 08:26:31 +00:00
astronit ca2a6bc0ef feat: добавлена 4-я часть о почтовом сервере 2026-04-19 19:26:28 +00:00
astronit 2057150a05 feat: отключил простмотры и лайки для статей 2026-03-19 12:34:29 +00:00
astronit 4036fb658d feat: 3 статьи о почтовом сервере + замена картинок 2026-03-18 19:55:55 +00:00
astronit 6fb1f08915 fix: формктирование статьи 2026-03-13 20:11:09 +00:00
astronit 9e0acd35de feat: Безопасный production-сервер на Debian 12: пошаговая настройка 2026-03-12 19:47:51 +00:00
69 changed files with 7494 additions and 65 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 720"><path fill="#0a0b0b" d="M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z"/></svg>

After

Width:  |  Height:  |  Size: 544 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M220.9 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5 .2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4 .2-.8 .7-.6 1.1 .3 1.3 2.3 1.1 3.4 1.7zM199 125c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6 .2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5 .1-1.3 .6-3.4 1.5-3.2 2.9 .1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72 .5-45.9 5.1-131.2-75.8-131.3-102.4-.2-76.8 103.4-77.9 135.2-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9 .6 7.9 1.2 11.8 1.2 8.1 2.5 15.7 .8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1 .6 2.3 1.4 4.6 2.5 6.7l0 .1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3 .4-8.2-4.4-17.3-15.5-29.7zM223.8 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183.1 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4 .7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4l0-.2c2.4-7.6 .6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7 .8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4 .6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1 .8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7 .4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6 .8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1 .3-.2 .7-.3 1-.5 .8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.4 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M219.8 171.3c3.9 .5 13.1 2.7 12.3 8.5-.8 5.9-9.2 9.1-14.2 8.4-4.7-.7-13.2-6.1-12.3-12.1l.3-2.1c4.2 0 8.3-3.5 13.9-2.7zm168.1-3.4c10.8 2.6-1.1 13.5-6.8 14.3-4.3 .6-12.1-2.2-12.8-7.4-1-7.4 15.7-7.9 19.7-6.9zm-22-140.8c35.7-.3 81.3 9.4 114.3 51.7 7.2 9.2 10 24 9.7 40.9-.8 49.6-26 129.4-70.7 197.4 4.5 2.9 17.6 7.4 51.1 .5 5.6-1.2 12.7-2.2 17.6 1.6 18.2 13.9-19.6 35.1-28.4 39.2-13.2 6.2-34.8 9.5-51.2 8.7-2.1-.2-4.3-.2-6.3-.6-5.1-1.1-7.4-1-8.3-.7-1.1 .3-1.4 2.9-1.6 3.8-2.8 24.9-7.7 64.7-10.7 82-2.8 16.3-7.7 29.3-17.2 39.2-9.5 9.9-22.8 15.7-40.6 19.5-22.3 4.8-37.9-.1-48.7-9.1-10.3-8.7-15.2-20.4-18-27.4-1.8-4.5-3-11.5-4-19.8-2.3-19.8-3.3-50.4-3-83.3-24.6 22.1-55 17.2-68.2 13.9-10.5-2.6-33-16.1-17.5-28.7 11.9-9.7 30.3-5.5 42.2-15 2.4-1.9 11.4-10.6 11.4-13.5-10-.3-19.6-2.9-28.1-7.5-13.5 14.5-26.4 29.5-39.3 44.6-8.3 9.9-17.4 15.8-27.4 16.2-9.9 .4-18.7-4.6-26.1-11.8-7.3-7.1-14.1-17.2-20.4-29-19-35.5-33.2-86.1-42.3-126.4-6-26.7-9.6-49.1-10.1-59-2.2-44.3 8-74.1 26-93.2 17.9-19 42.4-26 66.1-27.4 35.6-2 71 8.5 86.6 13.8l5 1.8c15.9-10.8 36.1-17.4 61.7-17 13.2 .2 25.5 2.2 36.7 4.2 18.6-7.1 39.8-9.5 59.4-9.7zm-96 20.5c-24.7-.4-42.9 6.5-56.6 16.8-.8 .6-1.8 1-2.8 1.2-14.4 11.8-23.9 28-30.3 44.8-7.2 19.1-10 38.5-11.1 51.5 7.6-4.3 17.9-8.7 28.7-11.2 10.5-2.4 22.4-3.2 32.7 .8 10.9 4.2 19 13.3 22.2 28.1 7.5 34.7 6.7 58.2 2.7 75.9-4.8 21-16.8 39.5-21.9 60.4 3.5-.9 7.1-.6 9.8 .1l7.2 2.9c7.7 4.4 12.8 13 14 21.7 2 6 .1 14.3 0 20.6 6.7 16.3 7.2 36.1 6.7 53.3-.7 25-1 40.2 3.2 51.7 2.9 7.9 4.7 16.4 10.4 22.8 2.6 3 6.1 5.5 10.9 6.8 18.5 5.1 44-4.7 56.6-18.2 7.7-8.2 12.3-19.3 13.4-33.9 1.1-13.6 4.2-27.6 6.3-41.1l2.9-8.8c1.7-14.8 3.4-29.6 5-44.4-.4-9.1 .9-16.1 3.9-21.5 3.1-5.7 7.6-8.9 11.8-10.8 1.8-.8 3.9-1.2 5.6-2-1.6-2.4-3.6-4.6-5.3-6.8-8.2-10.4-13.3-22.3-19.7-33.8-8.5-15.2-23.8-42.2-30.1-67.5-4.1-16.4-4.9-34.5 6-47 9.8-11.2 26.9-15.5 51.9-13-3.4-10-11.4-27.5-24.8-44.7-18-23-45.7-45.9-85.7-53.1-7.3-.9-15.2-1.5-23.6-1.7zm-32.2 282c-8 .7-15.5 18.2-21.7 23.1-6.2 4.9-14.5 7.6-30 10.7-4.5 .9-7.7 1.9-9.7 2.8 22.3 15.8 58.3 3.2 72.6-16.8 1.7-2.4 2.1-6 .5-10.2-1.7-4.5-6.4-10.1-11.9-9.6zM117.6 49.2c-21.6 1.2-42 7.5-56.4 22.8-14.4 15.2-24.2 40.6-22.1 82.5 .4 8.5 3.8 30 9.8 56.6 8.9 39.8 23.7 90.3 40.6 122.2 6 11.1 17.4 33 32.8 32.3 4.4-.2 10.1-2.8 17-11 12.6-14.8 25.2-29.5 38.5-43.7-17.9-15.4-28.5-40.3-24.8-67.2 3.4-24.3 .5-48.2 1.2-72.5 .4-11.9 2.3-38.7 12.6-65.8 5.9-15.5 14.5-31.3 27.4-44.2-16.4-5.4-47.1-13.5-76.6-11.9zM405.1 328.9c-3.8 1.1-6.7 2.2-8.7 5.8-1.3 2.4-2.5 6.7-2.2 14.2 4.8 3.9 14.1 3.3 19.8 3.2 13.9-.2 29.9-3.1 39.3-7.5 7.9-3.7 14.7-8.3 19-12.2-38.2 7.7-55.4 2.1-63.2-4.8-1.3 .4-2.9 .9-3.9 1.2zM225.3 164.9c-15.4-5.9-35.4 1.8-48.9 9.4-3.6 2-6.5 4-7.9 5.2 .4 8.8 2.8 36.1-1.4 66.3-5.1 36.6 21.7 66.6 52.5 66.7 5.1-20.8 17-39.2 21.8-60.1 3.5-15.3 4.5-36.6-2.7-69.7-2.3-10.5-7.4-15.4-13.4-17.7zM370.1 42.2c-15.2-.2-28.8 1.7-39.2 3.8 26 11.8 45.6 29.7 59.6 47.6 17.4 22.2 26.3 44.1 29 55 .7 2.7 1.3 5.7 .5 8.4 .6 18.9-4 31.3-4.6 49.2-.4 12.9 2.9 28.1 3.7 44.7 .8 15.7-1 32.5-11.2 49.5 .8 1 1.6 2.1 2.4 3.1 26.8-42.2 46-88.6 56.3-128.5 5.5-21.4 8.4-40.7 8.7-56.1 .2-15.2-2-25.4-5.8-30.4-28.3-36.1-66.8-45.8-99.3-46.2zm35.6 119.5c-25.3-3.2-37.3 1.5-42.8 7.7-6 6.8-6.8 18.5-2.9 34.1 5.7 22.6 20.1 48.6 28.7 64 3.6 6.4 6.4 13.1 10 19.4 5.5-11.7 6.6-23.5 6-35.5-.7-15-4.2-30.3-3.7-45.8l.4-7.4c1.2-14.3 4.1-24.6 4.2-36.5z"/></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M31.5 63.5C0 95 0 145.7 0 247L0 265C0 366.3 0 417 31.5 448.5S113.7 480 215 480l17.9 0c101.4 0 152.1 0 183.5-31.5S448 366.3 448 265l0-17.9c0-101.4 0-152.1-31.5-183.5S334.3 32 233 32L215 32C113.7 32 63 32 31.5 63.5zM75.6 168.3l51.1 0c1.7 85.5 39.4 121.7 69.3 129.2l0-129.2 48.2 0 0 73.7c29.5-3.2 60.5-36.8 70.9-73.7l48.2 0c-3.9 19.2-11.8 37.3-23.1 53.3s-25.7 29.5-42.5 39.6c18.7 9.3 35.2 22.4 48.4 38.5s22.9 34.9 28.3 55l-53 0c-4.9-17.5-14.8-33.1-28.6-45s-30.7-19.4-48.7-21.6l0 66.6-5.8 0c-102.1 0-160.3-70-162.8-186.5z"/></svg>

After

Width:  |  Height:  |  Size: 617 B

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

+2 -1
View File
@@ -22,11 +22,12 @@ title = "Олег Казанин"
headline = "Infrastructure Engineer | Linux & Open Source"
bio = "Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали."
links = [
{ vk = "https://vk.com/oakazanin/" },
{ email = "mailto:oakazanin@ya.ru" },
{ telegram = "https://t.me/oa_msk" },
{ link = "https://oakazanin.ru/" },
{ gitea = "https://git.jn4.ru/astronit" },
{ peertube = "https://obrtv.ru/a/chiefengineer" },
{ peertube = "https://xn--80abjycsbu.xn--p1acf/a/chiefengineer/video-channels" },
# { amazon = "https://www.amazon.com/hz/wishlist/ls/wishlist-id" },
# { apple = "https://www.apple.com" },
# { blogger = "https://username.blogspot.com/" },
+37 -36
View File
@@ -6,12 +6,12 @@
# https://blowfish.page/docs/configuration/#theme-parameters
colorScheme = "blowfish"
defaultAppearance = "dark" # valid options: light or dark
defaultAppearance = "light" # valid options: light or dark
autoSwitchAppearance = false
enableA11y = false
enableA11y = true
enableSearch = true
enableCodeCopy = false
enableCodeCopy = true
enableStructuredBreadcrumbs = false
# enableStyledScrollbar = true # disable to use native scrollbar style (defaults to true)
@@ -20,18 +20,19 @@ replyByEmail = false
# mainSections = ["section1", "section2"]
# robots = ""
disableImageOptimization = false
disableImageOptimizationMD = false
disableImageZoom = true
disableImageOptimization = true
# disableImageOptimizationMD = false
disableTextInHeader = true
# backgroundImageWidth = 1200
defaultBackgroundImage = "/img/background.png" # used as default for background images
defaultFeaturedImage = "/img/background.png" # used as default for featured images in all articles
defaultBackgroundImage = "/img/background.svg" # used as default for background images
# 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)
hotlinkFeatureImage = false
# imagePosition = "50% 50%"
# highlightCurrentMenuArea = true
highlightCurrentMenuArea = true
# smartTOC = true
# smartTOCHideUnfocusedChildren = true
@@ -54,65 +55,65 @@ forgejoDefaultServer = "https://v11.next.forgejo.org"
layout = "background" # valid options: page, profile, hero, card, background, custom
#homepageImage = "IMAGE.jpg" # used in: hero, and card
showRecent = true
showRecentItems = 18
showRecentItems = 5
showMoreLink = true
showMoreLinkDest = "/posts"
cardView = true
cardView = false
cardViewScreenWidth = false
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]
showDate = true
showViews = true
showLikes = true
showDateOnlyInArticle = false
showDateUpdated = false
# showViews = false
# showLikes = false
# showDateOnlyInArticle = false
# showDateUpdated = false
showAuthor = true
# showAuthorBottom = false
showHero = true
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
showBreadcrumbs = false
#showBreadcrumbs = false
showDraftLabel = true
showEdit = false
#showEdit = false
# editURL = "https://github.com/username/repo/"
editAppendPath = true
# editAppendPath = true
seriesOpened = true
showHeadingAnchors = true
showPagination = true
invertPagination = false
# invertPagination = false
showReadingTime = true
showTableOfContents = true
showRelatedContent = true
relatedContentLimit = 6
showTaxonomies = true # use showTaxonomies OR showCategoryOnly, not both
showCategoryOnly = false # use showTaxonomies OR showCategoryOnly, not both
relatedContentLimit = 3
# showTaxonomies = true # use showTaxonomies OR showCategoryOnly, not both
showCategoryOnly = true # use showTaxonomies OR showCategoryOnly, not both
showAuthorsBadges = false
showWordCount = false
# showWordCount = false
# sharingLinks = [ "linkedin", "twitter", "bluesky", "mastodon", "reddit", "pinterest", "facebook", "email", "whatsapp", "telegram", "line"]
sharingLinks = [ "reddit", "email", "telegram"]
showZenMode = false
# showZenMode = false
# externalLinkForceNewTab = false # disable to allow external links in the same tab (defaults to true)
# showComments = true
showComments = true
[list]
showHero = true
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
showBreadcrumbs = false
showSummary = false
showViews = false
showLikes = false
showTableOfContents = false
showCards = false
orderByWeight = false
groupByYear = false
cardView = false
cardViewScreenWidth = false
constrainItemsWidth = false
# showBreadcrumbs = false
showSummary = true
# showViews = false
# showLikes = false
# showTableOfContents = false
showCards = true
# orderByWeight = false
# groupByYear = false
# cardView = false
# cardViewScreenWidth = false
# constrainItemsWidth = false
[sitemap]
excludedKinds = ["taxonomy", "term"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+78
View File
@@ -29,3 +29,81 @@ layoutBackgroundHeaderSpace: false
Дома - лаборатория. Нет, не стойка в подвале (хотя идея заманчивая). Один сервер, но серьёзный: виртуализация, Kubernetes, хранилище, мониторинг - всё по-взрослому, но если что-то упало - нет многомиллионных издержек и мир не остановился. Идеальный полигон: придумал, реализовал, сломал, починил, задокументировал. Полный цикл.
Этот блог - не enterprise-гайды для внедрения в банке. Это production, но в человеческом масштабе: домашний сервер, небольшая компания, стартап на трёх разработчиках. Решения, которые реально работают - просто без бюджета на команду SRE из десяти человек - инженеров надёжности, чьи зарплаты съедают бюджет стартапа за квартал. Документирую грабли, чтобы вы на них не наступали. Чем больнее наступил сам - тем подробнее статья.
# Прокачка скиллов
{{< timeline >}}
{{< timelineItem icon="postgresql" header="Администратор баз данных PostgreSQL" badge="2025">}}
{{< gallery >}}
<img src="gallery/admin_pgs.jpg" class="grid-w100" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="code" header="Анализ данных на языке SQL" badge="2025">}}
{{< gallery >}}
<img src="gallery/sql_eng.gif" class="grid-w50" />
<img src="gallery/sql_ru.gif" class="grid-w50" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="postgresql" header="DBA1 - Администрирование PostgreSQL. Базовый курс" badge="2025" >}}
{{< gallery >}}
<img src="gallery/dba1_eng.gif" class="grid-w33" />
<img src="gallery/dba1_ru.gif" class="grid-w33" />
<img src="gallery/dba1_pgs.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="postgresql" header="DBA2 - Администрирование PostgreSQL. Настройка и мониторинг" badge="2025" >}}
{{< gallery >}}
<img src="gallery/dba2_eng.gif" class="grid-w33" />
<img src="gallery/dba2_ru.gif" class="grid-w33" />
<img src="gallery/dba2_pgs.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="postgresql" header="DBA3 - Администрирование PostgreSQL 16. Резервное копирование и репликация" badge="2025" >}}
{{< gallery >}}
<img src="gallery/dba3_eng.gif" class="grid-w33" />
<img src="gallery/dba3_ru.gif" class="grid-w33" />
<img src="gallery/dba3_pgs.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="postgresql" header="QPT - PostgreSQL. Оптимизация запросов" badge="2025" >}}
{{< gallery >}}
<img src="gallery/qpt_eng.gif" class="grid-w33" />
<img src="gallery/qpt_ru.gif" class="grid-w33" />
<img src="gallery/qpt_pgs.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="postgresql" header="PGPRO - Postgres Pro Enterprise" badge="2025" >}}
{{< gallery >}}
<img src="gallery/pgpro_eng.gif" class="grid-w33" />
<img src="gallery/pgpro_ru.gif" class="grid-w33" />
<img src="gallery/pgpro_pgs.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="linux" header="Сетевое администрирование Astra Linux SE" badge="2024" >}}
{{< gallery >}}
<img src="gallery/alse-1604.jpg" class="grid-w33" />
<img src="gallery/alse-1604_softline.jpg" class="grid-w33" />
<img src="gallery/alse-1604_softline-cert.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< timelineItem icon="linux" header="Astra Linux. Специальный курс" badge="2024" >}}
{{< gallery >}}
<img src="gallery/alse-1605.jpg" class="grid-w33" />
<img src="gallery/alse-1605_softline.jpg" class="grid-w33" />
<img src="gallery/alse-1605_softline-cert.jpg" class="grid-w33" />
{{< /gallery >}}
{{< /timelineItem >}}
{{< /timeline >}}
@@ -7,6 +7,7 @@ slug: "shortcodes"
tags: ["shortcodes", "mermaid", "icon", "lead", "docs"]
series: ["Документация"]
series_order: 8
showComments: true
---
Hugo даёт [стандартные shortcodes](https://gohugo.io/content-management/shortcodes/). Blowfish добавляет свои — для типичных задач блога.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

@@ -5,7 +5,6 @@ draft: false
description: "Универсальная шпаргалка по работе с Hugo блогом через Git с двумя окружениями. Префиксы коммитов, откаты, типичные ошибки и решения."
tags: ["git", "workflow", "hugo", "cheatsheet"]
categories: ["Шпаргалки"]
showComments: true
---
## Структура проекта
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 KiB

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

Before

Width:  |  Height:  |  Size: 1.4 MiB

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

Before

Width:  |  Height:  |  Size: 1.3 MiB

@@ -4,7 +4,7 @@ date: 2026-01-15
draft: false
description: "Dev окружение для Hugo в K3s: отдельный Hugo Builder, Nginx и Basic Auth через Traefik. Проверяем статьи до публикации в production."
tags: ["hugo", "k3s", "kubernetes", "traefik", "basic-auth"]
categories: ["Веб-разработка", "DevOps практики"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
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
draft: false
description: "Одна папка или две для dev и production? Разбираем Git workflow для Hugo блога в K3s — переключение веток против отдельных директорий."
summary: "Одна папка или две для dev и production? Разбираем Git workflow для Hugo блога в K3s — переключение веток против отдельных директорий."
tags: ["git", "workflow", "devops", "hugo"]
categories: ["Веб-разработка", "DevOps практики"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 4
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

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

Before

Width:  |  Height:  |  Size: 1.3 MiB

@@ -4,7 +4,7 @@ date: 2026-02-18
draft: false
description: "Переносим сервисы между namespace в Kubernetes: экспорт манифестов, миграция NFS данных, новый SSL за 32 секунды. Реальный пример с Gitea."
tags: ["kubernetes", "k3s", "gitea", "devops", "homelab", "namespace"]
categories: ["Kubernetes", "DevOps практики"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
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
description: "K3s HA кластер для homelab: архитектура с 3 master нодами и embedded etcd. 50MB бинарник вместо 1.5GB зависимостей — почему это работает."
tags: ["kubernetes", "k3s", "homelab", "proxmox", "architecture", "ha", "devops"]
categories: ["Kubernetes", "Homelab"]
categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"]
series_order: 1
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

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

Before

Width:  |  Height:  |  Size: 1.4 MiB

@@ -4,7 +4,7 @@ date: 2025-11-02
draft: false
description: "Устанавливаем K3s HA кластер: 3 master и 2 worker ноды за 15 минут. Один curl-скрипт на ноду — и кластер готов к работе."
tags: ["kubernetes", "k3s", "homelab", "installation", "ha", "etcd", "devops"]
categories: ["Kubernetes", "Homelab", "DevOps практики"]
categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"]
series_order: 3
---
@@ -0,0 +1,624 @@
---
title: "Свой почтовый сервер на Debian 12 вместо Gmail: полное руководство. Часть 1 - Начало"
date: 2026-03-05
draft: false
description: "Зачем поднимать свой mail-сервер вместо Gmail. Анатомия почтового сервера: Postfix, Dovecot, антиспам, антивирус. Системные требования и чек-лист готовности."
summaty: "Зачем поднимать свой mail-сервер вместо Gmail. Анатомия почтового сервера: Postfix, Dovecot, антиспам, антивирус. Системные требования и чек-лист готовности."
tags: ["postfix", "dovecot", "smtp", "imap", "self-hosting"]
categories: ["Почтовые системы"]
series: ["Почтовый сервер на Debian 12"]
series_order: 1
---
Решил поднять свой почтовый сервер? Отлично. Сейчас объясню, почему это одновременно лучшее и худшее решение, которое ты можешь принять для своей инфраструктуры.
## Зачем вообще заморачиваться
> Что говорят в интернете:
> "Gmail бесплатный и работает отлично, зачем изобретать велосипед?"
### Как есть на самом деле:
**Gmail и прочие публичные сервисы - это хорошо до тех пор, пока:**
- Тебя не смущает, что твою переписку читают для таргетинга рекламы
- Ты не против того, что твой домен висит на чужой инфраструктуре
- Тебе не критично, когда Google решит внезапно заблокировать аккаунт без объяснений
- Ты готов платить за каждый ящик в корпоративном тарифе
- Тебе норм, что лимиты на размер ящика устанавливает кто-то другой
**Свой почтовый сервер дает:**
- **Полный контроль** - ты сам решаешь кто, что и как
- **Безлимитные ящики** - сколько нужно, столько и создашь
- **Любой размер** - ограничение только в железе
- **Свои правила** - никаких внезапных "обновлений политики"
- **Прозрачность** - ты знаешь где лежит твоя почта и кто к ней имеет доступ
Но есть нюанс.
## Реальность: во что ты ввязываешься
### Первые 48 часов после запуска:
`Час 1:` Сервер работает, письма ходят, ты доволен собой.
`Час 3:` Gmail отправляет твои письма в спам. Начинаешь разбираться с SPF.
`Час 6:` SPF настроен. Письма все равно в спаме. Погружаешься в DKIM.
`Час 12:` DKIM работает. Половина писем доходит. Открываешь документацию DMARC.
`Час 24:` Понимаешь, что твой IP попал в какой-то DNSBL. Гуглишь что это вообще такое.
`Час 48:` Письма наконец-то доходят до inbox. Пользователь жалуется: "Я не получил письмо от клиента". Начинаешь копаться в логах.
`Неделя 1:` Обнаруживаешь, что сервер стал источником спама. Как это получилось - вопрос другой.
`Месяц 1:` Осознаешь, что забыл настроить бэкапы. Молишься, чтобы диск не "помер".
### Это нормально
Я не пугаю. Я готовлю к реальности. Почтовый сервер - это не "поставил и забыл". Это инфраструктура, которая требует:
- Начальной настройки (4-8 часов чистого времени)
- Отладки репутации (первые 2-4 недели)
- Регулярного мониторинга (15-30 минут в день)
- Периодического обслуживания (2-4 часа в месяц)
Но когда оно работает - работает **как часы**.
## Что ты получишь в итоге
### Техническая часть:
```
ТВОЙ ПОЧТОВЫЙ СЕРВЕР
├─ Прием и отправка почты (Postfix)
├─ Доступ к ящикам через IMAP (Dovecot)
├─ Веб-интерфейс (RoundCube)
├─ Защита от вирусов (ClamAV)
├─ Защита от спама (SpamAssassin)
├─ Защита от взлома (Fail2ban)
├─ Шифрование (Let's Encrypt)
├─ Подписи писем (DKIM)
└─ Мониторинг и логи (Pflogsumm)
```
### Функциональная часть:
**Отправка почты:**
- С любого почтового клиента (Thunderbird, Outlook, Apple Mail, K-9)
- Через веб-интерфейс из любой точки мира
- С защищенным соединением (TLS)
- С цифровой подписью (твои письма не подделать)
**Прием почты:**
- От любых отправителей
- С проверкой на вирусы
- С фильтрацией спама
- С пользовательскими правилами (Sieve)
**Управление:**
- Неограниченное количество доменов
- Неограниченное количество ящиков
- Псевдонимы (алиасы)
- Пересылки
- Автоответчики
- Квоты на размер (если нужно)
**Безопасность:**
- Шифрование при передаче (никто не прочитает по пути)
- Защита от подбора паролей
- Блокировка спамеров
- Проверка отправителей
## Из чего это состоит: анатомия почтового сервера
Почтовый сервер - это не одна программа. Это экосистема из нескольких компонентов, каждый из которых делает свою работу.
### Postfix - почтальон
**Что делает:** Принимает письма от отправителей и доставляет их получателям.
**Как работает:**
1. Кто-то отправляет письмо на user@твой-домен.ru
2. Postfix принимает письмо на порт 25
3. Проверяет: "А должен ли я вообще принимать почту для этого домена?"
4. Проверяет отправителя через кучу правил
5. Если все ОК - передает письмо дальше (Dovecot или другому Postfix)
6. Если что-то не так - отклоняет с кодом ошибки
**Конфиг:** `/etc/postfix/main.cf` - туда ты будешь лезть чаще всего.
### Dovecot - хранитель ящиков
**Что делает:** Дает доступ к почтовым ящикам по протоколам IMAP/POP3.
**Как работает:**
1. Почтовый клиент подключается к Dovecot (порт 143/993)
2. Вводит логин и пароль
3. Dovecot проверяет учетные данные
4. Открывает доступ к почтовому ящику
5. Клиент качает письма
**Дополнительно:**
- Работает как LDA (Local Delivery Agent) - складывает письма в ящики
- Поддерживает Sieve - пользовательские фильтры
- Управляет квотами
**Конфиг:** `/etc/dovecot/dovecot.conf` и куча подфайлов в `/etc/dovecot/conf.d/`.
### Amavis + ClamAV - санитары
**Что делает Amavis:** Прослойка между Postfix и антивирусом/антиспамом.
**Что делает ClamAV:** Сканирует вложения на вирусы.
**Как работает:**
1. Postfix получает письмо
2. Отправляет его в Amavis (порт 10024)
3. Amavis передает в ClamAV
4. ClamAV сканирует
5. Если вирус найден - письмо в карантин
6. Если чисто - Amavis возвращает в Postfix (порт 10025)
7. Postfix доставляет получателю
**Конфиг:** `/etc/amavis/conf.d/50-user`
### SpamAssassin - фильтр
**Что делает:** Определяет спам по куче признаков.
**Как работает:**
1. Анализирует заголовки письма
2. Проверяет тело письма
3. Смотрит IP отправителя в черных списках (DNSBL)
4. Применяет эвристические правила
5. Использует Bayesian фильтр (обучаемый)
6. Выставляет баллы
7. Если баллов больше порога (обычно 5) - помечает как SPAM
**Обучение:**
- Показываешь примеры спама → он учится
- Показываешь примеры нормальной почты → он учится
- Со временем точность растет
**Конфиг:** `/etc/spamassassin/local.cf`
### Postgrey - вышибала
**Что делает:** Временно отклоняет письма от новых отправителей.
**Как работает:**
1. Письмо приходит от нового сервера
2. Postgrey: "Приходи через 5 минут"
3. Легальный почтовый сервер вернется через 5 минут
4. Спам-бот не вернется (ему некогда)
5. При повторной попытке - пропускает
**Эффективность:** Отсекает ~70% спама вообще без анализа.
**Конфиг:** `/etc/default/postgrey`
### Fail2ban - охрана
**Что делает:** Блокирует IP-адреса, которые брутфорсят(взламывают методом перебора парольных комбинаций) пароли.
**Как работает:**
1. Следит за логами
2. Видит неудачные попытки входа
3. Считает количество попыток
4. Если больше 3-5 за короткое время - бан IP через iptables
5. Через час-два-пять(как настроишь) разбанивает (если не повторится)
**Защищает:**
- SMTP AUTH (порт 25, 587)
- IMAP/POP3 (порт 143, 993, 110, 995)
- Веб-интерфейс RoundCube
-
**Конфиг:** `/etc/fail2ban/jail.local`
### Let's Encrypt - замок
**Что делает:** Выдает бесплатные SSL-сертификаты.
**Зачем:** Шифрование соединений между клиентом и сервером.
**Как работает:**
1. Запускаешь Certbot
2. Он доказывает, что домен принадлежит тебе
3. Let's Encrypt выдает сертификат на 90 дней
4. Certbot автоматически продлевает каждые 60 дней
**Без этого:** Все пароли и письма идут открытым текстом по сети.
**Конфиг:** Автоматический, сертификаты в `/etc/letsencrypt/live/`
### OpenDKIM - печать
**Что делает:** Подписывает исходящие письма цифровой подписью.
**Зачем:** Доказывает, что письмо действительно от твоего домена.
**Как работает:**
1. Генеришь пару ключей (открытый + закрытый)
2. Открытый публикуешь в DNS
3. OpenDKIM подписывает каждое исходящее письмо закрытым ключом
4. Получатель проверяет подпись открытым ключом из DNS
5. Если подпись валидна - письмо не подделано
**Без этого:** Gmail/Outlook 100% отправят твои письма в спам.
**Конфиг:** `/etc/opendkim.conf`
### RoundCube - веб-морда
**Что делает:** Веб-интерфейс для работы с почтой.
**Зачем:** Читать/писать письма через браузер без настройки почтового клиента.
**Возможности:**
- Чтение/отправка писем
- Адресная книга
- Настройка фильтров (через плагин Sieve)
- Смена паролей
- Управление папками
- Поиск по почте
**Конфиг:** `/etc/roundcube/config.inc.php`
### PostgreSQL - база данных
**Что делает:** Хранит данные.
**Что хранит:**
- Базу RoundCube (сессии, адресная книга, кэш)
- Опционально: список пользователей и паролей
- Опционально: псевдонимы и пересылки
**Почему PostgreSQL, а не MySQL:**
- Строже к типам данных → меньше косяков
- Лучше работает с UTF-8
- Проще репликация
- В Debian 12 отличная интеграция
**Конфиг:** `/etc/postgresql/15/main/postgresql.conf`
### Apache + PHP
**Что делает:** Крутит RoundCube.
**Apache:** Веб-сервер, принимает HTTP-запросы.
**PHP:** Интерпретатор, выполняет код RoundCube.
**Альтернатива:** Nginx + PHP-FPM (быстрее, но сложнее, может в будущем рассмотрю и такой подход).
**Почему Apache:** Работает из коробки, тупо проще с той же эффективностью.
**Конфиг:** `/etc/apache2/sites-available/`
### Pflogsumm
**Что делает:** Анализирует логи Postfix и делает отчеты.
**Показывает:**
- Сколько писем отправлено/получено
- Сколько отклонено
- Топ отправителей/получателей
- Ошибки доставки
- Статистику по доменам
**Использование:**
```bash
pflogsumm /var/log/mail.log
```
**Автоматизация:** Настроишь cron - каждый день отчет на почту.
### Netdata - приборная панель
**Что делает:** Мониторинг в реальном времени.
**Показывает:**
- Загрузка CPU, RAM, Disk
- Очереди Postfix
- Соединения Dovecot
- Запросы к PostgreSQL
- Запросы к Apache
- Температура (если есть датчики)
**Интерфейс:** Веб на порту 19999.
**Потребление:** ~100MB RAM.
## Как все это работает вместе
### Сценарий 1: Получение письма
```
1. example.ru отправляет письмо на user@твой-домен.ru
2. DNS: "MX-запись для твой-домен.ru → mail.твой-домен.ru"
3. Письмо приходит на твой сервер (Postfix, порт 25)
4. Postfix: "Проверю отправителя..."
- SPF проверка
- DNSBL проверка
- Greylisting (Postgrey)
5. Postfix: "Отправлю на проверку в Amavis"
6. Amavis → ClamAV: "Есть вирусы?"
ClamAV: "Чисто"
7. Amavis → SpamAssassin: "Это спам?"
SpamAssassin: "3 балла из 5, норм"
8. Amavis возвращает в Postfix: "Все ок, доставляй"
9. Postfix → Dovecot (LMTP): "Положи в ящик user@твой-домен.ru"
10. Dovecot кладет в /var/spool/mail/твой-домен.ru/user/
11. Пользователь открывает RoundCube или Thunderbird
12. Dovecot (IMAP) отдает письмо клиенту
```
### Сценарий 2: Отправка письма
```
1. Пользователь пишет письмо в RoundCube
2. RoundCube → Postfix (порт 587, SMTP Submission)
3. Postfix: "Проверю авторизацию..."
- SMTP AUTH через Dovecot
4. Postfix: "Пользователь свой, подпишу письмо"
5. OpenDKIM добавляет DKIM-подпись
6. Postfix отправляет письмо на mail.example.ru
7. example.ru получает и проверяет:
- SPF (в DNS твоего домена)
- DKIM (подпись валидна?)
- DMARC (политика домена)
8. example.ru: "Все проверки пройдены" → Inbox
```
## Терминология: что есть что
### MTA (Mail Transfer Agent)
**Что:** Программа, которая передает почту между серверами.
**Пример:** Postfix, Sendmail, Exim.
### MDA (Mail Delivery Agent)
**Что:** Программа, которая кладет письма в почтовые ящики.
**Пример:** Dovecot (в режиме LDA/LMTP).
### MUA (Mail User Agent)
**Что:** Почтовый клиент.
**Пример:** Thunderbird, Outlook, RoundCube, K-9 Mail.
### SMTP (Simple Mail Transfer Protocol)
**Что:** Протокол отправки почты.
**Порты:** 25 (сервер-сервер), 587 (клиент-сервер с авторизацией).
### IMAP (Internet Message Access Protocol)
**Что:** Протокол доступа к почте (с синхронизацией).
**Порты:** 143 (открытый), 993 (с TLS).
**Особенность:** Письма хранятся на сервере.
### POP3 (Post Office Protocol)
**Что:** Протокол доступа к почте (скачивание).
**Порты:** 110 (открытый), 995 (с TLS).
**Особенность:** Письма скачиваются и удаляются с сервера.
**Статус:** Устарел, не будем использовать.
### TLS/SSL
**Что:** Шифрование соединения.
**Зачем:** Чтобы пароли и письма не перехватили.
**Пример:** HTTPS для почты.
### SPF (Sender Policy Framework)
**Что:** DNS-запись, которая говорит "с этих IP можно слать почту от моего домена".
**Пример:** `v=spf1 ip4:1.2.3.4 ~all`
**Зачем:** Защита от подделки отправителя.
### DKIM (DomainKeys Identified Mail)
**Что:** Цифровая подпись письма.
**Как:** Закрытый ключ на сервере, открытый в DNS.
**Зачем:** Доказать, что письмо не подделано.
### DMARC (Domain-based Message Authentication)
**Что:** Политика домена: что делать, если SPF или DKIM не прошли.
**Варианты:** none (ничего), quarantine (в спам), reject (не принимать).
**Пример:** `v=DMARC1; p=quarantine; rua=mailto:dmarc@домен.ru`
### DNSBL (DNS-based Blackhole List)
**Что:** Черные списки IP-адресов спамеров.
**Примеры:** zen.spamhaus.org, bl.spamcop.net.
**Как работает:** Postfix спрашивает DNSBL: "Этот IP спамер?" → DNSBL отвечает.
### Greylisting
**Что:** Временная задержка писем от новых отправителей.
**Логика:** Спам-боты не повторяют попытки, легальные серверы - повторяют.
**Задержка:** 5 минут (обычно).
### Relay
**Что:** Пересылка почты через промежуточный сервер.
**Пример:** Твой сервер → SMTP провайдера → получатель.
**Зачем:** Если твой IP в блэклистах или нет белого IP.
### Open Relay
**Что:** Сервер, который пересылает почту от кого угодно.
**Статус:** **ЗЛО**. Мгновенно попадешь в блэклисты.
**Защита:** SMTP AUTH + правильные restrictions.
### Maildir vs Mbox
**Mbox:** Все письма в одном файле.
**Maildir:** Каждое письмо - отдельный файл.
**Используем:** Maildir (надежнее, быстрее).
### Sieve
**Что:** Язык для создания почтовых фильтров.
**Пример:** "Если тема содержит 'счет', переложить в папку 'Финансы'".
**Управление:** Через плагин managesieve в RoundCube.
### Quota
**Что:** Ограничение на размер почтового ящика.
**Пример:** 5GB на пользователя.
**Наш случай:** Без ограничений (или устанавливаешь сам).
## Системные требования
### Минимальная конфигурация (1-50 пользователей):
```
CPU: 2 ядра (любой современный процессор)
RAM: 2 GB
Disk: 20 GB (система) + объем почты
SSD рекомендуется
Net: Стабильное подключение
Белый IP (желательно)
Открытые порты: 25, 587, 143, 993, 80, 443
```
### Рекомендуемая конфигурация (50-200 пользователей):
```
CPU: 4 ядра
RAM: 4 GB
Disk: 50 GB (система) + объем почты
SSD обязательно
Net: 100 Мбит/с
Белый статический IP
```
### Оптимальная конфигурация (200-500 пользователей):
```
CPU: 8 ядер
RAM: 8 GB
Disk: 100 GB (система) + объем почты
NVMe SSD
Net: 1 Гбит/с
Резервный канал
```
### Расчет дискового пространства:
```
Средний пользователь: 1-5 GB в год
Активный пользователь: 10-20 GB в год
Очень активный: 50+ GB в год
Пример на 100 пользователей:
100 × 5 GB = 500 GB
+ запас 20% = 600 GB
+ система 50 GB = 650 GB
Итого: диск на 1 TB с запасом
```
## Что нужно ДО начала установки
### 1. Домен
Зарегистрированный домен с доступом к управлению DNS.
**Пример:** `example.ru`
### 2. Сервер
VPS/Dedicated с Debian 12 и белым IP.
**Требования:**
- Root-доступ
- Чистая установка Debian 12
- Статический IP
- Обратная DNS (PTR) настроена на твое имя хоста
**Проверка PTR:**
```bash
host твой-IP
# Должно вернуть: mail.example.ru
```
### 3. DNS-записи (настроишь в процессе)
**A-запись:**
```
mail.example.ru. IN A твой-IP
```
**MX-запись:**
```
example.ru. IN MX 10 mail.example.ru.
```
**SPF-запись:**
```
example.ru. IN TXT "v=spf1 mx ~all"
```
**DKIM и DMARC** - настроим позже.
### 4. Открытые порты
**Обязательно:**
- `25 (SMTP)` - прием почты от других серверов
- `587 (Submission)` - отправка от клиентов
- `143 (IMAP)` - доступ к ящикам
- `993 (IMAPS)` - IMAP с TLS
- `80 (HTTP)` - для Let's Encrypt
- `443 (HTTPS)` - для RoundCube
**Опционально:**
- `22 (SSH)` - для администрирования
- `19999 (Netdata)` - для мониторинга
### 5. Время
**Реально необходимое:**
- Установка и базовая настройка: 4-6 часов
- Отладка доставки в Gmail/Outlook: 2-4 часа
- Настройка веб-интерфейса: 1-2 часа
- Тестирование и доработка: 2-4 часа
**Итого:** Закладывай полноценные выходные.
## Проверка готовности
Прежде чем начинать, убедись:
```
☐ Есть зарегистрированный домен
☐ Есть доступ к управлению DNS
☐ Есть VPS/Dedicated с Debian 12
☐ Есть root-доступ к серверу
☐ Настроен PTR для твоего IP
☐ Открыты необходимые порты
☐ Есть понимание, сколько времени займет
☐ Есть план резервного копирования
☐ Есть готовность разбираться в проблемах
☐ Прочитал эту статью до конца
```
Если все пункты отмечены - можешь начинать.
## Что дальше
В следующей части разберем установку и базовую настройку Postfix + Dovecot - сердца почтового сервера.
Ты получишь работающую систему приема и отправки почты. Без защиты, без веб-интерфейса, без красивостей - но работающую.
А потом будем навешивать остальное: антивирус, антиспам, шифрование, подписи и все остальное, что превращает голый сервер в production-ready решение.
Поехали.
---
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"vk": {
"icon": "vk",
"title": "sharing.vk",
"url": "http://vk.com/share.php?url={$url}&title={$titleVk}&description={$desc}&image={$image}&noparse=true"
}
}
+2 -2
View File
@@ -38,8 +38,8 @@ author:
byline_title: "Автор"
code:
copy: "Копировать"
copied: "Скопировано"
copy: "Копи."
copied: "ОК"
error:
404_title: "Страница не найдена: в замешательстве:"
+3 -14
View File
@@ -1,14 +1,3 @@
<!-- VK Comments Widget -->
<div class="vk-comments-wrap">
<div id="vk_comments"></div>
</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>
<!-- Comments Widget -->
<script defer src="https://comments.oakazanin.ru/comentario.js"></script>
<comentario-comments no-fonts="true" lang="ru" theme="light"></comentario-comments>
+4
View File
@@ -11,3 +11,7 @@
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/106995329" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<!-- Dzen.Metrika counter -->
<meta name="zen-verification" content="GDc1yxlsRy03jcI1t8NJ8OTleUE3f4I4PEmfOBjz4KBCezoDX1WWctO3l8Z2HGEx" />
<!-- /Dzen.Metrika counter -->
+6
View File
@@ -0,0 +1,6 @@
{{- $icon := resources.Get (print "icons/" . ".svg") -}}
{{- if $icon -}}
<span class="relative block icon">
{{- $icon.Content | safeHTML -}}
</span>
{{- end -}}