Compare commits

..

47 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
astronit 1c61dcf599 feat: добавил к статьям кнопки Отправить... 2026-03-10 14:02:09 +00:00
astronit 3d7016a480 seo: скорректировал частоту генерации sitemap 2026-03-05 07:48:58 +00:00
astronit 286434738d fix: добавляем пустой firebase-config для Blowfish v2.98.0 2026-03-04 18:55:08 +00:00
astronit 086c6e11dc seo: обновляем description для всех статей 2026-03-04 12:54:31 +00:00
astronit 697fb39c28 chore: добавил FETCH_HEAD в .gitignore 2026-02-28 11:20:12 +00:00
astronit 363374c6a9 fix: добавил .gitignore, удалил public/ и resources/ из Git 2026-02-28 11:17:13 +00:00
astronit 1cbbb645fb fix: дополнил robots.txt 2026-02-27 14:19:52 +00:00
astronit dc532ca592 feat: обновил robots.txt 2026-02-27 13:58:47 +00:00
astronit b63ff31c66 feat: счетчик Яндекс Метрика 2026-02-25 07:35:40 +00:00
astronit 07141970c9 fix: форматирование, опечатка в статье 2026-02-23 12:27:32 +00:00
astronit 2860cf8374 fix: опечатка в статье 2026-02-23 11:29:32 +00:00
astronit 4c36dd4ade feat: новый раздел Шпаргалки. новая статья Git Workflow для Hugo блога 2026-02-23 10:46:08 +00:00
astronit 9e9fcf1721 feat: добавил категории (Kubernetes, Веб-разработка, DevOps практики, Homelab) + Страницу Об авторе 2026-02-22 20:58:23 +00:00
astronit 11b97341aa feat: Блог на Hugo в K3s: часть 6 - миграция между namespace 2026-02-22 17:08:56 +00:00
astronit 44d254f1db style: добавил custom.css для VK комментариев под dark тему 2026-02-20 13:29:42 +00:00
astronit dacf5e6e60 feat: добавил VK комментарии (тест на одной статье) 2026-02-20 12:37:46 +00:00
astronit 589b0b01be Merge branch 'main' into dev 2026-02-20 12:31:28 +00:00
astronit 04c6e52579 Revert "feat: Добавил yandex-verification"
This reverts commit 7197209d92.
2026-02-20 12:27:02 +00:00
astronit dc5d480414 Revert "feat: добавил VK комментарии"
This reverts commit 961d51fb37.
2026-02-20 12:14:49 +00:00
astronit 961d51fb37 feat: добавил VK комментарии 2026-02-20 11:34:59 +00:00
astronit c977afe167 Merge branch 'dev'
merge: Обновления из dev - feat: Добавлен yandex-verification
2026-02-20 08:56:01 +00:00
astronit 7197209d92 feat: Добавил yandex-verification 2026-02-20 08:54:30 +00:00
astronit b7c1d5d478 Merge branch 'dev'
merge: Обновление из dev
2026-02-19 20:10:53 +00:00
astronit b05195a735 Добавил серию Blog 1-5 части. Подправил настройки и внешний вид сайта 2026-02-19 19:49:44 +00:00
227 changed files with 12288 additions and 32045 deletions
+21
View File
@@ -0,0 +1,21 @@
# Hugo generated files
public/
resources/_gen/
.hugo_build.lock
# OS files
.DS_Store
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Temporary files
*.tmp
*.log
FETCH_HEAD
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Скрипт добавляет categories в frontmatter статей
# Функция добавления категорий
add_categories() {
local file=$1
local categories=$2
echo "Обрабатываю: $file"
# Проверяем есть ли уже categories
if grep -q "^categories:" "$file"; then
echo " → Уже есть categories, заменяю"
# Удаляем старую строку categories
sed -i '/^categories:/d' "$file"
fi
# Вставляем categories после tags
awk -v cats="$categories" '
/^tags:/ {
print
getline
while ($0 ~ /^ -/ || $0 ~ /^\[/) {
print
if (getline <= 0) break
}
print cats
print
next
}
{ print }
' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
echo " ✓ Добавлены категории"
}
# K3s статьи
add_categories "content/posts/k3s-part1-architecture/index.md" 'categories: ["Kubernetes", "Homelab"]'
add_categories "content/posts/k3s-part2-infrastructure/index.md" 'categories: ["Kubernetes", "Homelab"]'
add_categories "content/posts/k3s-part3-installation/index.md" 'categories: ["Kubernetes", "Homelab", "DevOps практики"]'
# Blog статьи
add_categories "content/posts/blog-part-1-architecture/index.md" 'categories: ["Веб-разработка"]'
add_categories "content/posts/blog-part-2-k8s-deployment/index.md" 'categories: ["Kubernetes", "Веб-разработка"]'
add_categories "content/posts/blog-part-3-dev-environment/index.md" 'categories: ["Веб-разработка", "DevOps практики"]'
add_categories "content/posts/blog-part-4-git-workflow/index.md" 'categories: ["Веб-разработка", "DevOps практики"]'
add_categories "content/posts/blog-part-5-debugging/index.md" 'categories: ["Kubernetes", "DevOps практики"]'
add_categories "content/posts/blog-part-6-namespace-migration/index.md" 'categories: ["Kubernetes", "DevOps практики"]'
echo ""
echo "Готово! Проверь изменения:"
echo "git diff content/posts/"
+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

+1 -1
View File
@@ -31,7 +31,7 @@ enableEmoji = true
series = "series"
[sitemap]
changefreq = 'daily'
changefreq = 'weekly' # Реалистичное значение для блога
filename = 'sitemap.xml'
priority = 0.5
+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/" },
+17 -12
View File
@@ -11,15 +11,20 @@
# по weight от меньшего к большему.
#[[main]]
# name = "О блоге"
# pageRef = "about"
# weight = 10
[[main]]
name = "Об авторе"
pageRef = "about"
weight = 10
#[[main]]
# name = "Статьи"
# pageRef = "posts"
# weight = 20
[[main]]
name = "Статьи"
pageRef = "posts"
weight = 20
[[main]]
name = "Шпаргалки"
pageRef = "cheatsheets"
weight = 21
#[[main]]
# name = "Проекты"
@@ -94,7 +99,7 @@
pageRef = "categories"
weight = 20
[[footer]]
name = "Авторы"
pageRef = "authors"
weight = 30
#[[footer]]
# name = "Авторы"
# pageRef = "authors"
# weight = 30
+47 -44
View File
@@ -6,10 +6,10 @@
# 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 = true
enableStructuredBreadcrumbs = false
@@ -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,71 +55,73 @@ 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
showMoreLinkDest = "/posts"
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 = false
# heroStyle = "basic" # valid options: basic, big, background, thumbAndBackground
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 = false
# relatedContentLimit = 3
showTaxonomies = true # use showTaxonomies OR showCategoryOnly, not both
showCategoryOnly = false # use showTaxonomies OR showCategoryOnly, not both
showRelatedContent = true
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"]
showZenMode = false
sharingLinks = [ "reddit", "email", "telegram"]
# showZenMode = false
# externalLinkForceNewTab = false # disable to allow external links in the same tab (defaults to true)
showComments = true
[list]
showHero = false
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
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 = true
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"]
[taxonomy]
showTermCount = true
showHero = false
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
showHero = true
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
showBreadcrumbs = false
showViews = false
showLikes = false
@@ -127,11 +130,11 @@ forgejoDefaultServer = "https://v11.next.forgejo.org"
[term]
showHero = false
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
showBreadcrumbs = false
showViews = false
showLikes = false
showTableOfContents = true
showTableOfContents = false
groupByYear = false
cardView = false
cardViewScreenWidth = false
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

+109
View File
@@ -0,0 +1,109 @@
---
title: "Кто это пишет и зачем"
date: 2025-10-01
draft: false
showDate : false
showDateOnlyInArticle : false
showDateUpdated : false
showHeadingAnchors : false
showPagination : false
showReadingTime : false
showTableOfContents : true
showTaxonomies : false
showWordCount : false
showSummary : false
sharingLinks : false
showEdit: false
showViews: false
showLikes: false
showAuthor: true
layoutBackgroundHeaderSpace: false
---
Привет! Меня зовут Олег. За 20 лет в IT проблемы росли вместе со мной: от «почему не печатает принтер» до «почему три сервера не могут договориться, кто из них главный». Техподдержка научила терпению - там быстро понимаешь, что «не работает» может означать что угодно, от выключенного монитора до сбоя на другом континенте. Сети научили начинать диагностику с розетки, а не с теории заговора. Системное администрирование - что между «всё работает» и «всё сломалось» обычно стоит один непрочитанный warning в логах трёхнедельной давности.
Сейчас я инфраструктурный инженер. Строю и чиню серверную инфраструктуру: много enterprise, ещё больше Open Source, изрядно импортозамещённого - в общем, всё что работает на Linux, с Linux и благодаря Linux. Работаю в Главгосэкспертизе России. Общаюсь с серверами на Bash, Python и прочих машинопонятных языках. Иногда серверы даже отвечают. Живу в Москве.
Дома - лаборатория. Нет, не стойка в подвале (хотя идея заманчивая). Один сервер, но серьёзный: виртуализация, 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 >}}
+3
View File
@@ -0,0 +1,3 @@
---
title: "Категории"
---
+4
View File
@@ -0,0 +1,4 @@
---
title: "Шпаргалки"
description: "Справочники и шпаргалки по DevOps, Kubernetes, Git, Hugo и другим инструментам. Только практика, без воды."
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
---
title: "Git Workflow для Hugo блога"
date: 2026-02-23
draft: false
description: "Универсальная шпаргалка по работе с Hugo блогом через Git с двумя окружениями. Префиксы коммитов, откаты, типичные ошибки и решения."
tags: ["git", "workflow", "hugo", "cheatsheet"]
categories: ["Шпаргалки"]
---
## Структура проекта
```
~/projects/blog/ ← одна папка, две ветки
├── main ← production (blog.ru)
└── dev ← development (dev.blog.ru)
```
>[!IMPORTANT] **Принцип:** Одна папка, две ветки. Переключение через `git checkout`, не через разные директории.
---
## Золотые правила
**ВСЕ изменения только через dev**
✅ В main попадает только через `git merge dev`
✅ Никогда не редактировать находясь на main
✅ Всегда `git pull` перед началом работы
✅ Проверяй на dev окружении перед публикацией
❌ Не редактировать на ветке main
❌ Не делать `git push --force` в main
❌ Не забывать `git pull` перед merge
---
## Префиксы для commit сообщений
### Основные
**feat:** - новая функциональность
```bash
feat: новая статья про Kubernetes
feat: добавил комментарии
```
**fix:** - исправление ошибки
```bash
fix: опечатка в статье
fix: сломанная ссылка
```
**style:** - форматирование, стили
```bash
style: настроил CSS для тёмной темы
style: исправил отступы
```
**docs:** - изменения в документации
```bash
docs: обновил README
```
**refactor:** - рефакторинг без изменения функций
```bash
refactor: упростил структуру конфигов
```
**chore:** - рутинные задачи
```bash
chore: обновил зависимости
chore: удалил старые файлы
```
**revert:** - откат коммита
```bash
revert: "feat: добавил комментарии"
```
### Дополнительные
**perf:** - улучшение производительности
**test:** - тесты
**build:** - изменения сборки
**ci:** - изменения CI/CD
---
## Создание новой статьи
```bash
cd ~/projects/blog
# Переключаемся на dev
git checkout dev
git pull origin dev
# Создаём статью
hugo new content posts/название-статьи/index.md
# Локальный предпросмотр
hugo server -D --bind 0.0.0.0
# Редактируем, сохраняем, проверяем в браузере
# Коммитим
git add .
git commit -m "feat: новая статья про X"
git push origin dev
# → Автосборка → dev.blog.ru
```
---
## Публикация в production
```bash
cd ~/projects/blog
# Убеждаемся что dev актуален
git checkout dev
git pull origin dev
# Переключаемся на main и мержим
git checkout main
git pull origin main
git merge dev --no-edit
git push origin main
# → Автосборка → blog.ru
```
---
## Исправление опечатки
```bash
cd ~/projects/blog
# ВСЕ изменения через dev!
git checkout dev
git pull origin dev
# Исправляем
nano content/posts/статья/index.md
# Коммитим
git add .
git commit -m "fix: опечатка в статье"
git push origin dev
# Проверяем на dev, если ОК - публикуем
git checkout main
git merge dev --no-edit
git push origin main
```
---
## Обновление темы (Git submodule)
```bash
cd ~/projects/blog
git checkout dev
git pull origin dev
# Обновляем тему
git submodule update --remote themes/название-темы
# Коммитим
git add themes/название-темы
git commit -m "chore: обновил тему"
git push origin dev
# Проверяем на dev, если ОК - публикуем
git checkout main
git merge dev --no-edit
git push origin main
```
---
## Откат изменений
### Когда откатывать vs когда делать fix
**ОТКАТ → Эксперимент провалился:**
- Функция не работает
- Стили сломали дизайн
- Тестировал - не зашло
**НОВЫЙ КОММИТ → Исправление правильного:**
- Опечатка в статье
- Обновление информации
- Улучшение формулировок
---
### Вариант 1: Git revert (безопасный)
```bash
# Смотрим последние коммиты
git log --oneline -5
# Создаём коммит который отменяет изменения
git revert HEAD
git push origin dev
```
**Плюсы:** История сохранена
**Минусы:** Создаёт дополнительный коммит
---
### Вариант 2: Git reset (жёсткий откат)
```bash
# Откатываемся на предыдущий коммит
git log --oneline -5
git reset --hard HEAD~1
# Принудительно пушим
git push origin dev --force
```
**Плюсы:** Чистая история
**Минусы:** Опасно, нужен `--force`
---
### Откат несохранённых изменений
```bash
# Откатить один файл
git checkout -- путь/к/файлу
# Откатить всё
git reset --hard HEAD
```
---
## Проверка статуса
```bash
# На какой ветке?
git branch
# * dev ← текущая ветка
# Что изменилось?
git status
# История коммитов
git log --oneline -10
# Разница между dev и main
git diff main..dev
# График веток
git log --oneline --graph --all -10
```
---
## Типичные ошибки и их решения
### Случайно начал работать на main
```bash
# Отменяем все изменения (НЕ коммитили)
git checkout -- .
# Переключаемся на dev
git checkout dev
```
---
### Случайно сделал `git add` на main
```bash
# Убираем из staging
git reset
# Переключаемся на dev
git checkout dev
# Добавляем правильно
git add .
git commit -m "feat: ..."
git push origin dev
```
---
### Случайно закоммитил в main
```bash
# Откатываем коммит (изменения остаются)
git reset --soft HEAD~1
# Переключаемся на dev
git checkout dev
# Коммитим правильно
git add .
git commit -m "feat: ..."
git push origin dev
```
---
### Случайно запушил в main
```bash
# Откатываем на main
git checkout main
git reset --hard HEAD~1
git push origin main --force
# Переключаемся на dev и делаем правильно
git checkout dev
git add .
git commit -m "feat: ..."
git push origin dev
```
---
## Быстрые команды
```bash
# Переключиться на dev и подтянуть изменения
git checkout dev && git pull origin dev
# Опубликовать dev в main
git checkout main && git pull origin main && git merge dev --no-edit && git push origin main
# Проверить доступность сайта
curl -I https://blog.ru
curl -I https://dev.blog.ru
```
---
## Полезные алиасы для ~/.gitconfig
```ini
[alias]
st = status
co = checkout
br = branch
ci = commit
unstage = reset HEAD --
last = log -1 HEAD
visual = log --oneline --graph --all -10
dev = checkout dev
prod = checkout main
```
После этого можно:
```bash
git dev # → git checkout dev
git prod # → git checkout main
git visual # → git log --oneline --graph --all -10
```
---
## Стандартный workflow
```bash
# 1. Начало работы
cd ~/projects/blog
git checkout dev
git pull origin dev
# 2. Создаём/редактируем контент
hugo new content posts/название/index.md
hugo server -D --bind 0.0.0.0
# 3. Коммитим
git add .
git commit -m "feat: новая статья"
git push origin dev
# 4. Проверяем на dev окружении
# 5. Публикуем в production
git checkout main
git pull origin main
git merge dev --no-edit
git push origin main
```
---
>[!IMPORTANT] **Важно:** `Dev` для экспериментов, `main` для проверенного контента. Всегда тестируй перед публикацией.
+3
View File
@@ -0,0 +1,3 @@
---
title: "Статьи"
---
@@ -0,0 +1,307 @@
---
title: "Блог на Hugo в K3s: часть 1 - архитектура и первый запуск"
date: 2026-01-03
draft: false
description: "Переезд с Jekyll на Hugo: выбор темы Blowfish, настройка K3s кластера и два окружения с нуля. Старт цикла о production блоге на Open Source стеке."
tags: ["hugo", "blowfish", "gitea", "homelab", "devops"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 1
---
Предыдущий блог жил на Jekyll. Жил - громко сказано. Скорее существовал, периодически ломаясь при обновлениях и требуя ритуальных танцев с Ruby каждый раз когда я садился за новую статью.
Однажды я решил что хватит.
---
## Почему не Jekyll
Jekyll - зрелый инструмент с большим сообществом. Но у него есть фундаментальная проблема: он написан на Ruby.
Звучит невинно. На практике это означает:
**Версионный ад.** Ruby, Bundler, Gems - у каждого своя версия, и они регулярно конфликтуют друг с другом. Клонируешь репозиторий на новую машину - час уходит на то чтобы собрать рабочее окружение. Обновляешь Jekyll - ломаются плагины. Обновляешь плагины - ломается что-то ещё.
**Зависимости ради зависимостей.** Простой блог тянет 1000+ gems, половина из которых устаревшая или находится в состоянии "поддерживается постольку-поскольку". Каждый `bundle install` - это лотерея.
**Лишний мусор.** Jekyll генерирует страницы без ссылок, которые непонятно зачем существуют. Приходится явно прописывать что не генерировать.
Hugo решает все эти проблемы радикально: это один бинарник на Go. Никаких зависимостей, никаких конфликтов версий, никакого `bundle install`. Скачал - работает. На любой машине, всегда.
Бинарник весит ~50MB. Собирает сайт из 40+ страниц за полторы секунды. Jekyll на том же контенте думал заметно дольше.
---
## Почему Blowfish
Все просто: понравилась.
Смотрел темы несколько часов. Blowfish выглядела именно так как я хотел - чисто, без лишнего, с хорошей типографикой. Взял её.
Ставится как Git submodule - обновляется одной командой, не засоряет репозиторий.
---
## Архитектура: два окружения
У любого нормального инфраструктурного проекта есть тестовый и production контур. Блог - не исключение: обновления Hugo, изменения темы, новые статьи - всё это нужно проверять до того как это увидят читатели.
```
┌────────────────────────────────────┐
│ Gitea (внутренний) │
│ репозиторий: blog │
│ │
│ ветка: main ветка: dev │
└───────┬────────────────────┬───────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ hugo-builder │ │ hugo-builder │
│ prod │ │ dev │
└───────┬───────┘ └───────┬───────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ nginx │ │ nginx-dev │
│ blog.ru │ │ dev.blog.ru │
│ (публичный) │ │ (Basic Auth) │
└───────────────┘ └───────────────┘
```
**Production** (`blog.ru`) - публичный. Ветка `main`.
**Development** (`dev.blog.ru`) - тестовый контур. Ветка `dev`. Закрыт Basic Auth через Traefik Middleware - без логина и пароля не войти.
Оба окружения в одном K3s namespace. Один репозиторий, две ветки, два независимых пайплайна.
---
## Шаг 1: Создаём Hugo проект
```bash
# Создаём новый Hugo сайт
cd ~/projects
hugo new site blog
cd blog
# Инициализируем Git репозиторий
git init
git remote add origin https://git.example.com/user/blog.git
# Добавляем тему Blowfish как Git submodule
git submodule add -b main https://github.com/nunocoracao/blowfish.git themes/blowfish
# Подтягиваем файлы submodule
git submodule update --init --recursive
# Удаляем дефолтный конфиг Hugo
rm -f hugo.toml
# Создаём структуру для конфигов
mkdir -p config/_default
# Копируем примеры конфигов из темы
cp themes/blowfish/config/_default/*.toml config/_default/
```
Структура после инициализации:
```
blog/
├── content/ ← статьи в Markdown
├── static/ ← изображения, favicon
├── themes/
│ └── blowfish/ ← тема (Git submodule)
└── config/
└── _default/
├── hugo.toml
├── languages.ru.toml
├── menus.ru.toml
├── markup.toml
└── params.toml
```
---
## Шаг 2: Минимальная конфигурация
### hugo.toml
```toml
baseURL = "https://blog.ru/"
theme = "blowfish"
defaultContentLanguage = "ru"
```
### languages.ru.toml
```bash
# Переименовываем файл языка для русского
mv config/_default/languages.en.toml config/_default/languages.ru.toml
```
```toml
languageCode = "ru"
languageName = "Русский"
weight = 1
title = "Мой Блог"
[params]
displayName = "RU"
isoCode = "ru"
rtl = false
dateFormat = "2 January 2006"
[params.author]
name = "Ваше Имя"
headline = "DevOps Engineer | Kubernetes | Homelab"
```
### menus.ru.toml
```bash
# Переименовываем файл меню
mv config/_default/menus.en.toml config/_default/menus.ru.toml
```
```toml
[[main]]
name = "Блог"
pageRef = "posts"
weight = 10
[[main]]
name = "О сайте"
pageRef = "about"
weight = 20
```
---
## Шаг 3: Первая статья
```bash
# Создаём новую статью
hugo new content posts/hello-world/index.md
```
```markdown
---
title: "Hello World"
date: 2026-02-13
draft: false
description: "Первый пост на новом сайте"
tags: ["test"]
categories: ["Веб-разработка"]
---
Это первый пост на blog.ru.
```
---
## Шаг 4: Проверяем локально
```bash
# Запускаем локальный сервер Hugo
# -D: показывать черновики (draft: true)
# --bind 0.0.0.0: доступен с любого IP в локальной сети
hugo server -D --bind 0.0.0.0
```
Открываем `http://192.168.11.10:1313/` (ваш IP) - сайт с Blowfish темой и первой статьёй.
Hugo автоматически пересобирает сайт при сохранении файлов - изменения видны сразу после обновления страницы в браузере.
---
## Шаг 5: Пушим в Gitea
```bash
# Добавляем все файлы в Git
git add .
# Создаём первый коммит
git commit -m "Initial commit: Hugo + Blowfish v2.98.0"
# Переименовываем ветку в main (если по умолчанию master)
git branch -M main
# Пушим в Gitea
git push -u origin main
# Создаём ветку dev для development окружения
git checkout -b dev
git push -u origin dev
# Возвращаемся на main
git checkout main
```
---
## Workflow: как я пишу статьи
**Пишу (ветка dev):**
```bash
# Переходим в папку проекта
cd ~/projects/blog
# Переключаемся на ветку dev
git checkout dev
# Запускаем локальный предпросмотр
hugo server -D --bind 0.0.0.0
# Создаём новую статью
hugo new content posts/название-статьи/index.md
# Редактируем в любом редакторе, сохраняем, смотрим в браузере
# Когда готово - коммитим
git add .
git commit -m "feat: новая статья про X"
# Пушим в dev ветку
git push origin dev
# → webhook срабатывает → автосборка → dev.blog.ru
```
**Проверяю:**
Открываю `https://dev.blog.ru` (вводя логин/пароль Basic Auth) - вижу статью как её увидят читатели.
**Публикую:**
```bash
# Переключаемся на main
git checkout main
# Мержим изменения из dev
git merge dev
# Пушим в production
git push origin main
# → webhook срабатывает → автосборка → blog.ru
```
Золотое правило: **все изменения только через ветку `dev`**. В `main` - только через merge. Никогда не редактировать файлы находясь на `main`.
Почему это важно - расскажу отдельно, когда дойдём до того как я нарушил это правило и что из этого вышло.
---
## Что дальше
Локально всё работает. Но `hugo server` умрёт как только закрою терминал.
Нужно развернуть это в K3s: Hugo Builder который пересобирает сайт при каждом пуше, Nginx который раздаёт статику, SSL сертификаты, NFS хранилище. Об этом - в следующей части.
---
**Стек этой части:**
- Hugo v0.155.3 extended
- Blowfish v2.98.0
- Gitea (внутренний)
- Рабочая станция: Debian/Ubuntu
@@ -0,0 +1,593 @@
---
title: "Блог на Hugo в K3s: часть 2 - деплой в кластер"
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: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 2
---
В первой части мы запустили Hugo локально. Сайт работает пока открыт терминал. Закрыл терминал - сайт умер.
Пора переносить это в K3s.
---
## Архитектура деплоя
```
Git Push
Gitea (внутренний)
↓ webhook POST
Hugo Builder
├→ git clone + submodule
├→ hugo --minify
└→ output → NFS
/export/blog-public/
Nginx (x2 реплики)
Traefik Ingress
your-blog.ru (SSL)
```
Пять компонентов:
1. **NFS** - хранилище для статики (OpenMediaVault)
2. **Hugo Builder** - пересобирает сайт при каждом пуше
3. **Nginx** - раздаёт статику с NFS
4. **cert-manager** - автоматический SSL от Let's Encrypt
5. **Traefik IngressRoute** - маршрутизация с SSL терминацией
---
## Шаг 1: NFS хранилище
Hugo собирает статику в HTML/CSS/JS файлы. Nginx раздаёт эти файлы. Значит нужно общее хранилище куда Hugo пишет, а Nginx читает.
NFS - самый простой вариант для homelab. У меня OpenMediaVault на отдельной машине.
### Создаём директории на NAS
```bash
# Подключаемся к NAS (SSH на нестандартном порту для безопасности)
ssh -p 33322 nasadmin@192.168.11.30
# Создаём папки для production и development окружений
sudo mkdir -p /srv/storage/blog/blog-public
sudo mkdir -p /srv/storage/blog/blog-public-dev
# Выдаём права на запись (контейнеры пишут от root)
sudo chmod -R 775 /srv/storage/blog/
```
**Почему SSH на порту 33322?** Стандартный порт 22 - первая цель сканеров и ботов. Нестандартный порт снижает шум в логах и количество brute-force попыток до нуля. Безопасность через скрытность работает для домашних серверов.
### Настраиваем NFS через OMV Web UI
Storage → Shared Folders → Create:
- Name: `blog-public`
- Device: основной диск
- Path: `/blog/blog-public`
Services → NFS → Shares → Create:
- Shared folder: `blog-public`
- Client: `192.168.11.0/24`
- Privilege: Read/Write
- Extra options: `rw,sync,no_subtree_check,no_root_squash`
То же для `blog-public-dev`.
**Критично:** `no_root_squash` - без этого контейнеры не смогут записывать файлы (они пишут от root внутри контейнера).
### Проверяем экспорт
```bash
# Заходим на NAS
ssh -p 33322 nasadmin@192.168.11.30
# Проверяем что NFS экспортирует наши шары
sudo exportfs -v | grep blog
# Ожидаемый вывод - две строки с настройками экспорта:
# /export/blog-public 192.168.11.0/24(rw,sync,no_root_squash,...)
# /export/blog-public-dev 192.168.11.0/24(rw,sync,no_root_squash,...)
```
---
## Шаг 2: PersistentVolumes в K3s
K3s нужно сказать где лежат NFS шары. Создаём манифест с PersistentVolume ресурсами.
**Файл:** `02-pv.yaml`
```yaml
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: blog-public-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteMany
nfs:
server: 192.168.11.30 # IP вашего NAS
path: /export/blog-public
mountOptions:
- nfsvers=3
- hard
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: blog-public-dev-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteMany
nfs:
server: 192.168.11.30
path: /export/blog-public-dev
mountOptions:
- nfsvers=3
- hard
```
**Почему NFSv3, а не NFSv4?** Потому что NFSv4.2 в K3s не работал - поды виснут в `ContainerCreating` с ошибкой `mount.nfs: No such file or directory`. NFSv3 работает стабильно. Не надо усложнять то что работает.
```bash
# Применяем манифест
kubectl apply -f 02-pv.yaml
# Проверяем что PV создались и привязались
kubectl get pv | grep blog
# blog-public-pv 5Gi RWX Bound blog/blog-public-pvc
```
---
## Шаг 3: Hugo Builder
Нужен контейнер который слушает webhook от Gitea, клонирует репозиторий и собирает Hugo.
### Зачем нужен Hugo Builder?
**Проблема:** Hugo генерирует статику командой `hugo`. Где её запускать? На локальной машине? Тогда нужно вручную заливать файлы на сервер после каждого изменения. Неудобно и ломает автоматизацию.
**Решение:** Контейнер который живёт в K3s, слушает webhook от Gitea и автоматически пересобирает сайт при каждом `git push`.
### Dockerfile
```dockerfile
FROM alpine:3.19
# Устанавливаем всё что нужно Hugo и Git
RUN apk add --no-cache \
git nodejs npm bash curl wget \
libc6-compat libstdc++ ca-certificates
# Скачиваем Hugo Extended v0.155.3
WORKDIR /tmp
RUN wget https://github.com/gohugoio/hugo/releases/download/v0.155.3/hugo_extended_0.155.3_linux-amd64.tar.gz && \
tar -xzf hugo_extended_0.155.3_linux-amd64.tar.gz && \
cp hugo /usr/bin/hugo && \
chmod +x /usr/bin/hugo && \
rm -rf /tmp/*
WORKDIR /workspace
# Копируем скрипты
COPY webhook-listener.sh /usr/local/bin/
COPY build.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/*.sh
EXPOSE 8080
CMD ["/usr/local/bin/webhook-listener.sh"]
```
### build.sh - скрипт сборки Hugo
**Зачем:** Отдельный скрипт сборки нужен чтобы его можно было запускать не только из webhook listener, но и вручную для тестирования. Один скрипт - одна ответственность.
```bash
#!/bin/bash
set -e # Остановиться при первой ошибке
export GIT_TERMINAL_PROMPT=0 # Не запрашивать пароли интерактивно
REPO_URL="https://git.example.com/user/blog.git" # URL вашего Gitea репозитория
BRANCH="${BRANCH:-main}" # Ветка (передаётся через env)
OUTPUT_DIR="/mnt/blog-public" # Куда складывать собранную статику (NFS)
WORK_DIR="/tmp/build" # Временная папка для клонирования
# Чистим рабочую директорию от прошлой сборки
rm -rf ${WORK_DIR}
mkdir -p ${WORK_DIR}
# Клонируем репозиторий (только нужную ветку, без истории)
cd ${WORK_DIR}
git clone --branch ${BRANCH} --depth 1 ${REPO_URL} site 2>&1
cd site
# Подтягиваем тему Blowfish как Git submodule
git submodule update --init --recursive --depth 1 2>&1
# Собираем сайт (минифицируем CSS/JS/HTML)
hugo --minify --destination ${OUTPUT_DIR} 2>&1
# Проверяем что сборка прошла успешно
if [ -f "${OUTPUT_DIR}/index.html" ]; then
echo "Build successful!"
else
echo "Build failed - index.html not found"
exit 1
fi
# Убираем за собой
rm -rf ${WORK_DIR}
```
### webhook-listener.sh - слушатель webhook
**Зачем:** Gitea отправляет HTTP POST запрос при каждом `git push`. Нужен простой HTTP сервер который принимает этот запрос и запускает сборку. netcat - самый простой способ поднять HTTP listener без зависимостей.
```bash
#!/bin/bash
set -e
echo "Starting webhook listener on port 8080..."
while true; do
# Принимаем HTTP запрос через netcat и сразу отвечаем 200 OK
echo -e "HTTP/1.1 200 OK\r\n\r\nWebhook received" | nc -l -p 8080
# Запускаем сборку синхронно (чтобы видеть логи в kubectl logs)
echo "$(date): Webhook triggered, starting build..."
/usr/local/bin/build.sh
echo "$(date): Build completed, waiting for next webhook..."
done
```
### Сборка и деплой образа
```bash
# Собираем Docker образ
docker build -t hugo-builder:latest .
# Сохраняем в tar файл
docker save hugo-builder:latest -o /tmp/hugo-builder.tar
# Копируем на все K3s worker ноды
for ip in 210 211; do
scp /tmp/hugo-builder.tar k3s@192.168.11.$ip:/tmp/
# Импортируем образ в containerd K3s
ssh k3s@192.168.11.$ip "sudo k3s ctr images import /tmp/hugo-builder.tar && rm /tmp/hugo-builder.tar"
done
```
### Deployment и Service
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hugo-builder-prod
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
app: hugo-builder-prod
template:
metadata:
labels:
app: hugo-builder-prod
spec:
containers:
- name: hugo-builder
image: hugo-builder:latest
imagePullPolicy: Never # Образ локальный, не тянуть из registry
env:
- name: BRANCH
value: "main" # Для prod используем main ветку
volumeMounts:
- name: public
mountPath: /mnt/blog-public # NFS хранилище
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: public
persistentVolumeClaim:
claimName: blog-public-pvc
---
apiVersion: v1
kind: Service
metadata:
name: hugo-builder-prod
namespace: blog
spec:
selector:
app: hugo-builder-prod
ports:
- port: 8080
targetPort: 8080
name: webhook
```
```bash
# Применяем манифест
kubectl apply -f 01-hugo-builder-prod.yaml
# Проверяем что под запустился
kubectl get pods -n blog | grep hugo-builder
# Смотрим логи - должна быть строка "Starting webhook listener"
kubectl logs -n blog deployment/hugo-builder-prod
```
---
## Шаг 4: Nginx с Prometheus exporter
Nginx раздаёт статику с того же NFS где Hugo её собрал. Две реплики для минимальной доступности при обновлениях.
Бонус: sidecar контейнер с nginx-prometheus-exporter для мониторинга через Grafana.
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: blog
spec:
replicas: 2 # Две реплики для доступности
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
# Основной контейнер - Nginx
- name: nginx
image: nginx:1.25-alpine
ports:
- containerPort: 80
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
readOnly: true # Nginx только читает, не пишет
- name: config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
resources:
requests:
cpu: 50m
memory: 64Mi
# Sidecar - экспортер метрик для Prometheus
- name: nginx-exporter
image: nginx/nginx-prometheus-exporter:1.1.0
args:
- -nginx.scrape-uri=http://localhost/nginx_status
ports:
- containerPort: 9113
name: metrics
resources:
requests:
cpu: 10m
memory: 16Mi
volumes:
- name: html
persistentVolumeClaim:
claimName: blog-public-pvc # NFS хранилище
- name: config
configMap:
name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: blog
spec:
selector:
app: nginx
ports:
- port: 80
targetPort: 80
name: http
- port: 9113
targetPort: 9113
name: metrics # Для Prometheus
```
---
## Шаг 5: SSL сертификаты
cert-manager автоматически получает сертификаты от Let's Encrypt через HTTP-01 challenge.
**Важно:** Сначала настрой A-запись у DNS провайдера:
```
your-blog.ru A 77.37.XXX.XXX (ваш внешний IP)
www.your-blog.ru A 77.37.XXX.XXX
```
Без этого Let's Encrypt не сможет проверить что домен принадлежит вам.
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: blog-tls
namespace: blog
spec:
secretName: blog-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- your-blog.ru
- www.your-blog.ru
```
```bash
# Применяем манифест
kubectl apply -f 04-certificate.yaml
# Ждём 30-60 секунд пока cert-manager получит сертификат
kubectl get certificate -n blog
# Должно быть READY=True
# NAME READY SECRET AGE
# blog-tls True blog-tls 45s
```
---
## Шаг 6: IngressRoute через Traefik
Traefik маршрутизирует трафик на Nginx и делает SSL терминацию.
```yaml
---
# HTTP → HTTPS редирект (опционально)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-http
namespace: blog
spec:
entryPoints:
- web # Порт 80
routes:
- match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`)
kind: Rule
services:
- name: nginx
port: 80
---
# HTTPS с SSL
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-https
namespace: blog
spec:
entryPoints:
- websecure # Порт 443
routes:
- match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`)
kind: Rule
services:
- name: nginx
port: 80
tls:
secretName: blog-tls # Сертификат от cert-manager
```
```bash
# Применяем манифест
kubectl apply -f 05-ingressroute.yaml
# Проверяем что сайт доступен
curl -I https://your-blog.ru
# HTTP/2 200
```
---
## Шаг 7: Webhook в Gitea
Последний шаг - связать Gitea с Hugo Builder.
Gitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea
- **URL:** `http://hugo-builder-prod.blog.svc.cluster.local:8080`
- **HTTP Method:** POST
- **Content Type:** application/json
- **Trigger On:** Push events
- **Branch filter:** `main`
Нажимаем "Test Delivery" - должен вернуть `200 OK`.
Проверяем логи Hugo Builder:
```bash
# Следим за логами в реальном времени
kubectl logs -n blog deployment/hugo-builder-prod -f
# Должно появиться:
# Webhook triggered, starting build...
# Cloning repository...
# Initializing submodules...
# Building Hugo site...
# Build successful!
```
---
## Проверка работы
```bash
# Меняем статью
cd ~/hugo-projects/blog
git checkout main
echo "## Тестовая правка" >> content/posts/hello-world/index.md
# Коммитим и пушим
git add .
git commit -m "test: проверка автосборки"
git push origin main
# Следим за логами Hugo Builder
kubectl logs -n blog deployment/hugo-builder-prod -f
# Через 5-7 секунд сборка завершится
# Проверяем что изменение попало на сайт
curl -s https://your-blog.ru/posts/hello-world/ | grep "Тестовая правка"
```
Если видите "Тестовая правка" - всё работает. Каждый `git push` автоматически обновляет сайт.
---
## Что дальше
Production окружение развёрнуто. Но пока только для ветки `main`.
В следующей части добавим development окружение с отдельным Hugo Builder, Nginx и защитой через Basic Auth. Два независимых пайплайна в одном namespace.
---
**Стек этой части:**
- K3s 1.30
- NFS на OpenMediaVault
- Hugo Builder (Alpine + Hugo v0.155.3)
- Nginx 1.25 + Prometheus exporter
- cert-manager + Let's Encrypt
- Traefik IngressRoute
@@ -0,0 +1,504 @@
---
title: "Блог на Hugo в K3s: часть 3 - development окружение"
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: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 3
---
В части 2 мы развернули production окружение для ветки `main`. Каждый пуш в `main` автоматически обновляет публичный сайт.
Проблема: нельзя проверить как выглядит статья до публикации. Локальный `hugo server` показывает одно, а production может выглядеть по-другому из-за версий Hugo, конфигов, CSS.
Нужен второй пайплайн - тестовый контур где можно проверить изменения перед мержем в `main`.
---
## Архитектура dev окружения
```
Git Push (dev branch)
Gitea
↓ webhook
Hugo Builder Dev
├→ Clone dev branch
├→ hugo --minify
└→ Output → NFS (dev)
/export/blog-public-dev/
Nginx Dev (1 реплика)
Traefik Ingress
├→ Basic Auth Middleware
└→ dev.blog.ru (SSL)
```
Отличия от production:
- Отдельный Hugo Builder (переменная `BRANCH=dev`)
- Отдельный NFS volume (`blog-public-dev`)
- Отдельный Nginx (одна реплика вместо двух)
- **Basic Auth** - доступ только по логину и паролю
- Отдельный домен (`dev.blog.ru`)
Всё это живёт в том же namespace что и production. Два независимых пайплайна, нулевое пересечение.
---
## Шаг 1: Hugo Builder для dev
Используем тот же Docker образ что и для production. Разница - в переменной окружения `BRANCH`.
**Файл:** `01-hugo-builder-dev.yaml`
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: hugo-builder-dev
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
app: hugo-builder-dev
template:
metadata:
labels:
app: hugo-builder-dev
spec:
containers:
- name: hugo-builder
image: hugo-builder:latest
imagePullPolicy: Never
env:
- name: BRANCH
value: "dev" # Главное отличие - используем dev ветку
volumeMounts:
- name: public
mountPath: /mnt/blog-public
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: public
persistentVolumeClaim:
claimName: blog-public-dev-pvc # Отдельный PVC
---
apiVersion: v1
kind: Service
metadata:
name: hugo-builder-dev
namespace: blog
spec:
selector:
app: hugo-builder-dev
ports:
- port: 8080
targetPort: 8080
name: webhook
```
```bash
# Применяем манифест
kubectl apply -f 01-hugo-builder-dev.yaml
# Проверяем что под запустился
kubectl get pods -n blog | grep hugo-builder-dev
# Смотрим логи
kubectl logs -n blog deployment/hugo-builder-dev
# Starting webhook listener on port 8080...
```
---
## Шаг 2: Nginx для dev
Одна реплика вместо двух - для тестового окружения высокая доступность не критична.
**Файл:** `03-nginx-dev-deployment.yaml`
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-dev
namespace: blog
spec:
replicas: 1 # Тестовому окружению достаточно одной реплики
selector:
matchLabels:
app: nginx-dev
template:
metadata:
labels:
app: nginx-dev
spec:
containers:
- name: nginx
image: nginx:1.25-alpine
ports:
- containerPort: 80
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
- name: config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
resources:
requests:
cpu: 50m
memory: 64Mi
volumes:
- name: html
persistentVolumeClaim:
claimName: blog-public-dev-pvc # Отдельный PVC
- name: config
configMap:
name: nginx-dev-config
---
apiVersion: v1
kind: Service
metadata:
name: nginx-dev
namespace: blog
spec:
selector:
app: nginx-dev
ports:
- port: 80
targetPort: 80
name: http
```
```bash
# Применяем манифест
kubectl apply -f 03-nginx-dev-deployment.yaml
# Проверяем
kubectl get pods -n blog | grep nginx-dev
```
---
## Шаг 3: Basic Auth через Traefik
Dev окружение должно быть закрыто от посторонних. Traefik поддерживает Basic Auth через Middleware.
### Создаём пароль
```bash
# Генерируем htpasswd (логин: dev, пароль: ваш пароль)
htpasswd -nb dev your-password
# dev:$apr1$...хеш...
# Кодируем в base64 для Kubernetes Secret
echo -n "dev:$apr1$...хеш..." | base64
# ZGV2OiRhcHIxJC4uLg==
```
### Secret с паролем
**Файл:** `07-basic-auth-secret.yaml`
```yaml
---
apiVersion: v1
kind: Secret
metadata:
name: dev-basic-auth
namespace: blog
type: Opaque
data:
users: ZGV2OiRhcHIxJC4uLg== # ваш base64 хеш
```
```bash
# Применяем секрет
kubectl apply -f 07-basic-auth-secret.yaml
# Проверяем что секрет создался
kubectl get secret -n blog | grep dev-basic-auth
```
### Middleware для Basic Auth
**Файл:** `08-basic-auth-middleware.yaml`
```yaml
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: dev-basic-auth
namespace: blog
spec:
basicAuth:
secret: dev-basic-auth
removeHeader: true # Убираем заголовок Authorization после проверки
```
```bash
# Применяем middleware
kubectl apply -f 08-basic-auth-middleware.yaml
# Проверяем
kubectl get middleware -n blog
# NAME AGE
# dev-basic-auth 5s
```
---
## Шаг 4: IngressRoute с Basic Auth
Связываем всё вместе: домен → middleware → nginx-dev.
**Файл:** `06-ingressroute-dev.yaml`
```yaml
---
# HTTP (без SSL)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-dev-http
namespace: blog
spec:
entryPoints:
- web
routes:
- match: Host(`dev.blog.ru`)
kind: Rule
services:
- name: nginx-dev
port: 80
---
# HTTPS с Basic Auth
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-dev-https
namespace: blog
spec:
entryPoints:
- websecure
routes:
- match: Host(`dev.blog.ru`)
kind: Rule
middlewares:
- name: dev-basic-auth # Добавляем Basic Auth
services:
- name: nginx-dev
port: 80
tls:
secretName: blog-dev-tls
```
```bash
# Применяем IngressRoute
kubectl apply -f 06-ingressroute-dev.yaml
# Проверяем
kubectl get ingressroute -n blog | grep dev
```
---
## Шаг 5: SSL сертификат для dev
cert-manager выпустит отдельный сертификат для `dev.blog.ru`.
**Не забудьте:** Добавить A-запись в DNS:
```
dev.blog.ru A 77.37.XXX.XXX
```
**Файл:** `04-certificate-dev.yaml`
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: blog-dev-tls
namespace: blog
spec:
secretName: blog-dev-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- dev.blog.ru
```
```bash
# Применяем манифест
kubectl apply -f 04-certificate-dev.yaml
# Ждём получения сертификата (30-60 секунд)
kubectl get certificate -n blog
# Должно быть READY=True
# NAME READY SECRET AGE
# blog-dev-tls True blog-dev-tls 45s
```
---
## Шаг 6: Webhook в Gitea для dev
Создаём второй webhook который триггерится на пуши в ветку `dev`.
Gitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea
- **URL:** `http://hugo-builder-dev.blog.svc.cluster.local:8080`
- **HTTP Method:** POST
- **Content Type:** application/json
- **Trigger On:** Push events
- **Branch filter:** `dev` ← Главное отличие от prod
Нажимаем "Test Delivery" → должен вернуть `200 OK`.
Проверяем логи:
```bash
# Смотрим логи Hugo Builder Dev
kubectl logs -n blog deployment/hugo-builder-dev -f
# Должно появиться:
# Webhook triggered, starting build...
# Cloning repository (branch: dev)...
# Build successful!
```
---
## Проверка работы
```bash
# Создаём тестовую статью в dev
cd ~/projects/blog
git checkout dev
# Создаём статью
hugo new content posts/test-dev/index.md
echo "Тестовая статья в dev окружении" >> content/posts/test-dev/index.md
# Коммитим и пушим
git add .
git commit -m "test: проверка dev окружения"
git push origin dev
# Следим за логами Hugo Builder Dev
kubectl logs -n blog deployment/hugo-builder-dev -f
# Через 5-7 секунд сборка завершится
```
Открываем `https://dev.blog.ru` в браузере:
1. Браузер запросит логин и пароль (Basic Auth)
2. Вводим: логин `dev`, пароль который задали
3. Видим тестовую статью
**Критично:** Статья появилась на `dev.blog.ru`, но её **нет** на `blog.ru` - окружения изолированы.
---
## Workflow: dev → prod
Типичный процесс работы:
**1. Пишу статью в dev:**
```bash
cd ~/projects/blog
git checkout dev
# Создаю статью
hugo new content posts/kubernetes-intro/index.md
# Пишу контент, коммичу
git add .
git commit -m "feat: статья про Kubernetes"
git push origin dev
# → автосборка → dev.blog.ru
```
**2. Проверяю на dev.blog.ru:**
Открываю `https://dev.blog.ru` (вводя логин/пароль), читаю статью, проверяю форматирование, ссылки, изображения.
Нахожу опечатку - исправляю локально, пушу в `dev` снова. Повторяю пока не доволен результатом.
**3. Публикую в production:**
```bash
# Всё отлично на dev - мержу в main
git checkout main
git merge dev
git push origin main
# → автосборка → blog.ru
```
Статья появляется на публичном сайте.
---
## Итоговая архитектура
Два полностью независимых пайплайна в одном namespace:
```
Production:
main branch → hugo-builder-prod → blog-public-pvc → nginx (x2) → blog.ru
Development:
dev branch → hugo-builder-dev → blog-public-dev-pvc → nginx-dev (x1) → dev.blog.ru + Basic Auth
```
Общее:
- Namespace: `blog`
- Gitea репозиторий
- Docker образ Hugo Builder
- Traefik IngressRoute
- cert-manager
Отдельное:
- Deployments
- Services
- PersistentVolumes
- SSL сертификаты
- Домены
---
## Что дальше
Два окружения работают. Можно писать статьи, проверять на dev, публиковать в production.
Но есть проблема: я долго работал в двух папках - `~/projects/blog` (main) и `~/projects/blog-dev` (dev). Это создавало конфликты при merge, рассинхронизацию веток и головную боль.
В следующей части расскажу как я от этого избавился и почему одна папка с переключением веток лучше чем две отдельные папки.
---
**Стек этой части:**
- Hugo Builder Dev (та же версия Hugo)
- Nginx Dev (одна реплика)
- Traefik Basic Auth Middleware
- cert-manager (отдельный сертификат)
- NFS (отдельный volume)
@@ -0,0 +1,418 @@
---
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: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 4
---
В части 3 мы развернули два окружения - production и development. Один репозиторий, две ветки (`main` и `dev`), два пайплайна.
Теперь встаёт вопрос: **как организовать работу локально?**
---
## Проблема
У нас есть:
- Репозиторий в Gitea
- Две ветки: `main` (production) и `dev` (development)
- Необходимость постоянно переключаться между ними
Как это делать на локальной машине? Два варианта.
---
## Вариант А: Две отдельные папки
Клонируем репозиторий дважды - в разные папки:
```
~/projects/
├── blog/ ← ветка main (production)
└── blog-dev/ ← ветка dev (development)
```
**Логика:** Хочу работать с dev - иду в `blog-dev`. Хочу что-то проверить в production - иду в `blog`. Без переключения веток.
### Кажущиеся преимущества
**Параллельная работа.** Можно держать открытыми два терминала - в одном `hugo server` для dev, в другом смотреть production код.
**Изоляция.** Каждая папка - своя песочница. Изменения в одной не влияют на другую.
**Простота навигации.** `cd blog-dev` вместо `git checkout dev`. Меньше команд.
**Привычный паттерн.** Многие админы и разработчики держат несколько клонов для разных задач.
### Реальные проблемы
#### Проблема 1: Рассинхронизация локальных ветокin
Работаю в `blog-dev` - пишу статьи, коммичу, пушу в `origin/dev`. Всё хорошо.
Но локальная ветка `dev` в папке `blog` при этом **не обновляется**. Она отстаёт от `origin/dev`.
Приходишь делать merge:
```bash
cd blog
git checkout main
git merge dev
# Already up to date. ← НО есть НЮАНС!
```
Git говорит "всё актуально", имея в виду **локальную** ветку `dev`, которая отстала на три коммита. Статьи не попадают в production.
Приходится помнить делать `git pull origin dev` перед каждым merge. Забыл - публикуешь устаревшую версию.
#### Проблема 2: Конфликты при merge
Редактируешь `config/params.toml` в обеих папках независимо:
- В `blog-dev` добавил Firebase конфиг
- В `blog` изменил название сайта
При merge Git честно сообщает о конфликте:
```
CONFLICT (content): Merge conflict in config/params.toml
```
И это повторяется **каждый раз** когда трогаешь конфигурацию. Потому что две папки - это две независимые истории изменений одного файла.
#### Проблема 3: Работа не в той папке
Несколько раз ловил себя на том что редактирую статьи прямо в `blog` - папке production. Это нарушает весь смысл раздельных окружений.
#### Проблема 4: Умственная нагрузка
Постоянный вопрос "в какой папке я сейчас?" Для простого блога это лишняя когнитивная нагрузка.
---
## Вариант Б: Одна папка с переключением веток
Один клон репозитория, работа через `git checkout`:
```
~/projects/
└── blog/ ← одна папка, две ветки: main и dev
```
### Как это работает
**Пишу статью:**
```bash
cd ~/projects/blog
# Переключаюсь на dev
git checkout dev
# Проверяю что dev актуален
git pull origin dev
# Запускаю локальный сервер
hugo server -D --bind 0.0.0.0
# Создаю статью
hugo new content posts/название/index.md
# Коммичу и пушу
git add .
git commit -m "feat: новая статья"
git push origin dev
```
**Публикую:**
```bash
# Убеждаюсь что dev актуален
git checkout dev
git pull origin dev
# Переключаюсь на main и мержу
git checkout main
git pull origin main
git merge dev
git push origin main
```
### Реальные преимущества
**Никакой рассинхронизации.** Все ветки в одном репозитории. `git pull` обновляет всё что нужно.
**Нет конфликтов из-за независимых изменений.** Когда работаешь в одной папке, `params.toml` существует в одном экземпляре. Все изменения делаются в `dev`, в `main` попадают только через merge.
Конфликт возможен только если кто-то редактирует `main` напрямую - а это нарушение workflow.
**Невозможно ошибиться с веткой.** `git branch` показывает где ты сейчас. Случайно отредактировать файлы в `main` - сложнее.
**Меньше места на диске.** Один клон вместо двух. Один `.git` вместо двух.
---
## Сравнение на практических примерах
### Пример 1: Обновление темы Blowfish
**Две папки:**
```bash
cd blog-dev
git submodule update --remote themes/blowfish
git add themes/blowfish
git commit -m "update: Blowfish theme"
git push origin dev
# Проверяешь на dev.blog.ru
# Если всё ок - мержишь
cd ../blog
git checkout dev
git pull origin dev # ← ЛЕГКО ЗАБЫТЬ
git checkout main
git merge dev
git push origin main
```
**Одна папка:**
```bash
cd blog
git checkout dev
git pull origin dev
git submodule update --remote themes/blowfish
git add themes/blowfish
git commit -m "update: Blowfish theme"
git push origin dev
# Проверяешь на dev.blog.ru
# Если всё ок - мержишь
git checkout main
git pull origin main
git merge dev
git push origin main
```
Меньше команд, меньше переходов между папками, меньше шансов забыть `git pull`.
### Пример 2: Правка опечатки в production
Нашёл опечатку на `blog.ru`. Нужно исправить быстро.
**Две папки:**
Опасность: хочется исправить прямо в `blog` (ветка `main`). Это нарушает workflow - все изменения должны идти через `dev`.
Правильно:
```bash
cd blog-dev
git checkout dev
# Исправляешь
git commit -m "fix: опечатка"
git push origin dev
cd ../blog
git checkout main
git pull origin dev # ← опять легко забыть
git merge dev
git push origin main
```
**Одна папка:**
```bash
cd blog
git checkout dev
git pull origin dev
# Исправляешь
git commit -m "fix: опечатка"
git push origin dev
git checkout main
git merge dev
git push origin main
```
Проще, меньше команд, понятнее.
### Пример 3: Долгая работа над статьёй
Пишешь большую статью несколько дней. Между сеансами работы кто-то (или ты сам) запушил другие изменения в `dev`.
**Две папки:**
```bash
cd blog-dev
# День 1: пишешь
git add .
git commit -m "wip: статья"
# День 2: продолжаешь
git pull origin dev # Подтягиваешь чужие изменения
# Пишешь дальше
git add .
git commit -m "feat: закончил статью"
git push origin dev
```
Всё так же как и с одной папкой. Разницы нет.
**Одна папка:**
```bash
cd blog
git checkout dev
# День 1: пишешь
git add .
git commit -m "wip: статья"
# День 2: продолжаешь
git pull origin dev # Подтягиваешь чужие изменения
# Пишешь дальше
git add .
git commit -m "feat: закончил статью"
git push origin dev
```
Идентично. Этот пример работает одинаково в обоих вариантах.
---
## Что выбрать?
**Если ты только начинаешь - сразу делай одну папку.**
Две папки кажутся удобными, но создают проблемы которые регулярно прерывают работу:
- Рассинхронизация веток
- Конфликты при merge
- Когнитивная нагрузка
Одна папка с переключением веток - стандартный Git workflow, проверенный миллионами разработчиков. Требует чуть больше дисциплины (`git checkout dev` вместо `cd blog-dev`), но избавляет от всех проблем выше.
**Золотое правило:** Никогда не редактировать файлы находясь на ветке `main`. Все изменения - через `dev`. Всегда.
---
## Миграция: если начал с двух папок
Если уже работаешь в двух папках - переход простой.
### Шаг 1: Убеждаемся что всё запушено
```bash
# Проверяем обе папки
cd ~/projects/blog-dev
git status
git push origin dev
cd ~/projects/blog
git status
git push origin main
```
### Шаг 2: Синхронизируем ветку dev в основной папке
```bash
cd ~/projects/blog
# Обновляем локальную ветку dev из remote
git checkout dev
git pull origin dev
# Проверяем что всё актуально
git log --oneline -5
# Возвращаемся на main
git checkout main
```
### Шаг 3: Удаляем вторую папку
```bash
# Убеждаемся что в blog-dev нет несохранённых изменений
cd ~/projects/blog-dev
git status
# Должно быть: nothing to commit, working tree clean
# Удаляем папку
cd ~/projects
rm -rf blog-dev
```
### Шаг 4: Проверяем что всё работает
```bash
cd ~/projects/blog
# Переключаемся на dev и запускаем сервер
git checkout dev
hugo server -D --bind 0.0.0.0
# Открываем http://localhost:1313/
# Видим dev версию сайта
```
---
## Новый workflow: шпаргалка
### Создаю статью
```bash
cd ~/projects/blog
git checkout dev
hugo new content posts/название/index.md
# Пишу, сохраняю, проверяю в hugo server
git add .
git commit -m "feat: название статьи"
git push origin dev # → dev.blog.ru
```
### Публикую статью
```bash
git checkout dev
git pull origin dev # Убеждаюсь что dev актуален
git checkout main
git pull origin main # Убеждаюсь что main актуален
git merge dev
git push origin main # → blog.ru
```
### Меняю конфигурацию
```bash
git checkout dev # ВСЕ изменения только через dev!
nano config/params.toml
git add .
git commit -m "feat: изменил конфиг"
git push origin dev # Проверяю на dev.blog.ru
# Если всё ок
git checkout main
git merge dev
git push origin main
```
---
## Что дальше
Workflow выбран, окружения работают. Можно писать статьи.
Но есть ещё одна тема которую стоит разобрать - что делать когда что-то сломалось. Как диагностировать проблемы когда сайт вдруг начал отдавать 503, или SSL перестал работать, или webhook не срабатывает.
В следующей части покажу процесс диагностики на реальном примере - как я чинил `blog.ru` когда он внезапно стал недоступен из интернета.
---
**Рекомендация этой части:**
- Одна папка `~/projects/blog`
- Переключение веток через `git checkout`
- Все изменения через `dev` → merge в `main`
- Никогда не редактировать находясь на `main`
@@ -0,0 +1,634 @@
---
title: "Блог на Hugo в K3s: часть 5 - что делать когда всё внезапно сломалось"
date: 2026-02-17
draft: false
description: "Алгоритм диагностики Hugo блога в K3s за 5 минут: от DNS до пода. Сайт отдаёт 503 — находим причину без паники."
tags: ["kubernetes", "k3s", "traefik", "debugging", "nginx", "troubleshooting"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 5
---
В части 4 мы разобрались с Git workflow. Всё работает: пушишь в `dev` - видишь на тестовом окружении, мержишь в `main` - публикуется на production.
А потом в один прекрасный день открываешь свой сайт и видишь `503 Service Temporarily Unavailable`.
Вчера же все работало! Ты ничего не менял. Что произошло?
Добро пожаловать в мир эксплуатации Kubernetes, где проблемы тоже возникают и требуют системного подхода без паники.
Эта статья - алгоритм диагностики от DNS до пода. Проходишь по шагам сверху вниз, находишь проблему за 5 минут. Не гадаешь, не тыкаешь наугад - работаешь по системе.
---
## Анатомия HTTP запроса в K3s
Прежде чем искать проблему, нужно понять путь запроса от браузера до nginx:
```
Браузер
↓ DNS запрос
DNS сервер (провайдер или Cloudflare)
↓ Возвращает IP адрес
Роутер/Файрвол (OPNsense, MikroTik)
↓ Port Forward 443 → K3s node
MetalLB LoadBalancer
↓ External IP
Traefik Ingress Controller
↓ IngressRoute matching
Kubernetes Service
↓ Endpoint selection
Pod (Nginx контейнер)
↓ Volume mount
NFS хранилище
```
Проблема может быть на любом из этих уровней. Секрет эффективной диагностики - проверять снаружи внутрь, последовательно исключая рабочие компоненты.
Когда тыкаешь наугад, проверяя сначала поды, потом DNS, потом снова поды - тратишь время. Когда идёшь по алгоритму - находишь проблему за минуты.
---
## Шаг 1: DNS - доходит ли домен до твоего IP
Первым делом проверяем что домен резолвится в правильный IP. Без этого дальше проверять бессмысленно - браузер просто не знает куда направлять запрос.
### Проверка
```bash
# Проверяем DNS резолв (используй свой домен)
dig blog.example.com +short
# Альтернатива если dig не установлен
nslookup blog.example.com
```
### Ожидаемый результат
```
77.37.XXX.XXX
```
Должен вернуться твой **публичный IP адрес** (тот который прописан в A-записи у DNS провайдера).
### Что может пойти не так
| Симптом | Причина | Как проверить | Решение |
|---------|---------|---------------|---------|
| Возвращается старый IP | DNS кеш не обновился | `dig blog.example.com @8.8.8.8` | Подожди TTL (обычно 300-3600 сек) |
| `NXDOMAIN` ошибка | Домен не делегирован | Проверь NS записи у регистратора | Настрой DNS правильно |
| Возвращается `127.0.0.1` | Локальный override | `cat /etc/hosts \| grep blog` | Удали строку из /etc/hosts |
| Возвращается несколько IP | Round-robin DNS | Проверь все ли IP твои | Удали лишние A-записи |
Если DNS правильный - идём дальше.
---
## Шаг 2: Внешний доступ - доходит ли запрос до сервера
Теперь проверяем что запрос физически доходит до сервера. DNS может быть правильным, но файрвол может блокировать трафик.
### Проверка
```bash
# Пробуем подключиться извне (важно - НЕ из локальной сети!)
curl -v https://blog.example.com 2>&1 | head -30
```
**Важно:** Запускай эту команду с **внешнего** сервера или используй мобильный интернет. Тест из локальной сети ничего не докажет - можешь обходить файрвол.
### Ситуация А: Connection refused или timeout
```
curl: (7) Failed to connect to blog.example.com port 443: Connection refused
```
Запрос вообще не дошёл до сервера. Проблема на сетевом уровне.
**Возможные причины:**
**1. Порт 443 закрыт на файрволе/роутере**
Проверь Port Forward правила на OPNsense/MikroTik. Должно быть:
```
WAN:443 → 192.168.X.X:443 (IP любой K3s ноды)
```
**2. MetalLB не назначил External IP для Traefik**
```bash
# Проверяем MetalLB
kubectl get svc -n traefik traefik
# Ожидаемый результат
NAME TYPE EXTERNAL-IP PORT(S)
traefik LoadBalancer 192.168.X.X 80:30080/TCP,443:30443/TCP
```
Если `EXTERNAL-IP` показывает `<pending>` - MetalLB не работает или пул IP адресов не настроен.
**3. Traefik под не запущен**
```bash
# Проверяем что Traefik работает
kubectl get pods -n traefik
# Должны быть все Running
NAME READY STATUS
traefik-xxxxxxxxxx-xxxxx 1/1 Running
```
### Ситуация Б: TLS handshake прошёл, но 503
```
< HTTP/2 503
< content-type: text/plain; charset=utf-8
< content-length: 20
no available server
```
Отлично - наша ситуация! Traefik работает, SSL сертификат отдаёт, но дальше запрос упирается в стену.
Сообщение `no available server` означает что Traefik **нашёл роутер**, но **не нашёл живой бэкенд** за ним.
Проблема внутри кластера. Идём глубже.
### Ситуация В: SSL certificate problem
```
curl: (60) SSL certificate problem: unable to get local issuer certificate
```
Сертификат невалидный или не выпущен.
```bash
# Проверяем Certificate объект (используй свой namespace)
kubectl get certificate -n blog
# Должно быть READY=True
NAME READY SECRET AGE
blog-tls True blog-tls 2d
```
Если `READY=False` - cert-manager не смог выпустить сертификат.
```bash
# Смотрим что пошло не так
kubectl describe certificate blog-tls -n blog
# Ищем секцию Events внизу вывода - там описание проблемы
```
### Главное: Запрос доходит до Traefik
```bash
# Проверка (с ВНЕШНЕГО сервера!)
curl -I https://blog.example.com
# Ожидаемый результат (любой из двух)
HTTP/2 200 # Всё работает
HTTP/2 503 # Traefik работает, но бэкенд недоступен
# Если connection refused/timeout - проблема в сети (см. выше)
```
---
## Шаг 3: Traefik - правильно ли маршрутизируется трафик
Traefik получил запрос на твой домен. Что он с ним делает? Смотрим логи.
### Проверка логов Traefik
```bash
# Смотрим последние 50 строк логов Traefik
kubectl logs -n traefik deployment/traefik --tail=50
# Фильтруем только свой домен (убираем шум от других сервисов)
kubectl logs -n traefik deployment/traefik --tail=100 | grep blog.example
```
### Что искать в логах
**Нормальный запрос:**
```json
{
"request": "GET / HTTP/2.0",
"status": 200,
"size": 8994,
"router": "blog-blog-https-xxxxx@kubernetescrd",
"service": "blog-nginx-blog@kubernetescrd",
"backend": "http://10.42.2.40:80",
"duration": 12
}
```
Ключевые поля:
- **router:** Traefik нашёл нужный IngressRoute (`blog-blog-https`)
- **backend:** IP пода nginx куда проксируется запрос (`10.42.2.40:80`)
- **status:** HTTP код ответа от nginx (`200` = всё хорошо)
**Проблемный запрос:**
```json
{
"request": "GET / HTTP/2.0",
"status": 503,
"router": "blog-blog-https-xxxxx@kubernetescrd",
"error": "no available server"
}
```
Traefik нашёл роутер, но поле `backend` отсутствует - под недоступен или не существует.
### Проверяем список IngressRoute
```bash
# Смотрим все IngressRoute в кластере
kubectl get ingressroute -A
# Фильтруем только свой домен
kubectl get ingressroute -A | grep blog.example
```
**Важный момент:** Если один и тот же домен прописан в **двух разных IngressRoute** из разных namespace - Traefik будет балансировать между ними.
Например:
```bash
NAMESPACE NAME AGE
blog blog-https 10d # СТАРЫЙ namespace
blog-new blog-https 2d # НОВЫЙ namespace
```
Оба IngressRoute имеют `match: Host('blog.example.com')`. Traefik видит оба, честно балансирует трафик 50/50.
Если один из бэкендов мёртв - половина запросов уходит в пустоту. 503 через раз.
**Решение:** Удалить старый IngressRoute:
```bash
# Удаляем дубль из старого namespace
kubectl delete ingressroute blog-https blog-http -n blog
```
### Проверяем синтаксис match
Traefik очень требователен к синтаксису. Частая ошибка - забыть backticks или скобки.
**Неправильно:**
```yaml
match: Host(blog.example.com) # Нет backticks
match: Host `blog.example.com` # Нет скобок вокруг Host
match: Host("blog.example.com") # Двойные кавычки вместо backticks
```
**Правильно:**
```yaml
match: Host(`blog.example.com`)
```
Проверяем:
```bash
# Смотрим манифест IngressRoute
kubectl get ingressroute blog-https -n blog -o yaml | grep match:
# Должно быть со скобками и backticks
match: Host(`blog.example.com`)
```
### Главное: Traefik нашёл роутер
```bash
# Проверка
kubectl logs -n traefik deployment/traefik --tail=50 | grep blog.example
# Ожидаемый результат - есть строки с "router": "blog-blog-https"
# Если router не найден - проблема в IngressRoute match синтаксисе
```
---
## Шаг 4: Service - видит ли он поды
Traefik нашёл роутер, проксирует трафик на Service. Но Service может не видеть поды если selector неправильный.
### Проверка endpoints
```bash
# Смотрим endpoints для Service (используй своё имя Service)
kubectl get endpoints nginx -n blog
# Ожидаемый результат - НЕ пустой список IP
NAME ENDPOINTS
nginx 10.42.0.44:80,10.42.2.40:80
```
Если видишь `<none>` - Service не нашёл ни одного пода. Две возможные причины.
### Причина 1: Selector не совпадает с labels
```bash
# Смотрим selector у Service
kubectl get svc nginx -n blog -o yaml | grep -A3 "selector:"
# Вывод
selector:
app: nginx
# Смотрим labels у подов
kubectl get pods -n blog --show-labels | grep nginx
# Вывод
nginx-xxxxxxxxxx-xxxxx 1/1 Running app=nginx-old
```
Видишь проблему? Service ищет `app: nginx`, а под помечен `app: nginx-old`. Не совпадает.
**Решение:** Исправить Deployment или Service чтобы labels совпадали.
### Причина 2: Поды не Running
```bash
# Смотрим статус подов
kubectl get pods -n blog
# Видим
NAME READY STATUS
nginx-xxxxxxxxxx-xxxxx 0/1 CreateContainerError
```
Под существует, но не работает. Service правильно не включает его в endpoints. Идём в следующий шаг - разбираемся почему под не запускается.
### Главное: Service видит поды
```bash
# Проверка
kubectl get endpoints nginx -n blog
# Ожидаемый результат - НЕ пустой
nginx 10.42.0.44:80,10.42.2.40:80
# Если <none> - проблема в селекторах или поды не Running
```
---
## Шаг 5: Pod - что происходит внутри контейнера
Самый глубокий уровень. Под не запускается или падает в цикле перезапусков.
### Проверка статуса подов
```bash
# Смотрим все поды в namespace
kubectl get pods -n blog
# Фильтруем только nginx
kubectl get pods -n blog | grep nginx
```
**Возможные статусы проблем:**
### CreateContainerError
Контейнер вообще не может стартануть. Обычно проблема с volumes или образом.
```bash
# Смотрим детали пода (используй своё имя пода)
kubectl describe pod nginx-xxxxxxxxxx-xxxxx -n blog | tail -30
```
Ищем секцию `Events` внизу вывода. Там будет описание проблемы:
**Пример 1: PVC не примонтировался**
```
Events:
Warning FailedMount MountVolume.SetUp failed for volume "blog-public-pvc":
mount failed: mount.nfs: Connection timed out
```
NFS хранилище недоступно. Возможные причины:
- NFS сервер выключен или перезагружается
- Неправильный IP или путь в PersistentVolume
- Файрвол блокирует NFS трафик (порт 2049)
**Пример 2: Образ не скачался**
```
Events:
Warning Failed Failed to pull image "nginx:latest": rpc error: code = Unknown
```
Контейнер не может скачать образ. Обычно это означает что `imagePullPolicy: Never`, а образ не импортирован на ноду.
```bash
# Проверяем что образ есть на ноде (используй IP своей worker ноды)
ssh user@192.168.X.X "sudo k3s crictl images | grep nginx"
```
Если образа нет - импортируй его через `k3s ctr images import`.
**Пример 3: ConfigMap не найден**
```
Events:
Warning FailedMount ConfigMap "nginx-config" not found
```
Deployment ссылается на несуществующий ConfigMap.
```bash
# Проверяем что ConfigMap существует
kubectl get configmap -n blog | grep nginx-config
```
Если нет - создай или исправь имя в Deployment.
### CrashLoopBackOff
Контейнер запускается, но сразу падает. Смотрим логи **предыдущего** запуска:
```bash
# Логи последнего упавшего контейнера
kubectl logs nginx-xxxxxxxxxx-xxxxx -n blog --previous
```
**Пример: Nginx падает из-за неправильного конфига**
```
nginx: [emerg] unexpected "}" in /etc/nginx/nginx.conf:15
nginx: configuration file /etc/nginx/nginx.conf test failed
```
Синтаксическая ошибка в `nginx.conf`. Проверяем ConfigMap:
```bash
# Смотрим содержимое конфига
kubectl get configmap nginx-config -n blog -o yaml
```
Находим ошибку, исправляем, применяем. Под перезапустится автоматически.
### Главное: Под работает
```bash
# Проверка
kubectl get pods -n blog | grep nginx
# Ожидаемый результат - все Running
nginx-xxxxxxxxxx-xxxxx 1/1 Running 0 2d
# Если не Running - смотри troubleshooting выше
```
---
## Шаг 6: Контент - есть ли файлы для отдачи
Под работает, Service видит его, Traefik проксирует трафик. Но сайт отдаёт `404 Not Found` или пустую страницу.
Проблема: Hugo Builder не записал файлы на NFS или записал не туда.
### Проверка
```bash
# Заходим в под nginx (используй своё имя пода)
kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- sh
# Внутри пода смотрим что примонтировалось
ls -la /usr/share/nginx/html/
# Должен быть index.html и папки posts, tags, etc
```
**Если директория пустая** - Hugo Builder не сработал. Проверяем его логи:
```bash
# Логи Hugo Builder
kubectl logs -n blog deployment/hugo-builder-prod --tail=50
```
Ищем строку `Build successful!` и список созданных файлов. Если её нет:
1. **Webhook не сработал** - проверь настройки webhook в Gitea
2. **Hugo упал с ошибкой** - читай логи выше, смотри на что ругается
3. **Собрал в другую директорию** - проверь переменную `OUTPUT_DIR` в build.sh
### Главное: Контент на месте
```bash
# Проверка (используй своё имя пода)
kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- ls /usr/share/nginx/html/ | head -5
# Ожидаемый результат
index.html
posts/
tags/
categories/
# Если пусто - Hugo Builder не отработал (см. выше)
```
---
## Быстрый чеклист для любой проблемы
Сохрани эту последовательность - она работает для 95% проблем:
```
[ ] DNS: dig домен → правильный IP?
[ ] Сеть: curl -v https://домен → доходит до Traefik?
[ ] MetalLB: kubectl get svc -n traefik → External IP назначен?
[ ] Traefik: kubectl get pods -n traefik → Running?
[ ] IngressRoute: kubectl get ingressroute -A | grep домен → нет дублей?
[ ] Match синтаксис: Host(`домен`) со скобками и backticks?
[ ] Endpoints: kubectl get endpoints -n namespace → не пустые?
[ ] Selector: labels подов совпадают с selector Service?
[ ] Pods: kubectl get pods -n namespace → все Running?
[ ] PVC: kubectl get pvc -n namespace → все Bound?
[ ] Контент: kubectl exec ls /usr/share/nginx/html → файлы есть?
```
Проходишь по списку сверху вниз. Останавливаешься на первом `[ ]` где что-то не так. Чинишь. Проверяешь снова.
Не прыгай хаотично между уровнями. Алгоритм экономит время.
---
## Реальный пример: 503 через раз
Мой сайт отдавал `503` примерно в 50% запросов. Половина запросов работала, половина нет.
Прошёл по алгоритму:
1. ✅ DNS - правильный IP
2. ✅ Сеть - Traefik отвечает
3. ✅ MetalLB - External IP назначен
4. ✅ Traefik - поды Running
5. ❌ IngressRoute - **два роутера на один домен**
```bash
kubectl get ingressroute -A | grep oakazanin
NAMESPACE NAME
blog blog-https # СТАРЫЙ, бэкенд в CreateContainerError
oakazanin blog-https # НОВЫЙ, работает
```
Traefik видел два роутера, честно балансировал трафик 50/50. Каждый второй запрос улетал в мёртвый `blog/nginx`.
Диагноз поставлен за 3 минуты. Лечение - одна команда:
```bash
# Удаляем дубль из старого namespace
kubectl delete ingressroute blog-https blog-http -n blog
```
Мораль: **всегда чисти за собой**. Старые namespace с нерабочими сервисами - источник неочевидных проблем.
---
## Откат и cleanup
Если в процессе диагностики что-то сломал ещё больше - откатываемся:
```bash
# Восстанавливаем предыдущую версию манифеста
kubectl apply -f nginx-deployment.yaml
# Перезапускаем поды принудительно
kubectl rollout restart deployment/nginx -n blog
# Смотрим что изменения применились
kubectl rollout status deployment/nginx -n blog
```
**Золотое правило:** Перед экспериментами делай бэкапы манифестов:
```bash
# Экспортируем текущее состояние с датой
kubectl get deployment,service,ingressroute -n blog -o yaml > backup-$(date +%Y%m%d).yaml
```
---
## Что дальше
Ты умеешь диагностировать проблемы. Но лучше их вообще не создавать.
В следующей части покажу как правильно мигрировать сервисы между namespace - без даунтайма, дублей IngressRoute и других сюрпризов которые приводят к 503.
Разберём реальный пример: переносим Gitea между namespace с NFS данными, получаем новый SSL за 32 секунды, и удаляем старый namespace навсегда.
---
**Стек этой части:**
- Traefik 2.11 IngressRoute
- Kubernetes 1.30 (K3s)
- kubectl CLI
- curl для внешних проверок
- dig для DNS диагностики
@@ -0,0 +1,389 @@
---
title: "Блог на Hugo в K3s: часть 6 - миграция между namespace"
date: 2026-02-18
draft: false
description: "Переносим сервисы между namespace в Kubernetes: экспорт манифестов, миграция NFS данных, новый SSL за 32 секунды. Реальный пример с Gitea."
tags: ["kubernetes", "k3s", "gitea", "devops", "homelab", "namespace"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 6
---
В части 5 выяснили почему сайт отдавал `503` через раз. Виновник - брошенный namespace `blog` с нерабочим nginx, чей IngressRoute всё ещё висел на продакшн домене.
Мораль была проста: **чисти за собой**. Сегодня делаем именно это - переносим Gitea из старого namespace в `oakazanin` и удаляем старый namespace навсегда.
Заодно разберём универсальный алгоритм миграции, который работает для любого сервиса. Показываю на примере Gitea, но всё описанное применимо к любому stateful сервису - PostgreSQL, MySQL, Nextcloud, MinIO. Принципы одинаковые: экспортируй манифесты, поменяй namespace, создай новое перед удалением старого.
---
## Почему вообще нужна миграция между namespace
Namespace в Kubernetes - это логическая изоляция. Со временем они накапливаются: сначала `default`, потом `blog`, потом `public`, потом `oakazanin`. Каждый создавался "быстро, временно, потом разберёмся".
Проблемы начинаются когда:
- Один домен прописан в IngressRoute двух разных namespace
- Старый сервис давно не работает, но его ресурсы висят и путают
- Непонятно где искать логи - в `blog` или `oakazanin`?
Решение - собрать связанные сервисы в один namespace. В нашем случае всё что относится к блогу и его инфраструктуре живёт в `oakazanin`.
---
## Типы сервисов: stateless и stateful
Перед миграцией важно понять с чем имеешь дело.
**Stateless сервисы** - nginx, hugo-builder, любой под без постоянных данных. Миграция тривиальна: меняешь namespace в манифесте, применяешь, удаляешь старое. Данные не теряются потому что их нет.
**Stateful сервисы** - Gitea, базы данных, всё что хранит данные на диске. Здесь нужна осторожность: данные живут на PersistentVolume, который привязан к конкретному namespace через PersistentVolumeClaim.
Наша Gitea - stateful. Репозитории лежат на NFS:
```
192.168.11.30:/export/gitea-data
└── /data/git/repositories/ ← Git репозитории
└── /data/gitea/gitea.db ← База данных SQLite
```
Данные никуда не переносятся - они остаются на NFS. Мы просто создаём новый PV/PVC в целевом namespace, который указывает на тот же NFS путь.
---
## Шаг 1: Убеждаемся что данные целы
Прежде чем трогать что-либо - проверяем что данные на месте:
```bash
# Заходим в работающий под и проверяем репозитории
kubectl exec -n blog deployment/gitea -- find /data -maxdepth 3 -type d
```
Видим структуру:
```
/data/git/repositories/
/data/gitea/gitea.db
/data/gitea/conf
```
Репозитории есть - можно двигаться дальше. Также фиксируем NFS путь:
```bash
# Смотрим откуда PV берёт данные
kubectl get pv gitea-pv -o yaml | grep -A3 "nfs:"
# Вывод
# nfs:
# path: /export/gitea-data
# server: 192.168.11.30
```
---
## Шаг 2: Экспортируем текущие манифесты
```bash
# Создаём папку для бэкапов
mkdir -p ~/k8s-manifests/gitea-migration
# Экспортируем все ресурсы из старого namespace
kubectl get deployment gitea -n blog -o yaml > ~/k8s-manifests/gitea-migration/deployment.yaml
kubectl get service gitea -n blog -o yaml > ~/k8s-manifests/gitea-migration/service.yaml
kubectl get configmap gitea-config -n blog -o yaml > ~/k8s-manifests/gitea-migration/configmap.yaml
kubectl get pvc gitea-pvc -n blog -o yaml > ~/k8s-manifests/gitea-migration/pvc.yaml
kubectl get pv gitea-pv -o yaml > ~/k8s-manifests/gitea-migration/pv.yaml
kubectl get ingressroute gitea -n blog -o yaml > ~/k8s-manifests/gitea-migration/ingressroute.yaml
```
Это страховка - если что-то пойдёт не так, есть откуда восстановиться.
---
## Шаг 3: Готовим чистый манифест для нового namespace
Экспортированные манифесты содержат мусор: `uid`, `resourceVersion`, `creationTimestamp`, `status`. Всё это нужно убрать и поменять namespace.
Для PV и PVC дополнительно меняем имена - убираем префиксы старого namespace:
- `blog-gitea-pv``gitea-pv`
- `blog-gitea-pvc``gitea-pvc`
Собираем всё в один файл `gitea.yaml`:
```yaml
# PersistentVolume - тот же NFS путь, новое имя
apiVersion: v1
kind: PersistentVolume
metadata:
name: gitea-pv
spec:
accessModes:
- ReadWriteMany
capacity:
storage: 10Gi
mountOptions:
- nfsvers=3
- hard
- timeo=600
- retrans=2
nfs:
path: /export/gitea-data # ← тот же путь!
server: 192.168.11.30
persistentVolumeReclaimPolicy: Retain
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: gitea-pvc
namespace: oakazanin # ← новый namespace
---
# PersistentVolumeClaim - новый namespace, привязан к gitea-pv
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-pvc
namespace: oakazanin
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
storageClassName: ""
volumeName: gitea-pv
---
# Certificate - cert-manager выпустит новый
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitea-tls
namespace: oakazanin
spec:
dnsNames:
- git.example.com
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod
secretName: gitea-tls
# ... остальные ресурсы: ConfigMap, Deployment, Service, IngressRoute
```
### Важный момент про SSL сертификат
Есть два подхода:
**Скопировать существующий секрет:**
```bash
# Копируем секрет из старого namespace в новый
kubectl get secret gitea-tls -n blog -o yaml | \
sed 's/namespace: blog/namespace: oakazanin/' | \
kubectl apply -f -
```
Плюс - мгновенно, нет даунтайма по SSL. Минус - тащим старый секрет.
**Дать cert-manager выпустить новый:**
Просто создаём Certificate объект в новом namespace. cert-manager сам выпустит сертификат через Let's Encrypt за 30-60 секунд.
Выбираем второй вариант - чистое решение без наследия.
---
## Шаг 4: Применяем в новом namespace
```bash
# Применяем манифест
kubectl apply -f gitea.yaml
```
Проверяем что всё поднялось:
```bash
# PVC привязался к PV?
kubectl get pv gitea-pv
kubectl get pvc gitea-pvc -n oakazanin
# Pod запустился с данными?
kubectl get pods -n oakazanin | grep gitea
# Сертификат выпущен?
kubectl get certificate gitea-tls -n oakazanin
```
Ожидаемый результат:
```
gitea-pv Bound oakazanin/gitea-pvc
gitea-pvc Bound gitea-pv
gitea-... 1/1 Running
gitea-tls True
```
Проверяем что Gitea открывается и данные на месте:
```bash
# Проверяем доступность
curl -s -o /dev/null -w "%{http_code}" https://git.example.com
# 200
```
---
## Шаг 5: Останавливаем старый сервис
Только после того как убедились что новый работает:
```bash
# Останавливаем Gitea в старом namespace (не удаляем - просто 0 реплик)
kubectl scale deployment gitea -n blog --replicas=0
# Ждём несколько минут, проверяем что сайт всё ещё работает
curl -s -o /dev/null -w "%{http_code}" https://git.example.com
# 200 - трафик идёт через новый namespace
```
Масштабирование до 0 реплик - страховка. Если что-то пошло не так, поднимаем обратно за секунду:
```bash
kubectl scale deployment gitea -n blog --replicas=1
```
---
## Шаг 6: Зачищаем старый namespace
Когда убедились что всё работает - удаляем в правильном порядке:
```bash
# Сначала удаляем workloads
kubectl delete deployment gitea -n blog
kubectl delete service gitea -n blog
kubectl delete configmap gitea-config -n blog
kubectl delete secret gitea-tls -n blog
kubectl delete ingressroute gitea gitea-http -n blog
# Потом PVC (он держит PV)
kubectl delete pvc gitea-pvc -n blog
# Потом PV
kubectl delete pv blog-gitea-pv
# Последним - namespace
kubectl delete namespace blog
```
### Почему такой порядок?
PVC нельзя удалить пока его использует Pod - K8s заблокирует операцию через finalizer `kubernetes.io/pvc-protection`. Поэтому сначала удаляем Deployment, ждём пока Pod завершится, потом PVC.
PV с политикой `Retain` после удаления переходит в статус `Released`, но **данные на NFS остаются нетронутыми**. Это страховка от случайного удаления.
---
## Финальная проверка
```bash
# Namespace удалён?
kubectl get namespace | grep blog
# (пусто)
# Старый PV удалён?
kubectl get pv | grep blog
# (пусто)
# Новый PV работает?
kubectl get pv gitea-pv
# gitea-pv Bound oakazanin/gitea-pvc
# Все сервисы живые?
curl -s -o /dev/null -w "%{http_code}" https://blog.example.com # 200
curl -s -o /dev/null -w "%{http_code}" https://git.example.com # 200
```
---
## Универсальность подхода
Этот алгоритм работает не только для Gitea. Те же шаги применимы к любым stateful сервисам:
**PostgreSQL/MySQL:**
- Экспортируешь Deployment, Service, PVC
- Меняешь namespace
- PV указывает на тот же NFS путь с базой данных
- Данные остаются нетронутыми
**Nextcloud:**
- Аналогично - файлы на NFS не переносятся
- Только манифесты меняют namespace
- Zero downtime если создаёшь новое до удаления старого
**MinIO (S3-хранилище):**
- Stateful, работает через PV/PVC
- Те же принципы - новый namespace, тот же NFS путь
**Stateless сервисы (nginx, API):**
- Ещё проще - нет PV/PVC вообще
- Только Deployment + Service, меняешь namespace, готово
Главный принцип: **данные живут на PV, который привязан к NFS. Namespace меняется, путь на NFS остаётся.**
## Универсальный чеклист миграции
```
Подготовка
[ ] Проверить данные в поде (find /data)
[ ] Зафиксировать NFS путь (kubectl get pv -o yaml)
[ ] Экспортировать манифесты в отдельную папку
Создание в новом namespace
[ ] Убрать служебные поля (uid, resourceVersion, status)
[ ] Поменять namespace во всех манифестах
[ ] Переименовать PV/PVC (убрать старые префиксы)
[ ] Добавить Certificate объект (не копировать секрет)
[ ] Применить манифест
[ ] Проверить PVC Bound, Pod Running, Certificate True
Переключение
[ ] Убедиться что новый сервис работает (curl HTTP 200)
[ ] Остановить старый (scale --replicas=0)
[ ] Подождать 5 минут, проверить снова
Зачистка
[ ] Удалить Deployment, Service, ConfigMap, Secret
[ ] Удалить IngressRoute
[ ] Удалить PVC
[ ] Удалить PV
[ ] Удалить namespace
[ ] Финальная проверка всех сервисов
```
---
## Итог
Вся миграция Gitea заняла около 10 минут. Даунтайм - 0 секунд: новый под поднялся раньше чем остановили старый, сертификат выпустился за ~32 секунды, данные подхватились с NFS автоматически.
Главные принципы которые работают:
**Один namespace - один проект.** Все сервисы блога живут в одном namespace. Никакого разброса по трём разным местам.
**Сначала создай, потом удаляй.** Никогда не удаляй старое до того как убедился что новое работает.
**Retain политика для PV.** Данные на NFS переживут любые эксперименты с namespace.
**Чисти за собой.** Брошенные IngressRoute и namespace - источник неочевидных проблем в самый неподходящий момент.
---
## Что дальше
Блог работает, два окружения настроены, проблемы диагностируются за минуты, namespace чистый.
В следующей части добавим лайки и просмотры через Firebase Realtime Database и Cloud Firestore, исправим все ошибки интеграции с Blowfish, и обновим Hugo до 0.155 чтобы inline partials заработали.
---
**Стек этой части:**
- Kubernetes 1.30 (K3s)
- Gitea 1.21.11
- NFS для статичных данных
- cert-manager + Let's Encrypt
- kubectl для миграции
@@ -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: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -2,14 +2,11 @@
title: "K3s HA для homelab: архитектура без боли"
date: 2025-10-14
draft: false
description: "Полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Разбираем архитектуру K3s HA кластера: почему именно 3 master ноды, зачем embedded etcd и сколько ресурсов закладывать."
summary: "Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. K3s решает эту проблему - полноценный Kubernetes в 50MB. Разберём архитектуру HA кластера без боли."
description: "K3s HA кластер для homelab: архитектура с 3 master нодами и embedded etcd. 50MB бинарник вместо 1.5GB зависимостей — почему это работает."
tags: ["kubernetes", "k3s", "homelab", "proxmox", "architecture", "ha", "devops"]
categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"]
series_order: 1
# seriesOpened: false
# showTableOfContents: true
showAuthor: true
---
Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. Знакомо? K3s решает эту проблему - полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Но без правильного планирования вы получите нестабильную конструкцию, которая падает в самый неподходящий момент.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -1,14 +1,12 @@
---
title: "Подготовить инфраструктуру для K3s в Proxmox"
title: "K3s HA для homelab: Готовим инфраструктуру в Proxmox"
date: 2025-10-21
draft: false
description: "Создаём 5 VM в Proxmox с Debian 12, настраиваем сеть, отключаем swap и готовим систему для K3s. Пошаговая инструкция без сюрпризов."
summary: "Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. Подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов."
description: "Готовим инфраструктуру для K3s в Proxmox: 5 VM с Debian 12, настройка сети, отключение swap. Пошаговая инструкция без сюрпризов."
tags: ["kubernetes", "k3s", "homelab", "proxmox", "infrastructure", "debian", "devops"]
categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"]
series_order: 2
# seriesOpened: true
showTableOfContents: true
---
Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. В этой статье подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

@@ -1,14 +1,12 @@
---
title: "Установить K3s HA кластер"
title: "K3s HA для homelab: Ставим K3s HA кластер"
date: 2025-11-02
draft: false
description: "Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер. Устанавливаем K3s на 3 master и 2 worker ноды, настраиваем kubectl."
summary: "Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер."
description: "Устанавливаем K3s HA кластер: 3 master и 2 worker ноды за 15 минут. Один curl-скрипт на ноду — и кластер готов к работе."
tags: ["kubernetes", "k3s", "homelab", "installation", "ha", "etcd", "devops"]
categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"]
series_order: 3
# seriesOpened: true
showTableOfContents: true
---
Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@@ -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
+3
View File
@@ -0,0 +1,3 @@
---
title: "Теги"
---
+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"
}
}
+5 -3
View File
@@ -23,7 +23,7 @@ article:
other: "{{ .Count }} нравится"
part_of_series: "Эта статья — часть серии."
part: "Часть"
this_article: "Читаешь сейчас"
this_article: "Ты уже здесь"
related_articles: "Статьи по теме"
reply_by_email: "Ответить по электронной почте"
@@ -38,8 +38,8 @@ author:
byline_title: "Автор"
code:
copy: "Копировать"
copied: "Скопировано"
copy: "Копи."
copied: "ОК"
error:
404_title: "Страница не найдена: в замешательстве:"
@@ -72,9 +72,11 @@ sharing:
pinterest: "Добавить в Pinterest"
reddit: "Отправить в Reddit"
twitter: "Твитнуть в Twitter"
telegram: "Отправить в Телегу"
shortcode:
recent_articles: "Недавние"
posts: "Статьи"
recent:
show_more: "Показать еще"
+3
View File
@@ -0,0 +1,3 @@
<!-- Comments Widget -->
<script defer src="https://comments.oakazanin.ru/comentario.js"></script>
<comentario-comments no-fonts="true" lang="ru" theme="light"></comentario-comments>
+17
View File
@@ -0,0 +1,17 @@
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=106995329', 'ym');
ym(106995329, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</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 -}}
-582
View File
@@ -1,582 +0,0 @@
<!doctype html>
<html
lang="ru"
dir="ltr"
class="scroll-smooth"
data-default-appearance="dark"
data-auto-appearance="false"><head><script src="/livereload.js?mindelay=10&amp;v=2&amp;port=1313&amp;path=livereload" data-no-instant defer></script>
<meta charset="utf-8">
<meta http-equiv="content-language" content="ru">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="theme-color">
<title>404 Page not found &middot; Олег Казанин</title>
<meta name="title" content="404 Page not found &middot; Олег Казанин">
<link rel="canonical" href="http://192.168.11.190:1313/404.html">
<meta name="author" content="Олег Казанин">
<link href="https://t.me/oa_msk" rel="me">
<link href="https://oakazanin.ru/" rel="me">
<link href="https://git.jn4.ru/astronit" rel="me">
<link href="https://obrtv.ru/a/chiefengineer" rel="me">
<meta property="og:url" content="http://192.168.11.190:1313/404.html">
<meta property="og:site_name" content="Олег Казанин">
<meta property="og:title" content="404 Page not found">
<meta property="og:locale" content="ru">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="404 Page not found">
<link
type="text/css"
rel="stylesheet"
href="/css/main.bundle.min.b5bc3f4587655153415b7825fd1716f97df9c99f87c23f81146f4dd4f9f49f04ad031e3b5f0ee5c0416707a1455ca2cfa8fc1f3a19ece1486b0126dcd310a63e.css"
integrity="sha512-tbw/RYdlUVNBW3gl/RcW&#43;X35yZ&#43;Hwj&#43;BFG9N1Pn0nwStAx47Xw7lwEFnB6FFXKLPqPwfOhns4UhrASbc0xCmPg==">
<script
type="text/javascript"
src="/js/appearance.min.a0c4d367419d691bf95fc98ffcaf55ce81db3412c3dfbd6c4fbe968f56f77347f5a8512b0916a65a5f496dbec1ef0590dbadcf2fbd0de3c919e525f11c32d0e3.js"
integrity="sha512-oMTTZ0GdaRv5X8mP/K9VzoHbNBLD371sT76Wj1b3c0f1qFErCRamWl9Jbb7B7wWQ263PL70N48kZ5SXxHDLQ4w=="></script>
<script src="/lib/zoom/zoom.min.umd.a527109b68c082a70f3697716dd72a9d5aa8b545cf800cecbbc7399f2ca6f6e0ce3e431f2062b48bbfa47c9ea42822714060bef309be073f49b9c0e30d318d7b.js" integrity="sha512-pScQm2jAgqcPNpdxbdcqnVqotUXPgAzsu8c5nyym9uDOPkMfIGK0i7&#43;kfJ6kKCJxQGC&#43;8wm&#43;Bz9JucDjDTGNew=="></script>
<script
defer
type="text/javascript"
id="script-bundle"
src="/js/main.bundle.min.bdda7dece6cbaf08deef7d254f7f842f3261c2524d247905127c9a20decc03f1011a2950048464c79272c1ce0705a49a41147f39f2b95163bb71d404b33263ef.js"
integrity="sha512-vdp97ObLrwje730lT3&#43;ELzJhwlJNJHkFEnyaIN7MA/EBGilQBIRkx5Jywc4HBaSaQRR/OfK5UWO7cdQEszJj7w=="
data-copy="Копировать"
data-copied="Скопировано"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<script type="application/ld+json">
[{
"@context": "https://schema.org",
"@type": "Article",
"articleSection": "Олег Казанин",
"name": "404 Page not found",
"headline": "404 Page not found",
"inLanguage": "ru",
"url" : "http://192.168.11.190:1313/404.html",
"author" : {
"@type": "Person",
"name": "Олег Казанин"
},
"mainEntityOfPage": "true",
"wordCount": "0"
}]
</script>
</head>
<body class="flex flex-col h-screen m-auto leading-7 max-w-7xl px-6 sm:px-14 md:px-24 lg:px-32 text-lg bg-neutral text-neutral-900 dark:bg-neutral-800 dark:text-neutral bf-scrollbar">
<div id="the-top" class="absolute flex self-center">
<a
class="px-3 py-1 text-sm -translate-y-8 rounded-b-lg bg-primary-200 focus:translate-y-0 dark:bg-neutral-600"
href="#main-content">
<span class="font-bold text-primary-600 pe-2 dark:text-primary-400">&darr;</span>
Перейти к основному содержимому
</a>
</div>
<div class="min-h-[148px]"></div>
<div class="fixed inset-x-0 z-100">
<div
id="menu-blur"
class="absolute opacity-0 inset-x-0 top-0 h-full single_hero_background nozoom backdrop-blur-2xl shadow-2xl bg-neutral/25 dark:bg-neutral-800/25"></div>
<div class="relative m-auto leading-7 max-w-7xl px-6 sm:px-14 md:px-24 lg:px-32">
<div class="main-menu flex items-center w-full gap-2 p-1 pl-0">
<div>
<a href="/" class="flex">
<span class="sr-only">Олег Казанин</span>
<img
src="/img/logo.png"
width="285"
height="175"
class="logo max-h-20 max-w-20 object-scale-down object-left nozoom"
alt="">
</a>
</div>
<div class="flex items-center ms-auto">
<div class="hidden md:flex">
<nav class="flex items-center gap-x-5 h-12">
<button
id="search-button"
aria-label="Search"
class="text-base bf-icon-color-hover"
title="Поиск (/)">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</button>
<div class="flex items-center">
<button
id="appearance-switcher"
aria-label="Dark mode switcher"
type="button"
class="text-base bf-icon-color-hover">
<div class="flex items-center justify-center dark:hidden">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/></svg>
</span>
</div>
<div class="items-center justify-center hidden dark:flex">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/></svg>
</span>
</div>
</button>
</div>
</nav>
</div>
<div class="flex md:hidden">
<div class="flex items-center h-14 gap-4">
<button
id="search-button-mobile"
aria-label="Search"
class="flex items-center justify-center bf-icon-color-hover"
title="Поиск (/)">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</button>
<button
id="appearance-switcher-mobile"
type="button"
aria-label="Dark mode switcher"
class="flex items-center justify-center text-neutral-900 hover:text-primary-600 dark:text-neutral-200 dark:hover:text-primary-400">
<div class="dark:hidden">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/></svg>
</span>
</div>
<div class="hidden dark:block">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/></svg>
</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script
type="text/javascript"
src="/js/background-blur.min.605b3b942818f0ab5a717ae446135ec46b8ee5a2ad12ae56fb90dc2a76ce30c388f9fec8bcc18db15bd47e3fa8a09d779fa12aa9c184cf614a315bc72c6c163d.js"
integrity="sha512-YFs7lCgY8KtacXrkRhNexGuO5aKtEq5W&#43;5DcKnbOMMOI&#43;f7IvMGNsVvUfj&#43;ooJ13n6EqqcGEz2FKMVvHLGwWPQ=="
data-blur-id="menu-blur"></script>
<div class="relative flex flex-col grow">
<main id="main-content" class="grow">
<h1 class="mb-3 text-4xl font-extrabold">Страница не найдена: в замешательстве:</h1>
<p class="mt-8 mb-12 text-neutral-400 dark:text-neutral-500">
Ошибка 404
</p>
<div class="prose dark:prose-invert">
<p>Похоже, запрошенная вами страница не существует.</p>
</div>
<div
id="scroll-to-top"
class="fixed bottom-6 end-6 z-50 transform translate-y-4 opacity-0 duration-200">
<a
href="#the-top"
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-neutral/50 text-xl text-neutral-700 hover:text-primary-600 dark:bg-neutral-800/50 dark:text-neutral dark:hover:text-primary-400"
aria-label="Пролистать наверх"
title="Пролистать наверх">
&uarr;
</a>
</div>
</main><footer id="site-footer" class="py-10 print:hidden">
<nav class="flex flex-row pb-4 text-base font-medium text-neutral-500 dark:text-neutral-400 ">
<ul class="flex list-none flex-col sm:flex-row">
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/tags/"
title="Tags">
Теги
</a>
</li>
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/categories/"
title="Categories">
Категории
</a>
</li>
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/authors/"
title="Authors">
Авторы
</a>
</li>
</ul>
</nav>
<div class="flex items-center justify-between">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
&copy;
2026
Олег Казанин
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Работает на <a class="hover:underline hover:decoration-primary-400 hover:text-primary-500"
href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a> &amp; <a class="hover:underline hover:decoration-primary-400 hover:text-primary-500"
href="https://blowfish.page/" target="_blank" rel="noopener noreferrer">Blowfish</a>
</p>
</div>
<script>
mediumZoom(document.querySelectorAll("img:not(.nozoom)"), {
margin: 24,
background: "rgba(0,0,0,0.5)",
scrollOffset: 0,
});
</script>
</footer>
<div
id="search-wrapper"
class="invisible fixed inset-0 flex h-screen w-screen cursor-default flex-col bg-neutral-500/50 p-4 backdrop-blur-sm dark:bg-neutral-900/50 sm:p-6 md:p-[10vh] lg:p-[12vh] z-500"
data-url="http://192.168.11.190:1313/">
<div
id="search-modal"
class="flex flex-col w-full max-w-3xl min-h-0 mx-auto border rounded-md shadow-lg top-20 border-neutral-200 bg-neutral dark:border-neutral-700 dark:bg-neutral-800">
<header class="relative z-10 flex items-center justify-between flex-none px-2">
<form class="flex items-center flex-auto min-w-0">
<div class="flex items-center justify-center w-8 h-8 text-neutral-400">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</div>
<input
type="search"
id="search-query"
class="flex flex-auto h-12 mx-1 bg-transparent appearance-none focus:outline-dotted focus:outline-2 focus:outline-transparent"
placeholder="Поиск"
tabindex="0">
</form>
<button
id="close-search-button"
class="flex items-center justify-center w-8 h-8 text-neutral-700 hover:text-primary-600 dark:text-neutral dark:hover:text-primary-400"
title="Закрыть (Esc)">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z"/></svg>
</span>
</button>
</header>
<section class="flex-auto px-2 overflow-auto">
<ul id="search-results">
</ul>
</section>
</div>
</div>
</div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-622
View File
@@ -1,622 +0,0 @@
<!doctype html>
<html
lang="ru"
dir="ltr"
class="scroll-smooth"
data-default-appearance="dark"
data-auto-appearance="false"><head><script src="/livereload.js?mindelay=10&amp;v=2&amp;port=1313&amp;path=livereload" data-no-instant defer></script>
<meta charset="utf-8">
<meta http-equiv="content-language" content="ru">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="theme-color">
<title>Authors &middot; Олег Казанин</title>
<meta name="title" content="Authors &middot; Олег Казанин">
<link rel="canonical" href="http://192.168.11.190:1313/authors/">
<link rel="alternate" type="application/rss+xml" href="/authors/index.xml" title="Олег Казанин" />
<meta name="author" content="Олег Казанин">
<link href="https://t.me/oa_msk" rel="me">
<link href="https://oakazanin.ru/" rel="me">
<link href="https://git.jn4.ru/astronit" rel="me">
<link href="https://obrtv.ru/a/chiefengineer" rel="me">
<meta property="og:url" content="http://192.168.11.190:1313/authors/">
<meta property="og:site_name" content="Олег Казанин">
<meta property="og:title" content="Authors">
<meta property="og:locale" content="ru">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Authors">
<link
type="text/css"
rel="stylesheet"
href="/css/main.bundle.min.b5bc3f4587655153415b7825fd1716f97df9c99f87c23f81146f4dd4f9f49f04ad031e3b5f0ee5c0416707a1455ca2cfa8fc1f3a19ece1486b0126dcd310a63e.css"
integrity="sha512-tbw/RYdlUVNBW3gl/RcW&#43;X35yZ&#43;Hwj&#43;BFG9N1Pn0nwStAx47Xw7lwEFnB6FFXKLPqPwfOhns4UhrASbc0xCmPg==">
<script
type="text/javascript"
src="/js/appearance.min.a0c4d367419d691bf95fc98ffcaf55ce81db3412c3dfbd6c4fbe968f56f77347f5a8512b0916a65a5f496dbec1ef0590dbadcf2fbd0de3c919e525f11c32d0e3.js"
integrity="sha512-oMTTZ0GdaRv5X8mP/K9VzoHbNBLD371sT76Wj1b3c0f1qFErCRamWl9Jbb7B7wWQ263PL70N48kZ5SXxHDLQ4w=="></script>
<script src="/lib/zoom/zoom.min.umd.a527109b68c082a70f3697716dd72a9d5aa8b545cf800cecbbc7399f2ca6f6e0ce3e431f2062b48bbfa47c9ea42822714060bef309be073f49b9c0e30d318d7b.js" integrity="sha512-pScQm2jAgqcPNpdxbdcqnVqotUXPgAzsu8c5nyym9uDOPkMfIGK0i7&#43;kfJ6kKCJxQGC&#43;8wm&#43;Bz9JucDjDTGNew=="></script>
<script
defer
type="text/javascript"
id="script-bundle"
src="/js/main.bundle.min.bdda7dece6cbaf08deef7d254f7f842f3261c2524d247905127c9a20decc03f1011a2950048464c79272c1ce0705a49a41147f39f2b95163bb71d404b33263ef.js"
integrity="sha512-vdp97ObLrwje730lT3&#43;ELzJhwlJNJHkFEnyaIN7MA/EBGilQBIRkx5Jywc4HBaSaQRR/OfK5UWO7cdQEszJj7w=="
data-copy="Копировать"
data-copied="Скопировано"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<script type="application/ld+json">
[{
"@context": "https://schema.org",
"@type": "Article",
"articleSection": "Authors",
"name": "Authors",
"headline": "Authors",
"inLanguage": "ru",
"url" : "http://192.168.11.190:1313/authors/",
"author" : {
"@type": "Person",
"name": "Олег Казанин"
},
"mainEntityOfPage": "true",
"wordCount": "0"
}]
</script>
</head>
<body class="flex flex-col h-screen m-auto leading-7 max-w-7xl px-6 sm:px-14 md:px-24 lg:px-32 text-lg bg-neutral text-neutral-900 dark:bg-neutral-800 dark:text-neutral bf-scrollbar">
<div id="the-top" class="absolute flex self-center">
<a
class="px-3 py-1 text-sm -translate-y-8 rounded-b-lg bg-primary-200 focus:translate-y-0 dark:bg-neutral-600"
href="#main-content">
<span class="font-bold text-primary-600 pe-2 dark:text-primary-400">&darr;</span>
Перейти к основному содержимому
</a>
</div>
<div class="min-h-[148px]"></div>
<div class="fixed inset-x-0 z-100">
<div
id="menu-blur"
class="absolute opacity-0 inset-x-0 top-0 h-full single_hero_background nozoom backdrop-blur-2xl shadow-2xl bg-neutral/25 dark:bg-neutral-800/25"></div>
<div class="relative m-auto leading-7 max-w-7xl px-6 sm:px-14 md:px-24 lg:px-32">
<div class="main-menu flex items-center w-full gap-2 p-1 pl-0">
<div>
<a href="/" class="flex">
<span class="sr-only">Олег Казанин</span>
<img
src="/img/logo.png"
width="285"
height="175"
class="logo max-h-20 max-w-20 object-scale-down object-left nozoom"
alt="">
</a>
</div>
<div class="flex items-center ms-auto">
<div class="hidden md:flex">
<nav class="flex items-center gap-x-5 h-12">
<button
id="search-button"
aria-label="Search"
class="text-base bf-icon-color-hover"
title="Поиск (/)">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</button>
<div class="flex items-center">
<button
id="appearance-switcher"
aria-label="Dark mode switcher"
type="button"
class="text-base bf-icon-color-hover">
<div class="flex items-center justify-center dark:hidden">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/></svg>
</span>
</div>
<div class="items-center justify-center hidden dark:flex">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/></svg>
</span>
</div>
</button>
</div>
</nav>
</div>
<div class="flex md:hidden">
<div class="flex items-center h-14 gap-4">
<button
id="search-button-mobile"
aria-label="Search"
class="flex items-center justify-center bf-icon-color-hover"
title="Поиск (/)">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</button>
<button
id="appearance-switcher-mobile"
type="button"
aria-label="Dark mode switcher"
class="flex items-center justify-center text-neutral-900 hover:text-primary-600 dark:text-neutral-200 dark:hover:text-primary-400">
<div class="dark:hidden">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/></svg>
</span>
</div>
<div class="hidden dark:block">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/></svg>
</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script
type="text/javascript"
src="/js/background-blur.min.605b3b942818f0ab5a717ae446135ec46b8ee5a2ad12ae56fb90dc2a76ce30c388f9fec8bcc18db15bd47e3fa8a09d779fa12aa9c184cf614a315bc72c6c163d.js"
integrity="sha512-YFs7lCgY8KtacXrkRhNexGuO5aKtEq5W&#43;5DcKnbOMMOI&#43;f7IvMGNsVvUfj&#43;ooJ13n6EqqcGEz2FKMVvHLGwWPQ=="
data-blur-id="menu-blur"></script>
<div class="relative flex flex-col grow">
<main id="main-content" class="grow">
<header class="mt-5">
<h1 class="mt-5 text-4xl font-extrabold text-neutral-900 dark:text-neutral">Authors</h1>
<div class="mt-1 mb-2 text-base text-neutral-500 dark:text-neutral-400 print:hidden">
<div class="flex flex-row flex-wrap items-center">
</div>
</div>
</header>
<section class="flex flex-wrap max-w-prose -mx-2 overflow-hidden">
</section>
<div
id="scroll-to-top"
class="fixed bottom-6 end-6 z-50 transform translate-y-4 opacity-0 duration-200">
<a
href="#the-top"
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-neutral/50 text-xl text-neutral-700 hover:text-primary-600 dark:bg-neutral-800/50 dark:text-neutral dark:hover:text-primary-400"
aria-label="Пролистать наверх"
title="Пролистать наверх">
&uarr;
</a>
</div>
</main><footer id="site-footer" class="py-10 print:hidden">
<nav class="flex flex-row pb-4 text-base font-medium text-neutral-500 dark:text-neutral-400 ">
<ul class="flex list-none flex-col sm:flex-row">
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/tags/"
title="Tags">
Теги
</a>
</li>
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/categories/"
title="Categories">
Категории
</a>
</li>
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/authors/"
title="Authors">
Авторы
</a>
</li>
</ul>
</nav>
<div class="flex items-center justify-between">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
&copy;
2026
Олег Казанин
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Работает на <a class="hover:underline hover:decoration-primary-400 hover:text-primary-500"
href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a> &amp; <a class="hover:underline hover:decoration-primary-400 hover:text-primary-500"
href="https://blowfish.page/" target="_blank" rel="noopener noreferrer">Blowfish</a>
</p>
</div>
<script>
mediumZoom(document.querySelectorAll("img:not(.nozoom)"), {
margin: 24,
background: "rgba(0,0,0,0.5)",
scrollOffset: 0,
});
</script>
</footer>
<div
id="search-wrapper"
class="invisible fixed inset-0 flex h-screen w-screen cursor-default flex-col bg-neutral-500/50 p-4 backdrop-blur-sm dark:bg-neutral-900/50 sm:p-6 md:p-[10vh] lg:p-[12vh] z-500"
data-url="http://192.168.11.190:1313/">
<div
id="search-modal"
class="flex flex-col w-full max-w-3xl min-h-0 mx-auto border rounded-md shadow-lg top-20 border-neutral-200 bg-neutral dark:border-neutral-700 dark:bg-neutral-800">
<header class="relative z-10 flex items-center justify-between flex-none px-2">
<form class="flex items-center flex-auto min-w-0">
<div class="flex items-center justify-center w-8 h-8 text-neutral-400">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</div>
<input
type="search"
id="search-query"
class="flex flex-auto h-12 mx-1 bg-transparent appearance-none focus:outline-dotted focus:outline-2 focus:outline-transparent"
placeholder="Поиск"
tabindex="0">
</form>
<button
id="close-search-button"
class="flex items-center justify-center w-8 h-8 text-neutral-700 hover:text-primary-600 dark:text-neutral dark:hover:text-primary-400"
title="Закрыть (Esc)">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z"/></svg>
</span>
</button>
</header>
<section class="flex-auto px-2 overflow-auto">
<ul id="search-results">
</ul>
</section>
</div>
</div>
</div>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Authors on Олег Казанин</title>
<link>http://192.168.11.190:1313/authors/</link>
<description>Recent content in Authors on Олег Казанин</description>
<generator>Hugo -- gohugo.io</generator>
<language>ru</language>
<managingEditor>oakazanin@ya.ru (Олег Казанин)</managingEditor>
<webMaster>oakazanin@ya.ru (Олег Казанин)</webMaster>
<copyright>© 2026 Олег Казанин</copyright>
<atom:link href="http://192.168.11.190:1313/authors/index.xml" rel="self" type="application/rss+xml" />
</channel>
</rss>
-622
View File
@@ -1,622 +0,0 @@
<!doctype html>
<html
lang="ru"
dir="ltr"
class="scroll-smooth"
data-default-appearance="dark"
data-auto-appearance="false"><head><script src="/livereload.js?mindelay=10&amp;v=2&amp;port=1313&amp;path=livereload" data-no-instant defer></script>
<meta charset="utf-8">
<meta http-equiv="content-language" content="ru">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="theme-color">
<title>Categories &middot; Олег Казанин</title>
<meta name="title" content="Categories &middot; Олег Казанин">
<link rel="canonical" href="http://192.168.11.190:1313/categories/">
<link rel="alternate" type="application/rss+xml" href="/categories/index.xml" title="Олег Казанин" />
<meta name="author" content="Олег Казанин">
<link href="https://t.me/oa_msk" rel="me">
<link href="https://oakazanin.ru/" rel="me">
<link href="https://git.jn4.ru/astronit" rel="me">
<link href="https://obrtv.ru/a/chiefengineer" rel="me">
<meta property="og:url" content="http://192.168.11.190:1313/categories/">
<meta property="og:site_name" content="Олег Казанин">
<meta property="og:title" content="Categories">
<meta property="og:locale" content="ru">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Categories">
<link
type="text/css"
rel="stylesheet"
href="/css/main.bundle.min.b5bc3f4587655153415b7825fd1716f97df9c99f87c23f81146f4dd4f9f49f04ad031e3b5f0ee5c0416707a1455ca2cfa8fc1f3a19ece1486b0126dcd310a63e.css"
integrity="sha512-tbw/RYdlUVNBW3gl/RcW&#43;X35yZ&#43;Hwj&#43;BFG9N1Pn0nwStAx47Xw7lwEFnB6FFXKLPqPwfOhns4UhrASbc0xCmPg==">
<script
type="text/javascript"
src="/js/appearance.min.a0c4d367419d691bf95fc98ffcaf55ce81db3412c3dfbd6c4fbe968f56f77347f5a8512b0916a65a5f496dbec1ef0590dbadcf2fbd0de3c919e525f11c32d0e3.js"
integrity="sha512-oMTTZ0GdaRv5X8mP/K9VzoHbNBLD371sT76Wj1b3c0f1qFErCRamWl9Jbb7B7wWQ263PL70N48kZ5SXxHDLQ4w=="></script>
<script src="/lib/zoom/zoom.min.umd.a527109b68c082a70f3697716dd72a9d5aa8b545cf800cecbbc7399f2ca6f6e0ce3e431f2062b48bbfa47c9ea42822714060bef309be073f49b9c0e30d318d7b.js" integrity="sha512-pScQm2jAgqcPNpdxbdcqnVqotUXPgAzsu8c5nyym9uDOPkMfIGK0i7&#43;kfJ6kKCJxQGC&#43;8wm&#43;Bz9JucDjDTGNew=="></script>
<script
defer
type="text/javascript"
id="script-bundle"
src="/js/main.bundle.min.bdda7dece6cbaf08deef7d254f7f842f3261c2524d247905127c9a20decc03f1011a2950048464c79272c1ce0705a49a41147f39f2b95163bb71d404b33263ef.js"
integrity="sha512-vdp97ObLrwje730lT3&#43;ELzJhwlJNJHkFEnyaIN7MA/EBGilQBIRkx5Jywc4HBaSaQRR/OfK5UWO7cdQEszJj7w=="
data-copy="Копировать"
data-copied="Скопировано"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<script type="application/ld+json">
[{
"@context": "https://schema.org",
"@type": "Article",
"articleSection": "Categories",
"name": "Categories",
"headline": "Categories",
"inLanguage": "ru",
"url" : "http://192.168.11.190:1313/categories/",
"author" : {
"@type": "Person",
"name": "Олег Казанин"
},
"mainEntityOfPage": "true",
"wordCount": "0"
}]
</script>
</head>
<body class="flex flex-col h-screen m-auto leading-7 max-w-7xl px-6 sm:px-14 md:px-24 lg:px-32 text-lg bg-neutral text-neutral-900 dark:bg-neutral-800 dark:text-neutral bf-scrollbar">
<div id="the-top" class="absolute flex self-center">
<a
class="px-3 py-1 text-sm -translate-y-8 rounded-b-lg bg-primary-200 focus:translate-y-0 dark:bg-neutral-600"
href="#main-content">
<span class="font-bold text-primary-600 pe-2 dark:text-primary-400">&darr;</span>
Перейти к основному содержимому
</a>
</div>
<div class="min-h-[148px]"></div>
<div class="fixed inset-x-0 z-100">
<div
id="menu-blur"
class="absolute opacity-0 inset-x-0 top-0 h-full single_hero_background nozoom backdrop-blur-2xl shadow-2xl bg-neutral/25 dark:bg-neutral-800/25"></div>
<div class="relative m-auto leading-7 max-w-7xl px-6 sm:px-14 md:px-24 lg:px-32">
<div class="main-menu flex items-center w-full gap-2 p-1 pl-0">
<div>
<a href="/" class="flex">
<span class="sr-only">Олег Казанин</span>
<img
src="/img/logo.png"
width="285"
height="175"
class="logo max-h-20 max-w-20 object-scale-down object-left nozoom"
alt="">
</a>
</div>
<div class="flex items-center ms-auto">
<div class="hidden md:flex">
<nav class="flex items-center gap-x-5 h-12">
<button
id="search-button"
aria-label="Search"
class="text-base bf-icon-color-hover"
title="Поиск (/)">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</button>
<div class="flex items-center">
<button
id="appearance-switcher"
aria-label="Dark mode switcher"
type="button"
class="text-base bf-icon-color-hover">
<div class="flex items-center justify-center dark:hidden">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/></svg>
</span>
</div>
<div class="items-center justify-center hidden dark:flex">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/></svg>
</span>
</div>
</button>
</div>
</nav>
</div>
<div class="flex md:hidden">
<div class="flex items-center h-14 gap-4">
<button
id="search-button-mobile"
aria-label="Search"
class="flex items-center justify-center bf-icon-color-hover"
title="Поиск (/)">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</button>
<button
id="appearance-switcher-mobile"
type="button"
aria-label="Dark mode switcher"
class="flex items-center justify-center text-neutral-900 hover:text-primary-600 dark:text-neutral-200 dark:hover:text-primary-400">
<div class="dark:hidden">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/></svg>
</span>
</div>
<div class="hidden dark:block">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/></svg>
</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script
type="text/javascript"
src="/js/background-blur.min.605b3b942818f0ab5a717ae446135ec46b8ee5a2ad12ae56fb90dc2a76ce30c388f9fec8bcc18db15bd47e3fa8a09d779fa12aa9c184cf614a315bc72c6c163d.js"
integrity="sha512-YFs7lCgY8KtacXrkRhNexGuO5aKtEq5W&#43;5DcKnbOMMOI&#43;f7IvMGNsVvUfj&#43;ooJ13n6EqqcGEz2FKMVvHLGwWPQ=="
data-blur-id="menu-blur"></script>
<div class="relative flex flex-col grow">
<main id="main-content" class="grow">
<header class="mt-5">
<h1 class="mt-5 text-4xl font-extrabold text-neutral-900 dark:text-neutral">Categories</h1>
<div class="mt-1 mb-2 text-base text-neutral-500 dark:text-neutral-400 print:hidden">
<div class="flex flex-row flex-wrap items-center">
</div>
</div>
</header>
<section class="flex flex-wrap max-w-prose -mx-2 overflow-hidden">
</section>
<div
id="scroll-to-top"
class="fixed bottom-6 end-6 z-50 transform translate-y-4 opacity-0 duration-200">
<a
href="#the-top"
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-neutral/50 text-xl text-neutral-700 hover:text-primary-600 dark:bg-neutral-800/50 dark:text-neutral dark:hover:text-primary-400"
aria-label="Пролистать наверх"
title="Пролистать наверх">
&uarr;
</a>
</div>
</main><footer id="site-footer" class="py-10 print:hidden">
<nav class="flex flex-row pb-4 text-base font-medium text-neutral-500 dark:text-neutral-400 ">
<ul class="flex list-none flex-col sm:flex-row">
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/tags/"
title="Tags">
Теги
</a>
</li>
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/categories/"
title="Categories">
Категории
</a>
</li>
<li class=" flex mb-1 text-end sm:mb-0 sm:me-7 sm:last:me-0 ">
<a
class="decoration-primary-500 hover:underline hover:decoration-2 hover:underline-offset-2 flex items-center"
href="/authors/"
title="Authors">
Авторы
</a>
</li>
</ul>
</nav>
<div class="flex items-center justify-between">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
&copy;
2026
Олег Казанин
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Работает на <a class="hover:underline hover:decoration-primary-400 hover:text-primary-500"
href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a> &amp; <a class="hover:underline hover:decoration-primary-400 hover:text-primary-500"
href="https://blowfish.page/" target="_blank" rel="noopener noreferrer">Blowfish</a>
</p>
</div>
<script>
mediumZoom(document.querySelectorAll("img:not(.nozoom)"), {
margin: 24,
background: "rgba(0,0,0,0.5)",
scrollOffset: 0,
});
</script>
</footer>
<div
id="search-wrapper"
class="invisible fixed inset-0 flex h-screen w-screen cursor-default flex-col bg-neutral-500/50 p-4 backdrop-blur-sm dark:bg-neutral-900/50 sm:p-6 md:p-[10vh] lg:p-[12vh] z-500"
data-url="http://192.168.11.190:1313/">
<div
id="search-modal"
class="flex flex-col w-full max-w-3xl min-h-0 mx-auto border rounded-md shadow-lg top-20 border-neutral-200 bg-neutral dark:border-neutral-700 dark:bg-neutral-800">
<header class="relative z-10 flex items-center justify-between flex-none px-2">
<form class="flex items-center flex-auto min-w-0">
<div class="flex items-center justify-center w-8 h-8 text-neutral-400">
<span class="relative block icon"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"/></svg>
</span>
</div>
<input
type="search"
id="search-query"
class="flex flex-auto h-12 mx-1 bg-transparent appearance-none focus:outline-dotted focus:outline-2 focus:outline-transparent"
placeholder="Поиск"
tabindex="0">
</form>
<button
id="close-search-button"
class="flex items-center justify-center w-8 h-8 text-neutral-700 hover:text-primary-600 dark:text-neutral dark:hover:text-primary-400"
title="Закрыть (Esc)">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z"/></svg>
</span>
</button>
</header>
<section class="flex-auto px-2 overflow-auto">
<ul id="search-results">
</ul>
</section>
</div>
</div>
</div>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Categories on Олег Казанин</title>
<link>http://192.168.11.190:1313/categories/</link>
<description>Recent content in Categories on Олег Казанин</description>
<generator>Hugo -- gohugo.io</generator>
<language>ru</language>
<managingEditor>oakazanin@ya.ru (Олег Казанин)</managingEditor>
<webMaster>oakazanin@ya.ru (Олег Казанин)</webMaster>
<copyright>© 2026 Олег Казанин</copyright>
<atom:link href="http://192.168.11.190:1313/categories/index.xml" rel="self" type="application/rss+xml" />
</channel>
</rss>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Some files were not shown because too many files have changed in this diff Show More