使用Tempo、OpenTelemetry和Grafana Cloud进行分布式追踪的介绍

595 阅读12分钟

在我的职业生涯中,大部分时间都在与各种形式的技术打交道,在过去的十年左右,我一直专注于构建、维护和运行强大、可靠的系统。

这使我在研究、评估和实施不同的自动故障检测、监控以及最近的可观察性的解决方案方面投入了大量的时间。

在我们开始之前。什么是可观察性?可观察性是指将一个不透明的系统或系统中的动作,通过其输出让我们可以检查和推理的做法。

例如,考虑一个用户的交互,其结果是通过订单API下了一个订单。如果这个订单的放置出现问题,我们在多大程度上能够进行调试和排除故障?我们是否能够看到并跟踪互动留下的痕迹,阅读日志,或分析指标来推断发生了什么?

在一个典型的单片机应用中,这将是相当简单的--至少只要我们呆在一个非生产环境中,通常可以允许我们附加一个调试器,甚至可以放进断点,按照我们的意愿来完成逻辑。在生产环境中,这就比较困难了,因为我们在发布最终用户使用的软件时,很少(而且有充分的理由)保持调试功能。

虽然很麻烦,但这对这些单体系统来说效果很好。然而,随着交付速度的加快,系统开始变得越来越分散,以支持这种新的工作方式,即多个团队独立发布一个系统的部分。

单体系统与分布式系统

让我们来看看一个例子,向单体系统提出的请求是怎样的。这是非常直接的,控制流从一个方法传到另一个方法,在完成过程中跨越多个类。我们得到了一个连贯的堆栈跟踪,通过添加日志,我们至少可以对正在发生的事情和原因有一个很好的把握。

现在让我们来看看同一个系统,但作为一个使用微服务的分布式系统实现。在这里,每一个微服务--如果你真的要推敲的话,也可以是lambda函数--可以由不同的团队或个人提供。这种架构中的组件是松散耦合的,只受到运行时对它们之间的消息或互动的期望的限制。

这种架构风格也意味着没有一个总体性的过程将这些服务结合在一起。相反,它们都将作为自己独立的小应用程序运行,甚至可能在多个副本中复制,跨越多个数据中心,甚至不同的地理区域。

曾经直截了当的调试方式现在不再是一种选择。每个服务的调用之间不再有任何明显的关联,试图用时间戳或其他元数据将它们拼凑起来,很快就会变得难以管理。在一个每秒只有10个请求的系统中,手工劳动将是压倒性的,而且我们可能会经常出错。

想象一下,如果我们把它扩大到每秒10,000个请求,或者一百万个。如果对完全相同的方法进行多次调用,而两次调用之间只有几纳秒的时间,那该怎么办?它就是不能扩展。

那么,我们如何解决这个问题呢?这就是可观察性的作用。

可观察性的三大支柱

当人们谈论可观察性时,通常会听到他们提到可观察性的三个支柱:度量、日志和跟踪。虽然这三者并不能神奇地使系统变得更加可观察,但以正确的方式搭配,这三者构成了一个有点完整的技术堆栈,用于检查系统内部发生的事情。

追踪

虽然这三者都是使系统更易观察的重要部分,但本文将重点讨论第三个支柱:跟踪。追踪,或者在这里是分布式追踪,是为了解决分布式系统中的一致性损失问题。

我们通过仪器化我们的系统,使运行时信息成为我们追踪的一部分来做到这一点。像局部范围变量、堆栈痕迹和日志等都被添加到跟踪中,作为有时间戳的数据,供外部分析。

这可能会给我们的系统增加性能开销,但对于大多数团队来说,能够分析系统状态的便利性远远超过了任何性能方面的损失。

何时使用

我们应该总是默认在我们的应用程序中添加分布式跟踪吗?不一定。如果我们已经能够以一种令人满意的方式来推理我们系统的内部结构,可能还有其他的活动可以为团队或产品带来更多的价值。

然而,如果我们目前觉得我们缺乏做以下事情的手段,那么分布式跟踪可能正是我们需要的:

  • 轻松地看到这个系统的服务部分的健康状况。
  • 找到生产中出现的错误和缺陷的根本原因。
  • 找到性能问题,并指出它们发生的地点和原因。

追踪是如何工作的

那么,这在实践中是如何工作的呢?好吧,首先,我们需要一些方法来跟踪哪些微服务的调用属于什么跟踪。我们通过向请求上下文添加元数据来做到这一点,我们将把这些元数据传递给每一个后续的调用,让它们形成一个连贯的跟踪,然后我们可以使用例如跟踪或跨度ID进行搜索和分析。

我希望你们都还醒着。我保证我们很快就会进入有趣的部分,但在此之前,我们只需要多谈一点关于什么构成了一个跟踪。

由于追踪有多种格式,以及它们传播上下文的方式,所以根据你选择的格式,追踪的构成要素可能会有细微的差别。

在这篇文章中,我们将使用OpenTelemetry项目来追踪、传播上下文和输出。一般来说,这些概念也适用于大多数其他的跟踪实现。

所以,让我们从头开始。我们的任务是为假设的DogBook公司设置追踪。我们的一个用户,Floor,正试图使用我们的API(在这种情况下,/api/v1/kennels )来获取一个狗窝的列表以及住在那里的狗。

当她的请求被kennels 端点处理时,一个根跨度被创建。这个跨度将包含整个请求的时间,端到端的时间。它也将作为所有其他跨度的容器,作为请求的一部分创建。

对于每个后续的操作,额外的跨度将被创建并嵌套在其父跨度之下。因此,在我们的例子中,当请求到达api/v1/kennels ,根跨度被创建,然后,当我们要求狗舍微服务提供一个狗舍的列表时,另一个跨度将被创建。

kennel 微服务向dog 微服务询问该特定犬舍的狗的名字时,第三个跨度将被创建,以此类推,形成一个因果关系模型:

也许右边的图看起来有点熟悉?你可能以前见过类似的东西。虽然不完全相同,但大多数现代网络浏览器都做了类似的事情,并以类似的方式使用它,只是呈现的数据是以某种不同的方式收集的:

除了可嵌套外,每个跨度还持有仪器数据和时间,这就是分布式跟踪的强大之处。

一个跨度通常捕获以下数据:

  • 一个操作名称。
  • 开始和结束的时间戳。
  • 标签,基本上是任意数据的键值对。
  • 一组事件,每个事件都是三个元组,包含一个时间戳、一个名称和一组属性。
  • 一个父跨度标识符。
  • 与其他因果关系的跨度的链接(通过这些相关跨度的SpanContext )。
  • SpanContext,用于引用一个跨度。这些包括跟踪ID和跨度ID,以及其他东西。

演示

现在来看看有趣的东西!为了保持简单,我们将用模拟数据取代数据库。然而,我们仍然会把数据逻辑放在他们自己的函数中(有他们自己的跨度),只是为了得到一些更漂亮的跨度来看看。

我们将在JavaScript中实现这一点,作为三个不同的微服务,都在自己的Docker容器中运行:

$ tree

.
├── agent.yaml
├── docker-compose.yml
├── nginx
│   └── conf.d
│       └── default.conf
├── otel-config.yaml
└── services
    ├── dogs
    │   ├── Dockerfile
    │   ├── package.json
    │   └── src
    │       ├── index.js
    │       └── tracer.js
    ├── inventory
    │   ├── Dockerfile
    │   ├── package.json
    │   └── src
    │       ├── index.js
    │       └── tracer.js
    └── kennels
        ├── Dockerfile
        ├── package.json
        └── src
            ├── data.js
            ├── index.js
            └── tracer.js

这三个服务的tracer.js 文件和Docker文件都是相同的。就Docker文件而言,这可能只是暂时的,这就是为什么我们为每个项目保留一个单独的文件:

# ./services/*/Dockerfile

FROM node:latest
WORKDIR /app
COPY ./package.json package.json
RUN npm install

COPY ./src src

EXPOSE 8080
CMD ["npm", "start"]
// ./services/*/tracer.js

const initTracer = require('jaeger-client').initTracer;

function createTracer(serviceName, collectorEndpoint) {
  const config = {
    serviceName,
    sampler: {
      type: 'const',
      param: 1,
    },
    reporter: {
      logSpans: true,
      collectorEndpoint,
    },
  };
  const options = {
    logger: {
      info(msg) {
        console.log('INFO ', msg);
      },
      error(msg) {
        console.log('ERROR', msg);
      },
    },
  };

  return initTracer(config, options);
}

module.exports = {
  createTracer,
};

对于tracer.js ,我们当然可以在一个单独的实用程序包中保留一个共享文件,但为了简单起见,我只是去重复了它,以避免使构建复杂化。

在这个设置中,NGINX将被用作反向代理,使我们能够通过一个统一的接口来消费容器的API,而不是直接向外部暴露它们:

# ./nginx/conf.d/default.conf

upstream dogs {
    server dogs:3000;
}

upstream inventory {
    server inventory:8080;
}

upstream kennels {
    server kennels:8080;
}

server {
    server_name dogbook;

    location ~ ^/api/v1/dogs {
        proxy_pass http://dogs/$uri$is_args$args;
    }

    location ~ ^/api/v1/inventory {
        proxy_pass http://inventory/$uri$is_args$args;
    }

    location ~ ^/api/v1/kennels {
        proxy_pass http://kennels/$uri$is_args$args;
    }
}

虽然所有的端点都可以独立消费,但为了这个演示,我们感兴趣的是/api/v1/kennels ,因为这反过来会消费其他两个API作为其逻辑的一部分:

// ./services/kennels/src/index.js

const express = require('express');
const opentracing = require('opentracing');
const data = require('./data');
const { createTracer } = require('./tracer');

const tracer = createTracer(
  'kennels-service',
  'http://collector:14268/api/traces'
);

const app = express();

app.use('/', async (req, res) => {
  const parent = tracer.extract(opentracing.FORMAT_HTTP_HEADERS, req.headers);
  const span = tracer.startSpan('kennels.process-request', { childOf: parent });

  const id = req.query.id;
  const name = await data.getKennelName(id, span, tracer);

  if (!name) {
    res.status(404);
    res.send();
    span.finish();
    return;
  }

  const inventory = await data.getInventory(id, span, tracer);

  let dogs = await Promise.all(
    inventory.map(async (x) => {
      const name = await data.getDogDetails(x, span, tracer);
      return {
        id: x,
        name,
      };
    })
  );

  res.send({ name, dogs });
  span.finish();
});

app.listen('8080', '0.0.0.0');
// ./service/kennels/src/data.js

const axios = require('axios');
const opentracing = require('opentracing');

function getKennelName(id, parent, tracer) {
  const span = tracer.startSpan('kennels.get-dog-details', { childOf: parent });
  const name = ['awesome kennel', 'not as awesome kennel', 'some other kennel'][
    id
  ];
  span.finish();
  return name;
}

async function getDogDetails(id, parent, tracer) {
  const span = tracer.startSpan('kennels.get-dog-name', { childOf: parent });
  let name;
  try {
    let headers = {};
    tracer.inject(span, opentracing.FORMAT_HTTP_HEADERS, headers);
    const res = await axios.get(`http://dogs:8080/?id=${id}`, { headers });
    name = res.data;
  } catch (e) {
    console.log(e);
  }

  span.finish();
  return name || 'Nameless Dog';
}

async function getInventory(id, parent, tracer) {
  const span = tracer.startSpan('kennels.get-inventory', { childOf: parent });
  let result;

  try {
    let headers = {};
    tracer.inject(span, opentracing.FORMAT_HTTP_HEADERS, headers);
    const response = await axios.get(`http://inventory:8080/?kennelId=${id}`, {
      headers,
    });
    result = response.data;
  } catch (e) {
    console.log(e);
    result = [];
  }

  span.finish();
  return result;
}

module.exports = {
  getKennelName,
  getDogDetails,
  getInventory,
};

所以让我们试着解开这里发生了什么。我们正在使用Express来创建一个简单的HTTP API。它目前由一个单一的端点组成:根。然后我们检查请求的跟踪头,看它是否是现有跟踪的一部分,并创建一个新的span,包括父级的span ID(如果有的话)。

然后,我们执行实际的 "业务逻辑",从ID中解析犬舍的名称,并获取该犬舍的库存,在其中进行循环,逐一获取每条狗的名称。

值得注意的是,在每次调用data.js 的后续服务时,我们都会注入追踪头,以确保追踪保持连贯性,即使我们遍历了多个独立的服务,都在自己的进程中运行:

// ./services/inventory/index.js

const express = require('express');
const axios = require('axios');
const opentracing = require('opentracing');

const { createTracer } = require('./tracer.js');

const tracer = createTracer(
  'inventory-service',
  'http://collector:14268/api/traces'
);

const app = express();

app.use('/', async (req, res) => {
  const parent = tracer.extract(opentracing.FORMAT_HTTP_HEADERS, req.headers);
  const span = tracer.startSpan('inventory.process', { childOf: parent });
  const id = req.query.kennelId;
  span.setTag('inventory.kennel-id', id);

  const ids = await getInventoryByKennelId(id, span);
  res.send(ids);
  span.finish();
});

app.listen('8080', '0.0.0.0');

async function getInventoryByKennelId(id, parent) {
  const inventory = [[0], [1, 2], [3, 4, 5]];

  const span = tracer.startSpan('inventory.get-inventory-by-kennel-id', {
    childOf: parent,
  });

  await new Promise((resolve) => setTimeout(resolve, 100));
  span.finish();
  return inventory[id];
}

inventorydogs 服务基本上复制了同样的行为。为了使跟踪更加有趣,我们还将使用PromisesetTimeout ,给库存服务添加一个睡眠:

// ./services/dogs/index.js

const express = require('express');
const axios = require('axios');
const opentracing = require('opentracing');

const { createTracer } = require('./tracer.js');

const tracer = createTracer(
  'dogs-service',
  'http://collector:14268/api/traces'
);

const app = express();

app.use('/', async (req, res) => {
  const parent = tracer.extract(opentracing.FORMAT_HTTP_HEADERS, req.headers);
  const span = tracer.startSpan('dogs.process-request', { childOf: parent });
  const id = req.query.id;
  span.setTag('dogs.id', id);

  const name = await getDogName(id, span);
  res.send(name);
  span.finish();
});

app.listen('8080', '0.0.0.0');

async function getDogName(id, parent) {
  const names = ['Rufus', 'Rex', 'Dobby', 'Möhre', 'Jack', 'Charlie'];

  const span = tracer.startSpan('inventory.get-dog-name', {
    childOf: parent,
  });
  await new Promise((resolve) => setTimeout(resolve, 100));
  span.finish();
  return names[id];
}

现在让我们来试试这个。不过,在我们能够做到这一点之前,我们还需要创建一个编译文件,其中包含启动所有容器所需的配置:

version: '3.1'

services:
  nginx:
    image: nginx:latest
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
    depends_on:
      - kennels
  collector:
    image: otel/opentelemetry-collector:0.23.0
    command: '--config /etc/otel-config.yaml'
    volumes:
      - ./otel-config.yaml:/etc/otel-config.yaml
    ports:
      - 6831:6831
  dogs:
    build: ./services/dogs
  inventory:
    build: ./services/inventory
  kennels:
    build: ./services/kennels

我们不会为JavaScript服务添加任何端口映射,因为我们不会直接访问它们,而是通过NGINX的反向代理。我们还将设置一个opentelemetry-collector,我们将用它来收集我们的跟踪数据,并将其转发给我们的跟踪后端:

receivers:
 jaeger:
   protocols:
     thrift_compact:
     thrift_http:

processors:
 batch:

exporters:
 logging:
    loglevel: debug
 otlp:
   endpoint: tempo-us-central1.grafana.net:443
   headers:
     authorization: Basic <Base64 version of your username:api-key>

service:
 pipelines:
   traces:
     receivers: [jaeger]
     processors: [batch]
     exporters: [otlp]

当然,你可以使用你想要的任何追踪后端(和用户界面),但在这篇文章中,我们将使用开源项目Tempo的管理版本和Grafana Cloud。这将使我们能够快速、轻松地可视化、分析和探索我们的追踪。

完成所有的配置后,我们将部署我们的堆栈:

$ docker-compose up -d

然后我们将做一个请求,并检查日志中的跟踪ID:

$ curl localhost "localhost/api/v1/kennels?id=2" && docker logs distributed-tracing-demo_kennels_1

[...]

INFO  Reporting span c3d3fa296e838a66:c3d3fa296e838a66:0:1

让我们复制span ID的第一部分,c3d3fa296e838a66 ,这就是trace ID,然后在Grafana中搜索它:

好的,这其实已经很不错了!我们可以看到,在我们的网站上,有很多人都在谈论这个问题。我们能够看到我们之前谈到的因果关系模型,以及每个跨度中使用的执行时间的百分比:

如果我们再往下滚动一下页面,我们就能看到同一个跟踪的瀑布视图,并能进一步深入到细节,检查标签、日志等东西:

🤯

如何实现

那么我们如何在现有的项目中真正实现这一点呢?我完全理解,可能有多种原因导致在你目前的项目中实施分布式跟踪是不现实的,至少可以说是不现实的。除了需要开发人员主动对服务进行检测外,还需要花费相当多的精力才能做好。

不要感到绝望!现在要做到这一点很可能是遥不可及的,但要做到这一点却要耗费很多时间。对于许多流行的语言,如JavaScript、Go、Python和Java,许多标准包的自动工具化已经可以实现了

因此,与其什么都不做,不如先试一试!也许它们正是你所需要的,以追寻那个困扰你数月的讨厌的性能问题?只要得到每个服务的时间,就会对找出低效的代码有很大的帮助!你会很快建立起一套完整的程序。

你会很快建立起一套追踪系统拯救你的例子,有了这些例子,你就会有一个更有力的理由来获得组织上的支持,你需要不断地迭代,建立仪器化,慢慢地把你的工作做到 "正确"!