구현 근거는 Opentelemetry 공식 문서 https://opentelemetry.io/docs/languages/go/instrumentation/#metrics 에 기반합니다.
2024년 3월 3일 기준으로 Opentelemetry에서 Golang기반으로 Metric정보를 수집하기 위해선 모두 manually하게 metrics를 수집해야 합니다. Javascript나 Java 언어들은 Auto Implementation이 구현되어 있어, CPU와 Memory등의 Metric 정보를 수집하기 위한 설정을 따로 구현하지 않아도 SDK API만으로 수집이 가능합니다. 이에 유의하며 글을 봐주시길 바랍니다.
otlpmetrichttp
라이브러리를 활용해서 사용할 Exporter를 지정하고, otel/sdk/metric의 WithReader
함수를 통해 해당 Exporter와 맞는 Reader를 지정할 수 있습니다.MeterProvider는 Meter를 제공해주는 역할을 합니다. 이 때, MeterProvider를 Global하게 지정하여 해당 Provider에서 Meter를 자식처럼 생성할 수 있습니다.
여기서 Meter는, Metric을 수집하는 객체입니다. 저희는 manually하게 metric 정보를 수집해야 하기 때문에, CPU를 위한 Meter객체 하나, Memory를 위한 Meter객체 하나를 만들어서 이를 기반으로 Metric정보를 수집합니다.
아래는 적용 코드입니다.
(저는 아래 코드를 observability라는 디렉토리에 추가하여 main.go에서 직접 호출하였음을 인지하고 보시기 바랍니다.)
package observability
import (
"context"
"runtime"
"time"
"github.com/shirou/gopsutil/cpu"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)
const defaultMetricDuration time.Duration = 3 * time.Second
func SetGlobalMeterProvider() {
// Create resource.
res, err := newMetricResource()
if err != nil {
panic(err)
}
// Create a meter provider.
// You can pass this instance directly to your instrumented code if it
// accepts a MeterProvider instance.
meterProvider, err := newMeterProvider(res, defaultMetricDuration)
if err != nil {
panic(err)
}
// Register as global meter provider so that it can be used via otel.Meter
// and accessed using otel.GetMeterProvider.
// Most instrumentation libraries use the global meter provider as default.
// If the global meter provider is not set then a no-op implementation
// is used, which fails to generate data.
otel.SetMeterProvider(meterProvider)
}
func newMetricResource() (*resource.Resource, error) {
return resource.Merge(resource.Default(),
resource.NewWithAttributes(semconv.SchemaURL,
semconv.ServiceName("iris-metric"),
semconv.ServiceVersion("0.1.0"),
))
}
func newMeterProvider(res *resource.Resource, second time.Duration) (*sdkmetric.MeterProvider, error) {
// Use OLTP Exporter for Grafana Agent (Recommended)
otlpExporter, err := otlpmetrichttp.New(context.Background(), otlpmetrichttp.WithEndpointURL("http://localhost:4318/v1/metrics"))
if err != nil {
return nil, err
}
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(otlpExporter,
// Define duration of Metric
sdkmetric.WithInterval(second))),
)
return meterProvider, nil
}
생성하는 코드는 otel.Meter("이름지정")
로 Global하게 지정된 MeterProvider에서 언제든 꺼내와서 생성할 수 있습니다. (공식문서 참조)
앞서 말했지만, 저희는 두가지의 Metric 정보(CPU, Memory)가 목적입니다.
이를 위해, 일단 MeterProvider 생성함수가 정의된 go파일 안에 (observability/metric.go) 추가로 CPU Meter, Memory Meter 함수를 정의하여 main.go
파일 내에서 otel.Meter("memory-metrics”)
, otel.Meter("cpu-metrics")
로 Meter를 생성하여 각 함수의 인자로 넘겨주었습니다. 자세한 설명은 조금 이따가 진행하겠습니다.
아래는 그 코드입니다. (metric.go)
// observability/metric.go
package observability
import (
"context"
"runtime"
"time"
"github.com/shirou/gopsutil/cpu"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)
func GetMemoryMeter(meter metric.Meter) {
if _, err := meter.Int64ObservableGauge(
"memory.heap",
metric.WithDescription(
"Memory usage of the allocated heap objects.",
),
metric.WithUnit("By"), // UCUM 규약의 Byte
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
var m runtime.MemStats
runtime.ReadMemStats(&m)
o.Observe(int64(m.HeapAlloc))
return nil
}),
); err != nil {
panic(err)
}
}
func GetCPUMeter(meter metric.Meter, duration time.Duration) {
if _, err := meter.Float64ObservableGauge(
"cpu.usage",
metric.WithDescription(
"All CPU",
),
metric.WithUnit("%"),
metric.WithFloat64Callback(func(_ context.Context, o metric.Float64Observer) error {
cpuPercent, _ := cpu.Percent(duration, false)
o.Observe(float64(cpuPercent[0]))
return nil
}),
); err != nil {
panic(err)
}
}
그리고 main.go의 코드입니다.
func main() {
observability.SetGlobalMeterProvider()
// Aynchronous Instruments로써, go routine 불필요
observability.GetMemoryMeter(otel.Meter("memory-metrics"))
observability.GetCPUMeter(otel.Meter("cpu-metrics"), 15*time.Second)
// ..생략
}
main.go 함수에서 otel.Meter()
를 호출하여 Meter 객체를 생성하여 함수의 인자로 넘긴 것을 확인할 수 있습니다.
그러면, 각 함수를 살펴보겠습니다.
여기서 중요한 것은 Synchronous 한 Instrument와 Asynchronous한 Instrument가 존재한다는 사실입니다.
Synchronous Instrument
해당 metric은 함수가 불려질 때, 수치를 측정합니다. 그리고 이러한 측정 값들을 exporter가 주기적으로 grafana agent에게 보내줍니다.
Asynchronous Instrument
솔직히 말해서 공식문서를 봐도, 감이 잘 오지 않았습니다.
Asynchronous instruments, on the other hand, provide a measurement at the request of the SDK. When the SDK exports, a callback that was provided to the instrument on creation is invoked. This callback provides the SDK with a measurement that is immediately exported. All measurements on asynchronous instruments are performed once per export cycle.
대충 이해한 바로는, Synchronous와 반대의 개념으로 생각했습니다. 불려질 때 측정하는 것이 아닌, ‘비동기’적으로 측정한다. 하지만 이 말도 추상적이었습니다. 다행히도 공식문서에 Asynchronous를 도입하기 좋은 예시를 들어주었습니다.
수치를 일일히 증가시키는 것이 cost가 있고, 측정을 위해 thread가 wait하는 작업을 원치 않을 때
프로그램 실행과 상관 없이 일정 빈도수로 이벤트가 일어날 때
이러한 예시를 토대로, “비동기” Instrument의 성격을 지레짐작 할 수 있게 되었고 CPU 측정이나 Memory 정보 수집의 상황에서는 Asynchronous Instrument가 필요함을 알게 되었습니다.
Memory에는 Int64를, CPU는 ‘%’ 정보가 필요하므로 Float64 Gauge를 사용하였습니다.
getMemoryMeter 함수
해당 함수는 공식문서에 나와있는 코드와 거의 흡사합니다. 내부 코드를 조금 뜯어본 결과, runtime 라이브러리를 활용해서 다양한 Memory정보를 수집할 수 있었습니다. 그 중, Heap과 관련한 동적 Memory가 서버에 영향이 가장 크게 미칠 것 같아서 이를 수집하였습니다.
getCPUMeter함수
golang에서 CPU Usage를 측정하기 위한 마땅한 라이브러리가 없어서 shirou/gopsutil/cpu 의 라이브러리를 가져왔습니다. (공신력있는 오픈소스니 사용하셔도 됩니다) 해당 라이브러리를 활용하여 CPU의 usage정보를 얻어낼 수 있었습니다.
현재는 멀티코어의 환경에서도 평균 CPU usage정보로 수집하였습니다. cpu.Percent(duration, false)
의 false
인자가 perCPU 설정을 할 것인지 말 것인지 정하는 인자입니다. false로 지정하여 하나의 CPU usage정보를 도출하게 하였습니다.
CPU Usage는 t1 시점에서의 CPU status 정보와 t2시점에서의 CPU status정보의 차이로부터 구합니다.
CPU usage = CPU 사용 시간 / 전체 시간 * 100
t1, t2 인자가 특정 주기의 CPU status정보가 담긴 TimeStat입니다.
각 TimeStat에서 getAllBusy
함수를 통해 CPU 의 idle 시간을 추가한 전체 CPU시간, 그리고 idle 시간을 뺀 CPU사용 시간을 각각 구하여 CPU usage의 정보를 뽑아냅니다.
calculateAllBusy
함수에 TimeStat 배열을 돌긴 하는데, 저희는 perCPU설정을 false로 했으므로 calculateBusy 함수를 단 한번만 돌 것입니다. 만일 멀티코어 환경의 추적으로 변경한다면, TimeStat 배열의 수가 코어의 수만큼 늘어나고, 이를 기반으로 CPU usage정보를 추출할 수 있을 것입니다.
좀 더 자세히 들여다 보고 싶으시면, 코드를 더 뜯어보시는 것을 추천합니다.
코드 흐름은 다음과 같습니다.
cpu.Percent
→ PercentWithContext
(멀티코어 설정 여부에 따른 각 시점별 TimeStat정보 추출)→ calculateAllBusy
(멀티코어의 개수만큼 CPU usage측정) → calculateBusy
Otel에서 내부적으로 어떻게 Asynchronous하게 정보를 받아오는지 알 수는 없었습니다.
그래서 고루틴을 활용하여 억지로 asynchronous meter함수들을 실행시켰지만, 사용하지 않는 것과 큰 차이가 없었습니다. 이 점도 하나 알고가시면 좋을 것 같습니다. (누가 알아와줘요..ㅠㅠ)