בואו נדון בשאלה חשובה: איך אנו מנטרים את השירותים שלנו אם משהו הולך לאיבוד?
מצד אחד, יש לנו את Prometheus עם התראות ואת Kibana למרכזי מידע ותכונות נוספות. אנו גם יודעים כיצד לאסוף קבצי לוגים – מערכת ה-ELK היא הפתרון המועדף עלינו. אף על פי כן, הלוגים הפשוטים לא תמיד מספיקים: הם אינם מספקים תצוגה הוליסטית של המסע של בקשה לאורך כל האקוסיסטמה של רכיבים.
ניתן למצוא מידע נוסף על ELK כאן.
אך מה לעשות אם ברצוננו להמחיש בקשות? מה לעשות אם אנו צריכים לקורלציה בין בקשות שנוסעות בין מערכות? זה חל גם על מיקרו-שירותים ומונוליטים – זה לא משנה כמה שירותים יש לנו; העיקר היא איך אנו ניהלים את האיחוד שלהם.
למעשה, כל בקשת משתמש עשויה לעבור דרך שרשרת של שירותים עצמאיים, מסדי נתונים, תורי הודעות ו-APIים חיצוניים.
בסביבה כה מורכבת, זה נהיה קשה ביותר לזהות בדיוק איפה מתרחשים האיחודים, לזהות איזו חלק מהשרשרת פועל כנקודת בקבוק ביצועים, ולמצוא מהר את הגורם המרכזי לכשלים כאשר הם מתרחשים.
כדי לטפל באתגרים אלו באופן יעיל, אנו זקוקים למערכת מרכזית ועקבית לאיסוף נתוני טלמטריה – עקבות, מדדים ולוגים. זה המקום שבו OpenTelemetry ו-Jaeger מתגייסים לעזרה.
בואו נסתכל על היסודות
ישנם שני מונחים עיקריים שעלינו להבין:
זיהוי עקבות
זיהוי עקבות הוא מזהה בן 16 בתים, שבדרך כלל מיוצג כמחרוזת הקסדצימלית באורך 32 תווים. הוא נוצר באופן אוטומטי בתחילת העקבות ומשאיר את אותו ערך לאורך כל הספנים שנוצרים על ידי בקשה מסוימת. זה עוזר לראות בקלות איך בקשה נוסעת בין שירותים או רכיבים שונים במערכת.
זיהוי ספן
כל פעולה יחידה בתוך עקבות מקבלת זיהוי ספן משלה, שבדרך כלל מספר 64 ביטים שנוצר באופן אקראי. ספנים משתפים את אותו זיהוי עקבות, אך כל אחד מהם יש לו זיהוי ספן ייחודי, כך שניתן לסמן בו בדיוק איזו חלק מזרימת העבודה מייצג כל ספן (כגון שאילתת מסד נתונים או קריאה לשירות מיקרו אחר).
איך הם מתקשרים?
זיהוי עקבות וזיהוי ספן משלימים זה את זה.
כאשר בקשה מתחילה, נוצר זיהוי עקבות ומועבר לכל השירותים המעורבים. כל שירות, בתורו, יוצר ספן עם זיהוי ספן ייחודי המקושר לזיהוי עקבות, מאפשר לך להראות באופן ויזואלי את מחזור החיים המלא של הבקשה מהתחלה ועד סיום.
בסדר, אז למה לא פשוט להשתמש ב-Jaeger? למה אנחנו צריכים את OpenTelemetry (OTEL) ואת כל המפרטים שלו? זו שאלה מעולה! בואו נפרק את זה שלב אחר שלב.
מצאו עוד על Jaeger כאן.
לסיכום;
- Jaeger היא מערכת לאחסון ולהצגת עקבות מבוזרות. היא אוספת, אוחסנת, מחפשת ומציגה נתונים המראים כיצד בקשות "נוסעות" דרך השירותים שלך.
- OpenTelemetry (OTEL) היא תקן (וסט של ספריות) לאיסוף נתוני טלמטריה (עקבות, מדדים, לוגים) מהיישומים והתשתיות שלך. זה אינו קשור לכל כלי וזיפות ראש מסוים.
פשוט מדברים:
- OTEL היא כמו "שפה אוניברסלית" וסט של ספריות לאיסוף טלמטריה.
- Jaeger היא רקע וממשק משתמש לצפייה וניתוח של עקבות מבוזרות.
למה נזקק ל-OTEL אם כבר יש לנו את Jaeger?
1. תקן יחיד לאיסוף
בעבר, קיוו פרויקטים כמו OpenTracing ו-OpenCensus. OpenTelemetry מאחד את הגישות האלה לאיסוף מדדים ועקבות לתקן אוניברסלי אחד.
2. אינטגרציה קלה
אתה כותב את הקוד שלך ב-Go (או שפה אחרת), מוסיף ספריות OTEL להכנסת אינטרספטורים וספאנים באופן אוטומטי, וזהו. לאחר מכן, אין חשיבות לאיפה אתה רוצה לשלוח את הנתונים—Jaeger, Tempo, Zipkin, Datadog, רקע מותאם אישית—OpenTelemetry מטפלת בתשתיות. פשוט מחליפים את הייצוא.
3. לא רק עקבות
OpenTelemetry מכסה עקבות, אך גם מתמך במדדים ולוגים. אתה מסתיים עם סט כלים אחד לכל צרכי הטלמטריה שלך, לא רק עקיבה.
4. Jaeger כרקע
יעגר הוא בחירה מצוינת אם אתה מעוניין בעיקר בהדמיה של מעקב מבוזר. אבל הוא לא מספק את הכלים הבין-שפתיים כברירת מחדל. OpenTelemetry, מצד שני, נותן לך דרך סטנדרטית לאסוף נתונים, ואז אתה מחליט לאן לשלוח אותם (כולל יעגר).
בע实践, הם לעיתים קרובות עובדים יחד:
היישום שלך משתמש ב-OpenTelemetry → מתקשר דרך פרוטוקול OTLP → מגיע לאספן OpenTelemetry (HTTP או grpc) → מייצא ליעגר להדמיה.
חלק טכני
עיצוב מערכת (קצת)
בואו נצייר במהירות כמה שירותים שיעשו את הדברים הבאים:
- שירות רכישה – מעבד תשלום ומקליט אותו ב-MongoDB
- CDC עם Debezium – מקשיב לשינויים בטבלת MongoDB ושולח אותם ל-Kafka
- מעבד רכישות – צורך את ההודעה מ-Kafka ומתקשר לשירות האימות כדי לבדוק את
user_id
לאימות - שירות אימות – שירות משתמש פשוט
לסיכום:
- 3 שירותי Go
- Kafka
- CDC (Debezium)
- MongoDB
חלק קוד
בואו נתחיל עם התשתית. כדי לקשר הכל למערכת אחת, ניצור קובץ Docker Compose גדול. נתחיל בהגדרת טלמטריה.
שים לב: כל הקוד זמין דרך קישור בסוף המאמר, כולל התשתית.
services
jaeger
image jaegertracing/all-in-one1.52
ports
"6831:6831/udp" # UDP port for the Jaeger agent
"16686:16686" # Web UI
"14268:14268" # HTTP port for spans
networks
internal
prometheus
image prom/prometheus latest
volumes
./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports
"9090:9090"
depends_on
kafka
jaeger
otel-collector
command
--config.file=/etc/prometheus/prometheus.yml
networks
internal
otel-collector
image otel/opentelemetry-collector-contrib0.91.0
command'--config=/etc/otel-collector.yaml'
ports
"4317:4317" # OTLP gRPC receiver
volumes
./otel-collector.yaml:/etc/otel-collector.yaml
depends_on
jaeger
networks
internal
נקבע את האוסף — הרכיב שאוסף טלמטריה.
כאן, בחרנו ב- gRPC להעברת נתונים, שמשמעו תקשורת תתבצע דרך HTTP/2:
receivers
# Add the OTLP receiver listening on port 4317.
otlp
protocols
grpc
endpoint"0.0.0.0:4317"
processors
batch
# https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/memorylimiterprocessor
memory_limiter
check_interval 1s
limit_percentage80
spike_limit_percentage15
extensions
health_check
exporters
otlp
endpoint"jaeger:4317"
tls
insecuretrue
prometheus
endpoint 0.0.0.09090
debug
verbosity detailed
service
extensions health_check
pipelines
traces
receivers otlp
processors memory_limiter batch
exporters otlp
metrics
receivers otlp
processors memory_limiter batch
exporters prometheus
ודא שאתה מתאים כתובות לפי הצורך, וזהו, סיימת עם הגדרת הבסיס.
כבר ידוע לנו ש- OpenTelemetry (OTEL) משתמשת בשני מושגים מרכזיים — זיהוי עקבות (Trace ID) ו- זיהוי רווח (Span ID) — שעוזרים לעקוב ולנטר בקשות במערכות מבוזרות.
מיושמת הקוד
עכשיו, בואו נראה איך להפעיל זאת בקוד Go שלך. אנו זקוקים לייבואים הבאים:
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
לאחר מכן, אנו מוסיפים פונקציה לאתחול העוקב שלנו ב- main()
כאשר היישום מתחיל:
func InitTracer(ctx context.Context) func() {
exp, err := otlptrace.New(
ctx,
otlptracegrpc.NewClient(
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
),
)
if err != nil {
log.Fatalf("failed to create OTLP trace exporter: %v", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceNameKey.String("auth-service"),
semconv.ServiceVersionKey.String("1.0.0"),
semconv.DeploymentEnvironmentKey.String("stg"),
),
)
if err != nil {
log.Fatalf("failed to create resource: %v", err)
}
tp := trace.NewTracerProvider(
trace.WithBatcher(exp),
trace.WithResource(res),
)
otel.SetTracerProvider(tp)
return func() {
err := tp.Shutdown(ctx)
if err != nil {
log.Printf("error shutting down tracer provider: %v", err)
}
}
}
עם העקבות המוגדרות, עלינו רק למקם ספאנים בקוד כדי לעקוב אחר שיחות. לדוגמה, אם נרצה למדוד שיחות למסד נתונים (מאחר שזה בדרך כלל המקום הראשון שאנו מסתכלים בו לבעיות ביצועים), נוכל לכתוב משהו כזה:
tracer := otel.Tracer("auth-service")
ctx, span := tracer.Start(ctx, "GetUserInfo")
defer span.End()
tracedLogger := logging.AddTraceContextToLogger(ctx)
tracedLogger.Info("find user info",
zap.String("operation", "find user"),
zap.String("username", username),
)
user, err := s.userRepo.GetUserInfo(ctx, username)
if err != nil {
s.logger.Error(errNotFound)
span.RecordError(err)
span.SetStatus(otelCodes.Error, "Failed to fetch user info")
return nil, status.Errorf(grpcCodes.NotFound, errNotFound, err)
}
span.SetStatus(otelCodes.Ok, "User info retrieved successfully")
יש לנו עקבות בשכבת השירות — מעולה! אך אנו יכולים להעמיק עוד יותר, לכלול בתוכנה את שכבת מסד הנתונים:
func (r *UserRepository) GetUserInfo(ctx context.Context, username string) (*models.User, error) {
tracer := otel.Tracer("auth-service")
ctx, span := tracer.Start(ctx, "UserRepository.GetUserInfo",
trace.WithAttributes(
attribute.String("db.statement", query),
attribute.String("db.user", username),
),
)
defer span.End()
var user models.User
// Some code that queries the DB...
// err := doDatabaseCall()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "Failed to execute query")
return nil, fmt.Errorf("failed to fetch user info: %w", err)
}
span.SetStatus(codes.Ok, "Query executed successfully")
return &user, nil
}
עכשיו, יש לנו תצפית מלאה על מסע הבקשה. עבור לממשק ה- Jaeger, חפש את העקבות ה-20 האחרונות בקטגוריה auth-service
, ותראה את כל הספאנים ואיך הם מתחברים במקום אחד.
עכשיו, הכל נראה. אם נחוץ, תוכל לכלול את השאילתה המלאה בתוך התגי שאילתה. עם זאת, חשוב לא לעמוד במשקל הטלמטריה שלך — עליך להוסיף נתונים בצורה מכוונת. אני פשוט מדגים מה אפשר לעשות, אבל לכלול את השאילתה המלאה בדרך הזו אינו משהו שאני ממליץ עליו בדרך כלל.
לקוח-שרת gRPC
אם ברצונך לראות עקבות שמתרכזות בשני שירותי gRPC, זה די פשוט. כל מה שאתה צריך לעשות הוא להוסיף את האינטרספטורים הכלולים מהספרייה. לדוגמה, בצד השרת:
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterAuthServiceServer(server, authService)
בצד הלקוח, הקוד קצר בדיוק כמו בצד השרת:
shutdown := tracing.InitTracer(ctx)
defer shutdown()
conn, err := grpc.Dial(
"auth-service:50051",
grpc.WithInsecure(),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
logger.Fatal("error", zap.Error(err))
}
זה הכול! וודא שמותקנים המייצאים שלך בצורה תקינה, ותראה Trace ID יחיד מתעדכן על כל השירותים אלה כאשר הלקוח קורא לשרת.
טיפול באירועי CDC ועקיפה
רוצה לטפל באירועים מ- CDC גם? גישה פשוטה היא להטמיע את ה- Trace ID באובייקט שבו שומרת MongoDB. כך, כאשר Debezium תופסת את השינוי ושולחת אותו ל- Kafka, ה- Trace ID כבר חלק מהרשומה.
לדוגמה, אם אתה משתמש ב- MongoDB, אתה יכול לעשות משהו כמו זה:
func (r *mongoPurchaseRepo) SavePurchase(ctx context.Context, purchase entity.Purchase) error {
span := r.handleTracing(ctx, purchase)
defer span.End()
// Insert the record into MongoDB, including the current span's Trace ID
_, err := r.collection.InsertOne(ctx, bson.M{
"_id": purchase.ID,
"user_id": purchase.UserID,
"username": purchase.Username,
"amount": purchase.Amount,
"currency": purchase.Currency,
"payment_method": purchase.PaymentMethod,
// ...
"trace_id": span.SpanContext().TraceID().String(),
})
return err
}
אז Debezium מאחזרת את האובייקט הזה (כולל trace_id) ושולחת אותו ל- Kafka. בצד הצרכן, אתה פשוט מנתח את ההודעה הנכנסת, מוציא את ה- trace_id ומשלב אותו בהקשר העקיפה שלך:
// If we find a Trace ID in the payload, attach it to the context
newCtx := ctx
if traceID != "" {
log.Printf("Found Trace ID: %s", traceID)
newCtx = context.WithValue(ctx, "trace-id", traceID)
}
// Create a new span
tracer := otel.Tracer("purchase-processor")
newCtx, span := tracer.Start(newCtx, "handler.processPayload")
defer span.End()
if traceID != "" {
span.SetAttributes(
attribute.String("trace.id", traceID),
)
}
// Parse the "after" field into a Purchase struct...
var purchase model.Purchase
if err := mapstructure.Decode(afterDoc, &purchase); err != nil {
log.Printf("Failed to map 'after' payload to Purchase struct: %v", err)
return err
}
// If we find a Trace ID in the payload, attach it to the context
newCtx := ctx
if traceID != "" {
log.Printf("Found Trace ID: %s", traceID)
newCtx = context.WithValue(ctx, "trace-id", traceID)
}
// Create a new span
tracer := otel.Tracer("purchase-processor")
newCtx, span := tracer.Start(newCtx, "handler.processPayload")
defer span.End()
if traceID != "" {
span.SetAttributes(
attribute.String("trace.id", traceID),
)
}
// Parse the "after" field into a Purchase struct...
var purchase model.Purchase
if err := mapstructure.Decode(afterDoc, &purchase); err != nil {
log.Printf("Failed to map 'after' payload to Purchase struct: %v", err)
return err
}
אלטרנטיבה: שימוש בכותרות של Kafka.
לפעמים, יותר קל לאחסן את מזהה העקבות בכותרות של Kafka במקום בגוף ההודעה עצמה. בזרימות עבודה של CDC, זה יכול לא להיות זמין מראש – Debezium יכול להגביל מה יתווסף לכותרות. אבל אם יש לך שליטה על צד היוצר (או אם אתה משתמש ביוצר Kafka סטנדרטי), אתה יכול לעשות משהו דומה לכך עם Sarama:
הזרקת מזהה עקבות אל כותרות
// saramaHeadersCarrier is a helper to set/get headers in a Sarama message.
type saramaHeadersCarrier *[]sarama.RecordHeader
func (c saramaHeadersCarrier) Get(key string) string {
for _, h := range *c {
if string(h.Key) == key {
return string(h.Value)
}
}
return ""
}
func (c saramaHeadersCarrier) Set(key string, value string) {
*c = append(*c, sarama.RecordHeader{
Key: []byte(key),
Value: []byte(value),
})
}
// Before sending a message to Kafka:
func produceMessageWithTraceID(ctx context.Context, producer sarama.SyncProducer, topic string, value []byte) error {
span := trace.SpanFromContext(ctx)
traceID := span.SpanContext().TraceID().String()
headers := make([]sarama.RecordHeader, 0)
carrier := saramaHeadersCarrier(&headers)
carrier.Set("trace-id", traceID)
msg := &sarama.ProducerMessage{
Topic: topic,
Value: sarama.ByteEncoder(value),
Headers: headers,
}
_, _, err := producer.SendMessage(msg)
return err
}
חילוץ מזהה עקבות בצד הצרכן
for message := range claim.Messages() {
// Extract the trace ID from headers
var traceID string
for _, hdr := range message.Headers {
if string(hdr.Key) == "trace-id" {
traceID = string(hdr.Value)
}
}
// Now continue your normal tracing workflow
if traceID != "" {
log.Printf("Found Trace ID in headers: %s", traceID)
// Attach it to the context or create a new span with this info
}
}
בהתאם למקרה השימוש שלך ואיך הצינור CDC שלך מוגדר, אתה יכול לבחור בגישה שעובדת הכי טוב:
- להטמיע את מזהה העקבות ברשומת בסיס הנתונים כך שהיא תזרום באופן טבעי דרך CDC.
- השתמש בכותרות של Kafka אם יש לך שליטה יותר על צד היוצר או אם ברצונך למנוע תפיחת העומס בגוף ההודעה.
בכל מקרה, אתה יכול לשמור על העקבות שלך עקביות דרך שירותים מרובים- גם כאשר האירועים מעובדים באופן אסינכרוני דרך Kafka ו-Debezium.
מסקנה
שימוש ב-OpenTelemetry ו-Jaeger מספק עקבות בקשות מפורטות, עוזרות לך לאתר איפה ולמה השהיות מתרחשות במערכות מבוזרות.
הוספת Prometheus משלימה את התמונה עם מדדים – מראי הביצועים והיציבות. יחד, הכלים האלה מרכיבים סטאק תצפיות מקיף, שמאפשר איתור תקלות מהיר, אופטימיזצית ביצועים, ואמינות מערכתית כוללת.
אני יכול לומר שהגישה הזו מאיצה באופן משמעותי את פתרון בעיות בסביבת שירותים קטנים והיא אחת הדברים הראשונים שאנו מיישמים בפרויקטים שלנו.
קישורים
Source:
https://dzone.com/articles/control-services-otel-jaeger-prometheus