Add web stack skills bundle: 6 skills for production self-hosted web services
- traefik-architect: Traefik v3 reverse proxy patterns - docker-compose-architect: compose.yaml conventions and templates - gitea-actions-cd: workflow_dispatch CD pattern, Linux+Windows targets - web-security-hardening: OWASP Top 10, CSP, CrowdSec, sops+age - backup-restore: restic + WAL-G, GFS retention, tested restore - observability: Prometheus + Loki + Grafana + Alertmanager README: regenerated skill table and added 'Web stack skills bundle' section showing recommended composition order.
This commit is contained in:
@@ -6,18 +6,37 @@ Custom skills for Claude.ai (claude.ai → Settings → Skills).
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| **backup-restore** | Backup & restore strategy for self-hosted Docker stacks. restic to S3-compatible storage, Postgres logical+WAL, named-volume snapshots, retention policies (GFS), encryption at rest, tested restoration playbooks, automated verification, Telegram alerts on failure. |
|
||||
| **bulletproof** | 12-stage adaptive dev workflow (research → deploy). Adapted for Python/Docker/Traefik/MikroTik/embedded stacks, Gitea CI/CD, SonarQube. Based on Bulletproof v5.0 by Artemiy Miller. |
|
||||
| **docker-compose-architect** | Docker Compose v2 best practices. `compose.yaml` conventions, healthchecks, restart policies, named volumes, secrets, env_file, networks, resource limits, multi-stage builds, image pinning, log rotation. Production-ready stack templates. |
|
||||
| **embedded-firmware-engineer** | Bare-metal & RTOS firmware: ESP32/ESP-IDF, STM32 HAL/LL, Nordic nRF, FreeRTOS, Zephyr. NASA/JPL Power of Ten rules, banned functions, DMA/cache coherence, GPIO policy, watchdog strategy, brown-out testing. |
|
||||
| **gitea-actions-cd** | Gitea Actions CI/CD. `workflow_dispatch`-only deploy pattern, `DEPLOY_GIT_BASE=ssh://git@gitea-lan` convention, `template-cd` extension. Compose deploy to Linux hosts and Windows (NSSM) via SCP+SSH. Image build & push to Gitea registry, SonarQube BSL pipeline. |
|
||||
| **my-python-senior** | Senior-level Python engineer for systems, containers, LLM workflows, networking, and file processing. |
|
||||
| **observability** | Self-hosted observability stack. Prometheus + Grafana + Loki + Alertmanager + cAdvisor + node_exporter + blackbox_exporter. Service-instrumentation patterns, dashboards as code, alerting rules, Telegram delivery via TGServerService bot. |
|
||||
| **obsidian-memory** | Protocol for using `creator/obsidian-vault` (Gitea repo) as Claude's long-term memory for the user's **personal** projects (infra, embedded, 1C-consulting, lotus-eletre, etc.). Vault layout, frontmatter conventions, Gitea REST API mechanics, write-permission boundaries, domain routing vs `ucnl-market-memory`. |
|
||||
| **pcb-ai-engineer** | Code-driven schematic & PCB design using Circuit-Synth (Python) → KiCad → Altium. Universal STM-family abstraction with `family → package → pinmap → capabilities` data model. |
|
||||
| **traefik-architect** | Traefik v3 reverse proxy. Labels-based routing, TLS via Let's Encrypt (DNS-challenge + RSA), middleware (security headers, rate limit, BasicAuth, CrowdSec), secret-path pattern, sticky sessions, gRPC, websockets. Dynamic file provider for static routes. |
|
||||
| **ucnl-market-memory** | Protocol for using `ucnlmarket/ucnl-market-memory` (Gitea repo) as Claude's long-term memory for **UCN marketing and sales** (clients, distributors, leads, trade shows, pricing, uWave/Zima2/uSpeak/USBL product marketing, export deals, regional markets). Multi-user (creator / d.zaitsev / v.vinogradova). `sensitive: true` flag for commercial data. Strict domain routing vs `obsidian-memory`. |
|
||||
| **web-security-hardening** | Production web security. OWASP Top 10 mitigations, CSP/HSTS/COOP/COEP headers, CrowdSec bouncer for Traefik, rate limiting, secrets management (sops/age), TLS hardening, authentication patterns (OAuth2/OIDC, BasicAuth+IP), CSRF/XSS/SQLi defense, dependency scanning. Self-hosted infra focus. |
|
||||
|
||||
## Web stack skills bundle
|
||||
|
||||
Six skills covering full lifecycle of a production self-hosted web service. Designed to be used together:
|
||||
|
||||
1. **docker-compose-architect** — service definition
|
||||
2. **traefik-architect** — TLS, routing, middleware
|
||||
3. **web-security-hardening** — defense in depth
|
||||
4. **gitea-actions-cd** — build & deploy
|
||||
5. **observability** — metrics, logs, alerts
|
||||
6. **backup-restore** — data safety net
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
claude-skills/
|
||||
├── README.md
|
||||
├── backup-restore/
|
||||
│ └── SKILL.md
|
||||
├── bulletproof/
|
||||
│ ├── SKILL.md
|
||||
│ ├── agents/
|
||||
@@ -27,8 +46,12 @@ claude-skills/
|
||||
│ ├── plan.md
|
||||
│ ├── research.md
|
||||
│ └── spec.md
|
||||
├── docker-compose-architect/
|
||||
│ └── SKILL.md
|
||||
├── embedded-firmware-engineer/
|
||||
│ └── SKILL.md
|
||||
├── gitea-actions-cd/
|
||||
│ └── SKILL.md
|
||||
├── my-python-senior/
|
||||
│ ├── SKILL.md
|
||||
│ ├── ai-ml-llm.md
|
||||
@@ -36,6 +59,8 @@ claude-skills/
|
||||
│ ├── files-io.md
|
||||
│ ├── networking.md
|
||||
│ └── systems.md
|
||||
├── observability/
|
||||
│ └── SKILL.md
|
||||
├── obsidian-memory/
|
||||
│ └── SKILL.md
|
||||
├── pcb-ai-engineer/
|
||||
@@ -45,7 +70,11 @@ claude-skills/
|
||||
│ ├── mcu_db.py
|
||||
│ ├── mcu_peripherals.py
|
||||
│ └── power.py
|
||||
└── ucnl-market-memory/
|
||||
├── traefik-architect/
|
||||
│ └── SKILL.md
|
||||
├── ucnl-market-memory/
|
||||
│ └── SKILL.md
|
||||
└── web-security-hardening/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
---
|
||||
name: backup-restore
|
||||
version: 0.1.0
|
||||
description: Backup & restore strategy for self-hosted Docker stacks. restic to S3-compatible storage, Postgres logical+WAL, named-volume snapshots, retention policies (GFS), encryption at rest, tested restoration playbooks, automated verification, Telegram alerts on failure.
|
||||
command: /backup
|
||||
---
|
||||
|
||||
# Backup & Restore
|
||||
|
||||
Ты — инженер по сохранности данных. У пользователя — самостоятельная инфраструктура, единственная страховка — собственный backup. Правило: **бэкап, который не восстанавливали, не существует**.
|
||||
|
||||
## Жёсткие инварианты
|
||||
|
||||
1. **3-2-1**: 3 копии, 2 разных носителя, 1 off-site.
|
||||
2. **Encrypted at rest**: AES-256 для всех бэкапов, ключ НЕ на том же хосте, что и данные.
|
||||
3. **Tested restore**: ежемесячно — автоматический test restore в стейдж-окружение. Без теста бэкап = плацебо.
|
||||
4. **Retention (GFS)**: 7 daily, 4 weekly, 12 monthly, 5 yearly.
|
||||
5. **БД**: логические дампы + WAL/binlog. Никогда только snapshot volume для прод-БД.
|
||||
6. **Не сохраняем секреты в бэкапе вместе с шифрующим ключом** — иначе компрометация одного = доступа ко всему.
|
||||
7. **Notifications**: success — silent (в дашборд), failure — немедленно в Telegram.
|
||||
8. **Документация**: для каждого бэкапа — playbook восстановления в `creator/obsidian-vault`.
|
||||
|
||||
## Backup-стек: restic
|
||||
|
||||
```yaml
|
||||
# /opt/restic-backup/compose.yaml
|
||||
services:
|
||||
restic-backup:
|
||||
image: restic/restic:0.17.3
|
||||
container_name: restic-backup
|
||||
restart: "no" # запускается по cron
|
||||
network_mode: host
|
||||
environment:
|
||||
- RESTIC_REPOSITORY=${RESTIC_REPOSITORY} # s3:https://s3.endpoint/bucket
|
||||
- RESTIC_PASSWORD_FILE=/run/secrets/restic_password
|
||||
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/s3_access
|
||||
- AWS_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret
|
||||
- TZ=Europe/Moscow
|
||||
volumes:
|
||||
- /opt:/source/opt:ro # все стеки
|
||||
- /var/lib/docker/volumes:/source/volumes:ro
|
||||
- ./logs:/logs
|
||||
- ./scripts:/scripts:ro
|
||||
secrets:
|
||||
- restic_password
|
||||
- s3_access
|
||||
- s3_secret
|
||||
entrypoint: ["/scripts/run.sh"]
|
||||
|
||||
secrets:
|
||||
restic_password:
|
||||
file: ./secrets/restic_password.txt
|
||||
s3_access:
|
||||
file: ./secrets/s3_access.txt
|
||||
s3_secret:
|
||||
file: ./secrets/s3_secret.txt
|
||||
```
|
||||
|
||||
`scripts/run.sh`:
|
||||
```bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
export RESTIC_PASSWORD=$(cat "$RESTIC_PASSWORD_FILE")
|
||||
export AWS_ACCESS_KEY_ID=$(cat "$AWS_ACCESS_KEY_ID_FILE")
|
||||
export AWS_SECRET_ACCESS_KEY=$(cat "$AWS_SECRET_ACCESS_KEY_FILE")
|
||||
|
||||
# init repo if missing
|
||||
restic snapshots >/dev/null 2>&1 || restic init
|
||||
|
||||
# backup
|
||||
restic backup /source \
|
||||
--tag scheduled \
|
||||
--exclude='**/node_modules' \
|
||||
--exclude='**/.git/objects/pack' \
|
||||
--exclude='**/__pycache__' \
|
||||
--exclude='/source/volumes/*/restic-*' \
|
||||
--verbose
|
||||
|
||||
# retention
|
||||
restic forget \
|
||||
--tag scheduled \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 12 \
|
||||
--keep-yearly 5 \
|
||||
--prune
|
||||
|
||||
# integrity check (раз в неделю — полный, иначе fast)
|
||||
if [ "$(date +%u)" = "7" ]; then
|
||||
restic check --read-data-subset=5%
|
||||
else
|
||||
restic check
|
||||
fi
|
||||
```
|
||||
|
||||
Cron на хосте:
|
||||
```cron
|
||||
# /etc/cron.d/restic-backup
|
||||
0 3 * * * root cd /opt/restic-backup && docker compose run --rm restic-backup >> /opt/restic-backup/logs/cron.log 2>&1 || /opt/restic-backup/notify-failure.sh
|
||||
```
|
||||
|
||||
`notify-failure.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TG_CHAT_ID}" \
|
||||
-d "parse_mode=Markdown" \
|
||||
-d "text=❌ *Restic backup failed* on \`$(hostname)\` at \`$(date -Iseconds)\`. Check logs."
|
||||
```
|
||||
|
||||
## Postgres backup — pg_dump + WAL-G
|
||||
|
||||
`pg_dump` достаточен для приложений с RPO ≥ 24h. Для критичных — WAL-G с PITR.
|
||||
|
||||
### Логический дамп через sidecar
|
||||
|
||||
```yaml
|
||||
# В compose.yaml сервиса с Postgres:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16.5-alpine
|
||||
# ...
|
||||
|
||||
pg-backup:
|
||||
image: postgres:16.5-alpine
|
||||
container_name: pg-backup
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PGHOST=postgres
|
||||
- PGUSER=postgres
|
||||
- PGPASSWORD_FILE=/run/secrets/db_password
|
||||
- BACKUP_DIR=/backups
|
||||
volumes:
|
||||
- ./backups/pg:/backups
|
||||
- ./scripts/pg-dump.sh:/pg-dump.sh:ro
|
||||
secrets:
|
||||
- db_password
|
||||
networks:
|
||||
- internal
|
||||
entrypoint: ["/pg-dump.sh"]
|
||||
```
|
||||
|
||||
`scripts/pg-dump.sh`:
|
||||
```bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
export PGPASSWORD=$(cat "$PGPASSWORD_FILE")
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
DBS=$(psql -h "$PGHOST" -U "$PGUSER" -d postgres -tAc \
|
||||
"SELECT datname FROM pg_database WHERE datistemplate=false AND datname NOT IN ('postgres');")
|
||||
for db in $DBS; do
|
||||
pg_dump -h "$PGHOST" -U "$PGUSER" -d "$db" -Fc -Z 9 \
|
||||
-f "$BACKUP_DIR/${db}_${TS}.dump"
|
||||
done
|
||||
# retention локально — 7 дней (restic подберёт)
|
||||
find "$BACKUP_DIR" -name '*.dump' -mtime +7 -delete
|
||||
```
|
||||
|
||||
### WAL-G для PITR (для критичной БД)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16.5-alpine
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- wal_level=replica
|
||||
- -c
|
||||
- archive_mode=on
|
||||
- -c
|
||||
- archive_command=/usr/local/bin/wal-g wal-push %p
|
||||
- -c
|
||||
- archive_timeout=300
|
||||
environment:
|
||||
- WALG_S3_PREFIX=s3://backups/pg-wal
|
||||
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/s3_access
|
||||
# ...
|
||||
volumes:
|
||||
- ./scripts/wal-g:/usr/local/bin/wal-g:ro
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
Ежедневный base backup:
|
||||
```bash
|
||||
docker exec postgres wal-g backup-push /var/lib/postgresql/data
|
||||
```
|
||||
|
||||
## Restore playbook: Docker stack из restic
|
||||
|
||||
```bash
|
||||
# 1. На целевом хосте поднять restic
|
||||
export RESTIC_REPOSITORY=s3:https://s3.endpoint/bucket
|
||||
export RESTIC_PASSWORD=$(cat /secure/restic_password.txt)
|
||||
|
||||
# 2. Найти нужный snapshot
|
||||
restic snapshots --tag scheduled
|
||||
# восстановить последний:
|
||||
SNAP=$(restic snapshots --json --tag scheduled | jq -r '.[-1].id')
|
||||
|
||||
# 3. Восстановить определённый стек
|
||||
restic restore "$SNAP" --target /tmp/restore --include /source/opt/myapp
|
||||
|
||||
# 4. Перенести на место
|
||||
sudo systemctl stop docker # если на тот же хост
|
||||
sudo rsync -aHAX /tmp/restore/source/opt/myapp/ /opt/myapp/
|
||||
sudo systemctl start docker
|
||||
|
||||
# 5. Поднять стек
|
||||
cd /opt/myapp && docker compose up -d
|
||||
|
||||
# 6. Verify
|
||||
docker compose ps
|
||||
curl -fsS https://myapp.abelentsev.pro/health
|
||||
```
|
||||
|
||||
## Restore playbook: Postgres
|
||||
|
||||
```bash
|
||||
# Из логического дампа (полное восстановление БД)
|
||||
docker compose exec postgres dropdb -U postgres mydb
|
||||
docker compose exec postgres createdb -U postgres mydb
|
||||
docker compose exec -T postgres pg_restore -U postgres -d mydb < /opt/myapp/backups/pg/mydb_20260513_030000.dump
|
||||
|
||||
# PITR через WAL-G (к моменту t)
|
||||
docker compose down
|
||||
docker volume rm myapp_postgres_data
|
||||
docker volume create myapp_postgres_data
|
||||
docker run --rm -v myapp_postgres_data:/var/lib/postgresql/data \
|
||||
-e WALG_S3_PREFIX=s3://backups/pg-wal \
|
||||
-e AWS_ACCESS_KEY_ID=... \
|
||||
postgres-with-walg \
|
||||
wal-g backup-fetch /var/lib/postgresql/data LATEST
|
||||
|
||||
# recovery.conf указать recovery_target_time='2026-05-13 02:55:00 MSK'
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Verification — автоматический test restore
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /opt/restic-backup/verify-restore.sh — раз в месяц cron
|
||||
set -euo pipefail
|
||||
WORK=/tmp/verify-$(date +%s)
|
||||
mkdir -p "$WORK"
|
||||
|
||||
# Восстановить последний снапшот целиком
|
||||
restic restore latest --target "$WORK"
|
||||
|
||||
# Проверить, что критичные файлы есть и непустые
|
||||
test -s "$WORK/source/opt/postgres-app/backups/pg/mydb_"*.dump
|
||||
test -s "$WORK/source/opt/traefik/acme/acme.json"
|
||||
|
||||
# Попробовать pg_restore в тест-БД
|
||||
docker run --rm -d --name verify-pg -e POSTGRES_PASSWORD=t postgres:16.5-alpine
|
||||
sleep 10
|
||||
LATEST_DUMP=$(ls -t "$WORK/source/opt/postgres-app/backups/pg/"*.dump | head -1)
|
||||
docker cp "$LATEST_DUMP" verify-pg:/tmp/restore.dump
|
||||
docker exec verify-pg pg_restore -U postgres -d postgres /tmp/restore.dump
|
||||
docker stop verify-pg
|
||||
|
||||
rm -rf "$WORK"
|
||||
echo "Verify OK $(date -Iseconds)"
|
||||
```
|
||||
|
||||
Cron:
|
||||
```cron
|
||||
0 4 1 * * root /opt/restic-backup/verify-restore.sh >> /opt/restic-backup/logs/verify.log 2>&1
|
||||
```
|
||||
|
||||
## S3-compatible storage опции
|
||||
|
||||
Для off-site копии — любой S3-совместимый bucket с серверной стороны шифрованием:
|
||||
- **Backblaze B2** — $6/TB/мес, безлимитные API-операции
|
||||
- **Wasabi** — $7/TB/мес, лимит на egress
|
||||
- **Storj DCS** — децентрализованный, дешёвый
|
||||
- **MinIO** свой второй хост — для on-prem second copy перед off-site
|
||||
|
||||
Дублирование: первичный — MinIO на втором локальном хосте (`192.168.7.179`), вторичный — Backblaze B2 (`restic copy`).
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- Бэкап на тот же диск/хост, где данные. Disk failure уносит обоих.
|
||||
- Только snapshot Docker volume для prod БД — высокая вероятность corrupt на restore.
|
||||
- Backup без шифрования в публичном S3.
|
||||
- Ключ шифрования рядом с бэкапом (например, в `creator/obsidian-vault`, который сам бэкапится).
|
||||
- Никакой retention → bucket растёт бесконечно → счёт растёт.
|
||||
- Никогда не тестировал восстановление — Schrödinger's backup.
|
||||
- `--exclude` не учитывает динамические каталоги (`/tmp`, `node_modules`) → раздутый бэкап.
|
||||
- Хранить дампы БД на named volume рядом с самой БД — оба унесёт.
|
||||
- Бэкап Traefik `acme.json` без бэкапа — после restore новые серты на 7 дней rate-limit Let's Encrypt.
|
||||
|
||||
## Чек-лист для нового стека
|
||||
|
||||
- [ ] Volume(ы) с данными зафиксированы в backup-source list
|
||||
- [ ] Если есть БД — `pg_dump`/`mysqldump` sidecar добавлен
|
||||
- [ ] Если БД критичная — WAL-G / binlog настроены
|
||||
- [ ] Secrets (acme.json, age-keys) бэкапятся отдельным защищённым путём
|
||||
- [ ] Restore playbook написан в `creator/obsidian-vault/claude/memory/backup/restore-<service>.md`
|
||||
- [ ] Restore хотя бы один раз вручную выполнен и задокументирован
|
||||
- [ ] Cron-задача добавлена
|
||||
- [ ] Telegram-нотификация настроена
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Список снапшотов
|
||||
restic snapshots
|
||||
|
||||
# Размер репозитория
|
||||
restic stats --mode raw-data
|
||||
|
||||
# Что внутри снапшота
|
||||
restic ls latest /source/opt/myapp
|
||||
|
||||
# Восстановить один файл
|
||||
restic restore latest --target /tmp --include /source/opt/myapp/config.yaml
|
||||
|
||||
# Diff между двумя снапшотами
|
||||
restic diff snap_id_a snap_id_b
|
||||
|
||||
# Сменить пароль (новый писать сразу в Vault!)
|
||||
restic key add # добавить новый ключ
|
||||
restic key remove ID # удалить старый
|
||||
|
||||
# Скопировать в second repo
|
||||
RESTIC_REPOSITORY=s3:primary RESTIC_REPOSITORY2=s3:backblaze restic copy
|
||||
|
||||
# Очистить и пересжать (раз в полгода для эффективности)
|
||||
restic prune
|
||||
```
|
||||
|
||||
## Интеграция с инфрой пользователя
|
||||
|
||||
- **Primary repo**: MinIO на `192.168.7.179` (тот же хост, что SonarQube).
|
||||
- **Off-site**: Backblaze B2 bucket, отдельный аккаунт.
|
||||
- **Шифрующий ключ**: длинная фраза в 1Password vault `Infrastructure`. Дубликат в bank safe deposit box.
|
||||
- **Logs**: `/opt/restic-backup/logs/` → Promtail → Loki → Grafana dashboard `Backups`.
|
||||
- **Alerts**: Telegram через `homework/TGServerService` бот.
|
||||
- **Восстановление документации**: каждый сервис должен иметь playbook в `creator/obsidian-vault/claude/memory/backup/`.
|
||||
- **n8n flow**: ежемесячная Telegram-сводка «Last successful backups by stack» с `restic snapshots --json` → форматирование.
|
||||
@@ -0,0 +1,317 @@
|
||||
---
|
||||
name: docker-compose-architect
|
||||
version: 0.1.0
|
||||
description: Docker Compose v2 best practices. compose.yaml conventions, healthchecks, restart policies, named volumes, secrets, env_file, networks, resource limits, multi-stage builds, image pinning, log rotation. Production-ready stack templates.
|
||||
command: /compose
|
||||
---
|
||||
|
||||
# Docker Compose Architect
|
||||
|
||||
Ты — инженер по контейнеризации. Все сервисы пользователя крутятся в Docker Compose на нескольких хостах (Debian docker host `192.168.9.147`, Ubuntu sonar `192.168.7.179`, Windows host `192.168.7.195`). Стандарт — Compose v2 (плагин), spec v3.8+.
|
||||
|
||||
## Жёсткие инварианты
|
||||
|
||||
1. **Имя файла**: ВСЕГДА `compose.yaml` (не `docker-compose.yml`, не `docker-compose.yaml`).
|
||||
2. **Версия spec**: НЕ указывать `version:` (deprecated в Compose v2).
|
||||
3. **Restart policy**: всегда `unless-stopped` для прод-сервисов (НЕ `always` — мешает плановой остановке).
|
||||
4. **Healthcheck**: обязателен у каждого долгоживущего сервиса.
|
||||
5. **Логи**: `logging.driver: json-file` с `max-size: 10m, max-file: 3` для всех сервисов, иначе диск умирает.
|
||||
6. **Образы**: пиннить minor-версию (`postgres:16.5`, не `postgres:latest` и не `postgres:16`).
|
||||
7. **Сети**: явные `networks:` блоки, никогда не дефолтная сеть. Для проксируемых через Traefik — внешняя `traefik_proxy`.
|
||||
8. **Секреты**: НИКОГДА в `environment:` plaintext. Только через `secrets:` (Docker secrets) или `env_file:` (с файлом вне git).
|
||||
9. **Volumes**: named volumes для данных, bind mounts только для конфигов и для проброса хостовых ресурсов.
|
||||
10. **Корневые пути на хосте**: `/opt/<stack>/` для прод-стеков, `~/dev/<stack>/` для экспериментов.
|
||||
|
||||
## Структура прод-стека на хосте
|
||||
|
||||
```
|
||||
/opt/<stack-name>/
|
||||
├── compose.yaml
|
||||
├── .env # не в git
|
||||
├── .env.example # шаблон в git
|
||||
├── config/ # конфиги, монтируются read-only
|
||||
│ └── <service>.conf
|
||||
├── secrets/ # не в git, mode 0600
|
||||
│ └── <secret>.txt
|
||||
└── data/ # bind mounts, если named volumes не подходят
|
||||
```
|
||||
|
||||
## Базовый шаблон сервиса
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myapp:
|
||||
image: myorg/myapp:1.2.3 # пиннить версию
|
||||
container_name: myapp # явное имя
|
||||
restart: unless-stopped
|
||||
pull_policy: missing # не дергать registry каждый старт
|
||||
|
||||
environment:
|
||||
- TZ=Europe/Moscow
|
||||
- LOG_LEVEL=info
|
||||
env_file:
|
||||
- .env # секреты сюда
|
||||
|
||||
networks:
|
||||
- traefik_proxy
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
- myapp_data:/var/lib/myapp # named volume для данных
|
||||
- ./config/app.conf:/etc/myapp/app.conf:ro # bind config read-only
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s # время на разогрев
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "2.0"
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL # принципиально drop all и явно cap_add при необходимости
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.myapp.rule=Host(`myapp.abelentsev.pro`)"
|
||||
- "traefik.http.routers.myapp.entrypoints=websecure"
|
||||
- "traefik.http.routers.myapp.tls.certresolver=cloudflare"
|
||||
- "traefik.http.routers.myapp.middlewares=public-site-stack@file"
|
||||
- "traefik.http.services.myapp.loadbalancer.server.port=8080"
|
||||
|
||||
networks:
|
||||
traefik_proxy:
|
||||
external: true
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
myapp_data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
## Паттерн: secrets через Docker secrets
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: app:1.0.0
|
||||
secrets:
|
||||
- db_password
|
||||
- api_key
|
||||
environment:
|
||||
- DB_PASSWORD_FILE=/run/secrets/db_password # приложение читает файл
|
||||
- API_KEY_FILE=/run/secrets/api_key
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
file: ./secrets/db_password.txt
|
||||
api_key:
|
||||
file: ./secrets/api_key.txt
|
||||
```
|
||||
|
||||
`./secrets/*.txt` — `chmod 0600`, в `.gitignore`. В git только `.env.example` с пустыми значениями.
|
||||
|
||||
## Паттерн: depends_on с healthcheck
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16.5-alpine
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
```
|
||||
|
||||
## Паттерн: multi-arch и multi-stage Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG PYTHON_VERSION=3.12
|
||||
|
||||
FROM python:${PYTHON_VERSION}-slim AS builder
|
||||
WORKDIR /build
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --upgrade pip
|
||||
COPY requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --user --no-warn-script-location -r requirements.txt
|
||||
|
||||
FROM python:${PYTHON_VERSION}-slim AS runtime
|
||||
RUN groupadd -r app && useradd -r -g app -u 1000 app
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=app:app /root/.local /home/app/.local
|
||||
COPY --chown=app:app . .
|
||||
USER app
|
||||
ENV PATH=/home/app/.local/bin:$PATH \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "myapp"]
|
||||
```
|
||||
|
||||
## Паттерн: VPN-контейнер (host networking)
|
||||
|
||||
Для AmneziaWG / xray / SoftEther контейнеров, которым нужен прямой доступ к интерфейсу:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
amneziawg:
|
||||
image: amneziavpn/amneziawg:latest
|
||||
container_name: amneziawg
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
volumes:
|
||||
- ./config/awg0.conf:/etc/amneziawg/awg0.conf:ro
|
||||
- /lib/modules:/lib/modules:ro
|
||||
```
|
||||
|
||||
`network_mode: host` ⇒ нельзя одновременно использовать `ports:` и `networks:`.
|
||||
|
||||
## Паттерн: backup-volume-копия
|
||||
|
||||
Для бэкапа named volume через временный контейнер:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v myapp_data:/source:ro \
|
||||
-v $(pwd)/backups:/backup \
|
||||
alpine:3.20 \
|
||||
tar czf /backup/myapp_data_$(date +%Y%m%d_%H%M%S).tar.gz -C /source .
|
||||
```
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- `image: latest` или `image: my-image` без тега — не воспроизводимо.
|
||||
- `version: '3.8'` строкой — устарело в v2.
|
||||
- `network_mode: host` без явной необходимости — теряется изоляция и Traefik не видит.
|
||||
- `restart: always` — на стеках с graceful-стопом мешает.
|
||||
- `privileged: true` — почти всегда можно заменить точечным `cap_add`.
|
||||
- Секреты в `environment:` — попадают в `docker inspect` и логи.
|
||||
- Отсутствие `logging.options.max-size` — рано или поздно убьёт диск.
|
||||
- `depends_on` без `condition: service_healthy` — приложение стартует раньше БД.
|
||||
- `volumes: ./data:/var/lib/postgres` без явного chown — права ломаются.
|
||||
- Один `compose.yaml` на 20 сервисов — разнести по логическим стекам в отдельные каталоги.
|
||||
|
||||
## .env.example шаблон (всегда в git)
|
||||
|
||||
```bash
|
||||
# === Required ===
|
||||
DB_PASSWORD=
|
||||
JWT_SECRET=
|
||||
API_KEY=
|
||||
|
||||
# === Optional ===
|
||||
LOG_LEVEL=info
|
||||
TZ=Europe/Moscow
|
||||
|
||||
# === Traefik ===
|
||||
DOMAIN=example.abelentsev.pro
|
||||
```
|
||||
|
||||
`.gitignore`:
|
||||
```
|
||||
.env
|
||||
secrets/
|
||||
data/
|
||||
acme/
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Поднять стек в фоне
|
||||
docker compose up -d
|
||||
|
||||
# Только пересоздать один сервис, не трогая остальные
|
||||
docker compose up -d --no-deps --build app
|
||||
|
||||
# Посмотреть, что Compose реально сделает (dry-run)
|
||||
docker compose config
|
||||
|
||||
# Логи с follow
|
||||
docker compose logs -f --tail=100 app
|
||||
|
||||
# Проверить healthcheck'и всех сервисов
|
||||
docker compose ps --format json | jq -r '.[] | "\(.Name): \(.Health // .State)"'
|
||||
|
||||
# Удалить стек, оставив volumes
|
||||
docker compose down
|
||||
|
||||
# Удалить стек ВМЕСТЕ с volumes (опасно)
|
||||
docker compose down -v
|
||||
|
||||
# Pull новых образов и rolling-перезапуск
|
||||
docker compose pull && docker compose up -d --remove-orphans
|
||||
|
||||
# Запустить разовую команду в существующем сервисе
|
||||
docker compose exec app bash
|
||||
|
||||
# Запустить разовую команду в одноразовом контейнере (миграции, init)
|
||||
docker compose run --rm app python manage.py migrate
|
||||
```
|
||||
|
||||
## Чек-лист перед prod-выкаткой стека
|
||||
|
||||
- [ ] Имя файла — `compose.yaml`
|
||||
- [ ] Все образы запиннены до minor-версии
|
||||
- [ ] У каждого долгоживущего сервиса есть healthcheck
|
||||
- [ ] `restart: unless-stopped` везде
|
||||
- [ ] `logging` с `max-size`/`max-file` везде
|
||||
- [ ] Секреты не в `compose.yaml`, а в `.env`/`secrets/`
|
||||
- [ ] `.env.example` коммитнут, `.env` — нет
|
||||
- [ ] Resource limits (CPU/memory) выставлены
|
||||
- [ ] `security_opt: no-new-privileges` + `cap_drop: ALL`
|
||||
- [ ] Named volumes для всех stateful данных
|
||||
- [ ] Bind mounts конфигов — `:ro`
|
||||
- [ ] Traefik labels (если публичный) с минимум `security-headers@file`
|
||||
- [ ] Backup-стратегия для volumes документирована
|
||||
|
||||
## Интеграция с инфрой пользователя
|
||||
|
||||
- Все прод-стеки лежат в `/opt/<stack>/` на `192.168.9.147`.
|
||||
- Деплой через Gitea Actions `workflow_dispatch` (см. `gitea-actions-cd` skill).
|
||||
- Прокси через единственный Traefik (см. `traefik-architect` skill).
|
||||
- Бэкап volumes через restic в S3-совместимое хранилище (см. `backup-restore` skill).
|
||||
- Мониторинг через Prometheus + cAdvisor + node_exporter (см. `observability` skill).
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
name: gitea-actions-cd
|
||||
version: 0.1.0
|
||||
description: Gitea Actions CI/CD. workflow_dispatch-only deploy pattern, DEPLOY_GIT_BASE=ssh://git@gitea-lan convention, template-cd extension. Compose deploy to Linux hosts and Windows (NSSM) via SCP+SSH. Image build & push to Gitea registry, SonarQube BSL pipeline.
|
||||
command: /gitea-cd
|
||||
---
|
||||
|
||||
# Gitea Actions CD
|
||||
|
||||
Ты — инженер CI/CD на Gitea Actions (форк GitHub Actions). У пользователя — Gitea 1.25+ на `git.h3fq32.golive.ru`, runners на разных машинах с лейблами `ubuntu-latest`, `linux-amd64`, `windows-latest`.
|
||||
|
||||
## Жёсткие инварианты
|
||||
|
||||
1. **Trigger**: ВСЕГДА `workflow_dispatch` для деплой-пайплайнов. Никогда `push` на `main` для прод-деплоя без явной команды.
|
||||
2. **DEPLOY_GIT_BASE**: всегда `ssh://git@gitea-lan` (через MikroTik static DNS, не публичный hostname). LAN runner ходит в LAN-Gitea.
|
||||
3. **Template-репо**: `homework/template-cd` — базовый шаблон. Новые репы наследуют workflow оттуда.
|
||||
4. **Образы**: в Gitea container registry `git.h3fq32.golive.ru/<org>/<repo>:<tag>`. Тег = `${{ github.sha }}` короткий + `latest`.
|
||||
5. **Кеши**: использовать `actions/cache` для apt, pip, npm, gradle, cargo. Имена ключей включают runner.os и хеш lock-файла.
|
||||
6. **Секреты**: НИКОГДА в логах. Используй `${{ secrets.NAME }}`, маскируется автоматически.
|
||||
7. **Runner labels**: явные. `runs-on: linux-amd64` или `runs-on: windows-latest`, не дефолтные.
|
||||
|
||||
## Базовый паттерн: deploy.yml (Linux + Docker Compose)
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Target environment'
|
||||
type: choice
|
||||
options: [staging, production]
|
||||
default: staging
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.event.inputs.environment }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: git.h3fq32.golive.ru
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DEPLOY_GIT_BASE: ssh://git@gitea-lan
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} # 192.168.9.147 для prod
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }} # deploy
|
||||
DEPLOY_PATH: /opt/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux-amd64
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image: ${{ steps.meta.outputs.image }}
|
||||
tag: ${{ steps.meta.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Compute metadata
|
||||
id: meta
|
||||
run: |
|
||||
TAG=$(git rev-parse --short HEAD)
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
provenance: false
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: linux-amd64
|
||||
environment: ${{ github.event.inputs.environment }}
|
||||
steps:
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Pull on remote & restart stack
|
||||
run: |
|
||||
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} <<'EOF'
|
||||
set -euo pipefail
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
git fetch --depth=1 origin main
|
||||
git reset --hard origin/main
|
||||
echo "${{ secrets.GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
EOF
|
||||
|
||||
- name: Health-check
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS https://${{ vars.DEPLOY_DOMAIN }}/health >/dev/null; then
|
||||
echo "OK"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Health-check failed"
|
||||
exit 1
|
||||
|
||||
- name: Notify Telegram on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${{ secrets.TG_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TG_CHAT_ID }}" \
|
||||
-d "text=❌ Deploy failed: ${{ github.repository }} → ${{ github.event.inputs.environment }}"
|
||||
```
|
||||
|
||||
## Паттерн: Windows-деплой через SCP + SSH + NSSM
|
||||
|
||||
Для `ITServicesAgent` и подобных Windows-сервисов на `192.168.7.195`.
|
||||
|
||||
```yaml
|
||||
name: Windows Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Build
|
||||
run: dotnet publish -c Release -r win-x64 --self-contained -o publish
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: publish/
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: linux-amd64 # SCP с linux runner
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: ./publish
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.WIN_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 192.168.7.195 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Stop service, upload, start
|
||||
run: |
|
||||
ssh ${{ vars.WIN_USER }}@192.168.7.195 'powershell -Command "nssm stop ITServicesAgent"'
|
||||
scp -r ./publish/* ${{ vars.WIN_USER }}@192.168.7.195:'C:/Services/ITServicesAgent/'
|
||||
ssh ${{ vars.WIN_USER }}@192.168.7.195 'powershell -Command "nssm start ITServicesAgent"'
|
||||
```
|
||||
|
||||
## Паттерн: SonarQube для BSL (1С)
|
||||
|
||||
Шаблон `AS/template-sonar-bsl`. SonarQube на `http://192.168.7.179:9000` (hairpin NAT обход).
|
||||
|
||||
```yaml
|
||||
name: SonarQube BSL
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
sonar:
|
||||
runs-on: linux-amd64
|
||||
container:
|
||||
image: sonarsource/sonar-scanner-cli:latest
|
||||
env:
|
||||
SONAR_HOST_URL: http://192.168.7.179:9000
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # SonarQube нужен полный git history
|
||||
|
||||
- name: Run scanner
|
||||
run: |
|
||||
sonar-scanner \
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} \
|
||||
-Dsonar.projectName="${{ github.event.repository.name }}" \
|
||||
-Dsonar.sources=. \
|
||||
-Dsonar.bsl.file.suffixes=.bsl,.os \
|
||||
-Dsonar.exclusions=**/Templates/**,**/Reports/**/Form/**
|
||||
```
|
||||
|
||||
## Паттерн: lint + test перед deploy
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'requirements.txt'
|
||||
|
||||
- name: Install
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Ruff
|
||||
run: ruff check . && ruff format --check .
|
||||
|
||||
- name: Mypy
|
||||
run: mypy --strict src/
|
||||
|
||||
- name: Pytest
|
||||
run: pytest --cov=src --cov-report=xml --cov-report=term
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage.xml
|
||||
```
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- `on: push` для прод-деплоя — случайный коммит в `main` → выкатка в прод. Только `workflow_dispatch` для prod, или `push` с явным гейтом окружения.
|
||||
- `password` плейн-текстом в шагах — даже короткоживущий, попадает в кеш.
|
||||
- `runs-on: ubuntu-latest` без проверки, что такой раннер есть у пользователя — может зависнуть.
|
||||
- `actions/checkout@v3` или старее — устарело, использовать `v4`.
|
||||
- `docker login` через `echo` без `--password-stdin` — пароль в `ps`.
|
||||
- Кеш с ключом без хеша lock-файла — старый кеш переживёт обновление зависимостей и сломает сборку.
|
||||
- Long-living токены в `secrets` без ротации — раз в полгода менять.
|
||||
- Деплой через `rsync --delete` без бэкапа — снёс конфиг и не откатишься.
|
||||
- `concurrency` без `group` — параллельный deploy на один хост → race.
|
||||
|
||||
## Чек-лист нового CD-репо
|
||||
|
||||
- [ ] Шаблон унаследован от `homework/template-cd`
|
||||
- [ ] `workflow_dispatch` для деплоя (не `push`)
|
||||
- [ ] Secrets настроены: `GITEA_TOKEN`, `DEPLOY_SSH_KEY`, `TG_BOT_TOKEN`, `TG_CHAT_ID`
|
||||
- [ ] Variables настроены: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_DOMAIN`
|
||||
- [ ] Environment защищён (required reviewers, если `production`)
|
||||
- [ ] `concurrency.group` уникальный на (репо, environment)
|
||||
- [ ] Health-check после деплоя
|
||||
- [ ] Telegram-нотификация на failure
|
||||
- [ ] Образы пиннуются по `git sha`, а не только `latest`
|
||||
- [ ] SonarQube подключён (если код, не конфиг)
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Список workflow runs для репо
|
||||
curl -fsS -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/runs" | jq
|
||||
|
||||
# Запустить workflow_dispatch через API
|
||||
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/workflows/deploy.yml/dispatches" \
|
||||
-d '{"ref":"main","inputs":{"environment":"production"}}'
|
||||
|
||||
# Скачать логи последнего run
|
||||
RUN_ID=$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/runs?limit=1" | jq -r '.workflow_runs[0].id')
|
||||
curl -fsS -H "Authorization: token $GITEA_TOKEN" \
|
||||
-o run-$RUN_ID.zip \
|
||||
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/runs/$RUN_ID/logs"
|
||||
```
|
||||
|
||||
## Интеграция с инфрой пользователя
|
||||
|
||||
- **Runners**: linux-amd64 на docker host, windows-latest на 192.168.7.195. Регистрация — `act_runner register` с лейблами.
|
||||
- **Container registry**: `git.h3fq32.golive.ru` (Gitea built-in).
|
||||
- **DEPLOY_GIT_BASE**: `ssh://git@gitea-lan` — MikroTik static DNS `gitea-lan` → 192.168.7.179.
|
||||
- **Notifications**: Telegram через `homework/TGServerService` бота.
|
||||
- **Secrets rotation**: GITEA_TOKEN (PAT) — раз в 6 месяцев. Записывать новый секрет в `creator/obsidian-vault` (зашифровано).
|
||||
@@ -0,0 +1,596 @@
|
||||
---
|
||||
name: observability
|
||||
version: 0.1.0
|
||||
description: Self-hosted observability stack. Prometheus + Grafana + Loki + Alertmanager + cAdvisor + node_exporter + blackbox_exporter. Service-instrumentation patterns (OpenTelemetry, Python/Node), dashboards as code, alerting rules, Telegram delivery via TGServerService bot.
|
||||
command: /observability
|
||||
---
|
||||
|
||||
# Observability
|
||||
|
||||
Ты — инженер по мониторингу и наблюдаемости. Стек — Prometheus + Loki + Grafana, всё self-hosted на docker host пользователя. Никаких Datadog/NewRelic.
|
||||
|
||||
## Жёсткие инварианты
|
||||
|
||||
1. **Три столпа**: metrics (Prometheus), logs (Loki), traces (опционально Tempo). Без metrics нет SLO, без logs нет debug, без traces нет distributed performance.
|
||||
2. **Метрики — pull-модель**: Prometheus scrape'ит exporters. Push (Pushgateway) — только для batch-job'ов.
|
||||
3. **Cardinality control**: НИКОГДА не метить метрику high-cardinality лейблами (user_id, request_id). Только bounded set (status_code, method, endpoint_pattern).
|
||||
4. **Alert hygiene**: алерт = «человек должен немедленно что-то сделать». Если не должен — это лог/дашборд, не алерт.
|
||||
5. **Retention**: Prometheus локально 30 дней, долговременное — Thanos/Mimir в S3 (опционально). Loki — 90 дней с compression.
|
||||
6. **Dashboards as code**: каждый дашборд в git как JSON + provisioning.
|
||||
7. **Все сервисы экспортируют /metrics**: либо нативно (FastAPI + `prometheus_client`), либо через sidecar exporter.
|
||||
|
||||
## Базовый стек
|
||||
|
||||
```yaml
|
||||
# /opt/observability/compose.yaml
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.55.1
|
||||
container_name: prometheus
|
||||
restart: unless-stopped
|
||||
user: "65534:65534"
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
- '--storage.tsdb.retention.size=50GB'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.enable-remote-write-receiver'
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./prometheus/rules:/etc/prometheus/rules:ro
|
||||
- prometheus_data:/prometheus
|
||||
networks:
|
||||
- obs
|
||||
- traefik_proxy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.prometheus.rule=Host(`prom.abelentsev.pro`)"
|
||||
- "traefik.http.routers.prometheus.entrypoints=websecure"
|
||||
- "traefik.http.routers.prometheus.tls.certresolver=cloudflare"
|
||||
- "traefik.http.routers.prometheus.middlewares=auth-basic@file,ipallowlist-lan@file"
|
||||
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.28.0
|
||||
container_name: alertmanager
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
secrets:
|
||||
- tg_bot_token
|
||||
- tg_chat_id
|
||||
networks:
|
||||
- obs
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.3.2
|
||||
container_name: loki
|
||||
restart: unless-stopped
|
||||
user: "10001:10001"
|
||||
command: ["-config.file=/etc/loki/loki.yml"]
|
||||
volumes:
|
||||
- ./loki/loki.yml:/etc/loki/loki.yml:ro
|
||||
- loki_data:/loki
|
||||
networks:
|
||||
- obs
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:3.3.2
|
||||
container_name: promtail
|
||||
restart: unless-stopped
|
||||
command: ["-config.file=/etc/promtail/promtail.yml"]
|
||||
volumes:
|
||||
- ./promtail/promtail.yml:/etc/promtail/promtail.yml:ro
|
||||
- /var/log:/var/log:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /opt/traefik/logs:/logs/traefik:ro
|
||||
networks:
|
||||
- obs
|
||||
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
container_name: cadvisor
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker:/var/lib/docker:ro
|
||||
- /dev/disk:/dev/disk:ro
|
||||
networks:
|
||||
- obs
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.2
|
||||
container_name: node-exporter
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
pid: host
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/host/root'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/host/root:ro,rslave
|
||||
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.25.0
|
||||
container_name: blackbox-exporter
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||
networks:
|
||||
- obs
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.4.0
|
||||
container_name: grafana
|
||||
restart: unless-stopped
|
||||
user: "472:472"
|
||||
environment:
|
||||
- GF_SERVER_ROOT_URL=https://grafana.abelentsev.pro
|
||||
- GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=false
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
secrets:
|
||||
- grafana_admin
|
||||
networks:
|
||||
- obs
|
||||
- traefik_proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.grafana.rule=Host(`grafana.abelentsev.pro`)"
|
||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||
- "traefik.http.routers.grafana.tls.certresolver=cloudflare"
|
||||
- "traefik.http.routers.grafana.middlewares=security-headers@file"
|
||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||
|
||||
networks:
|
||||
obs:
|
||||
traefik_proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
alertmanager_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
|
||||
secrets:
|
||||
grafana_admin:
|
||||
file: ./secrets/grafana_admin.txt
|
||||
tg_bot_token:
|
||||
file: ./secrets/tg_bot_token.txt
|
||||
tg_chat_id:
|
||||
file: ./secrets/tg_chat_id.txt
|
||||
```
|
||||
|
||||
## `prometheus.yml`
|
||||
|
||||
```yaml
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
cluster: home
|
||||
environment: production
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: [alertmanager:9093]
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/rules/*.yml
|
||||
|
||||
scrape_configs:
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: [localhost:9090]
|
||||
|
||||
- job_name: node-exporter
|
||||
static_configs:
|
||||
- targets:
|
||||
- 192.168.9.147:9100 # docker host
|
||||
- 192.168.7.179:9100 # sonar host
|
||||
- 192.168.7.195:9100 # win host (windows_exporter)
|
||||
|
||||
- job_name: cadvisor
|
||||
static_configs:
|
||||
- targets: [cadvisor:8080]
|
||||
|
||||
- job_name: traefik
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: [traefik:8080]
|
||||
|
||||
- job_name: blackbox-http
|
||||
metrics_path: /probe
|
||||
params:
|
||||
module: [http_2xx]
|
||||
static_configs:
|
||||
- targets:
|
||||
- https://example.abelentsev.pro
|
||||
- https://grafana.abelentsev.pro
|
||||
- https://git.h3fq32.golive.ru
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: __param_target
|
||||
- source_labels: [__param_target]
|
||||
target_label: instance
|
||||
- target_label: __address__
|
||||
replacement: blackbox-exporter:9115
|
||||
|
||||
- job_name: docker-services
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
filters:
|
||||
- name: label
|
||||
values: ["prometheus.scrape=true"]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_docker_container_label_prometheus_port]
|
||||
target_label: __address__
|
||||
regex: (.+)
|
||||
replacement: ${1}
|
||||
- source_labels: [__meta_docker_container_name]
|
||||
target_label: container
|
||||
```
|
||||
|
||||
Сервис, который должен скрейпиться:
|
||||
```yaml
|
||||
labels:
|
||||
- "prometheus.scrape=true"
|
||||
- "prometheus.port=myapp:8000"
|
||||
```
|
||||
|
||||
## Alerting rules (`rules/web.yml`)
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: web-services
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: ServiceDown
|
||||
expr: probe_success{job="blackbox-http"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Service {{ $labels.instance }} is down"
|
||||
description: "Blackbox probe failed for 2 minutes"
|
||||
|
||||
- alert: HighErrorRate
|
||||
expr: |
|
||||
sum by (service) (rate(traefik_service_requests_total{code=~"5.."}[5m]))
|
||||
/
|
||||
sum by (service) (rate(traefik_service_requests_total[5m]))
|
||||
> 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High 5xx rate on {{ $labels.service }}"
|
||||
description: "Error rate is {{ $value | humanizePercentage }} (>5%)"
|
||||
|
||||
- alert: HighLatency
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
sum by (service, le) (rate(traefik_service_request_duration_seconds_bucket[5m]))
|
||||
) > 1.0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "P95 latency >1s on {{ $labels.service }}"
|
||||
|
||||
- alert: CertExpiringSoon
|
||||
expr: probe_ssl_earliest_cert_expiry{job="blackbox-http"} - time() < 7 * 24 * 3600
|
||||
for: 1h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "TLS cert {{ $labels.instance }} expires in <7 days"
|
||||
|
||||
- name: host
|
||||
rules:
|
||||
- alert: HostHighCpu
|
||||
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
|
||||
- alert: HostLowDisk
|
||||
expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes) < 0.10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
|
||||
- alert: HostHighMemory
|
||||
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
|
||||
- alert: ContainerOomKilled
|
||||
expr: rate(container_memory_failures_total{failure_type="oom"}[5m]) > 0
|
||||
for: 0m
|
||||
labels:
|
||||
severity: warning
|
||||
|
||||
- name: backups
|
||||
rules:
|
||||
- alert: BackupMissing
|
||||
expr: time() - max(restic_last_successful_backup_timestamp) > 36 * 3600
|
||||
for: 0m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "No successful backup in last 36 hours"
|
||||
```
|
||||
|
||||
## Alertmanager → Telegram
|
||||
|
||||
```yaml
|
||||
# alertmanager.yml
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
receiver: telegram-critical
|
||||
group_by: [alertname, severity]
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
routes:
|
||||
- matchers: [severity="critical"]
|
||||
receiver: telegram-critical
|
||||
- matchers: [severity="warning"]
|
||||
receiver: telegram-warning
|
||||
repeat_interval: 12h
|
||||
|
||||
receivers:
|
||||
- name: telegram-critical
|
||||
telegram_configs:
|
||||
- bot_token_file: /run/secrets/tg_bot_token
|
||||
chat_id: -100123456789 # из файла tg_chat_id
|
||||
parse_mode: HTML
|
||||
message: |
|
||||
🔥 <b>CRITICAL: {{ .CommonLabels.alertname }}</b>
|
||||
{{ range .Alerts }}
|
||||
• <b>{{ .Labels.instance }}</b>: {{ .Annotations.summary }}
|
||||
{{ end }}
|
||||
|
||||
- name: telegram-warning
|
||||
telegram_configs:
|
||||
- bot_token_file: /run/secrets/tg_bot_token
|
||||
chat_id: -100123456789
|
||||
parse_mode: HTML
|
||||
message: |
|
||||
⚠️ <b>{{ .CommonLabels.alertname }}</b>
|
||||
{{ range .Alerts }}
|
||||
• {{ .Annotations.summary }}
|
||||
{{ end }}
|
||||
|
||||
inhibit_rules:
|
||||
- source_matchers: [severity="critical"]
|
||||
target_matchers: [severity="warning"]
|
||||
equal: [alertname, instance]
|
||||
```
|
||||
|
||||
## Loki `loki.yml`
|
||||
|
||||
```yaml
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
common:
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
limits_config:
|
||||
retention_period: 90d
|
||||
max_query_series: 5000
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
delete_request_store: filesystem
|
||||
retention_enabled: true
|
||||
```
|
||||
|
||||
## Promtail — Traefik access log
|
||||
|
||||
```yaml
|
||||
# promtail.yml
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
- job_name: traefik-access
|
||||
static_configs:
|
||||
- targets: [localhost]
|
||||
labels:
|
||||
job: traefik
|
||||
__path__: /logs/traefik/access.log
|
||||
pipeline_stages:
|
||||
- json:
|
||||
expressions:
|
||||
method: RequestMethod
|
||||
host: RequestHost
|
||||
status: DownstreamStatus
|
||||
duration: Duration
|
||||
- labels:
|
||||
method:
|
||||
host:
|
||||
status:
|
||||
|
||||
- job_name: docker
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_docker_container_name]
|
||||
target_label: container
|
||||
- source_labels: [__meta_docker_container_log_stream]
|
||||
target_label: stream
|
||||
```
|
||||
|
||||
## Инструментация: FastAPI пример
|
||||
|
||||
```python
|
||||
from prometheus_client import Counter, Histogram, make_asgi_app
|
||||
from fastapi import FastAPI, Request
|
||||
import time
|
||||
|
||||
REQUESTS = Counter(
|
||||
"http_requests_total",
|
||||
"HTTP requests",
|
||||
["method", "path", "status"], # path — pattern, не реальный URL!
|
||||
)
|
||||
LATENCY = Histogram(
|
||||
"http_request_duration_seconds",
|
||||
"HTTP request latency",
|
||||
["method", "path"],
|
||||
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.mount("/metrics", make_asgi_app())
|
||||
|
||||
@app.middleware("http")
|
||||
async def metrics_middleware(request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
elapsed = time.perf_counter() - start
|
||||
route = request.scope.get("route")
|
||||
path = route.path if route else "unknown" # pattern, не raw URL
|
||||
REQUESTS.labels(request.method, path, response.status_code).inc()
|
||||
LATENCY.labels(request.method, path).observe(elapsed)
|
||||
return response
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
## RED/USE метод
|
||||
|
||||
**RED** для request-driven сервисов:
|
||||
- **R**ate (RPS)
|
||||
- **E**rrors (error rate)
|
||||
- **D**uration (P50/P95/P99)
|
||||
|
||||
**USE** для ресурсов:
|
||||
- **U**tilization (% занято)
|
||||
- **S**aturation (очередь)
|
||||
- **E**rrors
|
||||
|
||||
Эти 6 метрик — минимальный must-have дашборд.
|
||||
|
||||
## SLO/SLI пример
|
||||
|
||||
Для публичного сайта:
|
||||
- **SLI availability**: `1 - error_rate` за окно 30 дней
|
||||
- **SLO**: 99.5% (≈ 3.6h downtime/мес — реалистично для self-hosted)
|
||||
- **Error budget**: 0.5%
|
||||
|
||||
```promql
|
||||
# SLO availability за 30 дней
|
||||
1 - (
|
||||
sum(rate(traefik_service_requests_total{code=~"5..", service="myapp"}[30d]))
|
||||
/
|
||||
sum(rate(traefik_service_requests_total{service="myapp"}[30d]))
|
||||
)
|
||||
```
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- Метить метрику user_id, request_id, raw URL — взрыв cardinality (Prometheus умрёт на 1M+ серий).
|
||||
- Алерт на всё подряд — alert fatigue, перестанут читать.
|
||||
- Алерт «CPU > 80%» сам по себе — это симптом, не проблема. Алертить надо на user-facing impact (latency, errors).
|
||||
- Дашборд из 50 графиков — никто не читает. Один экран = 6-10 ключевых метрик.
|
||||
- Sampling traces без головы — теряются редкие медленные запросы.
|
||||
- Логи без структуры (plain text) — невозможно агрегировать.
|
||||
- Loki без retention → диск умрёт.
|
||||
- Grafana с дефолтным admin/admin — публичный доступ = катастрофа.
|
||||
- Push в Pushgateway долгоживущих метрик — теряется state на restart.
|
||||
|
||||
## Чек-лист для нового сервиса
|
||||
|
||||
- [ ] Endpoint `/health` (или `/healthz`) — простой 200 OK
|
||||
- [ ] Endpoint `/metrics` — Prometheus exposition format
|
||||
- [ ] Labels `prometheus.scrape=true` и `prometheus.port=...` в Docker-сервисе
|
||||
- [ ] Blackbox probe в `prometheus.yml` для публичных URL
|
||||
- [ ] Alerts: `ServiceDown`, `HighErrorRate`, `HighLatency`, `CertExpiringSoon`
|
||||
- [ ] Grafana dashboard provisioned (JSON в git)
|
||||
- [ ] Логи структурированные JSON, отправляются в Loki
|
||||
- [ ] SLO документирован в `creator/obsidian-vault/claude/memory/observability/`
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Reload Prometheus без рестарта
|
||||
curl -X POST http://prom.abelentsev.pro/-/reload
|
||||
|
||||
# Проверить, что alerting правила корректны
|
||||
docker exec prometheus promtool check rules /etc/prometheus/rules/*.yml
|
||||
|
||||
# Список активных алертов
|
||||
curl -s http://prometheus:9090/api/v1/alerts | jq
|
||||
|
||||
# LogQL query через CLI (logcli)
|
||||
docker run --rm -e LOKI_ADDR=http://loki:3100 \
|
||||
--network observability_obs grafana/logcli:3.3.2 \
|
||||
query '{job="traefik"} |= "status=500"' --limit=100
|
||||
|
||||
# Размер Loki storage
|
||||
docker exec loki du -sh /loki/chunks
|
||||
|
||||
# Проверить scrape targets
|
||||
curl -s http://prometheus:9090/api/v1/targets | jq '.data.activeTargets[] | {job: .labels.job, health: .health}'
|
||||
```
|
||||
|
||||
## Интеграция с инфрой пользователя
|
||||
|
||||
- **Traefik**: `--metrics.prometheus=true` в static config, скрейпится Prometheus.
|
||||
- **MikroTik**: SNMP exporter (отдельный контейнер `prom/snmp-exporter`) → метрики маршрутизатора, VPN-каналов, hairpin NAT.
|
||||
- **PostgreSQL**: `postgres_exporter` sidecar к каждой БД.
|
||||
- **1С**: `windows_exporter` на `192.168.7.195` + кастомный exporter, читающий `Performance Counters` 1С (через `TGServerService` агент).
|
||||
- **Telegram**: alerts → Alertmanager → Telegram (НЕ через `TGServerService`, а напрямую — `bot_token_file` Alertmanager'а).
|
||||
- **Дашборды в git**: `creator/obsidian-vault/claude/memory/observability/dashboards/` (или отдельный репо `homework/grafana-dashboards`).
|
||||
- **n8n**: weekly Telegram-сводка SLO/error budget по сервисам.
|
||||
@@ -0,0 +1,383 @@
|
||||
---
|
||||
name: traefik-architect
|
||||
version: 0.1.0
|
||||
description: Traefik v3 reverse proxy. Labels-based routing, TLS via Let's Encrypt (DNS-challenge + RSA), middleware (security headers, rate limit, BasicAuth, CrowdSec), secret-path pattern, sticky sessions, gRPC, websockets. Dynamic file provider for static routes.
|
||||
command: /traefik
|
||||
---
|
||||
|
||||
# Traefik Architect
|
||||
|
||||
Ты — инженер по reverse-proxy инфраструктуре на Traefik v3. Все веб-сервисы пользователя проксируются через **единственный** инстанс Traefik на Debian Docker host (`192.168.9.147`). Внешние домены — `*.abelentsev.pro`, `*.golive.ru`, поддомены ЛПСН/UCNL.
|
||||
|
||||
## Жёсткие инварианты
|
||||
|
||||
1. **Версия**: только Traefik v3.x (не v2). Синтаксис лейблов — v3.
|
||||
2. **Файл compose**: `compose.yaml` (не `docker-compose.yml`).
|
||||
3. **certResolver**: только DNS-challenge (HTTP-01 запрещён — у пользователя сервисы за NAT/RKN-маршрутами). Провайдеры — Cloudflare DNS API.
|
||||
4. **TLS**: RSA-2048 для совместимости с SoftEther/legacy клиентами; ECDSA только если явно сказано.
|
||||
5. **Сети**: один внешний docker network `traefik_proxy` (external), все проксируемые контейнеры подключены к нему. Внутренние сервисы — на своей внутренней сети.
|
||||
6. **Лейблы — единственный способ конфигурации routes** (не файловый провайдер, кроме TCP/UDP).
|
||||
7. **HTTP→HTTPS redirect** — глобальный middleware `redirect-to-https`, без исключений.
|
||||
8. **Dashboard** — только через BasicAuth + IP whitelist (LAN-only), никогда не выставляется голым.
|
||||
|
||||
## Структура базового стека Traefik
|
||||
|
||||
```yaml
|
||||
# /opt/traefik/compose.yaml
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.2
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- traefik_proxy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- CF_DNS_API_TOKEN_FILE=/run/secrets/cf_api_token
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik.yaml:/etc/traefik/traefik.yaml:ro
|
||||
- ./dynamic:/etc/traefik/dynamic:ro
|
||||
- ./acme:/acme
|
||||
- ./logs:/logs
|
||||
secrets:
|
||||
- cf_api_token
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.dashboard.rule=Host(`traefik.abelentsev.pro`)"
|
||||
- "traefik.http.routers.dashboard.entrypoints=websecure"
|
||||
- "traefik.http.routers.dashboard.tls.certresolver=cloudflare"
|
||||
- "traefik.http.routers.dashboard.service=api@internal"
|
||||
- "traefik.http.routers.dashboard.middlewares=auth-basic@file,ipallowlist-lan@file"
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
traefik_proxy:
|
||||
external: true
|
||||
|
||||
secrets:
|
||||
cf_api_token:
|
||||
file: ./secrets/cf_api_token.txt
|
||||
```
|
||||
|
||||
## Статический конфиг `traefik.yaml`
|
||||
|
||||
```yaml
|
||||
global:
|
||||
checkNewVersion: false
|
||||
sendAnonymousUsage: false
|
||||
|
||||
api:
|
||||
dashboard: true
|
||||
insecure: false
|
||||
|
||||
ping: {}
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
filePath: /logs/traefik.log
|
||||
format: json
|
||||
|
||||
accessLog:
|
||||
filePath: /logs/access.log
|
||||
format: json
|
||||
bufferingSize: 100
|
||||
filters:
|
||||
statusCodes:
|
||||
- "400-599"
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
permanent: true
|
||||
websecure:
|
||||
address: ":443"
|
||||
http:
|
||||
tls:
|
||||
certResolver: cloudflare
|
||||
domains:
|
||||
- main: "abelentsev.pro"
|
||||
sans:
|
||||
- "*.abelentsev.pro"
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "192.168.0.0/16"
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false
|
||||
network: traefik_proxy
|
||||
file:
|
||||
directory: /etc/traefik/dynamic
|
||||
watch: true
|
||||
|
||||
certificatesResolvers:
|
||||
cloudflare:
|
||||
acme:
|
||||
email: arthur@unavlab.com
|
||||
storage: /acme/acme.json
|
||||
keyType: RSA2048
|
||||
dnsChallenge:
|
||||
provider: cloudflare
|
||||
resolvers:
|
||||
- "1.1.1.1:53"
|
||||
- "8.8.8.8:53"
|
||||
delayBeforeCheck: 30
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: false
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
crowdsec:
|
||||
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
version: v1.4.4
|
||||
```
|
||||
|
||||
## Динамический конфиг `dynamic/middlewares.yaml`
|
||||
|
||||
Все переиспользуемые middleware — здесь, чтобы не дублировать в лейблах.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
|
||||
# Security headers (применять КО ВСЕМ публичным сервисам)
|
||||
security-headers:
|
||||
headers:
|
||||
frameDeny: true
|
||||
contentTypeNosniff: true
|
||||
browserXssFilter: true
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
|
||||
stsSeconds: 63072000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
customResponseHeaders:
|
||||
X-Robots-Tag: "noindex, nofollow" # снять для публичных сайтов
|
||||
Server: ""
|
||||
X-Powered-By: ""
|
||||
contentSecurityPolicy: >-
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: https:;
|
||||
font-src 'self' data:;
|
||||
connect-src 'self';
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
|
||||
# Rate limit — стандартный для публичных API
|
||||
rate-limit-api:
|
||||
rateLimit:
|
||||
average: 100
|
||||
burst: 50
|
||||
period: 1s
|
||||
sourceCriterion:
|
||||
ipStrategy:
|
||||
depth: 1 # учитывать X-Forwarded-For
|
||||
|
||||
# Rate limit — жёсткий для авторизационных endpoints
|
||||
rate-limit-auth:
|
||||
rateLimit:
|
||||
average: 5
|
||||
burst: 10
|
||||
period: 1m
|
||||
|
||||
# IP whitelist — только LAN
|
||||
ipallowlist-lan:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- "192.168.0.0/16"
|
||||
- "10.0.0.0/8"
|
||||
- "127.0.0.1/32"
|
||||
|
||||
# BasicAuth для dashboard и internal-сервисов
|
||||
# генерация: htpasswd -nbB admin 'PASSWORD' | sed -e 's/\$/\$\$/g'
|
||||
auth-basic:
|
||||
basicAuth:
|
||||
users:
|
||||
- "admin:$$2y$$10$$REPLACE_WITH_BCRYPT_HASH"
|
||||
removeHeader: true
|
||||
|
||||
# CrowdSec bouncer (требует плагин в static config)
|
||||
crowdsec-bouncer:
|
||||
plugin:
|
||||
crowdsec:
|
||||
enabled: true
|
||||
crowdsecMode: live
|
||||
crowdsecLapiKey: "{{ env "CROWDSEC_LAPI_KEY" }}"
|
||||
crowdsecLapiHost: "crowdsec:8080"
|
||||
|
||||
# Сжатие
|
||||
compress-default:
|
||||
compress:
|
||||
excludedContentTypes:
|
||||
- "text/event-stream"
|
||||
|
||||
# Полный production-stack для публичного сайта
|
||||
public-site-stack:
|
||||
chain:
|
||||
middlewares:
|
||||
- security-headers
|
||||
- compress-default
|
||||
- rate-limit-api
|
||||
- crowdsec-bouncer
|
||||
```
|
||||
|
||||
## Паттерн: обычный публичный веб-сервис
|
||||
|
||||
В `compose.yaml` сервиса (отдельный compose-stack):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
webapp:
|
||||
image: nginx:alpine
|
||||
container_name: webapp
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- traefik_proxy
|
||||
- internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.webapp.rule=Host(`example.abelentsev.pro`)"
|
||||
- "traefik.http.routers.webapp.entrypoints=websecure"
|
||||
- "traefik.http.routers.webapp.tls.certresolver=cloudflare"
|
||||
- "traefik.http.routers.webapp.middlewares=public-site-stack@file"
|
||||
- "traefik.http.routers.webapp.service=webapp"
|
||||
|
||||
# Service
|
||||
- "traefik.http.services.webapp.loadbalancer.server.port=80"
|
||||
- "traefik.docker.network=traefik_proxy"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
traefik_proxy:
|
||||
external: true
|
||||
internal:
|
||||
```
|
||||
|
||||
## Паттерн: secret-path (как у n8n)
|
||||
|
||||
Используется для webhook'ов и MCP-серверов — endpoint не должен индексироваться/брутиться.
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.n8n.rule=Host(`webhooks.abelentsev.pro`) && PathPrefix(`/api-gw/{{SECRET_PATH}}/`)"
|
||||
- "traefik.http.routers.n8n.entrypoints=websecure"
|
||||
- "traefik.http.routers.n8n.tls.certresolver=cloudflare"
|
||||
- "traefik.http.routers.n8n.middlewares=strip-secret-prefix"
|
||||
- "traefik.http.middlewares.strip-secret-prefix.stripprefix.prefixes=/api-gw/{{SECRET_PATH}}"
|
||||
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
|
||||
```
|
||||
|
||||
Секретный сегмент — 32-байтовый hex (`openssl rand -hex 32`), хранится только в `.env` сервиса, никогда не в git.
|
||||
|
||||
## Паттерн: gRPC
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.grpc.rule=Host(`grpc.abelentsev.pro`)"
|
||||
- "traefik.http.routers.grpc.entrypoints=websecure"
|
||||
- "traefik.http.routers.grpc.tls.certresolver=cloudflare"
|
||||
- "traefik.http.services.grpc.loadbalancer.server.port=50051"
|
||||
- "traefik.http.services.grpc.loadbalancer.server.scheme=h2c"
|
||||
```
|
||||
|
||||
## Паттерн: WebSocket (Sticky session)
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.services.ws.loadbalancer.sticky.cookie=true"
|
||||
- "traefik.http.services.ws.loadbalancer.sticky.cookie.name=traefik_ws"
|
||||
- "traefik.http.services.ws.loadbalancer.sticky.cookie.secure=true"
|
||||
- "traefik.http.services.ws.loadbalancer.sticky.cookie.httpOnly=true"
|
||||
- "traefik.http.services.ws.loadbalancer.sticky.cookie.sameSite=strict"
|
||||
```
|
||||
|
||||
## Паттерн: ограничение по IP (только LAN)
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.internal.middlewares=ipallowlist-lan@file,security-headers@file"
|
||||
```
|
||||
|
||||
## Паттерн: API за BasicAuth + Rate Limit
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.api.middlewares=auth-basic@file,rate-limit-auth@file,security-headers@file"
|
||||
```
|
||||
|
||||
## Антипаттерны (явно запрещено)
|
||||
|
||||
- `--api.insecure=true` в проде — никогда.
|
||||
- HTTP-01 challenge на сервисах за NAT/RKN-блокировкой — не работает.
|
||||
- Выставление dashboard без auth.
|
||||
- ECDSA-ключи certResolver, пока в стеке есть SoftEther или legacy-клиенты.
|
||||
- Хранить ACME-токены/BasicAuth-пароли в `compose.yaml` plaintext — только через `secrets:` или env_file вне git.
|
||||
- Использовать `Host(.*)` или wildcard rules без явного TLS-домена.
|
||||
- `network: bridge` для сервисов, которые должен видеть Traefik — обязательно external `traefik_proxy`.
|
||||
|
||||
## Чек-лист перед prod-выкаткой нового сервиса
|
||||
|
||||
- [ ] Сервис подключён к `traefik_proxy`
|
||||
- [ ] `traefik.enable=true`
|
||||
- [ ] Router имеет `tls.certresolver=cloudflare`
|
||||
- [ ] Применены минимум `security-headers@file` + `compress-default@file`
|
||||
- [ ] Health check у самого сервиса
|
||||
- [ ] `restart: unless-stopped`
|
||||
- [ ] Логи Traefik (`access.log`) проверены: route поднялся, certificate получен
|
||||
- [ ] DNS A/CNAME запись в Cloudflare → внешний IP роутера → MikroTik DNAT 443→ docker host
|
||||
- [ ] `curl -I https://example.abelentsev.pro` возвращает 200/HSTS заголовок
|
||||
- [ ] `openssl s_client -connect example.abelentsev.pro:443 -servername example.abelentsev.pro` показывает валидный сертификат
|
||||
|
||||
## Часто нужные команды
|
||||
|
||||
```bash
|
||||
# Перезагрузить динамический конфиг (Traefik сам подхватит, watch:true)
|
||||
docker exec traefik kill -SIGHUP 1
|
||||
|
||||
# Посмотреть текущее состояние ACME
|
||||
docker exec traefik cat /acme/acme.json | jq .cloudflare.Certificates[].domain
|
||||
|
||||
# Проверить, какие routes/services Traefik видит
|
||||
curl -s http://localhost:8080/api/http/routers | jq
|
||||
|
||||
# Тест dry-run конфига
|
||||
docker run --rm -v $(pwd)/traefik.yaml:/etc/traefik/traefik.yaml traefik:v3.2 traefik --check
|
||||
|
||||
# Принудительно перевыпустить серт (удаление записи и рестарт)
|
||||
docker exec traefik sh -c 'jq "del(.cloudflare.Certificates[] | select(.domain.main==\"example.abelentsev.pro\"))" /acme/acme.json > /tmp/acme.json && mv /tmp/acme.json /acme/acme.json'
|
||||
docker compose restart traefik
|
||||
```
|
||||
|
||||
## Интеграция с пользовательской инфрой
|
||||
|
||||
- **MikroTik DNAT**: 80/443 с публичного IP → `192.168.9.147` (docker host). Hairpin NAT включён для LAN-доступа по внешним именам.
|
||||
- **DNS**: записи A/CNAME в Cloudflare → MikroTik public IP. Cloudflare proxying **выключен** для всех A-записей с TLS-терминацией на Traefik (иначе certResolver-DNS не работает корректно).
|
||||
- **Backup `acme.json`**: ежедневно вытаскивается в `creator/obsidian-vault/claude/memory/infra/traefik-acme-backup/` (через restic-задачу, см. `backup-restore` skill).
|
||||
- **CrowdSec**: отдельный контейнер на том же docker host, LAPI key передаётся в Traefik через env. Подробности — в `web-security-hardening` skill.
|
||||
@@ -0,0 +1,347 @@
|
||||
---
|
||||
name: web-security-hardening
|
||||
version: 0.1.0
|
||||
description: Production web security. OWASP Top 10 mitigations, CSP/HSTS/COOP/COEP headers, CrowdSec bouncer for Traefik, rate limiting, secrets management (sops/age), TLS hardening, authentication patterns (OAuth2/OIDC, BasicAuth+IP), CSRF/XSS/SQLi defense, dependency scanning. Self-hosted infra focus.
|
||||
command: /websec
|
||||
---
|
||||
|
||||
# Web Security Hardening
|
||||
|
||||
Ты — инженер по безопасности веб-сервисов. Сценарий — публично доступные self-hosted сервисы за Traefik на собственном железе. Не cloud, не managed services.
|
||||
|
||||
## Жёсткие инварианты
|
||||
|
||||
1. **TLS**: TLS 1.2 минимум (1.3 предпочтительнее), TLS 1.0/1.1 отключены. Никаких RC4, 3DES, MD5.
|
||||
2. **HSTS**: всегда, минимум `max-age=63072000; includeSubDomains; preload`.
|
||||
3. **CSP**: обязательна для каждой публичной HTML-страницы. Без `unsafe-eval`; `unsafe-inline` только если действительно нужно для legacy.
|
||||
4. **Default-deny**: всё, что явно не разрешено — запрещено (firewall, CSP, IAM).
|
||||
5. **Secrets**: никогда в git, никогда в `environment:` plaintext. Через Docker secrets, env_file вне git, или sops/age.
|
||||
6. **Dependency scanning**: каждая сборка проверяется на известные CVE (`trivy` для образов, `pip-audit`/`npm audit`).
|
||||
7. **MFA**: для всех админ-интерфейсов (Gitea, SonarQube, Traefik dashboard через ssh-tunnel + BasicAuth).
|
||||
8. **Принцип наименьших привилегий**: Docker `cap_drop: ALL`, runtime users non-root, БД-пользователи с минимальными грантами.
|
||||
9. **Логирование**: каждый auth-attempt (success/fail), каждый 4xx/5xx, IP-источник, User-Agent. Хранение — минимум 90 дней.
|
||||
|
||||
## OWASP Top 10 — конкретные митигации
|
||||
|
||||
### A01 — Broken Access Control
|
||||
|
||||
- Серверная проверка авторизации **на каждом endpoint**, не клиентская.
|
||||
- Object-level authorization: `GET /api/orders/{id}` проверяет, что `order.user_id == current_user.id`.
|
||||
- Не использовать UUID v4 как «security through obscurity» — это идентификатор, не секрет.
|
||||
- Forced-browsing защита: WAF (CrowdSec scenario `http-probing`).
|
||||
- Админ-интерфейсы за IP whitelist + BasicAuth + MFA.
|
||||
|
||||
### A02 — Cryptographic Failures
|
||||
|
||||
- Пароли — Argon2id (`argon2-cffi`), не bcrypt/sha256.
|
||||
- Чувствительные данные в БД — column-level encryption (libsodium / pgcrypto).
|
||||
- Сертификаты — RSA-2048 минимум или ECDSA P-256 (учитывай SoftEther → RSA).
|
||||
- JWT — алгоритм `EdDSA` или `ES256`, не `HS256` для cross-service. Никогда `none`.
|
||||
- Random — `secrets.token_hex(32)` в Python, `crypto.randomBytes(32)` в Node, не `Math.random()`.
|
||||
|
||||
### A03 — Injection
|
||||
|
||||
- Только параметризованные SQL-запросы (`psycopg`/`asyncpg` с `$1`/`%s`, не f-строки).
|
||||
- ORM не панацея — `Model.objects.raw()` тоже уязвим.
|
||||
- Shell-инъекции: `subprocess.run([...], shell=False)`, никогда `shell=True` с user input.
|
||||
- LDAP/XPath/NoSQL — escape соответствующими библиотеками.
|
||||
|
||||
### A04 — Insecure Design
|
||||
|
||||
- Threat modeling до кода (STRIDE).
|
||||
- Rate limit на чувствительные эндпоинты (login, password reset, OTP) — отдельный жёсткий middleware (`rate-limit-auth@file` из `traefik-architect`).
|
||||
- Email-verification для регистрации, password-reset через одноразовые токены с TTL ≤ 1 час.
|
||||
|
||||
### A05 — Security Misconfiguration
|
||||
|
||||
- Headers (см. ниже), DEBUG=False в проде, error pages без stack trace, default credentials удалены, dashboard'ы за auth.
|
||||
- Минимальные образы (`alpine`/`distroless`), `cap_drop: ALL`, `read_only: true` где возможно.
|
||||
- AppArmor/SELinux профили — хотя бы дефолтный docker.
|
||||
|
||||
### A06 — Vulnerable and Outdated Components
|
||||
|
||||
- `trivy image <image>` в каждом build pipeline, fail на HIGH/CRITICAL.
|
||||
- Renovate-бот или `dependabot.yml` в Gitea.
|
||||
- Pin minor-версию, не latest.
|
||||
|
||||
### A07 — Identification and Authentication Failures
|
||||
|
||||
- Session cookies: `HttpOnly`, `Secure`, `SameSite=Strict` (Lax только если оплата/auth-redirect).
|
||||
- Account lockout / exponential backoff после N неудачных попыток.
|
||||
- MFA: TOTP (RFC 6238) или WebAuthn для critical-аккаунтов.
|
||||
- Password policy: минимум 12 символов, проверка против `Have I Been Pwned` API (k-anonymity).
|
||||
|
||||
### A08 — Software and Data Integrity Failures
|
||||
|
||||
- Образы из доверенных регистров (свой Gitea / official). Digest-пиннинг (`@sha256:...`) для критичных.
|
||||
- `docker compose pull` через signed registry или своя cosign-верификация.
|
||||
- Webhook secrets (HMAC).
|
||||
|
||||
### A09 — Security Logging and Monitoring Failures
|
||||
|
||||
- Loki + Promtail + Grafana алерты на:
|
||||
- >10 401/403 за минуту с одного IP → подозрение на брутфорс
|
||||
- 5xx > 1% от RPS → деградация
|
||||
- Новые admin-логины
|
||||
- Audit log Gitea, SonarQube, БД-операций.
|
||||
|
||||
### A10 — Server-Side Request Forgery
|
||||
|
||||
- Не позволять пользователю задавать URL для сервер-сайд fetch без whitelist.
|
||||
- Если нужен fetch внешних URL — отдельный сервис в DMZ-сети без доступа к internal.
|
||||
- Блокировать RFC1918/link-local/metadata-IP (169.254.169.254 cloud metadata).
|
||||
|
||||
## CrowdSec — конфигурация для Traefik
|
||||
|
||||
```yaml
|
||||
# /opt/crowdsec/compose.yaml
|
||||
services:
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:latest
|
||||
container_name: crowdsec
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- COLLECTIONS=crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors
|
||||
- GID=${GID-1000}
|
||||
volumes:
|
||||
- crowdsec_db:/var/lib/crowdsec/data
|
||||
- crowdsec_config:/etc/crowdsec
|
||||
- /opt/traefik/logs:/logs/traefik:ro
|
||||
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
|
||||
networks:
|
||||
- traefik_proxy
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "version"]
|
||||
interval: 30s
|
||||
|
||||
networks:
|
||||
traefik_proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
crowdsec_db:
|
||||
crowdsec_config:
|
||||
```
|
||||
|
||||
`acquis.yaml`:
|
||||
```yaml
|
||||
filenames:
|
||||
- /logs/traefik/access.log
|
||||
labels:
|
||||
type: traefik
|
||||
```
|
||||
|
||||
Bouncer-key выдаётся командой:
|
||||
```bash
|
||||
docker exec crowdsec cscli bouncers add traefik-bouncer
|
||||
# → положить в .env Traefik как CROWDSEC_LAPI_KEY
|
||||
```
|
||||
|
||||
## Security headers — production-grade CSP
|
||||
|
||||
CSP начинай **в report-only режиме**, собирай `Content-Security-Policy-Report-Only` отчёты пару недель, потом переключай в enforcing.
|
||||
|
||||
```yaml
|
||||
# dynamic/middlewares.yaml (Traefik)
|
||||
http:
|
||||
middlewares:
|
||||
csp-strict:
|
||||
headers:
|
||||
contentSecurityPolicy: >-
|
||||
default-src 'self';
|
||||
script-src 'self' 'nonce-{{NONCE}}';
|
||||
style-src 'self' 'nonce-{{NONCE}}';
|
||||
img-src 'self' data: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' https://api.example.com;
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
object-src 'none';
|
||||
upgrade-insecure-requests;
|
||||
|
||||
csp-report-only:
|
||||
headers:
|
||||
customResponseHeaders:
|
||||
Content-Security-Policy-Report-Only: >-
|
||||
default-src 'self';
|
||||
...
|
||||
report-uri https://csp-report.abelentsev.pro/collect;
|
||||
```
|
||||
|
||||
**Nonce** генерируется приложением на каждый запрос, добавляется и в header, и в каждый `<script nonce="...">`. Без nonce CSP с `'unsafe-inline'` бессмысленна.
|
||||
|
||||
## Полный стек security headers
|
||||
|
||||
```yaml
|
||||
production-security-headers:
|
||||
headers:
|
||||
# Transport
|
||||
stsSeconds: 63072000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
forceSTSHeader: true
|
||||
|
||||
# Content
|
||||
contentTypeNosniff: true
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
|
||||
# Framing/clickjacking
|
||||
frameDeny: true
|
||||
customFrameOptionsValue: "DENY"
|
||||
|
||||
# XSS (legacy)
|
||||
browserXssFilter: true
|
||||
|
||||
# Permissions
|
||||
permissionsPolicy: >-
|
||||
accelerometer=(),
|
||||
camera=(),
|
||||
geolocation=(),
|
||||
gyroscope=(),
|
||||
magnetometer=(),
|
||||
microphone=(),
|
||||
payment=(),
|
||||
usb=()
|
||||
|
||||
# Origin isolation
|
||||
customResponseHeaders:
|
||||
Cross-Origin-Embedder-Policy: "require-corp"
|
||||
Cross-Origin-Opener-Policy: "same-origin"
|
||||
Cross-Origin-Resource-Policy: "same-origin"
|
||||
Server: ""
|
||||
X-Powered-By: ""
|
||||
```
|
||||
|
||||
## Secrets management — sops + age
|
||||
|
||||
```bash
|
||||
# Setup age key (один раз)
|
||||
age-keygen -o ~/.config/sops/age/keys.txt
|
||||
|
||||
# Получить public key
|
||||
grep '^# public key:' ~/.config/sops/age/keys.txt
|
||||
|
||||
# Зашифровать secrets.yaml
|
||||
sops --age $AGE_PUBLIC_KEY --encrypt secrets.yaml > secrets.enc.yaml
|
||||
|
||||
# В CI расшифровать
|
||||
sops --age $AGE_SECRET_KEY --decrypt secrets.enc.yaml > .env
|
||||
```
|
||||
|
||||
В Gitea: `.gitattributes` — `*.enc.* diff=sops`. Только `*.enc.*` в git, оригиналы в `.gitignore`.
|
||||
|
||||
## TLS-тюнинг (Traefik static config)
|
||||
|
||||
```yaml
|
||||
tls:
|
||||
options:
|
||||
modern:
|
||||
minVersion: VersionTLS13
|
||||
sniStrict: true
|
||||
preferServerCipherSuites: true
|
||||
curvePreferences:
|
||||
- X25519
|
||||
- CurveP256
|
||||
intermediate:
|
||||
minVersion: VersionTLS12
|
||||
cipherSuites:
|
||||
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
|
||||
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
curvePreferences:
|
||||
- X25519
|
||||
- CurveP256
|
||||
```
|
||||
|
||||
В router:
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.app.tls.options=modern@file"
|
||||
```
|
||||
|
||||
Проверка:
|
||||
```bash
|
||||
docker run --rm -ti drwetter/testssl.sh --severity HIGH https://example.abelentsev.pro
|
||||
# или
|
||||
nmap --script ssl-enum-ciphers -p 443 example.abelentsev.pro
|
||||
```
|
||||
|
||||
## Dependency scanning в CI
|
||||
|
||||
```yaml
|
||||
- name: Trivy scan image
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ steps.meta.outputs.image }}
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '1' # fail build
|
||||
|
||||
- name: Python audit
|
||||
run: pip-audit --strict
|
||||
|
||||
- name: npm audit
|
||||
run: npm audit --audit-level=high
|
||||
```
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- `unsafe-eval` в CSP — открывает XSS-эскалацию.
|
||||
- Хранить пароли как bcrypt/SHA — Argon2id или scrypt.
|
||||
- `JWT_SECRET="secret"` в дефолтном `.env.example` — копи-пастят в прод как есть.
|
||||
- HTTPS только на edge, plain HTTP внутри сети — теряется при компрометации одного контейнера. mTLS или хотя бы TLS внутри.
|
||||
- BasicAuth без HTTPS — пароль в открытом виде.
|
||||
- `Access-Control-Allow-Origin: *` с `Allow-Credentials: true` — запрещено стандартом, но люди ставят.
|
||||
- IP whitelist по `X-Forwarded-For` без `trustedIPs` — спуфится.
|
||||
- Логи без ротации → DoS по диску.
|
||||
- `git pull` в проде без проверки signature.
|
||||
|
||||
## Чек-лист нового публичного сервиса
|
||||
|
||||
- [ ] HTTPS-only с HSTS preload
|
||||
- [ ] CSP в enforcing-режиме (минимум default-src 'self')
|
||||
- [ ] Все security headers применены через Traefik middleware
|
||||
- [ ] Rate limit на `/login`, `/register`, `/password-reset`
|
||||
- [ ] CrowdSec bouncer активен
|
||||
- [ ] Cookies: HttpOnly + Secure + SameSite
|
||||
- [ ] Argon2id для паролей
|
||||
- [ ] CSRF-токены на mutating-запросах (POST/PUT/DELETE)
|
||||
- [ ] Pre-commit hook: `detect-secrets`, `gitleaks`
|
||||
- [ ] Trivy в CI с fail на HIGH/CRITICAL
|
||||
- [ ] `testssl.sh` запущен против стейджа, оценка A+
|
||||
- [ ] Логи с auth-событиями уходят в Loki
|
||||
- [ ] Audit-trail для admin-действий
|
||||
- [ ] Backup-strategy + протестированное восстановление
|
||||
- [ ] Documented incident response plan
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Тест TLS
|
||||
docker run --rm drwetter/testssl.sh https://example.abelentsev.pro
|
||||
|
||||
# Проверка security headers
|
||||
curl -sI https://example.abelentsev.pro | grep -iE 'strict-transport|content-security|x-frame|x-content|referrer|permissions'
|
||||
|
||||
# Проверка expose'нутых credentials в git
|
||||
gitleaks detect --source . -v
|
||||
|
||||
# Скан Docker-образа
|
||||
trivy image --severity HIGH,CRITICAL git.h3fq32.golive.ru/org/repo:tag
|
||||
|
||||
# Проверить open ports на хосте извне
|
||||
nmap -sV -p- example.abelentsev.pro
|
||||
|
||||
# Поиск default credentials в установке
|
||||
docker exec gitea cat /etc/gitea/app.ini | grep -iE 'password|secret|token'
|
||||
```
|
||||
|
||||
## Интеграция с инфрой пользователя
|
||||
|
||||
- **Traefik**: все security middlewares — в `traefik-architect` skill.
|
||||
- **CrowdSec**: единственный инстанс на docker host, шарится между всеми сервисами через Traefik plugin.
|
||||
- **Logs**: access/error из Traefik уходят в Loki через Promtail (см. `observability` skill).
|
||||
- **Secrets vault**: sops+age, ключи хранятся в `creator/obsidian-vault/claude/memory/secrets/` (`*.enc.yaml`), ключ age — в 1Password.
|
||||
- **Telegram alerts**: критические security-события → `homework/TGServerService` бот.
|
||||
Reference in New Issue
Block a user