package main import ( "encoding/json" "fmt" "log" "os" "os/exec" "strings" "time" ) // ── Типы ───────────────────────────────────────────────────────────────────── type dcContainerInfo struct { ID string Name string Status string // running, exited, dead, restarting, paused, created Health string // healthy, unhealthy, starting, none Image string } type dcStateFile struct { Timestamp string `json:"timestamp"` Containers map[string]dcContainerState `json:"containers"` // name → state } type dcContainerState struct { Status string `json:"status"` Health string `json:"health"` } type dcChangeKind string const ( dcKindStatusChanged dcChangeKind = "status_changed" dcKindHealthChanged dcChangeKind = "health_changed" dcKindDisappeared dcChangeKind = "disappeared" dcKindAppeared dcChangeKind = "appeared" ) type dcStateChange struct { Name string Kind dcChangeKind PrevState dcContainerState CurrState dcContainerState Image string } func (ch dcStateChange) isProblem() bool { switch ch.Kind { case dcKindDisappeared: return true case dcKindStatusChanged: return ch.CurrState.Status != "running" case dcKindHealthChanged: return ch.CurrState.Health == "unhealthy" } return false } func (ch dcStateChange) isNew() bool { return ch.Kind == dcKindAppeared } func (ch dcStateChange) isRecovery() bool { switch ch.Kind { case dcKindStatusChanged: return ch.CurrState.Status == "running" && ch.PrevState.Status != "running" case dcKindHealthChanged: return ch.CurrState.Health == "healthy" && ch.PrevState.Health == "unhealthy" } return false } // ── Точка входа ─────────────────────────────────────────────────────────────── func runDockerCheck() error { hostname, err := os.Hostname() if err != nil { hostname = "unknown" log.Printf("⚠️ Не удалось получить hostname: %v", err) } log.Printf("🔍 DockerCheck запущен на хосте: %s", hostname) // Проверяем доступность docker if err := dcCheckDockerAvailable(); err != nil { return fmt.Errorf("docker недоступен: %v", err) } // Получаем текущий список контейнеров containers, err := dcGetContainers() if err != nil { return fmt.Errorf("ошибка получения списка контейнеров: %v", err) } if len(containers) == 0 { log.Println("ℹ️ Запущенных контейнеров не найдено") } log.Printf("📦 Найдено контейнеров: %d", len(containers)) // Выводим текущее состояние for _, c := range containers { emoji := dcStatusEmoji(c.Status, c.Health) if c.Health != "" && c.Health != "none" { log.Printf("%s %-30s | %-12s | health: %s | %s", emoji, c.Name, c.Status, c.Health, c.Image) } else { log.Printf("%s %-30s | %-12s | %s", emoji, c.Name, c.Status, c.Image) } } // Загружаем предыдущее состояние prevState := dcLoadState() isFirstRun := len(prevState.Containers) == 0 // Строим map текущего состояния current := make(map[string]dcContainerInfo, len(containers)) for _, c := range containers { current[c.Name] = c } // Сравниваем changes := dcCompareStates(prevState.Containers, current, isFirstRun) // Статистика runningCount := 0 problemCount := 0 for _, c := range containers { if c.Status == "running" && c.Health != "unhealthy" { runningCount++ } else { problemCount++ } } log.Println("\n📊 Итоги проверки контейнеров:") log.Printf(" ✅ Работают нормально: %d", runningCount) log.Printf(" ❌ Проблемных: %d", problemCount) log.Printf(" 🔄 Изменений статуса: %d", len(changes)) if len(changes) > 0 { log.Println("\n🔄 Изменения с последней проверки:") log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") for _, ch := range changes { arrow := dcChangeArrow(ch) switch ch.Kind { case dcKindAppeared: log.Printf(" %s %-30s | появился (%s) | %s", arrow, ch.Name, ch.CurrState.Status, ch.Image) case dcKindDisappeared: log.Printf(" %s %-30s | исчез (был: %s)", arrow, ch.Name, ch.PrevState.Status) case dcKindStatusChanged: log.Printf(" %s %-30s | %s → %s", arrow, ch.Name, ch.PrevState.Status, ch.CurrState.Status) case dcKindHealthChanged: log.Printf(" %s %-30s | health: %s → %s", arrow, ch.Name, ch.PrevState.Health, ch.CurrState.Health) } } log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") if isTelegramConfigured() { dcSendAlert(hostname, changes) } else { log.Println("\nℹ️ Telegram уведомления отключены (не настроены данные)") } } else { if len(prevState.Containers) == 0 { log.Println("\nℹ️ Первый запуск — состояние сохранено, уведомления при изменениях") } else { log.Println("\n✅ Изменений нет — уведомления не отправляются") } } // Сохраняем текущее состояние dcSaveState(current) log.Println("=== Проверка контейнеров завершена ===") return nil } // ── Получение контейнеров ──────────────────────────────────────────────────── func dcCheckDockerAvailable() error { cmd := exec.Command("docker", "info") out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("docker info завершился с ошибкой: %v\n%s", err, string(out)) } return nil } func dcGetContainers() ([]dcContainerInfo, error) { // Шаг 1: получаем ID, Name, Status, Image без .Health — оно доступно // только через docker inspect и отсутствует у контейнеров без healthcheck. cmd := exec.Command( "docker", "ps", "-a", "--format", "{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}", ) out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("docker ps завершился с ошибкой: %v\n%s", err, string(out)) } var containers []dcContainerInfo for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } parts := strings.SplitN(line, "|", 4) if len(parts) != 4 { continue } c := dcContainerInfo{ ID: strings.TrimSpace(parts[0]), Name: strings.TrimSpace(parts[1]), Image: strings.TrimSpace(parts[3]), } c.Status = dcNormalizeStatus(parts[2]) containers = append(containers, c) } // Шаг 2: получаем health отдельно через docker inspect для каждого контейнера. // Контейнеры без healthcheck вернут пустую строку — это нормально. for i := range containers { containers[i].Health = dcGetHealth(containers[i].ID) } return containers, nil } // dcGetHealth возвращает health статус контейнера через docker inspect. // Если healthcheck не настроен или inspect завершился с ошибкой — возвращает "none". func dcGetHealth(containerID string) string { out, err := exec.Command( "docker", "inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", containerID, ).Output() if err != nil { return "none" } health := strings.ToLower(strings.TrimSpace(string(out))) if health == "" { return "none" } return health } // dcNormalizeStatus приводит вывод docker ps к короткому статусу func dcNormalizeStatus(raw string) string { raw = strings.TrimSpace(raw) lower := strings.ToLower(raw) switch { case strings.HasPrefix(lower, "up"): return "running" case strings.HasPrefix(lower, "exited"): return "exited" case strings.HasPrefix(lower, "restarting"): return "restarting" case strings.HasPrefix(lower, "dead"): return "dead" case strings.HasPrefix(lower, "paused"): return "paused" case strings.HasPrefix(lower, "created"): return "created" case strings.HasPrefix(lower, "removal"): return "removing" default: return lower } } // ── Сравнение состояний ────────────────────────────────────────────────────── func dcCompareStates(prev map[string]dcContainerState, curr map[string]dcContainerInfo, isFirstRun bool) []dcStateChange { var changes []dcStateChange // Проверяем текущие контейнеры for name, currInfo := range curr { prevInfo, existed := prev[name] if !existed { if !isFirstRun { // Новый контейнер появился после первого запуска — алертим changes = append(changes, dcStateChange{ Name: name, Kind: dcKindAppeared, PrevState: dcContainerState{}, CurrState: dcContainerState{Status: currInfo.Status, Health: currInfo.Health}, Image: currInfo.Image, }) } continue } currState := dcContainerState{Status: currInfo.Status, Health: currInfo.Health} // Изменение статуса if prevInfo.Status != currState.Status { changes = append(changes, dcStateChange{ Name: name, Kind: dcKindStatusChanged, PrevState: prevInfo, CurrState: currState, Image: currInfo.Image, }) } // Изменение health (независимо от статуса) if prevInfo.Health != currState.Health && currState.Health != "" && currState.Health != "none" && prevInfo.Health != "" && prevInfo.Health != "none" { changes = append(changes, dcStateChange{ Name: name, Kind: dcKindHealthChanged, PrevState: prevInfo, CurrState: currState, Image: currInfo.Image, }) } } // Проверяем исчезнувшие контейнеры (были в state, нет сейчас) for name, prevInfo := range prev { if _, exists := curr[name]; !exists { changes = append(changes, dcStateChange{ Name: name, Kind: dcKindDisappeared, PrevState: prevInfo, CurrState: dcContainerState{}, Image: "", }) } } return changes } // ── State-файл ──────────────────────────────────────────────────────────────── func dcLoadState() dcStateFile { data, err := os.ReadFile(cfgDockerStateFilePath) if err != nil { return dcStateFile{Containers: make(map[string]dcContainerState)} } var state dcStateFile if err := json.Unmarshal(data, &state); err != nil { log.Printf("⚠️ Не удалось прочитать state-файл docker: %v", err) return dcStateFile{Containers: make(map[string]dcContainerState)} } return state } func dcSaveState(current map[string]dcContainerInfo) { containers := make(map[string]dcContainerState, len(current)) for name, info := range current { containers[name] = dcContainerState{ Status: info.Status, Health: info.Health, } } state := dcStateFile{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), Containers: containers, } data, err := json.MarshalIndent(state, "", " ") if err != nil { log.Printf("⚠️ Ошибка сериализации state-файла docker: %v", err) return } if err := os.WriteFile(cfgDockerStateFilePath, data, 0644); err != nil { log.Printf("⚠️ Ошибка записи state-файла %s: %v", cfgDockerStateFilePath, err) } } // ── Отправка алерта ────────────────────────────────────────────────────────── func dcSendAlert(hostname string, changes []dcStateChange) { var problems, recoveries, appeared []dcStateChange for _, ch := range changes { switch { case ch.isNew(): appeared = append(appeared, ch) case ch.isRecovery(): recoveries = append(recoveries, ch) case ch.isProblem(): problems = append(problems, ch) } } // Если нет никаких изменений — молчим if len(problems) == 0 && len(recoveries) == 0 && len(appeared) == 0 { return } // Заголовок по наиболее критичному событию var header string switch { case len(problems) > 0: header = "🚨 АЛЕРТ: Проблемы с контейнерами!" case len(recoveries) > 0 && len(appeared) > 0: header = "⚠️ Изменения в состоянии контейнеров" case len(recoveries) > 0: header = "✅ Контейнеры восстановлены" default: header = "ℹ️ Новые контейнеры" } message := fmt.Sprintf( "%s\n\n"+ "🖥 Хост: %s\n"+ "⏰ Время: %s\n\n", header, hostname, time.Now().Format("2006-01-02 15:04:05"), ) // Проблемные контейнеры if len(problems) > 0 { message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" message += fmt.Sprintf("❌ Проблемы (%d):\n\n", len(problems)) for _, ch := range problems { emoji := dcChangeEmoji(ch) switch ch.Kind { case dcKindDisappeared: message += fmt.Sprintf( "%s %s\n"+ " 📌 Контейнер исчез (был: %s)\n\n", emoji, ch.Name, ch.PrevState.Status, ) case dcKindStatusChanged: message += fmt.Sprintf( "%s %s\n"+ " 📌 %s%s\n"+ " 🐳 %s\n"+ " 🔧 docker logs %s --tail 50\n\n", emoji, ch.Name, ch.PrevState.Status, ch.CurrState.Status, ch.Image, ch.Name, ) case dcKindHealthChanged: message += fmt.Sprintf( "%s %s\n"+ " 📌 health: %s%s\n"+ " 🐳 %s\n"+ " 🔧 docker inspect %s\n\n", emoji, ch.Name, ch.PrevState.Health, ch.CurrState.Health, ch.Image, ch.Name, ) } } } // Восстановленные контейнеры if len(recoveries) > 0 { message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" message += fmt.Sprintf("✅ Восстановились (%d):\n\n", len(recoveries)) for _, ch := range recoveries { switch ch.Kind { case dcKindStatusChanged: message += fmt.Sprintf( "✅ %s\n"+ " 📌 %s%s\n\n", ch.Name, ch.PrevState.Status, ch.CurrState.Status, ) case dcKindHealthChanged: message += fmt.Sprintf( "✅ %s\n"+ " 📌 health: %s%s\n\n", ch.Name, ch.PrevState.Health, ch.CurrState.Health, ) } } } // Новые контейнеры if len(appeared) > 0 { message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" message += fmt.Sprintf("🆕 Появились (%d):\n\n", len(appeared)) for _, ch := range appeared { message += fmt.Sprintf( "🆕 %s\n"+ " 📌 Статус: %s\n"+ " 🐳 %s\n\n", ch.Name, ch.CurrState.Status, ch.Image, ) } } message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" message += "💡 docker ps -a | docker logs <имя> --tail 50" if err := telegramSendHTML(message); err != nil { log.Printf("❌ Ошибка отправки в Telegram: %v", err) } else { log.Println("✅ Алерт успешно отправлен в Telegram") } } // ── Вспомогательные функции ────────────────────────────────────────────────── func dcStatusEmoji(status, health string) string { if health == "unhealthy" { return "🤒" } switch status { case "running": return "✅" case "restarting": return "🔄" case "exited": return "🔴" case "dead": return "💀" case "paused": return "⏸" default: return "❓" } } func dcChangeEmoji(ch dcStateChange) string { switch ch.Kind { case dcKindAppeared: return "🆕" case dcKindDisappeared: return "👻" case dcKindHealthChanged: return "🤒" case dcKindStatusChanged: switch ch.CurrState.Status { case "restarting": return "🔄" case "exited": return "🔴" case "dead": return "💀" } } return "❓" } func dcChangeArrow(ch dcStateChange) string { if ch.isNew() { return "🆕" } if ch.isRecovery() { return "✅" } if ch.isProblem() { return "❌" } return "🔄" }