Compare commits
55 Commits
cc5f65be6b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7be5dd910b | |||
| 07bf07f78e | |||
| 132f194432 | |||
| 9138b45459 | |||
| 5f56173ae9 | |||
| 0f6bdfd02b | |||
| b8594dff65 | |||
| e7ddb7bc82 | |||
| 40e42fd8c6 | |||
| 05e5ffdad4 | |||
| c9bbffc3bf | |||
| f6026656d0 | |||
| 9cda70f120 | |||
| 612222c222 | |||
| 1147582f48 | |||
| f4076d1cc7 | |||
| 97a9aa1d45 | |||
| c8e6459276 | |||
| ca2a6bc0ef | |||
| 2057150a05 | |||
| 4036fb658d | |||
| 6fb1f08915 | |||
| 9e0acd35de | |||
| 1c61dcf599 | |||
| 3d7016a480 | |||
| 286434738d | |||
| 086c6e11dc | |||
| 697fb39c28 | |||
| 363374c6a9 | |||
| 1cbbb645fb | |||
| dc532ca592 | |||
| b63ff31c66 | |||
| 07141970c9 | |||
| 2860cf8374 | |||
| 4c36dd4ade | |||
| 9e9fcf1721 | |||
| 11b97341aa | |||
| 44d254f1db | |||
| dacf5e6e60 | |||
| 589b0b01be | |||
| 04c6e52579 | |||
| dc5d480414 | |||
| 961d51fb37 | |||
| c977afe167 | |||
| 7197209d92 | |||
| b7c1d5d478 | |||
| b05195a735 | |||
| 156f12a107 | |||
| 3ecd02c4ab | |||
| 7de05deb3c | |||
| d4ed184220 | |||
| 2e967e5656 | |||
| bdba1274f6 | |||
| 7b08cef369 | |||
| 49ab08074f |
@@ -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 @@
|
|||||||
Production webhook test: Sat Feb 14 09:52:40 UTC 2026
|
Webhook test: Sat Feb 14 09:49:12 UTC 2026
|
||||||
|
|||||||
@@ -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/"
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/" },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
|
|
||||||
[[main]]
|
[[main]]
|
||||||
name = "О блоге"
|
name = "Об авторе"
|
||||||
pageRef = "about"
|
pageRef = "about"
|
||||||
weight = 10
|
weight = 10
|
||||||
|
|
||||||
@@ -22,19 +22,24 @@
|
|||||||
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
|
||||||
@@ -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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
@@ -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 >}}
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
title: "Категории"
|
||||||
|
---
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Шпаргалки"
|
||||||
|
description: "Справочники и шпаргалки по DevOps, Kubernetes, Git, Hugo и другим инструментам. Только практика, без воды."
|
||||||
|
---
|
||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -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` для проверенного контента. Всегда тестируй перед публикацией.
|
||||||
@@ -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.
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Hello World"
|
|
||||||
date: 2026-02-13
|
|
||||||
draft: false
|
|
||||||
description: "Первый пост на новом сайте"
|
|
||||||
tags: ["test"]
|
|
||||||
---
|
|
||||||
|
|
||||||
## Привет!
|
|
||||||
|
|
||||||
Это первый пост на новом сайте **oakazanin.ru**.
|
|
||||||
|
|
||||||
Сайт работает на Hugo + Blowfish теме.
|
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
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?"
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
**Что выкинули:**
|
||||||
|
|
||||||
|
- Интеграции с облачными провайдерами (вы же не в 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. Одна упала - две оставшиеся продолжают работать.
|
||||||
|
|
||||||
|
Это как договор, требующий подписи обоих директоров - заболел один, и компания парализована.
|
||||||
|
|
||||||
|
С двумя нодами вы не получили отказоустойчивость. Вы удвоили количество точек отказа и назвали это "высокой доступностью".
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Правило:** или 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 виртуальные машины
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Сравнение подходов
|
||||||
|
|
||||||
|
| Критерий | 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'а второй держит нагрузку
|
||||||
|
- Легко добавить третью, четвёртую ноду потом
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура нашего кластера
|
||||||
|
|
||||||
|
Вот что мы будем строить:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Ключевые моменты:**
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Порты между нодами
|
||||||
|
|
||||||
|
| Порт | Протокол | Направление | Назначение |
|
||||||
|
|------|----------|-------------|------------|
|
||||||
|
| 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)
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 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
|
||||||
|
- Проверим работу кластера
|
||||||
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 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 между нодами
|
||||||
|
|
||||||
|
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 решение.
|
||||||
|
|
||||||
|
Поехали.
|
||||||
|
|
||||||
|
---
|
||||||
|
After Width: | Height: | Size: 149 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
title: "Теги"
|
||||||
|
---
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }} & {{ .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: "Показать еще"
|
||||||
@@ -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>
|
||||||
@@ -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 -->
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{- $icon := resources.Get (print "icons/" . ".svg") -}}
|
||||||
|
{{- if $icon -}}
|
||||||
|
<span class="relative block icon">
|
||||||
|
{{- $icon.Content | safeHTML -}}
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
@@ -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&v=2&port=1313&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 · Олег Казанин</title>
|
|
||||||
<meta name="title" content="404 Page not found · Олег Казанин">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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+X35yZ+Hwj+BFG9N1Pn0nwStAx47Xw7lwEFnB6FFXKLPqPwfOhns4UhrASbc0xCmPg==">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script
|
|
||||||
type="text/javascript"
|
|
||||||
src="/js/appearance.min.6f41174b3a05b680820fe08cadbfa5fb7a7ca347b76a0955cdc68b9d8aca1ce24f0547e138cea33bcc7904d551a90afcb1cc7f2d9fe8557075d501419046c08c.js"
|
|
||||||
integrity="sha512-b0EXSzoFtoCCD+CMrb+l+3p8o0e3aglVzcaLnYrKHOJPBUfhOM6jO8x5BNVRqQr8scx/LZ/oVXB11QFBkEbAjA=="></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="/lib/zoom/zoom.min.umd.a527109b68c082a70f3697716dd72a9d5aa8b545cf800cecbbc7399f2ca6f6e0ce3e431f2062b48bbfa47c9ea42822714060bef309be073f49b9c0e30d318d7b.js" integrity="sha512-pScQm2jAgqcPNpdxbdcqnVqotUXPgAzsu8c5nyym9uDOPkMfIGK0i7+kfJ6kKCJxQGC+8wm+Bz9JucDjDTGNew=="></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script
|
|
||||||
defer
|
|
||||||
type="text/javascript"
|
|
||||||
id="script-bundle"
|
|
||||||
src="/js/main.bundle.min.858f7f82734cfae08d59fcf8d0eb186f01706a84e2f7d20d39edfd7bc8bed6166e02d5c65ecce1de82b1ac52d1e01d77bd1a82d19186fdae5fe6e12d867fcf68.js"
|
|
||||||
integrity="sha512-hY9/gnNM+uCNWfz40OsYbwFwaoTi99INOe39e8i+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">↓</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="Пролистать наверх">
|
|
||||||
↑
|
|
||||||
</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">
|
|
||||||
©
|
|
||||||
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> & <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>
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -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&v=2&port=1313&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 · Олег Казанин</title>
|
|
||||||
<meta name="title" content="Authors · Олег Казанин">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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+X35yZ+Hwj+BFG9N1Pn0nwStAx47Xw7lwEFnB6FFXKLPqPwfOhns4UhrASbc0xCmPg==">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script
|
|
||||||
type="text/javascript"
|
|
||||||
src="/js/appearance.min.6f41174b3a05b680820fe08cadbfa5fb7a7ca347b76a0955cdc68b9d8aca1ce24f0547e138cea33bcc7904d551a90afcb1cc7f2d9fe8557075d501419046c08c.js"
|
|
||||||
integrity="sha512-b0EXSzoFtoCCD+CMrb+l+3p8o0e3aglVzcaLnYrKHOJPBUfhOM6jO8x5BNVRqQr8scx/LZ/oVXB11QFBkEbAjA=="></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="/lib/zoom/zoom.min.umd.a527109b68c082a70f3697716dd72a9d5aa8b545cf800cecbbc7399f2ca6f6e0ce3e431f2062b48bbfa47c9ea42822714060bef309be073f49b9c0e30d318d7b.js" integrity="sha512-pScQm2jAgqcPNpdxbdcqnVqotUXPgAzsu8c5nyym9uDOPkMfIGK0i7+kfJ6kKCJxQGC+8wm+Bz9JucDjDTGNew=="></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script
|
|
||||||
defer
|
|
||||||
type="text/javascript"
|
|
||||||
id="script-bundle"
|
|
||||||
src="/js/main.bundle.min.858f7f82734cfae08d59fcf8d0eb186f01706a84e2f7d20d39edfd7bc8bed6166e02d5c65ecce1de82b1ac52d1e01d77bd1a82d19186fdae5fe6e12d867fcf68.js"
|
|
||||||
integrity="sha512-hY9/gnNM+uCNWfz40OsYbwFwaoTi99INOe39e8i+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">↓</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="Пролистать наверх">
|
|
||||||
↑
|
|
||||||
</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">
|
|
||||||
©
|
|
||||||
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> & <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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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&v=2&port=1313&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 · Олег Казанин</title>
|
|
||||||
<meta name="title" content="Categories · Олег Казанин">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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+X35yZ+Hwj+BFG9N1Pn0nwStAx47Xw7lwEFnB6FFXKLPqPwfOhns4UhrASbc0xCmPg==">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script
|
|
||||||
type="text/javascript"
|
|
||||||
src="/js/appearance.min.6f41174b3a05b680820fe08cadbfa5fb7a7ca347b76a0955cdc68b9d8aca1ce24f0547e138cea33bcc7904d551a90afcb1cc7f2d9fe8557075d501419046c08c.js"
|
|
||||||
integrity="sha512-b0EXSzoFtoCCD+CMrb+l+3p8o0e3aglVzcaLnYrKHOJPBUfhOM6jO8x5BNVRqQr8scx/LZ/oVXB11QFBkEbAjA=="></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="/lib/zoom/zoom.min.umd.a527109b68c082a70f3697716dd72a9d5aa8b545cf800cecbbc7399f2ca6f6e0ce3e431f2062b48bbfa47c9ea42822714060bef309be073f49b9c0e30d318d7b.js" integrity="sha512-pScQm2jAgqcPNpdxbdcqnVqotUXPgAzsu8c5nyym9uDOPkMfIGK0i7+kfJ6kKCJxQGC+8wm+Bz9JucDjDTGNew=="></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script
|
|
||||||
defer
|
|
||||||
type="text/javascript"
|
|
||||||
id="script-bundle"
|
|
||||||
src="/js/main.bundle.min.858f7f82734cfae08d59fcf8d0eb186f01706a84e2f7d20d39edfd7bc8bed6166e02d5c65ecce1de82b1ac52d1e01d77bd1a82d19186fdae5fe6e12d867fcf68.js"
|
|
||||||
integrity="sha512-hY9/gnNM+uCNWfz40OsYbwFwaoTi99INOe39e8i+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">↓</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="Пролистать наверх">
|
|
||||||
↑
|
|
||||||
</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">
|
|
||||||
©
|
|
||||||
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> & <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>
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 737 B |