Compare commits

...

55 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
astronit 156f12a107 merge: Firebase конфиг из dev ветки 2026-02-17 13:02:11 +00:00
astronit 3ecd02c4ab feat: подключил Firebase для просмотров и лайков 2026-02-17 12:31:21 +00:00
astronit 7de05deb3c fix: исправил синтаксис Firebase конфига (TOML) 2026-02-17 12:18:40 +00:00
astronit d4ed184220 feat: подключил Firebase для просмотров и лайков 2026-02-17 11:49:11 +00:00
astronit 2e967e5656 Merge dev into main: добавлены статьи о K3S cluster 2026-02-16 20:22:29 +00:00
astronit bdba1274f6 Опубликовал статьи о k3s cluster 2026-02-16 14:18:38 +00:00
astronit 7b08cef369 Настроил. Добавил серию статей о K3S Cluster 2026-02-16 14:15:59 +00:00
astronit 49ab08074f Тест: триггер вебхука для dev-ветки 2026-02-14 09:50:13 +00:00
132 changed files with 14030 additions and 7714 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
+1 -1
View File
@@ -1 +1 @@
Production webhook test: Sat Feb 14 09:52:40 UTC 2026 Webhook test: Sat Feb 14 09:49:12 UTC 2026
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+3 -3
View File
@@ -2,11 +2,11 @@
# Refer to the theme docs for more details about each of these parameters. # Refer to the theme docs for more details about each of these parameters.
# https://blowfish.page/docs/getting-started/ # https://blowfish.page/docs/getting-started/
theme = "blowfish" # UNCOMMENT THIS LINE theme = "blowfish"
baseURL = "https://oakazanin.ru/" baseURL = "https://oakazanin.ru/"
defaultContentLanguage = "ru" defaultContentLanguage = "ru"
# pluralizeListTitles = "true" # hugo function useful for non-english languages, find out more in https://gohugo.io/getting-started/configuration/#pluralizelisttitles pluralizeListTitles = "true" # hugo function useful for non-english languages, find out more in https://gohugo.io/getting-started/configuration/#pluralizelisttitles
enableRobotsTXT = true enableRobotsTXT = true
summaryLength = 0 summaryLength = 0
@@ -31,7 +31,7 @@ enableEmoji = true
series = "series" series = "series"
[sitemap] [sitemap]
changefreq = 'daily' changefreq = 'weekly' # Реалистичное значение для блога
filename = 'sitemap.xml' filename = 'sitemap.xml'
priority = 0.5 priority = 0.5
+7 -6
View File
@@ -9,24 +9,25 @@ title = "Олег Казанин"
isoCode = "ru" isoCode = "ru"
rtl = false rtl = false
dateFormat = "2 January 2006" dateFormat = "2 January 2006"
# logo = "img/logo.png" logo = "img/logo.png"
# secondaryLogo = "img/secondary-logo.png" secondaryLogo = "img/logo_inverted.png"
# description = "My awesome website" # description = "My awesome website"
# copyright = "Copy, _right?_ :thinking_face:" # copyright = "Copy, _right?_ :thinking_face:"
# [params.author] [params.author]
name = "Олег Казанин" name = "Олег Казанин"
email = "oakazanin@ya.ru" email = "oakazanin@ya.ru"
# image = "img/blowfish_logo.png" image = "img/profile.png"
# imageQuality = 96 # imageQuality = 96
headline = "Infrastructure Engineer | Linux & Open Source" headline = "Infrastructure Engineer | Linux & Open Source"
bio = "Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали." bio = "Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали."
links = [ links = [
{ vk = "https://vk.com/oakazanin/" },
{ email = "mailto:oakazanin@ya.ru" }, { email = "mailto:oakazanin@ya.ru" },
{ telegram = "https://t.me/oa_msk" }, { telegram = "https://t.me/oa_msk" },
{ link = "https://oakazanin.ru/" }, { link = "https://oakazanin.ru/" },
{ gitea = "https://git.jn4.ru/astronit" }, { 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" }, # { amazon = "https://www.amazon.com/hz/wishlist/ls/wishlist-id" },
# { apple = "https://www.apple.com" }, # { apple = "https://www.apple.com" },
# { blogger = "https://username.blogspot.com/" }, # { blogger = "https://username.blogspot.com/" },
+24 -19
View File
@@ -12,29 +12,34 @@
[[main]] [[main]]
name = "О блоге" name = "Об авторе"
pageRef = "about" pageRef = "about"
weight = 10 weight = 10
[[main]] [[main]]
name = "Статьи" name = "Статьи"
pageRef = "posts" pageRef = "posts"
weight = 20 weight = 20
[[main]] [[main]]
name = "Проекты" name = "Шпаргалки"
pageRef = "projects" pageRef = "cheatsheets"
weight = 30 weight = 21
[[main]] #[[main]]
name = "Резюме" # name = "Проекты"
pageRef = "resume" # pageRef = "projects"
weight = 40 # weight = 30
[[main]] #[[main]]
name = "Музыка" # name = "Резюме"
pageRef = "music" # pageRef = "resume"
weight = 50 # weight = 40
#[[main]]
# name = "Музыка"
# pageRef = "music"
# weight = 50
#[[main]] #[[main]]
# name = "Blog" # name = "Blog"
@@ -94,7 +99,7 @@
pageRef = "categories" pageRef = "categories"
weight = 20 weight = 20
[[footer]] #[[footer]]
name = "Авторы" # name = "Авторы"
pageRef = "authors" # pageRef = "authors"
weight = 30 # weight = 30
+61 -58
View File
@@ -7,11 +7,11 @@
colorScheme = "blowfish" colorScheme = "blowfish"
defaultAppearance = "light" # valid options: light or dark defaultAppearance = "light" # valid options: light or dark
autoSwitchAppearance = true autoSwitchAppearance = false
enableA11y = false enableA11y = true
enableSearch = true enableSearch = true
enableCodeCopy = false enableCodeCopy = true
enableStructuredBreadcrumbs = false enableStructuredBreadcrumbs = false
# enableStyledScrollbar = true # disable to use native scrollbar style (defaults to true) # enableStyledScrollbar = true # disable to use native scrollbar style (defaults to true)
@@ -20,18 +20,19 @@ replyByEmail = false
# mainSections = ["section1", "section2"] # mainSections = ["section1", "section2"]
# robots = "" # robots = ""
disableImageOptimization = false disableImageZoom = true
disableImageOptimizationMD = false disableImageOptimization = true
disableTextInHeader = false # disableImageOptimizationMD = false
disableTextInHeader = true
# backgroundImageWidth = 1200 # backgroundImageWidth = 1200
# defaultBackgroundImage = "IMAGE.jpg" # used as default for background images defaultBackgroundImage = "/img/background.svg" # used as default for background images
# defaultFeaturedImage = "IMAGE.jpg" # used as default for featured images in all articles # defaultFeaturedImage = "/img/background.png" # used as default for featured images in all articles
# defaultSocialImage = "/android-chrome-512x512.png" # used as default for social media sharing (Open Graph and Twitter) # defaultSocialImage = "/android-chrome-512x512.png" # used as default for social media sharing (Open Graph and Twitter)
hotlinkFeatureImage = false hotlinkFeatureImage = false
# imagePosition = "50% 50%" # imagePosition = "50% 50%"
# highlightCurrentMenuArea = true highlightCurrentMenuArea = true
# smartTOC = true # smartTOC = true
# smartTOCHideUnfocusedChildren = true # smartTOCHideUnfocusedChildren = true
@@ -41,7 +42,7 @@ giteaDefaultServer = "https://git.fsfe.org"
forgejoDefaultServer = "https://v11.next.forgejo.org" forgejoDefaultServer = "https://v11.next.forgejo.org"
[header] [header]
layout = "basic" # valid options: basic, fixed, fixed-fill, fixed-gradient, fixed-fill-blur layout = "fixed" # valid options: basic, fixed, fixed-fill, fixed-gradient, fixed-fill-blur
[footer] [footer]
showMenu = true showMenu = true
@@ -51,74 +52,76 @@ forgejoDefaultServer = "https://v11.next.forgejo.org"
showScrollToTop = true showScrollToTop = true
[homepage] [homepage]
layout = "profile" # valid options: page, profile, hero, card, background, custom layout = "background" # valid options: page, profile, hero, card, background, custom
#homepageImage = "IMAGE.jpg" # used in: hero, and card #homepageImage = "IMAGE.jpg" # used in: hero, and card
showRecent = false showRecent = true
showRecentItems = 5 showRecentItems = 5
showMoreLink = false showMoreLink = true
showMoreLinkDest = "/posts/" showMoreLinkDest = "/posts"
cardView = false cardView = false
cardViewScreenWidth = false cardViewScreenWidth = false
layoutBackgroundBlur = false # only used when layout equals background layoutBackgroundBlur = true # only used when layout equals background
disableHeroImageFilter = false # only used when layout equals hero disableHeroImageFilter = true # only used when layout equals hero
[article] [article]
showDate = true showDate = true
showViews = false # showViews = false
showLikes = false # showLikes = false
showDateOnlyInArticle = false # showDateOnlyInArticle = false
showDateUpdated = false # showDateUpdated = false
showAuthor = true showAuthor = true
# showAuthorBottom = false # showAuthorBottom = false
showHero = false showHero = true
# heroStyle = "basic" # valid options: basic, big, background, thumbAndBackground heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
showBreadcrumbs = false #showBreadcrumbs = false
showDraftLabel = true showDraftLabel = true
showEdit = false #showEdit = false
# editURL = "https://github.com/username/repo/" # editURL = "https://github.com/username/repo/"
editAppendPath = true # editAppendPath = true
seriesOpened = false seriesOpened = true
showHeadingAnchors = true showHeadingAnchors = true
showPagination = true showPagination = true
invertPagination = false # invertPagination = false
showReadingTime = true showReadingTime = true
showTableOfContents = false showTableOfContents = true
# showRelatedContent = false showRelatedContent = true
# relatedContentLimit = 3 relatedContentLimit = 3
showTaxonomies = false # use showTaxonomies OR showCategoryOnly, not both # showTaxonomies = true # use showTaxonomies OR showCategoryOnly, not both
showCategoryOnly = false # use showTaxonomies OR showCategoryOnly, not both showCategoryOnly = true # use showTaxonomies OR showCategoryOnly, not both
showAuthorsBadges = false showAuthorsBadges = false
showWordCount = true # showWordCount = false
# sharingLinks = [ "linkedin", "twitter", "bluesky", "mastodon", "reddit", "pinterest", "facebook", "email", "whatsapp", "telegram", "line"] # sharingLinks = [ "linkedin", "twitter", "bluesky", "mastodon", "reddit", "pinterest", "facebook", "email", "whatsapp", "telegram", "line"]
showZenMode = false sharingLinks = [ "reddit", "email", "telegram"]
# showZenMode = false
# externalLinkForceNewTab = false # disable to allow external links in the same tab (defaults to true) # externalLinkForceNewTab = false # disable to allow external links in the same tab (defaults to true)
showComments = true
[list] [list]
showHero = false showHero = true
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
showBreadcrumbs = false # showBreadcrumbs = false
showSummary = false showSummary = true
showViews = false # showViews = false
showLikes = false # showLikes = false
showTableOfContents = false # showTableOfContents = false
showCards = false showCards = true
orderByWeight = false # orderByWeight = false
groupByYear = true # groupByYear = false
cardView = false # cardView = false
cardViewScreenWidth = false # cardViewScreenWidth = false
constrainItemsWidth = false # constrainItemsWidth = false
[sitemap] [sitemap]
excludedKinds = ["taxonomy", "term"] excludedKinds = ["taxonomy", "term"]
[taxonomy] [taxonomy]
showTermCount = true showTermCount = true
showHero = false showHero = true
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
showBreadcrumbs = false showBreadcrumbs = false
showViews = false showViews = false
showLikes = false showLikes = false
@@ -127,23 +130,23 @@ forgejoDefaultServer = "https://v11.next.forgejo.org"
[term] [term]
showHero = false showHero = false
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
showBreadcrumbs = false showBreadcrumbs = false
showViews = false showViews = false
showLikes = false showLikes = false
showTableOfContents = true showTableOfContents = false
groupByYear = false groupByYear = false
cardView = false cardView = false
cardViewScreenWidth = false cardViewScreenWidth = false
[firebase] [firebase]
# apiKey = "XXXXXX" apiKey = "AIzaSyBBfzADrGgnwTIyW67gfZSrAtkoybxvmdI"
# authDomain = "XXXXXX" authDomain = "oakazanin-hugo-blowfish.firebaseapp.com"
# projectId = "XXXXXX" databaseURL = "https://oakazanin-hugo-blowfish-default-rtdb.europe-west1.firebasedatabase.app"
# storageBucket = "XXXXXX" projectId = "oakazanin-hugo-blowfish"
# messagingSenderId = "XXXXXX" storageBucket = "oakazanin-hugo-blowfish.firebasestorage.app"
# appId = "XXXXXX" messagingSenderId = "945151844512"
# measurementId = "XXXXXX" appId = "1:945151844512:web:22602cc010f5b7e0cca9c5"
[fathomAnalytics] [fathomAnalytics]
# site = "ABC12345" # site = "ABC12345"
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.
-13
View File
@@ -1,13 +0,0 @@
---
title: "Hello World"
date: 2026-02-13
draft: false
description: "Первый пост на новом сайте"
tags: ["test"]
---
## Привет!
Это первый пост на новом сайте **oakazanin.ru**.
Сайт работает на Hugo + Blowfish теме.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,371 @@
---
title: "K3s HA для homelab: архитектура без боли"
date: 2025-10-14
draft: false
description: "K3s HA кластер для homelab: архитектура с 3 master нодами и embedded etcd. 50MB бинарник вместо 1.5GB зависимостей — почему это работает."
tags: ["kubernetes", "k3s", "homelab", "proxmox", "architecture", "ha", "devops"]
categories: ["devops-практики"]
series: ["K3s HA кластер для homelab"]
series_order: 1
---
Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. Знакомо? K3s решает эту проблему - полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Но без правильного планирования вы получите нестабильную конструкцию, которая падает в самый неподходящий момент.
В этой статье разберём архитектуру K3s HA кластера: почему именно 3 master ноды, зачем embedded etcd и сколько ресурсов закладывать. В конце - готовый план для установки.
**Результат:** понимание архитектуры + таблица ресурсов + сетевая схема. Всё, что нужно перед тем, как создавать VM.
---
## Для кого это
**Подходит:**
- Знаком с базовыми концепциями Kubernetes (pod, service, deployment)
- Есть Proxmox с 14+ vCPU и 56GB+ RAM
- Хочешь понять *что* устанавливать, прежде чем устанавливать
**Не подходит:**
- Нужна одна нода для экспериментов - достаточно docker-compose или K3s single-node
- Ищешь managed Kubernetes для бизнеса - смотри в сторону Yandex Cloud или VK Cloud
- Хочешь сразу команды без теории - переходи к статье 2
---
## K3s vs Kubernetes: в чём разница
**Kubernetes (K8s)** - оркестратор контейнеров, стандарт индустрии. Добро пожаловать в enterprise, где для запуска трёх контейнеров нужно поддерживать шесть виртуальных машин.
**K3s** - тот же Kubernetes, но кто-то в Rancher (теперь SUSE) задумался: "А что если выкинуть всё, что нужно только Сберу и Yandex Cloud?"
![Kubernetes](k8s-architecture.svg "**Kubernetes**")
![K3s](k3s-architecture.svg "**K3s**")
**Что выкинули:**
- Интеграции с облачными провайдерами (вы же не в VK Cloud)
- Legacy API (вы же не мигрируете кластер 2016 года)
- Встроенные драйверы хранилищ на все случаи жизни (вы же не используете 47 типов СХД)
- Альфа/бета-функции (нестабильные эксперименты)
**Что осталось:** полноценный Kubernetes, сертифицированный CNCF (Cloud Native Computing Foundation - организация, которая решает, что считать "настоящим" Kubernetes). Все манифесты работают. Helm работает. kubectl работает. Ответы со StackOverflow работают.
### Сравнение в цифрах
| Характеристика | Kubernetes | K3s |
|----------------|------------|-----|
| Размер | ~1.5GB образы | 50MB бинарник |
| RAM на control plane | ~2GB на ноду | ~500MB на ноду |
| Установка | kubeadm, 10+ шагов | один curl-скрипт |
| etcd | Отдельный кластер (3+ VM) | Встроенный |
| CNI | Нужно устанавливать | Flannel из коробки |
| Совместимость | 100% | 100% |
*"Но я потеряю гибкость!"* - скажете вы. Да, вы не сможете заменить сетевой плагин Flannel без пересборки. Это критично примерно для одного проекта из тысячи, и ваш homelab в их число не входит.
**Вердикт:** для homelab K3s - очевидный выбор. Теряем 5% гибкости, получаем 90% простоты.
---
## Что такое High Availability и зачем оно вам
**HA (High Availability)** - способность системы продолжать работу при отказе компонентов. Звучит как enterprise-термин для больших компаний? На практике это разница между "кластер упал в субботу, но я починил в понедельник" и "кластер сам пережил падение ноды, пока я спал".
### Без HA (single node)
```
┌─────────────────┐
│ K3s Master │ ← Единственная точка отказа
│ + Worker │
└─────────────────┘
Нода упала → кластер мёртв → ваши сервисы недоступны
```
### С HA (3+ master nodes)
```
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Master 1 │ │ Master 2 │ │ Master 3 │
│ + etcd │ │ + etcd │ │ + etcd │
└──────────┘ └──────────┘ └──────────┘
↓ ↓ ↓
┌──────────┐ ┌──────────┐
│ Worker 1 │ │ Worker 2 │
└──────────┘ └──────────┘
Одна master упала → кластер работает
Один worker упал → поды переехали на другой
```
### Сколько master нод нужно
Вот тут начинается интересное. Интуиция подсказывает: одна нода - плохо, две - уже лучше. Логично? Логично. И неправильно.
| Master нод | Выдержит отказов | Кворум | Вердикт |
|------------|------------------|--------|---------|
| 1 | 0 | 1/1 | Нет HA, но честно |
| 2 | 0 | **Ловушка!** | ⛔ Хуже, чем 1 |
| 3 | 1 | 2/3 | ✅ Минимум для HA |
| 5 | 2 | 3/5 | Для критичных систем |
### Почему 2 master ноды хуже, чем 1
etcd (база данных кластера, где хранится вообще всё) работает по принципу голосования. Чтобы записать данные, нужно согласие большинства нод. Не "хотя бы одной" - именно большинства.
Считаем:
- **1 нода:** большинство = 1. Упала - кластер мёртв. Честная игра, вы знали на что шли.
- **2 ноды:** большинство = 2. Упала одна - кворума нет, кластер мёртв. Сюрприз!
- **3 ноды:** большинство = 2. Одна упала - две оставшиеся продолжают работать.
Это как договор, требующий подписи обоих директоров - заболел один, и компания парализована.
С двумя нодами вы не получили отказоустойчивость. Вы удвоили количество точек отказа и назвали это "высокой доступностью".
![High Availability](k3s-ha.svg)
**Правило:** или 1 нода (и честное понимание рисков), или 3+ (и настоящий HA). Двойка - ловушка для тех, кто не дочитал документацию.
---
## Embedded etcd vs External etcd
**etcd** - распределённое key-value хранилище. Единственный источник истины для всего состояния Kubernetes: все объекты (поды, сервисы, секреты), конфигурации, сетевые политики. Без etcd кластер не работает. Точка.
Есть два варианта архитектуры:
### External etcd (классический Kubernetes)
```
Control Plane (3 VM) etcd кластер (3 VM)
┌────────────────┐ ┌──────────────┐
│ API Server │ │ etcd-1 │
│ Scheduler │ ──────────────>│ (только etcd)│
│ Controller │ └──────────────┘
└────────────────┘ ┌──────────────┐
┌────────────────┐ │ etcd-2 │
│ API Server │ ──────────────>│ (только etcd)│
│ Scheduler │ └──────────────┘
│ Controller │ ┌──────────────┐
└────────────────┘ │ etcd-3 │
┌────────────────┐ │ (только etcd)│
│ API Server │ ──────────────>└──────────────┘
│ Scheduler │
│ Controller │
└────────────────┘
Итого: 6 виртуальных машин
```
### Embedded etcd (K3s)
```
┌─────────────────────────┐
│ K3s Master 1 │
│ ┌───────────────────┐ │
│ │ API + Scheduler │ │
│ │ + Controller │ │
│ └───────────────────┘ │
│ ┌───────────────────┐ │
│ │ etcd (встроенный) │◄─┼──┐
│ └───────────────────┘ │ │
└─────────────────────────┘ │ Raft protocol
┌─────────────────────────┐ │ (синхронизация)
│ K3s Master 2 │ │
│ etcd ◄────────────────────┤
└─────────────────────────┘ │
┌─────────────────────────┐ │
│ K3s Master 3 │ │
│ etcd ◄────────────────────┘
└─────────────────────────┘
Итого: 3 виртуальные машины
```
![etcd](etcd.svg)
### Сравнение подходов
| Критерий | External etcd | Embedded etcd |
|----------|---------------|---------------|
| Количество VM | 6 (3 master + 3 etcd) | 3 (всё вместе) |
| Сложность настройки | Высокая | Один флаг `--cluster-init` |
| Сложность обновления | Отдельно etcd и K8s | Одна команда |
| Производительность | Чуть лучше | Достаточно для homelab |
| Масштаб | >500 нод | До 100-200 нод |
**Для homelab embedded etcd - очевидный выбор.** Теряем 5-10% производительности etcd, экономим 3 VM и часы настройки.
*"А если мне понадобится масштаб?"* - официально embedded etcd поддерживает до 100 нод и 5000 подов. Для homelab это как ограничение скорости 300 км/ч на велосипеде.
---
## Зачем отдельные worker ноды
**Worker ноды** - машины для запуска ваших приложений (подов). На них не запускаются компоненты control plane.
*"А можно запускать приложения прямо на master нодах?"*
Технически - да. K3s не ставит ограничений на master ноды (в отличие от обычного Kubernetes). Но это плохая идея:
- **Control plane должен быть стабильным.** Ваше приложение съело всю память → API server упал → кластер недоступен.
- **etcd чувствителен к диску.** База данных на той же ноде создаёт I/O нагрузку → etcd тормозит → весь кластер тормозит.
- **Изоляция отказов.** Проблема с приложением не должна убивать control plane.
**2 worker ноды - минимум для HA приложений:**
- Можно запускать 2 реплики (на разных нодах)
- При падении одного worker'а второй держит нагрузку
- Легко добавить третью, четвёртую ноду потом
---
## Архитектура нашего кластера
Вот что мы будем строить:
![Архитектура нашего кластера](final_arch.svg)
**Ключевые моменты:**
1. **Все master ноды равны** - нет "главной", kubectl подключается к любой.
2. **etcd синхронизируется через Raft** - алгоритм консенсуса, гарантирует согласованность данных.
3. **Workers знают только про API** - они не подключаются к etcd напрямую.
4. **Flannel создаёт overlay-сеть** - все поды получают IP из 10.42.0.0/16, видят друг друга.
---
## Планирование ресурсов
### Таблица VM
| Hostname | VM ID | IP | vCPU | RAM | Disk | Роль |
|----------|-------|------------|------|-----|------|------|
| k3s-master-1 | 201 | 192.168.11.201 | 2 | 8GB | 32GB | Control Plane + etcd |
| k3s-master-2 | 202 | 192.168.11.202 | 2 | 8GB | 32GB | Control Plane + etcd |
| k3s-master-3 | 203 | 192.168.11.203 | 2 | 8GB | 32GB | Control Plane + etcd |
| k3s-worker-1 | 210 | 192.168.11.210 | 4 | 16GB | 50GB | Workloads |
| k3s-worker-2 | 211 | 192.168.11.211 | 4 | 16GB | 50GB | Workloads |
| **Итого** | - | - | **14** | **56GB** | **196GB** | - |
### Почему именно такие ресурсы
**Master ноды (2 vCPU / 8GB RAM / 32GB Disk):**
Реальное потребление в idle:
- API server: ~200-300MB RAM
- etcd: ~100-200MB RAM (растёт со временем)
- Scheduler + Controller: ~150MB RAM
- Системные поды: ~100-200MB RAM
- **Итого:** ~600-900MB используется
*"Зачем тогда 8GB?"* - запас для burst-нагрузки. Когда вы деплоите 50 подов одновременно, API server временно съедает больше. etcd при большом кластере может вырасти до 1-2GB. Golang GC работает лучше с запасом памяти.
**Worker ноды (4 vCPU / 16GB RAM / 50GB Disk):**
Здесь будут ваши приложения. При 16GB можно запустить:
- 5-10 средних приложений (256MB-2GB каждое)
- Или 2-3 базы данных (PostgreSQL любит память)
- Или комбинацию
50GB диска - под образы контейнеров (10-20GB), логи (5-10GB), временные данные.
### Можно ли меньше?
**Минимальная конфигурация (для экспериментов):**
- Master: 1 vCPU / 4GB RAM / 20GB Disk
- Worker: 2 vCPU / 8GB RAM / 30GB Disk
- **Итого:** 9 vCPU / 36GB RAM
**Риски:**
- Медленная работа API server
- OOM killer при нагрузке
- Нет запаса для burst
Для production-like homelab рекомендую таблицу выше. Комфортный запас стоит дешевле, чем отладка странных падений.
---
## Сетевая схема
### IP-адреса (адаптируй под свою сеть)
```
192.168.11.0/24 - Локальная сеть
192.168.11.1 - Gateway (роутер)
192.168.11.201-203 - Master ноды
192.168.11.210-211 - Worker ноды
192.168.11.220-230 - Резерв для MetalLB (статья 2)
```
### Kubernetes внутренние сети (создаются автоматически)
```
10.42.0.0/16 - Pod network (Flannel VXLAN overlay)
10.43.0.0/16 - Service network (ClusterIP)
10.43.0.10 - CoreDNS
```
![Сети](k3s_network.svg)
### Порты между нодами
| Порт | Протокол | Направление | Назначение |
|------|----------|-------------|------------|
| 6443 | TCP | Master ← Worker | Kubernetes API |
| 2379-2380 | TCP | Master ↔ Master | etcd (client + peer) |
| 10250 | TCP | Master ↔ All | Kubelet API |
| 8472 | UDP | All ↔ All | Flannel VXLAN |
---
## Требования к железу и софту
### Железо (Proxmox хост)
**Минимум:**
- CPU: 14 vCPU свободных
- RAM: 56GB свободных
- Disk: 200GB на SSD
- Network: 1 Gbit
**Рекомендуется:**
- CPU: 18+ vCPU (запас для приложений)
- RAM: 64GB+ (базы данных прожорливые)
- Disk: NVMe для etcd
- Network: 2.5 Gbit (для NFS, если будете использовать)
### Софт
| Компонент | Версия |
|-----------|--------|
| Proxmox VE | 7.x или 8.x |
| K3s | v1.31+ (stable) |
| ОС на нодах | Debian 12 или Ubuntu 22.04+ |
| Kernel | 5.15+ (для cgroup v2) |
## Итог
**Что мы спроектировали:**
- 5 VM: 3 master + 2 worker
- K3s с embedded etcd (HA без лишних VM)
- Отказоустойчивость: выдерживает падение 1 master и любого worker
- Ресурсы: 14 vCPU / 56GB RAM / 196GB Disk
**Что НЕ входит в эту серию** (отдельные статьи):
- LoadBalancer (MetalLB)
- Ingress (Traefik)
- SSL (cert-manager)
- Мониторинг (Prometheus/Grafana)
---
## Что дальше
**👉 "Подготовить инфраструктуру для K3s в Proxmox"**
Там мы:
- Создадим template VM с Debian 12
- Склонируем 5 VM с правильными ресурсами
- Настроим статические IP
- Подготовим ОС (swap, cgroup v2, firewall)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,617 @@
---
title: "K3s HA для homelab: Готовим инфраструктуру в Proxmox"
date: 2025-10-21
draft: false
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
---
Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. В этой статье подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов.
Звучит просто? В теории - да. На практике: забытый swap, cgroup v1 вместо v2, закрытые порты firewall - и вы тратите час на отладку того, что должно было работать "из коробки".
**Результат:** 5 VM (3 master + 2 worker) с Debian 12, настроенной сетью, отключённым swap и правильными параметрами ядра. SSH доступ работает, ноды видят друг друга.
---
## Для кого это
**Подходит:**
- Прочитал первую статью (или понимаешь архитектуру K3s HA)
- Есть Proxmox с 14+ vCPU и 56GB+ RAM свободных
- Умеешь работать в терминале Proxmox (или готов учиться)
**Не подходит:**
- Proxmox ещё не установлен - сначала разберись с ним
- Хочешь использовать LXC вместо VM - K3s в контейнерах работает, но с нюансами (не покрываем)
---
## Что понадобится
| Компонент | Значение |
|-----------|----------|
| Proxmox VE | 7.x или 8.x |
| Storage pool | local-lvm или другой (минимум 200GB свободно) |
| Сетевой bridge | vmbr0 (или ваш) |
| SSH-ключ | Публичный ключ для доступа к VM |
| ОС для VM | Debian 12 cloud image |
---
## Шаг 1: Скачать cloud image Debian 12
Cloud image - готовый образ с поддержкой cloud-init. Не нужно проходить установщик вручную: задаёшь параметры (IP, пользователь, SSH-ключ) - VM стартует уже настроенной.
**На Proxmox хосте (SSH или Shell в Web UI):**
```bash
# Перейти в директорию для образов
cd /var/lib/vz/template/iso
# Скачать Debian 12 cloud image
wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2
# Проверить
ls -lh debian-12-generic-amd64.qcow2
```
**Ожидаемый результат:**
```
-rw-r--r-- 1 root root 521M ... debian-12-generic-amd64.qcow2
```
<!-- СКРИНШОТ: Терминал Proxmox с выводом wget и ls -lh, показывающим скачанный образ ~500MB -->
**Если скачивание медленное:** образ ~500MB, на слабом канале может занять время. Альтернатива - скачать на локальную машину и загрузить через Proxmox Web UI (Datacenter → Storage → Upload).
---
## Шаг 2: Создать template VM
Template - шаблон VM, из которого будем клонировать все 5 нод. Настраиваем один раз, клонируем пять.
### 2.1. Задать переменные
```bash
# Настрой под свою конфигурацию
TEMPLATE_ID=9000 # ID для template (любой свободный)
STORAGE=local-lvm # Твой storage pool
BRIDGE=vmbr0 # Сетевой bridge
SSH_KEY_PATH=~/.ssh/id_rsa.pub # Путь к публичному SSH-ключу
```
**Как узнать имя storage:**
```bash
pvesm status
```
**Ожидаемый результат**
```
Name Type Status Total Used Available %
local dir active 229199360 9095552 220103808 3.97%
local-lvm pool active 220103964 96 220103868 0.00%
```
<!-- СКРИНШОТ: Вывод pvesm status с выделенным именем storage pool (например, local-lvm) -->
### 2.2. Создать VM и импортировать диск
```bash
# 1. Создать пустую VM
qm create $TEMPLATE_ID \
--name debian-12-template \
--memory 2048 \
--cores 2 \
--net0 virtio,bridge=$BRIDGE
# 2. Импортировать скачанный образ как диск
qm importdisk $TEMPLATE_ID \
/var/lib/vz/template/iso/debian-12-generic-amd64.qcow2 \
$STORAGE
```
**Ожидаемый результат:**
```
importing disk '/var/lib/vz/template/iso/debian-12-generic-amd64.qcow2' to VM 9000 ...
Successfully imported disk as 'unused0:local-lvm:vm-9000-disk-0'
```
### 2.3. Настроить диск и загрузку
```bash
# 3. Подключить диск к VM
qm set $TEMPLATE_ID \
--scsihw virtio-scsi-pci \
--scsi0 $STORAGE:vm-$TEMPLATE_ID-disk-0
# 4. Настроить загрузку и cloud-init
qm set $TEMPLATE_ID \
--boot c \
--bootdisk scsi0 \
--ide2 $STORAGE:cloudinit \
--serial0 socket \
--vga serial0
```
### 2.4. Настроить cloud-init
```bash
# 5. Пользователь, пароль, SSH-ключ
qm set $TEMPLATE_ID \
--ciuser k3s \
--cipassword "ВашНадёжныйПароль" \
--sshkeys $SSH_KEY_PATH \
--ipconfig0 ip=dhcp
# 6. Увеличить диск до 32GB (базовый размер для master)
qm resize $TEMPLATE_ID scsi0 32G
```
**Замени:**
- `ВашНадёжныйПароль` - пароль для пользователя k3s (резервный доступ, если SSH не работает)
- `$SSH_KEY_PATH` - путь к твоему публичному ключу
### 2.5. Превратить в template
```bash
# 7. Конвертировать VM в template
qm template $TEMPLATE_ID
```
После этого VM 9000 станет шаблоном - её нельзя запустить, только клонировать.
<!-- СКРИНШОТ: Proxmox Web UI - список VM, где debian-12-template отображается с иконкой template (отличается от обычных VM) -->
### Checkpoint: Template создан
```bash
# Проверить что template существует
qm list | grep template
```
**Ожидаемый результат:**
```
9000 debian-12-template stopped 2048 32.00 0
```
**Если ошибка "disk import failed":**
- Проверь свободное место: `pvesm status`
- Проверь путь к образу: `ls -l /var/lib/vz/template/iso/`
---
## Шаг 3: Клонировать master ноды
Теперь создаём 3 master ноды из template. Каждая получит свой IP, имя и ресурсы.
```bash
TEMPLATE_ID=9000
# ─────────────────────────────────────────────
# Master 1
# ─────────────────────────────────────────────
qm clone $TEMPLATE_ID 201 --name k3s-master-1 --full
qm set 201 --cores 2 --memory 8192
qm set 201 --ipconfig0 ip=192.168.11.201/24,gw=192.168.11.1
qm set 201 --nameserver 8.8.8.8
qm resize 201 scsi0 32G
# ─────────────────────────────────────────────
# Master 2
# ─────────────────────────────────────────────
qm clone $TEMPLATE_ID 202 --name k3s-master-2 --full
qm set 202 --cores 2 --memory 8192
qm set 202 --ipconfig0 ip=192.168.11.202/24,gw=192.168.11.1
qm set 202 --nameserver 8.8.8.8
qm resize 202 scsi0 32G
# ─────────────────────────────────────────────
# Master 3
# ─────────────────────────────────────────────
qm clone $TEMPLATE_ID 203 --name k3s-master-3 --full
qm set 203 --cores 2 --memory 8192
qm set 203 --ipconfig0 ip=192.168.11.203/24,gw=192.168.11.1
qm set 203 --nameserver 8.8.8.8
qm resize 203 scsi0 32G
```
**Параметры:**
- `--full` - полное клонирование (не linked clone), VM независима от template
- `--cores 2 --memory 8192` - 2 vCPU, 8GB RAM (как планировали)
- `--ipconfig0` - статический IP через cloud-init
- `--nameserver` - DNS сервер (можешь указать свой)
**Адаптируй под свою сеть:**
- `192.168.11.0/24` → твоя подсеть
- `192.168.11.1` → твой gateway
---
## Шаг 4: Клонировать worker ноды
Workers получают больше ресурсов - здесь будут работать приложения.
```bash
# ─────────────────────────────────────────────
# Worker 1
# ─────────────────────────────────────────────
qm clone $TEMPLATE_ID 210 --name k3s-worker-1 --full
qm set 210 --cores 4 --memory 16384
qm set 210 --ipconfig0 ip=192.168.11.210/24,gw=192.168.11.1
qm set 210 --nameserver 8.8.8.8
qm resize 210 scsi0 50G
# ─────────────────────────────────────────────
# Worker 2
# ─────────────────────────────────────────────
qm clone $TEMPLATE_ID 211 --name k3s-worker-2 --full
qm set 211 --cores 4 --memory 16384
qm set 211 --ipconfig0 ip=192.168.11.211/24,gw=192.168.11.1
qm set 211 --nameserver 8.8.8.8
qm resize 211 scsi0 50G
```
**Отличия от master:**
- 4 vCPU вместо 2
- 16GB RAM вместо 8GB
- 50GB диск вместо 32GB
---
## Шаг 5: Запустить все VM
```bash
# Запустить все 5 VM
for vmid in 201 202 203 210 211; do
qm start $vmid
echo "Запущена VM $vmid"
sleep 3
done
# Проверить статус
qm list | grep k3s
```
**Ожидаемый результат:**
```
201 k3s-master-1 running 8192 32.00 12345
202 k3s-master-2 running 8192 32.00 12346
203 k3s-master-3 running 8192 32.00 12347
210 k3s-worker-1 running 16384 50.00 12348
211 k3s-worker-2 running 16384 50.00 12349
```
<!-- СКРИНШОТ: Proxmox Web UI - Summary или список VM, все 5 нод в статусе "running" с правильными ресурсами -->
### Checkpoint: VM работают
Подожди 1-2 минуты (cloud-init применяет настройки при первом запуске), затем проверь SSH:
```bash
# Проверить доступность всех нод
for ip in 192.168.11.201 192.168.11.202 192.168.11.203 192.168.11.210 192.168.11.211; do
echo -n "Проверяю $ip... "
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no k3s@$ip "hostname" 2>/dev/null && echo "OK" || echo "FAIL"
done
```
**Ожидаемый результат:**
```
Проверяю 192.168.11.201... k3s-master-1
OK
Проверяю 192.168.11.202... k3s-master-2
OK
...
```
**Если SSH не работает:**
| Симптом | Причина | Решение |
|---------|---------|---------|
| Connection refused | VM не загрузилась или SSH не запущен | Открой консоль в Proxmox, проверь загрузку |
| Connection timeout | Неправильный IP или firewall | Проверь IP в консоли: `ip addr` |
| Permission denied | Неправильный SSH-ключ | Проверь `~/.ssh/authorized_keys` на VM |
| Host key verification failed | Первое подключение | Добавь `-o StrictHostKeyChecking=no` |
<!-- СКРИНШОТ: Proxmox Console для одной из VM - экран входа или вывод `ip addr show` с правильным IP -->
---
## Шаг 6: Подготовить ОС на всех нодах
Теперь нужно настроить каждую ноду: обновить пакеты, отключить swap, настроить ядро. Команды одинаковые для всех 5 нод.
### 6.1. Обновить систему
**На каждой ноде (или через цикл):**
```bash
# Вариант 1: по одной
ssh k3s@192.168.11.201
sudo apt update
sudo apt upgrade -y
sudo apt install -y curl wget vim htop iptables
# Вариант 2: массово (с локальной машины)
for ip in 192.168.11.{201..203} 192.168.11.{210..211}; do
echo "=== Обновляю $ip ==="
ssh k3s@$ip "sudo apt update && sudo apt upgrade -y && sudo apt install -y curl wget vim htop iptables"
done
```
### 6.2. Отключить swap
Kubernetes не любит swap. При включённом swap поды ведут себя непредсказуемо - OOMKiller срабатывает не тогда, когда ожидаешь.
**На всех нодах:**
```bash
# Отключить swap сейчас
sudo swapoff -a
# Отключить навсегда (закомментировать в fstab)
sudo sed -i '/swap/s/^/#/' /etc/fstab
# Проверить
free -h | grep Swap
```
**Ожидаемый результат:**
```
Swap: 0B 0B 0B
```
### 6.3. Загрузить kernel-модули
K3s использует overlay filesystem и bridge netfilter. Без этих модулей - ошибки при старте.
**На всех нодах:**
```bash
# Загрузить модули
sudo modprobe overlay
sudo modprobe br_netfilter
# Настроить автозагрузку
cat <<EOF | sudo tee /etc/modules-load.d/k3s.conf
overlay
br_netfilter
EOF
# Проверить
lsmod | grep -E 'overlay|br_netfilter'
```
**Ожидаемый результат:**
```
overlay 151552 0
br_netfilter 32768 0
```
### 6.4. Настроить sysctl
Параметры для сетевого взаимодействия между подами.
**На всех нодах:**
```bash
# Создать конфиг
cat <<EOF | sudo tee /etc/sysctl.d/k3s.conf
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
# Применить
sudo sysctl --system
# Проверить
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables
```
**Ожидаемый результат:**
```
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
```
### 6.5. Проверить cgroup v2
Debian 12 по умолчанию использует cgroup v2 - просто проверим.
```bash
mount | grep cgroup
```
**Ожидаемый результат (cgroup v2):**
```
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
```
**Если видишь `tmpfs on /sys/fs/cgroup type tmpfs`** - это cgroup v1. Нужно включить v2:
```bash
# Добавить параметр ядра
sudo sed -i 's|^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"|GRUB_CMDLINE_LINUX_DEFAULT="\1 systemd.unified_cgroup_hierarchy=1"|' /etc/default/grub
# Обновить GRUB
sudo update-grub
# Перезагрузить
sudo reboot
# После reboot проверить
mount | grep cgroup2
```
---
## Шаг 7: Настроить firewall
UFW - простой интерфейс к iptables. Откроем только нужные порты.
### 7.1. На master нодах (201, 202, 203)
```bash
# Установить UFW
sudo apt install -y ufw
# Базовые правила
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH (чтобы не потерять доступ)
sudo ufw allow 22/tcp
# Kubernetes API
sudo ufw allow 6443/tcp
# etcd (между masters)
sudo ufw allow 2379:2380/tcp
# Kubelet
sudo ufw allow 10250/tcp
# Flannel VXLAN
sudo ufw allow 8472/udp
# Включить
sudo ufw --force enable
# Проверить
sudo ufw status
```
### 7.2. На worker нодах (210, 211)
```bash
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 10250/tcp # Kubelet
sudo ufw allow 8472/udp # Flannel VXLAN
sudo ufw --force enable
sudo ufw status
```
![](mermaid-diagram.svg)
---
## Шаг 8: Настроить /etc/hosts
Не обязательно, но удобно - ноды смогут обращаться друг к другу по имени.
**На всех нодах:**
```bash
cat <<EOF | sudo tee -a /etc/hosts
# K3s Cluster
192.168.11.201 k3s-master-1
192.168.11.202 k3s-master-2
192.168.11.203 k3s-master-3
192.168.11.210 k3s-worker-1
192.168.11.211 k3s-worker-2
EOF
```
Проверить:
```bash
ping -c 1 k3s-master-2
```
---
## Финальная проверка
Перед переходом к установке K3s убедись, что всё готово. Запусти на любой ноде:
```bash
echo "=== Проверка готовности ноды ==="
echo -n "1. Swap отключён: "
[ $(free | grep Swap | awk '{print $2}') -eq 0 ] && echo "✓" || echo "✗ ОШИБКА"
echo -n "2. Модуль overlay: "
lsmod | grep -q overlay && echo "✓" || echo "✗ ОШИБКА"
echo -n "3. Модуль br_netfilter: "
lsmod | grep -q br_netfilter && echo "✓" || echo "✗ ОШИБКА"
echo -n "4. IP forwarding: "
[ $(sysctl -n net.ipv4.ip_forward) -eq 1 ] && echo "✓" || echo "✗ ОШИБКА"
echo -n "5. bridge-nf-call-iptables: "
[ $(sysctl -n net.bridge.bridge-nf-call-iptables) -eq 1 ] && echo "✓" || echo "✗ ОШИБКА"
echo -n "6. cgroup v2: "
mount | grep -q "cgroup2" && echo "✓" || echo "✗ ОШИБКА"
echo -n "7. UFW активен: "
sudo ufw status | grep -q "Status: active" && echo "✓" || echo "✗ ОШИБКА"
echo -n "8. Пинг k3s-master-1: "
ping -c 1 -W 1 k3s-master-1 >/dev/null 2>&1 && echo "✓" || echo "✗ ОШИБКА"
```
**Ожидаемый результат:**
```
=== Проверка готовности ноды ===
1. Swap отключён: ✓
2. Модуль overlay: ✓
3. Модуль br_netfilter: ✓
4. IP forwarding: ✓
5. bridge-nf-call-iptables: ✓
6. cgroup v2: ✓
7. UFW активен: ✓
8. Пинг k3s-master-1: ✓
```
Если где-то ✗ - вернись к соответствующему шагу.
---
## Troubleshooting
| Симптом | Причина | Решение |
|---------|---------|---------|
| VM не получает IP | cloud-init не отработал | Проверь консоль, жди 2-3 минуты, перезагрузи VM |
| SSH connection refused | sshd не запущен | Открой консоль, проверь `systemctl status ssh` |
| Swap не отключается | Строка не закомментирована в fstab | `cat /etc/fstab`, проверь swap строку, `reboot` |
| cgroup v1 после reboot | GRUB не обновился | Проверь `/proc/cmdline`, повтори `update-grub` |
| UFW блокирует всё | Забыл разрешить SSH до включения | Через консоль Proxmox: `ufw allow 22/tcp` |
| Ноды не пингуются | UFW или неправильный IP | Проверь `ip addr`, проверь правила UFW |
---
## Итог
**Что сделано:**
- ✅ Скачан Debian 12 cloud image
- ✅ Создан template VM с cloud-init
- ✅ Склонированы 5 VM (3 master + 2 worker)
- ✅ Настроены статические IP
- ✅ Подготовлена ОС (swap, modules, sysctl, cgroup v2)
- ✅ Настроен firewall с нужными портами
- ✅ SSH работает на все ноды
**Что дальше:**
👉 **Следующая статья: "Установить K3s HA кластер"**
Там мы:
- Сгенерируем token для кластера
- Установим K3s на первую master ноду
- Добавим ещё 2 master ноды (HA)
- Подключим worker ноды
- Настроим kubectl
- Проверим работу кластера
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

@@ -0,0 +1,648 @@
---
title: "K3s HA для homelab: Ставим K3s HA кластер"
date: 2025-11-02
draft: false
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
---
Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер.
Звучит слишком просто? Потому что сложная часть уже позади - в предыдущих статьях. Теперь осталось не перепутать флаги и порядок установки.
**Результат:** 5 нод в статусе Ready, etcd кластер 3/3 healthy, kubectl работает с локальной машины.
---
## Для кого это
**Подходит:**
- Прошёл статьи 1 и 2 (или имеешь готовые VM с настроенной ОС)
- Все 5 нод доступны по SSH
- Понимаешь разницу между master и worker
**Не подходит:**
- VM ещё не созданы → статья 2
- Не понимаешь зачем 3 master ноды → статья 1
---
## Что понадобится
| Компонент | Значение |
|-----------|----------|
| K3s версия | v1.31.4+k3s1 (или актуальная stable) |
| Token | Сгенерируем на первом шаге |
| SSH доступ | Ко всем 5 нодам |
| Время | ~15-20 минут |
---
## Шаг 1: Сгенерировать token
Token - общий секрет для всех нод кластера. Без правильного token нода не присоединится.
**На локальной машине:**
```bash
# Сгенерировать случайный token
openssl rand -base64 32
```
**Пример вывода:**
```
K10f8c9a7b6e5d4c3b2a1f0e9d8c7b6a5e4d3c2b1a0f9e8d7c6b5a4==
```
**Сохрани этот token** - он понадобится для каждой ноды. Положи в менеджер паролей или временный файл.
```bash
# Для удобства - сохранить в переменную (на время сессии)
export K3S_TOKEN="твой_сгенерированный_token"
echo $K3S_TOKEN
```
---
## Шаг 2: Установить K3s на первую master ноду
Первая нода инициализирует etcd кластер. Она особенная - использует флаг `--cluster-init`.
**SSH на k3s-master-1:**
```bash
ssh k3s@192.168.11.201
```
**Установка:**
```bash
# Задать переменные
export K3S_TOKEN="твой_сгенерированный_token"
export INSTALL_K3S_VERSION="v1.31.4+k3s1"
# Установить K3s
curl -sfL https://get.k3s.io | sh -s - server \
--cluster-init \
--tls-san=192.168.11.201 \
--disable=traefik \
--disable=servicelb \
--write-kubeconfig-mode=644
```
**Разбор флагов:**
| Флаг | Зачем |
|------|-------|
| `server` | Режим control plane (не agent) |
| `--cluster-init` | **Ключевой!** Инициализирует etcd. Только на первой ноде |
| `--tls-san=192.168.11.201` | Добавить IP в сертификат API server |
| `--disable=traefik` | Отключить встроенный Traefik (установим свой через Helm) |
| `--disable=servicelb` | Отключить встроенный LB (установим MetalLB) |
| `--write-kubeconfig-mode=644` | Разрешить чтение kubeconfig без sudo |
**Установка займёт 1-2 минуты.** K3s скачает бинарник (~50MB) и запустит все компоненты.
### Проверка
```bash
# 1. Статус сервиса
sudo systemctl status k3s
```
**Ожидаемый результат:**
```
● k3s.service - Lightweight Kubernetes
Loaded: loaded
Active: active (running) since ...
```
```bash
# 2. Статус ноды
sudo k3s kubectl get nodes
```
**Ожидаемый результат:**
```
NAME STATUS ROLES AGE VERSION
k3s-master-1 Ready control-plane,etcd,master 45s v1.31.4+k3s1
```
<!-- СКРИНШОТ: Терминал с выводом `sudo k3s kubectl get nodes` - одна нода в статусе Ready -->
### Checkpoint: Первая master работает
```bash
# Быстрая проверка
sudo systemctl is-active k3s && \
sudo k3s kubectl get nodes | grep -q "Ready" && \
echo "✓ Master-1 готов" || echo "✗ Проблема"
```
**Если статус NotReady или сервис не запустился:**
```bash
# Смотреть логи
sudo journalctl -u k3s -f --no-pager | tail -50
```
| Ошибка в логах | Причина | Решение |
|----------------|---------|---------|
| `cgroup v1 is not supported` | Нужен cgroup v2 | Вернись к статье 2, шаг 6.5 |
| `port 6443 already in use` | Что-то занимает порт | `sudo ss -tlnp \| grep 6443` |
| `etcd failed to start` | Мало места на диске | `df -h`, увеличь диск |
---
## Шаг 3: Добавить вторую master ноду
Теперь присоединяем вторую master. Она подключается к существующему кластеру - **без** `--cluster-init`.
**SSH на k3s-master-2:**
```bash
ssh k3s@192.168.11.202
```
**Установка:**
```bash
export K3S_TOKEN="твой_сгенерированный_token"
export INSTALL_K3S_VERSION="v1.31.4+k3s1"
curl -sfL https://get.k3s.io | sh -s - server \
--server=https://192.168.11.201:6443 \
--tls-san=192.168.11.202 \
--disable=traefik \
--disable=servicelb \
--write-kubeconfig-mode=644
```
**Ключевое отличие:**
- ❌ Нет `--cluster-init` - кластер уже инициализирован
- ✅ Есть `--server=https://192.168.11.201:6443` - адрес существующего кластера
### Проверка
```bash
# На master-2
sudo k3s kubectl get nodes
```
**Ожидаемый результат:**
```
NAME STATUS ROLES AGE VERSION
k3s-master-1 Ready control-plane,etcd,master 3m v1.31.4+k3s1
k3s-master-2 Ready control-plane,etcd,master 30s v1.31.4+k3s1
```
Две ноды - но кворума ещё нет. etcd требует большинство, а 2 из 3 - это ещё не "большинство от трёх".
---
## Шаг 4: Добавить третью master ноду
**SSH на k3s-master-3:**
```bash
ssh k3s@192.168.11.203
```
**Установка (аналогично master-2):**
```bash
export K3S_TOKEN="твой_сгенерированный_token"
export INSTALL_K3S_VERSION="v1.31.4+k3s1"
curl -sfL https://get.k3s.io | sh -s - server \
--server=https://192.168.11.201:6443 \
--tls-san=192.168.11.203 \
--disable=traefik \
--disable=servicelb \
--write-kubeconfig-mode=644
```
### Checkpoint: Control Plane HA готов
**На любой master ноде:**
```bash
# 1. Все 3 master ноды Ready
sudo k3s kubectl get nodes
```
**Ожидаемый результат:**
```
NAME STATUS ROLES AGE VERSION
k3s-master-1 Ready control-plane,etcd,master 5m v1.31.4+k3s1
k3s-master-2 Ready control-plane,etcd,master 3m v1.31.4+k3s1
k3s-master-3 Ready control-plane,etcd,master 1m v1.31.4+k3s1
```
```bash
# 2. etcd кластер здоров
sudo k3s kubectl exec -n kube-system \
$(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \
-- etcdctl endpoint health --cluster
```
**Ожидаемый результат:**
```
https://192.168.11.201:2379 is healthy: successfully committed proposal: took = 2.1ms
https://192.168.11.202:2379 is healthy: successfully committed proposal: took = 1.8ms
https://192.168.11.203:2379 is healthy: successfully committed proposal: took = 2.3ms
```
<!-- СКРИНШОТ: Терминал с выводом etcdctl endpoint health - все 3 endpoint healthy -->
**Теперь у вас настоящий HA.** Можете остановить любую master ноду - кластер продолжит работать.
### Тест отказоустойчивости (опционально)
Хотите убедиться, что HA работает? Проверьте:
```bash
# С локальной машины - остановить master-2
ssh k3s@192.168.11.202 "sudo systemctl stop k3s"
# Подождать 30-40 секунд, затем на master-1:
ssh k3s@192.168.11.201 "sudo k3s kubectl get nodes"
# master-2 станет NotReady, но кластер работает
# Проверить etcd - 2/3 кворум есть
ssh k3s@192.168.11.201 "sudo k3s kubectl exec -n kube-system \
\$(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \
-- etcdctl endpoint health --cluster"
# 2 из 3 healthy - кворум есть
# Вернуть master-2
ssh k3s@192.168.11.202 "sudo systemctl start k3s"
```
---
## Шаг 5: Добавить worker ноды
Worker ноды устанавливаются как **agent** - они не участвуют в etcd и не запускают control plane.
### 5.1. Установить K3s agent на worker-1
**SSH на k3s-worker-1:**
```bash
ssh k3s@192.168.11.210
```
**Установка:**
```bash
export K3S_TOKEN="твой_сгенерированный_token"
export INSTALL_K3S_VERSION="v1.31.4+k3s1"
curl -sfL https://get.k3s.io | sh -s - agent \
--server=https://192.168.11.201:6443
```
**Отличия от master:**
- `agent` вместо `server` - режим worker
- Нет флагов `--disable` и `--tls-san` - они не нужны для worker
- Только `--server` - куда подключаться
### 5.2. Установить K3s agent на worker-2
**SSH на k3s-worker-2:**
```bash
ssh k3s@192.168.11.211
```
**Установка:**
```bash
export K3S_TOKEN="твой_сгенерированный_token"
export INSTALL_K3S_VERSION="v1.31.4+k3s1"
curl -sfL https://get.k3s.io | sh -s - agent \
--server=https://192.168.11.201:6443
```
### Checkpoint: Все ноды в кластере
**На любой master ноде:**
```bash
sudo k3s kubectl get nodes -o wide
```
**Ожидаемый результат:**
```
NAME STATUS ROLES AGE VERSION INTERNAL-IP
k3s-master-1 Ready control-plane,etcd,master 10m v1.31.4+k3s1 192.168.11.201
k3s-master-2 Ready control-plane,etcd,master 8m v1.31.4+k3s1 192.168.11.202
k3s-master-3 Ready control-plane,etcd,master 6m v1.31.4+k3s1 192.168.11.203
k3s-worker-1 Ready <none> 2m v1.31.4+k3s1 192.168.11.210
k3s-worker-2 Ready <none> 1m v1.31.4+k3s1 192.168.11.211
```
<!-- СКРИНШОТ: Терминал с выводом kubectl get nodes - все 5 нод Ready -->
**Обрати внимание:**
- Master: роли `control-plane,etcd,master`
- Worker: роли `<none>` - только выполнение workloads
![K3S HA Cluster](mermaid-diagram.svg)
---
## Шаг 6: Настроить kubectl на локальной машине
Сейчас kubectl работает только на master нодах через `sudo k3s kubectl`. Настроим доступ с вашей рабочей машины.
### 6.1. Скопировать kubeconfig
**На локальной машине (не на ноде):**
```bash
# 1. Создать директорию
mkdir -p ~/.kube
# 2. Скопировать конфиг с master-1
scp k3s@192.168.11.201:/etc/rancher/k3s/k3s.yaml ~/.kube/config
# 3. Заменить localhost на реальный IP
sed -i 's/127.0.0.1/192.168.11.201/g' ~/.kube/config
# Для macOS:
# sed -i '' 's/127.0.0.1/192.168.11.201/g' ~/.kube/config
# 4. Права доступа
chmod 600 ~/.kube/config
```
### 6.2. Установить kubectl (если нет)
**Linux:**
```bash
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
rm kubectl
```
**macOS:**
```bash
brew install kubectl
```
### 6.3. Проверить подключение
```bash
# Версия
kubectl version
# Ноды
kubectl get nodes
# Все поды
kubectl get pods -A
```
**Ожидаемый результат `kubectl get pods -A`:**
```
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-xxx 1/1 Running 0 10m
kube-system local-path-provisioner-xxx 1/1 Running 0 10m
kube-system metrics-server-xxx 1/1 Running 0 10m
```
<!-- СКРИНШОТ: Терминал локальной машины с выводом kubectl get nodes и kubectl get pods -A -->
**Если kubectl не подключается:**
| Симптом | Причина | Решение |
|---------|---------|---------|
| `connection refused` | k3s не запущен | `ssh k3s@192.168.11.201 "sudo systemctl status k3s"` |
| `connection timeout` | Firewall блокирует | Проверь UFW на master: `sudo ufw status \| grep 6443` |
| `certificate signed by unknown authority` | Неправильный kubeconfig | Скопируй заново с master ноды |
### 6.4. Настроить автодополнение (опционально)
```bash
# Bash
echo 'source <(kubectl completion bash)' >> ~/.bashrc
echo 'alias k=kubectl' >> ~/.bashrc
echo 'complete -o default -F __start_kubectl k' >> ~/.bashrc
source ~/.bashrc
# Zsh
echo 'source <(kubectl completion zsh)' >> ~/.zshrc
source ~/.zshrc
```
Теперь работает `k get nodes` и Tab-автодополнение.
---
## Финальная проверка
Полный чеклист работоспособности кластера:
```bash
echo "=== Проверка K3s HA кластера ==="
echo -n "1. Все ноды Ready: "
[ $(kubectl get nodes --no-headers | grep -c "Ready") -eq 5 ] && echo "✓ (5/5)" || echo "✗"
echo -n "2. Master ноды: "
kubectl get nodes --no-headers | grep -c "control-plane" | xargs -I {} echo "✓ ({}/3)"
echo -n "3. Worker ноды: "
kubectl get nodes --no-headers | grep -c "<none>" | xargs -I {} echo "✓ ({}/2)"
echo -n "4. etcd healthy: "
kubectl exec -n kube-system \
$(kubectl get pods -n kube-system -l component=etcd -o name | head -1) \
-- etcdctl endpoint health --cluster 2>/dev/null | grep -c "is healthy" | xargs -I {} echo "✓ ({}/3)"
echo -n "5. CoreDNS Running: "
kubectl get pods -n kube-system -l k8s-app=kube-dns --no-headers | grep -q "Running" && echo "✓" || echo "✗"
echo -n "6. Metrics Server Running: "
kubectl get pods -n kube-system -l k8s-app=metrics-server --no-headers | grep -q "Running" && echo "✓" || echo "✗"
echo ""
echo "=== Информация о кластере ==="
kubectl cluster-info
```
**Ожидаемый результат:**
```
=== Проверка K3s HA кластера ===
1. Все ноды Ready: ✓ (5/5)
2. Master ноды: ✓ (3/3)
3. Worker ноды: ✓ (2/2)
4. etcd healthy: ✓ (3/3)
5. CoreDNS Running: ✓
6. Metrics Server Running: ✓
=== Информация о кластере ===
Kubernetes control plane is running at https://192.168.11.201:6443
CoreDNS is running at https://192.168.11.201:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
Metrics-server is running at https://192.168.11.201:6443/api/v1/namespaces/kube-system/services/https:metrics-server:https/proxy
```
---
## Тестовый деплой
Убедимся, что кластер может запускать приложения:
```bash
# 1. Создать тестовый под
kubectl run nginx-test --image=nginx:alpine --port=80
# 2. Подождать запуска
kubectl wait --for=condition=Ready pod/nginx-test --timeout=60s
# 3. Проверить на какой ноде запустился
kubectl get pod nginx-test -o wide
```
**Ожидаемый результат:**
```
NAME READY STATUS RESTARTS AGE IP NODE
nginx-test 1/1 Running 0 30s 10.42.1.5 k3s-worker-1
```
Под запустился на worker ноде - как и должно быть.
```bash
# 4. Проверить доступность изнутри кластера
kubectl run -it --rm debug --image=busybox --restart=Never -- wget -qO- nginx-test
```
**Ожидаемый результат:** HTML-страница nginx.
```bash
# 5. Удалить тестовые ресурсы
kubectl delete pod nginx-test
```
---
## Troubleshooting
### Нода не присоединяется к кластеру
**Симптом:** После установки нода не появляется в `kubectl get nodes`.
| Причина | Диагностика | Решение |
|---------|-------------|---------|
| Неправильный token | Логи: `sudo journalctl -u k3s-agent -f` | Проверь token, переустанови |
| Firewall блокирует | `curl -k https://192.168.11.201:6443` | Открой порт 6443 на masters |
| DNS не резолвит | `ping k3s-master-1` | Проверь /etc/hosts или DNS |
### etcd не формирует кворум
**Симптом:** `etcdctl endpoint health` показывает unhealthy.
```bash
# Проверить членов etcd
sudo k3s kubectl exec -n kube-system \
$(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \
-- etcdctl member list --write-out=table
```
| Причина | Диагностика | Решение |
|---------|-------------|---------|
| Порты 2379-2380 закрыты | `sudo ufw status` | `sudo ufw allow 2379:2380/tcp` |
| Нода недоступна по сети | `ping 192.168.11.20X` | Проверь сеть, UFW |
| etcd ещё стартует | `uptime` на ноде | Подожди 2-3 минуты |
### Поды не запускаются на workers
**Симптом:** Все поды на master нодах, workers пустые.
```bash
# Проверить taints
kubectl describe node k3s-worker-1 | grep Taints
```
Если есть taints - убрать:
```bash
kubectl taint nodes k3s-worker-1 node.kubernetes.io/not-ready:NoSchedule-
```
---
## Откат: удаление K3s
Если нужно начать заново:
**На master ноде:**
```bash
sudo /usr/local/bin/k3s-uninstall.sh
```
**На worker ноде:**
```bash
sudo /usr/local/bin/k3s-agent-uninstall.sh
```
**Что удаляется:**
- Бинарники K3s
- Systemd сервисы
- Данные из `/var/lib/rancher/k3s/`
- etcd данные (на masters)
- Контейнеры и образы
**⚠️ Внимание:** etcd снапшоты тоже удаляются. Если нужен бэкап:
```bash
# Перед удалением - сохранить снапшоты
sudo cp -r /var/lib/rancher/k3s/server/db/snapshots/ ~/k3s-backup/
```
---
## Итог
**Что сделано:**
- ✅ Сгенерирован token для кластера
- ✅ Установлен K3s на 3 master ноды с embedded etcd
- ✅ Добавлены 2 worker ноды
- ✅ Настроен kubectl на локальной машине
- ✅ Проверена работоспособность кластера
**Что имеем:**
- HA Control Plane - выдерживает падение 1 master ноды
- 2 worker ноды для приложений
- kubectl доступ с локальной машины
- Встроенные компоненты: CoreDNS, metrics-server, local-path storage
**Что ещё не настроено** (следующие статьи):
- LoadBalancer (MetalLB) - для доступа к сервисам извне
- Ingress Controller (Traefik) - для HTTP/HTTPS routing
- SSL сертификаты (cert-manager) - для автоматического HTTPS
- Мониторинг (Prometheus/Grafana) - для наблюдения за кластером
---
## Что дальше
Кластер готов, но пока он изолирован от внешнего мира. Чтобы запускать реальные приложения с доступом извне, нужны:
1. **MetalLB** - выдаёт IP-адреса для LoadBalancer сервисов
2. **Traefik** - маршрутизирует HTTP/HTTPS трафик
3. **cert-manager** - автоматически получает SSL сертификаты
Это темы для следующей серии статей.
**А пока можно:**
- Поэкспериментировать с kubectl
- Задеплоить тестовые приложения
- Изучить как работает scheduling между нодами
File diff suppressed because one or more lines are too long

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"
}
}
+82
View File
@@ -0,0 +1,82 @@
global:
language: "RU"
article:
anchor_label: "Якорь"
date: "{{ .Date }}"
date_updated: "Обновлено: {{ .Date }}"
draft: "Черновик"
edit_title: "Редактировать содержимое"
reading_time:
one: "{{ .Count }} минута"
other: "{{ .Count }} минут"
reading_time_title: "Время чтения"
table_of_contents: "Оглавление"
word_count:
one: "{{ .Count }} слово"
other: "{{ .Count }} слов"
views:
one: "{{ .Count }} просмотр"
other: "{{ .Count }} просмотров"
likes:
one: "{{ .Count }} нравится"
other: "{{ .Count }} нравится"
part_of_series: "Эта статья — часть серии."
part: "Часть"
this_article: "Ты уже здесь"
related_articles: "Статьи по теме"
reply_by_email: "Ответить по электронной почте"
a11y:
title: "Настройки доступности"
disable_blur: "Отключить размытие"
disable_images: "Отключить изображения"
show_link_underline: "Показать подчеркивание ссылок"
font_size: "Размер шрифта"
author:
byline_title: "Автор"
code:
copy: "Копи."
copied: "ОК"
error:
404_title: "Страница не найдена: в замешательстве:"
404_error: "Ошибка 404"
404_description: "Похоже, запрошенная вами страница не существует."
footer:
dark_appearance: "Переключить на темный вид"
light_appearance: "Переключить на светлый вид"
powered_by: "Работает на {{ .Hugo }} &amp; {{ .Theme }}"
list:
externalurl_title: "Ссылка на внешний сайт"
no_articles: "Здесь пока нет статей."
nav:
scroll_to_top_title: "Пролистать наверх"
skip_to_main: "Перейти к основному содержимому"
search:
open_button_title: "Поиск (/)"
close_button_title: "Закрыть (Esc)"
input_placeholder: "Поиск"
sharing:
email: "Отправить по электронной почте"
facebook: "Поделиться через Facebook"
line: "Поделиться через LINE"
linkedin: "Поделиться через LinkedIn"
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 -}}
-719
View File
@@ -1,719 +0,0 @@
<!doctype html>
<html
lang="ru"
dir="ltr"
class="scroll-smooth"
data-default-appearance="light"
data-auto-appearance="true"><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 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.6f41174b3a05b680820fe08cadbfa5fb7a7ca347b76a0955cdc68b9d8aca1ce24f0547e138cea33bcc7904d551a90afcb1cc7f2d9fe8557075d501419046c08c.js"
integrity="sha512-b0EXSzoFtoCCD&#43;CMrb&#43;l&#43;3p8o0e3aglVzcaLnYrKHOJPBUfhOM6jO8x5BNVRqQr8scx/LZ/oVXB11QFBkEbAjA=="></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.858f7f82734cfae08d59fcf8d0eb186f01706a84e2f7d20d39edfd7bc8bed6166e02d5c65ecce1de82b1ac52d1e01d77bd1a82d19186fdae5fe6e12d867fcf68.js"
integrity="sha512-hY9/gnNM&#43;uCNWfz40OsYbwFwaoTi99INOe39e8i&#43;1hZuAtXGXszh3oKxrFLR4B13vRqC0ZGG/a5f5uEthn/PaA=="
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="main-menu flex items-center w-full gap-2 p-1 pl-0">
<a href="/" class="text-base font-medium truncate min-w-0 shrink">
Олег Казанин
</a>
<div class="flex items-center ms-auto">
<div class="hidden md:flex">
<nav class="flex items-center gap-x-5 h-12">
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="О блоге"
title="">
<span class="text-base font-medium break-normal">
О блоге
</span>
</a>
<a
href="/posts/"
class="flex items-center bf-icon-color-hover"
aria-label="Статьи"
title="Posts">
<span class="text-base font-medium break-normal">
Статьи
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Проекты"
title="">
<span class="text-base font-medium break-normal">
Проекты
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Резюме"
title="">
<span class="text-base font-medium break-normal">
Резюме
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Музыка"
title="">
<span class="text-base font-medium break-normal">
Музыка
</span>
</a>
<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>
<input type="checkbox" id="mobile-menu-toggle" autocomplete="off" class="hidden peer">
<label for="mobile-menu-toggle" class="flex items-center justify-center cursor-pointer bf-icon-color-hover">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 96C0 78.33 14.33 64 32 64H416C433.7 64 448 78.33 448 96C448 113.7 433.7 128 416 128H32C14.33 128 0 113.7 0 96zM0 256C0 238.3 14.33 224 32 224H416C433.7 224 448 238.3 448 256C448 273.7 433.7 288 416 288H32C14.33 288 0 273.7 0 256zM416 448H32C14.33 448 0 433.7 0 416C0 398.3 14.33 384 32 384H416C433.7 384 448 398.3 448 416C448 433.7 433.7 448 416 448z"/></svg>
</span>
</label>
<div
role="dialog"
aria-modal="true"
style="scrollbar-gutter: stable;"
class="fixed inset-0 z-50 invisible overflow-y-auto px-6 py-20 opacity-0 transition-[opacity,visibility] duration-300 peer-checked:visible peer-checked:opacity-100 bg-neutral-50/97 dark:bg-neutral-900/99
bf-scrollbar">
<label
for="mobile-menu-toggle"
class="fixed end-8 top-5 flex items-center justify-center z-50 h-12 w-12 cursor-pointer select-none rounded-full bf-icon-color-hover border bf-border-color bf-border-color-hover bg-neutral-50 dark:bg-neutral-900">
<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>
</label>
<nav class="mx-auto max-w-md space-y-6">
<div class="px-2">
<a
href=""
aria-label="О блоге"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
О блоге
</span>
</a>
</div>
<div class="px-2">
<a
href="/posts/"
aria-label="Статьи"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="Posts" class="text-2xl font-bold tracking-tight">
Статьи
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Проекты"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Проекты
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Резюме"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Резюме
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Музыка"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Музыка
</span>
</a>
</div>
</nav>
</div>
</div>
</div>
</div>
</div>
<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

-759
View File
@@ -1,759 +0,0 @@
<!doctype html>
<html
lang="ru"
dir="ltr"
class="scroll-smooth"
data-default-appearance="light"
data-auto-appearance="true"><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 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.6f41174b3a05b680820fe08cadbfa5fb7a7ca347b76a0955cdc68b9d8aca1ce24f0547e138cea33bcc7904d551a90afcb1cc7f2d9fe8557075d501419046c08c.js"
integrity="sha512-b0EXSzoFtoCCD&#43;CMrb&#43;l&#43;3p8o0e3aglVzcaLnYrKHOJPBUfhOM6jO8x5BNVRqQr8scx/LZ/oVXB11QFBkEbAjA=="></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.858f7f82734cfae08d59fcf8d0eb186f01706a84e2f7d20d39edfd7bc8bed6166e02d5c65ecce1de82b1ac52d1e01d77bd1a82d19186fdae5fe6e12d867fcf68.js"
integrity="sha512-hY9/gnNM&#43;uCNWfz40OsYbwFwaoTi99INOe39e8i&#43;1hZuAtXGXszh3oKxrFLR4B13vRqC0ZGG/a5f5uEthn/PaA=="
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="main-menu flex items-center w-full gap-2 p-1 pl-0">
<a href="/" class="text-base font-medium truncate min-w-0 shrink">
Олег Казанин
</a>
<div class="flex items-center ms-auto">
<div class="hidden md:flex">
<nav class="flex items-center gap-x-5 h-12">
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="О блоге"
title="">
<span class="text-base font-medium break-normal">
О блоге
</span>
</a>
<a
href="/posts/"
class="flex items-center bf-icon-color-hover"
aria-label="Статьи"
title="Posts">
<span class="text-base font-medium break-normal">
Статьи
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Проекты"
title="">
<span class="text-base font-medium break-normal">
Проекты
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Резюме"
title="">
<span class="text-base font-medium break-normal">
Резюме
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Музыка"
title="">
<span class="text-base font-medium break-normal">
Музыка
</span>
</a>
<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>
<input type="checkbox" id="mobile-menu-toggle" autocomplete="off" class="hidden peer">
<label for="mobile-menu-toggle" class="flex items-center justify-center cursor-pointer bf-icon-color-hover">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 96C0 78.33 14.33 64 32 64H416C433.7 64 448 78.33 448 96C448 113.7 433.7 128 416 128H32C14.33 128 0 113.7 0 96zM0 256C0 238.3 14.33 224 32 224H416C433.7 224 448 238.3 448 256C448 273.7 433.7 288 416 288H32C14.33 288 0 273.7 0 256zM416 448H32C14.33 448 0 433.7 0 416C0 398.3 14.33 384 32 384H416C433.7 384 448 398.3 448 416C448 433.7 433.7 448 416 448z"/></svg>
</span>
</label>
<div
role="dialog"
aria-modal="true"
style="scrollbar-gutter: stable;"
class="fixed inset-0 z-50 invisible overflow-y-auto px-6 py-20 opacity-0 transition-[opacity,visibility] duration-300 peer-checked:visible peer-checked:opacity-100 bg-neutral-50/97 dark:bg-neutral-900/99
bf-scrollbar">
<label
for="mobile-menu-toggle"
class="fixed end-8 top-5 flex items-center justify-center z-50 h-12 w-12 cursor-pointer select-none rounded-full bf-icon-color-hover border bf-border-color bf-border-color-hover bg-neutral-50 dark:bg-neutral-900">
<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>
</label>
<nav class="mx-auto max-w-md space-y-6">
<div class="px-2">
<a
href=""
aria-label="О блоге"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
О блоге
</span>
</a>
</div>
<div class="px-2">
<a
href="/posts/"
aria-label="Статьи"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="Posts" class="text-2xl font-bold tracking-tight">
Статьи
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Проекты"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Проекты
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Резюме"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Резюме
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Музыка"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Музыка
</span>
</a>
</div>
</nav>
</div>
</div>
</div>
</div>
</div>
<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>
-13
View File
@@ -1,13 +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>
<copyright>© 2026 </copyright>
<atom:link href="http://192.168.11.190:1313/authors/index.xml" rel="self" type="application/rss+xml" />
</channel>
</rss>
-759
View File
@@ -1,759 +0,0 @@
<!doctype html>
<html
lang="ru"
dir="ltr"
class="scroll-smooth"
data-default-appearance="light"
data-auto-appearance="true"><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 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.6f41174b3a05b680820fe08cadbfa5fb7a7ca347b76a0955cdc68b9d8aca1ce24f0547e138cea33bcc7904d551a90afcb1cc7f2d9fe8557075d501419046c08c.js"
integrity="sha512-b0EXSzoFtoCCD&#43;CMrb&#43;l&#43;3p8o0e3aglVzcaLnYrKHOJPBUfhOM6jO8x5BNVRqQr8scx/LZ/oVXB11QFBkEbAjA=="></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.858f7f82734cfae08d59fcf8d0eb186f01706a84e2f7d20d39edfd7bc8bed6166e02d5c65ecce1de82b1ac52d1e01d77bd1a82d19186fdae5fe6e12d867fcf68.js"
integrity="sha512-hY9/gnNM&#43;uCNWfz40OsYbwFwaoTi99INOe39e8i&#43;1hZuAtXGXszh3oKxrFLR4B13vRqC0ZGG/a5f5uEthn/PaA=="
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="main-menu flex items-center w-full gap-2 p-1 pl-0">
<a href="/" class="text-base font-medium truncate min-w-0 shrink">
Олег Казанин
</a>
<div class="flex items-center ms-auto">
<div class="hidden md:flex">
<nav class="flex items-center gap-x-5 h-12">
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="О блоге"
title="">
<span class="text-base font-medium break-normal">
О блоге
</span>
</a>
<a
href="/posts/"
class="flex items-center bf-icon-color-hover"
aria-label="Статьи"
title="Posts">
<span class="text-base font-medium break-normal">
Статьи
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Проекты"
title="">
<span class="text-base font-medium break-normal">
Проекты
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Резюме"
title="">
<span class="text-base font-medium break-normal">
Резюме
</span>
</a>
<a
href=""
class="flex items-center bf-icon-color-hover"
aria-label="Музыка"
title="">
<span class="text-base font-medium break-normal">
Музыка
</span>
</a>
<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>
<input type="checkbox" id="mobile-menu-toggle" autocomplete="off" class="hidden peer">
<label for="mobile-menu-toggle" class="flex items-center justify-center cursor-pointer bf-icon-color-hover">
<span class="relative block icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 96C0 78.33 14.33 64 32 64H416C433.7 64 448 78.33 448 96C448 113.7 433.7 128 416 128H32C14.33 128 0 113.7 0 96zM0 256C0 238.3 14.33 224 32 224H416C433.7 224 448 238.3 448 256C448 273.7 433.7 288 416 288H32C14.33 288 0 273.7 0 256zM416 448H32C14.33 448 0 433.7 0 416C0 398.3 14.33 384 32 384H416C433.7 384 448 398.3 448 416C448 433.7 433.7 448 416 448z"/></svg>
</span>
</label>
<div
role="dialog"
aria-modal="true"
style="scrollbar-gutter: stable;"
class="fixed inset-0 z-50 invisible overflow-y-auto px-6 py-20 opacity-0 transition-[opacity,visibility] duration-300 peer-checked:visible peer-checked:opacity-100 bg-neutral-50/97 dark:bg-neutral-900/99
bf-scrollbar">
<label
for="mobile-menu-toggle"
class="fixed end-8 top-5 flex items-center justify-center z-50 h-12 w-12 cursor-pointer select-none rounded-full bf-icon-color-hover border bf-border-color bf-border-color-hover bg-neutral-50 dark:bg-neutral-900">
<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>
</label>
<nav class="mx-auto max-w-md space-y-6">
<div class="px-2">
<a
href=""
aria-label="О блоге"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
О блоге
</span>
</a>
</div>
<div class="px-2">
<a
href="/posts/"
aria-label="Статьи"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="Posts" class="text-2xl font-bold tracking-tight">
Статьи
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Проекты"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Проекты
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Резюме"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Резюме
</span>
</a>
</div>
<div class="px-2">
<a
href=""
aria-label="Музыка"
class="flex items-center gap-4 group bf-icon-color-hover text-neutral-700 dark:text-neutral-200">
<span title="" class="text-2xl font-bold tracking-tight">
Музыка
</span>
</a>
</div>
</nav>
</div>
</div>
</div>
</div>
</div>
<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>
-13
View File
@@ -1,13 +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>
<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

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