Fork me on GitHub

版权声明 本站原创文章 由 萌叔 发表
转载请注明 萌叔 | https://vearne.cc

1.引言

前段时间利用grafana/grafana-image-renderer 做了一个自动截图服务。 见萌叔的文章抓取GRAFANA PANEL视图。但是我发现了一个问题(grafana版本v6.0.1),如果grafana的实例数量超过1个,在grafana中访问grafana render link时,就会有一定的概率失败,图片无法成功渲染。这到底是是为什么?

本文将给出完整的问题排查和解决过程,希望读者可以从中汲取一些养分,为以后排查其它问题提供一些思路。

2. 日志,一切从日志开始

看到这种问题,首先看看日志中是否有线索

2.1 首先看grafana的日志


只能了解到点击grafana render link之后,会触发一个GET请求,访问grafana服务的
/render/d-solo/P3a2p0cZz/redis-and-mysql-and-cache,然后引起一个内部错误

2.2 看看grafana-image-renderer的日志

我们已经知道,图片渲染的过程,grafana要调用grafana-image-renderer,让我们来看下grafana-image-renderer的日志。
需要注意,grafana-image-renderer的日志默认是打印在标准输出和标准错误输出里的。
对比成功和失败的case我发现,在失败的case中,日志中会多了一条401 (Unauthorized)的日志

对应的链接,就是我们要渲染成图片的panel所对应的网页地址

猜测1

现在我们可以猜测grafana-image-renderer可能是请求grafana服务对应的网页资源失败,然后导致图片渲染失败。

2.3 抓包,需要API入口

2.3.1 对grafana-image-renderer抓包

为了进一步验证这个上面的猜测,抓包看一下,grafana-image-renderer所收到的请求

萌叔抓包使用的是buger/goreplay

./gor --input-raw :8081 --output-stdout
1 1a1a5c7c2e733bf0ed41f7ce35bee28da845ab49 1616730655714291898 2541293
GET /render?domain=grafana.example.com&encoding=&height=500&renderKey=HPXorbVBhC6taLHFEe6Jg9O3e5w6R5xn&timeout=60&timezone=Asia%2FShanghai&url=http%3a%2f%2fgrafana.example.com%2fd-solo%2fP3a2p0cZz%2fredis-and-mysql-and-cache%3forgId%3d1%26from%3d1616719854090%26to%3d1616730654091%26var-instance%3d192.168.1.100%3a9090%26panelId%3d8%26width%3d1000%26height%3d500%26tz%3dAsia%2fShanghai%26render%3d1&width=1000 HTTP/1.1
X-Forwarded-Proto: http
Host: grafana-image-renderer.example.com
Connection: keep-alive
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip

展开来看

url参数解码以后,确实就是grafanapanel对应的网页地址,
到这里猜测1,已经被证实了。

猜测2

请求的参数中有个renderKey非常的可疑,它会不会是请求grafana获取对应网页时,用来鉴权的token呢?

2.3.2 对grafana抓包

这里使用tcpdump抓取之后,然后用wireshark中观察

tcpdump -i eth0 host 192.168.1.102 and port 3000 -w /tmp/xxx.cap

grafana-image-renderer请求grafana时,确实在Cookie中携带了renderkey。离真相越来越近了。

3. 阅读源码

Talk is cheap. Show me the code.
— Linus Torvalds

grafana v7.4.5

萌叔阅读了grafana项目中,renderKey出现的地方,发现主要在 pkg/services/rendering

访问控制的代码

/pkg/services/contexthandler/contexthandler.go

func (h *ContextHandler) Middleware(c *macaron.Context) {
    ...
    switch {
    case h.initContextWithRenderAuth(ctx):
    case h.initContextWithAPIKey(ctx):
    case h.initContextWithBasicAuth(ctx, orgID):
    case h.initContextWithAuthProxy(ctx, orgID):
    case h.initContextWithToken(ctx, orgID):
    case h.initContextWithAnonymousUser(ctx):
    }
    ...
}
func (h *ContextHandler) initContextWithRenderAuth(ctx *models.ReqContext) bool {
    key := ctx.GetCookie("renderKey")
    if key == "" {
        return false
    }

    renderUser, exists := h.RenderService.GetRenderUser(key)
    if !exists {
        ctx.JsonApiErr(401, "Invalid Render Key", nil)
        return true
    }

    ctx.IsSignedIn = true
    ctx.SignedInUser = &models.SignedInUser{
        OrgId:   renderUser.OrgID,
        UserId:  renderUser.UserID,
        OrgRole: models.RoleType(renderUser.OrgRole),
    }
    ctx.IsRenderCall = true
    ctx.LastSeenAt = time.Now()
    return true
}

看到这里,猜测2已经被证实了。

调用grafana-image-render的部分

func (rs *RenderingService) render(ctx context.Context, opts Opts) (*RenderResult, error) {
    ... 

    rs.log.Info("Rendering", "path", opts.Path)
    if math.IsInf(opts.DeviceScaleFactor, 0) || math.IsNaN(opts.DeviceScaleFactor) || opts.DeviceScaleFactor <= 0 {
        opts.DeviceScaleFactor = 1
    }
    // 在生成renderKey
    renderKey, err := rs.generateAndStoreRenderKey(opts.OrgId, opts.UserId, opts.OrgRole)
    if err != nil {
        return nil, err
    }
    // 调用完成后就清除掉renderKey
    defer rs.deleteRenderKey(renderKey)

    defer func() {
        rs.inProgressCount--
        metrics.MRenderingQueue.Set(float64(rs.inProgressCount))
    }()

    rs.inProgressCount++
    metrics.MRenderingQueue.Set(float64(rs.inProgressCount))
    // 调用`grafana-image-render`
    return rs.renderAction(ctx, renderKey, opts)
}
func (rs *RenderingService) generateAndStoreRenderKey(orgId, userId int64, orgRole models.RoleType) (string, error) {
    key, err := util.GetRandomString(32)
    if err != nil {
        return "", err
    }
    // 使用remote cache 存储 renderKey  --> RenderUser的对应关系
    err = rs.RemoteCacheService.Set(fmt.Sprintf(renderKeyPrefix, key), &RenderUser{
        OrgID:   orgId,
        UserID:  userId,
        OrgRole: string(orgRole),
    }, 5*time.Minute)
    if err != nil {
        return "", err
    }

    return key, nil
}

可以看出renderKey就是临时生成的,供grafana-image-render访问grafana使用

grafana v6.0.1

pkg/middleware/render_auth.go

var renderKeysLock sync.Mutex
// v6.0.1 renderKey存储在单机的map renderKeys中
var renderKeys map[string]*m.SignedInUser = make(map[string]*m.SignedInUser)

func initContextWithRenderAuth(ctx *m.ReqContext) bool {
    key := ctx.GetCookie("renderKey")
    if key == "" {
        return false
    }

    renderKeysLock.Lock()
    defer renderKeysLock.Unlock()

    renderUser, exists := renderKeys[key]
    if !exists {
        ctx.JsonApiErr(401, "Invalid Render Key", nil)
        return true
    }

    ctx.IsSignedIn = true
    ctx.SignedInUser = renderUser
    ctx.IsRenderCall = true
    ctx.LastSeenAt = time.Now()
    return true
}


读者可能已经看出来了,由于grafana v6.0.1 中renderKey是单机存储的,如果garafana服务部署了多个实例,如上图,instance1和instance2。那么grafana-image-render可能会拿着instance1产生的renderKey,去访问instance2,显然鉴权是无法成功的。

在grafana v7.4.5中,renderKey被存储在remote cache中。

参看配置文件 grafana.ini

[remote_cache]
# Either "redis", "memcached" or "database" default is "database"
type = database

# cache connectionstring options
# database: will use Grafana primary database.
# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`. Only addr is required. ssl may be 'true', 'false', or 'insecure'.
# memcache: 127.0.0.1:11211
;connstr =

remote cache默认使用的就是数据库的配置,那么一切都OK了。

猜测3

只需要把grafana的版本升级一下就应该能解决图片渲染失败的问题

4. 实验证明

grafana升级到v7.4.5,问题解决。

总结

整个排障的过程,其实是一个大胆猜测,不断小心求证的过程。其中日志、抓包工具、阅读源码等,都是我们排障的好帮手。
希望这篇文章对大家有所帮助,如果有帮助,别忘了打赏哦。


微信公众号

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据