בניית אגרגטור מצב שירותים ב־Go עם מטמון ונתונים מיושנים
גרסה לימודית מתוקנת; תאריך המקור האנגלי: 2026-06-05T10:18:35.034+03:00. המהדורה בעברית משתמשת בתאריך הפרסום שלה בפועל, ולא בתאריך המקור באנגלית. כל דוגמאות הקוד והמספרים להמחשה בלבד ולא הורצו או נמדדו במסגרת ההכנה הזאת. English original
דמיינו את המצב הבא: יום שלישי, השעה 14:00. ה־Slack של הצוות שקט, צינור ה־CI/CD פועל היטב, ובדיוק מזגתם קפה טרי. ואז מגיעה ההתראה הראשונה. webhook נכשל. עשר שניות אחר כך Sentry מוצף בשגיאות חריגה מזמן ההמתנה. האם ספק הענן נפל? האם AWS us-east-1 עובר שוב יום קשה? האם GitHub Pages לא זמין, או שרק סביבת הניסוי של Stripe שוב עושה בעיות?
בזמן תקרית, מעבר בין דפי מצב של ספקים יכול לבזבז תשומת לב. אגרגטור מצב מרכז את דיווחי הספקים בתצוגה אחת. המקור הזכיר את IsUpMap ודירוג ב־Hacker News, אך הדירוג, מספר השירותים והתנהגותו הנוכחית לא אומתו כאן; התכנון הזה עצמאי ואינו מתיימר לשחזר את הפרויקט.
השאלה ההנדסית המועילה היא כיצד לאסוף דיווחי ספקים בלי מספר בקשות מופרז, גישה בין מקורות שאינה נתמכת בדפדפן, או הצגת מידע ישן כעדכני. [מקור 1]
נתכנן אגרגטור מצב ב־Go עם גבולות משאבים, תשאול ברקע, מצבי ״לא ידוע״ ו״מיושן״ מפורשים ונקודת קצה HTTP שמגישה מטמון. זו דוגמה לימודית שלא הורצה, לא שירות שנמדד או הוסמך לייצור. [מקור 1]
האתגרים הטכניים של איסוף מצב משירותים רבים
ping או GET מוצלחים בודקים נתיב רשת או נקודת קצה מסוימים, לא כל יכולת של שירות. דיווחי המצב של הספק הם אות נוסף, שעשוי להתעכב או לא לכלול תקלות מקומיות. כשמרכזים מאות שירותים חשוב להפריד בין האותות האלה. [מקור 1]
1 הבעיה של מגוון דפי המצב
אין תקן יחיד לדפי מצב. חברות מסוימות משתמשות ב־Statuspage API של Atlassian, אחרות בלוחות HTML מותאמים, ויש כאלה שמסתמכות על נקודות קצה פשוטות של JSON. כדי לרכז אותן המערכת צריכה לטפל ב:
- ממשקי JSON מובנים, כגון נקודת הקצה המתועדת של GitHub Status:
https://www.githubstatus.com/api/v2/status.json. [מקור 1] - חילוץ מ־HTML רק כשהוא מותר ונחוץ; העדיפו הזנות או ממשקי API מתועדים וצפו לשינויים במבנה הדף. [מקור 1]
- סכמות שונות, למשל שירות אחד מחזיר
"status": "good"ואחר מחזיר"indicator": "none"
2 הגבלת קצב וחסימת כתובות IP
תדירות התשאול חייבת להתאים למגבלות ולתנאים המתועדים של כל ספק. הגבלת מקביליות אינה מגבלת קצב לכל מארח. תזמון בייצור צריך לכבד Retry-After כשהוא מסופק, להשתמש בהשהיה חוזרת מוגבלת ובאקראיות תזמון, ולהימנע מתשאול מסונכרן בין עותקים; מרווח כללי של 60 שניות הוא רק דוגמה. [מקור 1]
3 השהיה בצד הלקוח
תשאול ישיר של ספקים רבים מהדפדפן יכול להוסיף תקורת בקשות ולהיכשל כשהספקים אינם מאפשרים קריאה בין מקורות. מטמון בצד השרת הוא אפשרות מעשית לריכוז, אך אינו הארכיטקטורה האפשרית היחידה. ההשפעה על הדפדפן ועל הסוללה תלויה במימוש ובעומס. [מקור 1]
תכנון הארכיטקטורה
הפרידו בין אחזור מתוזמן לבין הגשת נתונים מהמטמון. אחזור סינכרוני מקבילי עדיין מחכה לבקשה האיטית ביותר או לסיום הזמן המוקצב; אחזור סדרתי יכול להימשך כסכום זמני ההשהיה. עבודת רקע מסירה את ההמתנה לספקים מנתיב הקריאה הרגיל, אך דורשת חותמות זמן ומדיניות טריות. [מקור 1]
זו הארכיטקטורה שנרצה לבנות:
+-------------------------------------------------------+
| Admin / Cron |
+-------------------------------------------------------+
|
v (Every 60s)
+-------------------------------------------------------+
| Go Worker (Concurrent Fetcher) |
+-------------------------------------------------------+
| | |
v v v
+--------------+ +--------------+ +--------------+
| GitHub API | | Stripe API | | Vercel API |
+--------------+ +--------------+ +--------------+
| | |
+-------------------+-------------------+
|
v (Parse & Normalize)
+-------------------------------------------------------+
| In-Memory Cache (Go) |
+-------------------------------------------------------+
|
v (Expose)
+-------------------------------------------------------+
| Fast HTTP API Endpoint |
+-------------------------------------------------------+
|
v (Fetch JSON / SSE)
+-------------------------------------------------------+
| Frontend Dashboard UI |
+-------------------------------------------------------+
תרגום התרשים: מנהל או מתזמן מפעיל בכל 60 שניות עובד Go לאחזור מקבילי; העובד פונה לממשקי GitHub, Stripe ו־Vercel; התוצאות מפוענחות ומנורמלות למטמון בזיכרון; נקודת קצה HTTP מגישה אותן; לוח המחוונים בצד הלקוח מקבל JSON או SSE. הספקים והמרווח בתרשים להמחשה; הדוגמה המתוקנת מממשת GitHub בלבד ואינה מממשת שרת SSE.
מטמון בזיכרון יכול לצמצם עבודה בנתיב הקריאה ותעבורה לספקים. תפוקה והשהיה בפועל דורשות בדיקות עומס. התדירות של דקה בתרשים היא דוגמת מדיניות, לא הוכחה שכל ספק מתיר אותה או שהמידע הוא בזמן אמת. [מקור 1]
מימוש שלב אחר שלב ב־Go
Go מספקת goroutines, ערוצים ומנגנוני סנכרון המתאימים לקלט ופלט מקביליים. עדיין צריך להגביל את מספר הבקשות, גודלי התשובות וזמני ההמתנה ולנהל את הגישה למטמון; בחירת השפה לבדה אינה מבססת ביצועים. [מקור 1] [מקור 2]
1 הגדרת מבני הנתונים
קטעי ה־Go הבאים מרכיבים קובץ main.go אחד להמחשה כשהם מחוברים לפי הסדר; תרשים ה־ASCII ו־JavaScript נפרדים. שמרו בנפרד את התצפית הנוכחית, הדיווח האחרון שהתקבל בהצלחה מהספק וטריותו. אחזור שנכשל פירושו מצב לא ידוע, לא הוכחה לתקלה. יש לאתחל את המפה לפני כתיבה. [מקור 1] [מקור 2] [מקור 3]
// Runtime-untested. Combine the four Go blocks into main.go.
package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"sync"
"time"
)
type Status string
const (
StatusOperational Status = "operational"
StatusDegraded Status = "degraded"
StatusOutage Status = "outage"
StatusUnknown Status = "unknown"
)
type Service struct { ID, Name, URL string }
type ServiceStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Status Status `json:"status"`
CheckedAt time.Time `json:"checked_at"`
FetchDurationMS int64 `json:"fetch_duration_ms"`
LastKnownStatus Status `json:"last_known_status"`
LastSuccessAt *time.Time `json:"last_success_at"`
Stale bool `json:"stale"`
FetchError string `json:"fetch_error,omitempty"`
}
type StatusCache struct {
mu sync.RWMutex
Results map[string]ServiceStatus
}
var allowedURLs = map[string]bool{
"https://www.githubstatus.com/api/v2/status.json": true,
}
var sharedClient = &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
2 כתיבת מנוע האחזור
המאחזר, שלא הורץ, משתמש בלקוח HTTP משותף, במגבלת זמן של חמש שניות, במגבלת גוף של MiB אחד, בבדיקת קוד HTTP ובאימות התחביר והטיפוסים של JSON עם טיפול מפורש בשדה המצב. שדות JSON לא מוכרים אינם מעובדים. הוא תומך רק בכתובות קבועות ומהימנות של GitHub Status ואינו מאפשר הפניות; אין להפוך אותו לפרוקסי לכל כתובת שרירותית. משך הזמן הוא זמן האחזור והפענוח של דף המצב, לא ההשהיה של השירות עצמו. [מקור 1] [מקור 2] [מקור 3]
// Runtime-untested. A fetch failure is unknown, not an outage.
func FetchStatus(ctx context.Context, svc Service) (result ServiceStatus) {
started := time.Now()
result = ServiceStatus{
ID: svc.ID, Name: svc.Name, Source: svc.URL,
Status: StatusUnknown, Stale: true,
}
defer func() {
result.CheckedAt = time.Now().UTC()
result.FetchDurationMS = time.Since(started).Milliseconds()
}()
if !allowedURLs[svc.URL] {
result.FetchError = "target_not_allowed"
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.URL, nil)
if err != nil { result.FetchError = "invalid_request"; return }
resp, err := sharedClient.Do(req)
if err != nil { result.FetchError = "fetch_failed"; return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
result.FetchError = "http_non_200" // Scheduler must handle 429/backoff.
return
}
const maxBody = 1 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody + 1))
if err != nil || len(body) > maxBody {
result.FetchError = "invalid_body_size_or_read"
return
}
var data struct {
Status struct { Indicator string `json:"indicator"` } `json:"status"`
}
if json.Unmarshal(body, &data) != nil {
result.FetchError = "invalid_json"
return
}
switch data.Status.Indicator {
case "none": result.Status = StatusOperational
case "minor": result.Status = StatusDegraded
case "major", "critical": result.Status = StatusOutage
default: result.FetchError = "unknown_indicator"; return
}
now := time.Now().UTC()
result.LastSuccessAt = &now
result.LastKnownStatus = result.Status
result.Stale = false
return
}
3 מקביליות מוגבלת ומדיניות תשאול [מקור 1] [מקור 2]
בדוגמה הזאת סמפור עם מאגר מגביל אחזורים פעילים ל־20. לולאת רקע יחידה מחכה לסיום מחזור לפני תזמון הבא, ולכן המחזורים אינם חופפים. הדוגמה אינה מממשת השהיה חוזרת מסתגלת לכל ספק או תיאום מבוזר; הוסיפו אותם לפני הרחבת רשימת הכתובות המותרות הקבועה. [מקור 1] [מקור 2]
// Runtime-untested. Concurrency control is not per-provider rate limiting.
func UpdateAllStatuses(ctx context.Context, services []Service, cache *StatusCache) {
var wg sync.WaitGroup
sem := make(chan struct{}, 20)
for _, svc := range services {
select {
case sem <- struct{}{}:
case <-ctx.Done(): wg.Wait(); return
}
wg.Add(1)
go func(s Service) {
defer wg.Done()
defer func() { <-sem }()
requestCtx, cancel := context.WithTimeout(ctx, 5 * time.Second)
defer cancel()
result := FetchStatus(requestCtx, s)
cache.mu.Lock()
old := cache.Results[s.ID]
if result.FetchError != "" {
result.LastKnownStatus = old.LastKnownStatus
result.LastSuccessAt = old.LastSuccessAt
}
cache.Results[s.ID] = result
cache.mu.Unlock()
}(svc)
}
wg.Wait()
}
func runPoller(ctx context.Context, services []Service, cache *StatusCache) {
for {
UpdateAllStatuses(ctx, services, cache)
timer := time.NewTimer(60 * time.Second)
select {
case <-ctx.Done(): timer.Stop(); return
case <-timer.C:
}
}
}
func main() {
services := []Service{{
ID: "github", Name: "GitHub",
URL: "https://www.githubstatus.com/api/v2/status.json",
}}
cache := &StatusCache{Results: make(map[string]ServiceStatus)}
for _, svc := range services {
cache.Results[svc.ID] = ServiceStatus{
ID: svc.ID, Name: svc.Name, Source: svc.URL,
Status: StatusUnknown, LastKnownStatus: StatusUnknown, Stale: true,
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runPoller(ctx, services, cache)
mux := http.NewServeMux()
mux.HandleFunc("/status", handleGetStatus(cache))
server := &http.Server{
Addr: "127.0.0.1:8080", Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Print(err)
}
}
הגשת הנתונים לממשק המשתמש
המטפל מעתיק רשומות מטמון תחת נעילת קריאה, משחרר אותה ואז מסריאל ושולח. כך לקוחות איטיים אינם מחזיקים את נעילת המטמון בזמן השליחה. התשובה מסמנת כמיושן מידע שגילו יותר משתי דקות, לפי מדיניות לדוגמה. ברירת המחדל היא גישה מאותו מקור; לא מתווספת כותרת CORS עם כוכבית. [מקור 1] [מקור 2]
// Runtime-untested; copy under lock, serialize after unlocking.
func handleGetStatus(cache *StatusCache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
cache.mu.RLock()
snapshot := make(map[string]ServiceStatus, len(cache.Results))
for id, value := range cache.Results {
if value.LastSuccessAt == nil || time.Since(*value.LastSuccessAt) > 2*time.Minute {
value.Stale = true
}
snapshot[id] = value
}
cache.mu.RUnlock()
body, err := json.Marshal(snapshot)
if err != nil {
http.Error(w, "encoding failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if _, err := w.Write(body); err != nil { return }
}
}
קריאות רבות יכולות להשתמש שוב בתמונת מטמון בלי לגרום למספר זהה של בקשות לספקים. השרת עדיין יכול למצות מעבד, זיכרון, רוחב פס או קיבולת חיבורים, ולכן מהתכנון לא נובעת הבטחה ל־10,000 משתמשים או למיקרושניות. הוסיפו בדיקות עומס ומגבלות קבלת בקשות HTTP לפני חשיפה לציבור. [מקור 1]
מעבר לכך הוספת עדכוני דחיפה באמצעות SSE
SSE יכול להעביר תמונת מטמון חדשה אחרי כל מחזור תשאול. זו מסירה בדחיפה של מידע שנאסף תקופתית, לא זיהוי מיידי של תקריות אצל הספק. [מקור 1]
במקום לכפות על הלקוח לטעון מחדש או לתשאל את ה־API שלנו בכל 30 שניות, אפשר להשתמש ב־Server-Sent Events, או SSE. זהו פרוטוקול קל משקל וחד־כיווני מעל HTTP רגיל, שמאפשר לשרת לדחוף עדכונים ישירות לדפדפן. בניגוד ל־WebSockets, הוא אינו דורש שדרוגי חיבור מורכבים או פרוטוקולים מותאמים.
קטע הדפדפן הבא, שלא הורץ, מדגים רק EventSource. שרת ה־Go שלעיל אינו מממש את /status/stream. נקודת קצה אמיתית של SSE זקוקה למסגור text/event-stream, לשליחת נתונים ללא אגירה, לניקוי חיבורים שהתנתקו, לתורים מוגבלים לכל לקוח, לטיפול בחיבור מחדש ולהגדרות פרוקסי מתאימות. השתמשו בנתיבים מאותו מקור או ב־CORS שהוגדר במכוון. [מקור 1]
// Runtime-untested fragment; /status/stream is NOT implemented above.
const eventSource = new EventSource('/status/stream');
eventSource.onmessage = (event) => {
try {
const statuses = JSON.parse(event.data);
updateDashboardUI(statuses); // Application-supplied rendering function.
} catch {
console.warn('Invalid status event');
}
};
eventSource.onerror = () => {
console.warn('Status stream interrupted; EventSource may reconnect');
};
window.addEventListener('pagehide', () => eventSource.close(), { once: true });
לסיכום
תצוגת מצב מרכזית יכולה להקל על מיון תקריות, אך אינה מקור אמת סמכותי יחיד על היישום שלכם. הציגו את מקורות הספקים, זמני התצפית, מצבי לא ידוע או מיושן והיקף הרכיבים. שלבו דיווחי ספקים עם בדיקות סינתטיות, יומנים ומדדים משלכם לפני ייחוס תקרית. [מקור 1]
האם אתם בונים כלים פנימיים או לוחות מותאמים למעקב אחר תקינות התלויות בסביבה שלכם? האם אתם מעדיפים לפענח דפי מצב בצד הלקוח, או תמיד מרכזים את העבודה דרך מטמון בצד השרת?
בואו נדבר בתגובות, או שתפו את המחשבות ב־Twitter/X ותייגו את @sysseder!