В сегодняшнем посте мы постараемся охватить очень интересную и важную тему: трассировка распределенной системы. Практически это означает, что мы попытаемся отследить запрос с момента его выдачи клиентом до момента получения ответа на этот запрос. Сначала это выглядит довольно просто, но на самом деле это может включать в себя множество обращений к нескольким другим системам, базам данных, хранилищам NoSQL, кэшам, вы называете это …
В 2010 году Google опубликовал статью о Dapper , крупномасштабной инфраструктуре отслеживания распределенных систем (кстати, очень интересное чтение). Позже, Twitter построили свою собственную реализацию , основанную на Dapper бумаге, под названием Цыпкин , и это тот , который мы будем смотреть.
Мы создадим простой сервер JAX-RS 2.0 с использованием великолепной библиотеки Apache CXF . На стороне клиента мы будем использовать клиентский API JAX-RS 2.0, а с помощью Zipkin мы будем отслеживать все взаимодействия между клиентом и сервером (а также все, что происходит на стороне сервера). Чтобы сделать пример более наглядным, представим, что сервер использует какую-то базу данных для извлечения данных. Наш код будет представлять собой смесь чистой Java и немного Scala (выбор Scala скоро будет прояснен).
Еще одна зависимость для работы Zipkin — это Apache Zookeeper . Это необходимо для координации и должно быть начато заранее. К счастью, это очень легко сделать:
- загрузите релиз с http://zookeeper.apache.org/releases.html (текущая стабильная версия на момент написания статьи — 3.4.5 )
- распакуйте его в zookeeper-3.4.5
- скопируйте zookeeper-3.4.5 / conf / zoo_sample.cfg в zookeeper-3.4.5 / conf / zoo.cfg
- и просто запустите сервер Apache Zookeeper
Windows: zookeeper-3.4.5/bin/zkServer.cmd Linux: zookeeper-3.4.5/bin/zkServer.sh start
Теперь вернемся к Зипкину . Зипкин написан на Scala . Он все еще находится в активной разработке, и лучший способ начать с него — просто клонировать его репозиторий GitHub и собрать его из источников:
git clone https://github.com/twitter/zipkin.git
С точки зрения архитектуры Zipkin состоит из трех основных компонентов:
- сборщик : собирает следы по всей системе
- query : запрашивает собранные следы
- web : предоставляет веб-интерфейс для отображения следов
Чтобы запустить их, ребята из Zipkin предоставляют полезные скрипты в папке bin с единственным требованием, что JDK 1.7 должен быть установлен:
- бен / Коллектор
- бен / запрос
- бен / веб
Давайте выполним эти сценарии и убедимся, что каждый компонент был запущен успешно, без следов стека на консоли (для любопытных читателей я не смог заставить Zipkin работать в Windows, поэтому я предполагаю, что мы запускаем его в Linux). По умолчанию веб-интерфейс Zipkin доступен через порт 8080 . Хранилище для трасс — встроенный движок SQLite . Хотя это работает, лучшие хранилища (такие как удивительный Redis ) доступны.
Подготовка закончена, давайте сделаем немного кода. Мы начнем с клиентской части JAX-RS 2.0, поскольку она очень проста ( ClientStarter.java ):
package com.example.client; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import com.example.zipkin.Zipkin; import com.example.zipkin.client.ZipkinRequestFilter; import com.example.zipkin.client.ZipkinResponseFilter; public class ClientStarter { public static void main( final String[] args ) throws Exception { final Client client = ClientBuilder .newClient() .register( new ZipkinRequestFilter( "People", Zipkin.tracer() ), 1 ) .register( new ZipkinResponseFilter( "People", Zipkin.tracer() ), 1 ); final Response response = client .target( "http://localhost:8080/rest/api/people" ) .request( MediaType.APPLICATION_JSON ) .get(); if( response.getStatus() == 200 ) { System.out.println( response.readEntity( String.class ) ); } response.close(); client.close(); // Small delay to allow tracer to send the trace over the wire Thread.sleep( 1000 ); } }
За исключением пары импортов и классов с Zipkin , все должно выглядеть просто. Так для чего эти ZipkinRequestFilter и ZipkinResponseFilter ? Зипкин потрясающий, но это не волшебный инструмент. Чтобы отследить любой запрос в распределенной системе, должен быть передан некоторый контекст. В мире REST / HTTP это обычно заголовки запроса / ответа. Давайте сначала посмотрим на ZipkinRequestFilter ( ZipkinRequestFilter.scala ):
package com.example.zipkin.client import javax.ws.rs.client.ClientRequestFilter import javax.ws.rs.ext.Provider import javax.ws.rs.client.ClientRequestContext import com.twitter.finagle.http.HttpTracing import com.twitter.finagle.tracing.Trace import com.twitter.finagle.tracing.Annotation import com.twitter.finagle.tracing.TraceId import com.twitter.finagle.tracing.Tracer @Provider class ZipkinRequestFilter( val name: String, val tracer: Tracer ) extends ClientRequestFilter { def filter( requestContext: ClientRequestContext ): Unit = { Trace.pushTracerAndSetNextId( tracer, true ) requestContext.getHeaders().add( HttpTracing.Header.TraceId, Trace.id.traceId.toString ) requestContext.getHeaders().add( HttpTracing.Header.SpanId, Trace.id.spanId.toString ) Trace.id._parentId foreach { id => requestContext.getHeaders().add( HttpTracing.Header.ParentSpanId, id.toString ) } Trace.id.sampled foreach { sampled => requestContext.getHeaders().add( HttpTracing.Header.Sampled, sampled.toString ) } requestContext.getHeaders().add( HttpTracing.Header.Flags, Trace.id.flags.toLong.toString ) if( Trace.isActivelyTracing ) { Trace.recordRpcname( name, requestContext.getMethod() ) Trace.recordBinary( "http.uri", requestContext.getUri().toString() ) Trace.record( Annotation.ClientSend() ) } } }
Небольшое количество внутренностей Zipkin сделает этот код неясным. Центральная часть Zipkin API — это класс Trace . Каждый раз, когда мы хотим инициировать трассировку, у нас должен быть Trace Id и трассировщик для его фактического отслеживания. Эта единственная строка генерирует новый идентификатор трассировки и регистрирует трассировщик (внутренне эти данные хранятся в локальном состоянии потока).
Trace.pushTracerAndSetNextId( tracer, true )
Трассы имеют иерархическую природу, как и идентификаторы трасс: каждый идентификатор трассы может быть корнем или частью другой трассы. В нашем примере мы точно знаем, что являемся первыми и, следовательно, корнем трассы. Позже Trace Id оборачивается в заголовки HTTP и будет передаваться по запросу (мы увидим на стороне сервера, как он используется). Последние три строки связывают полезную информацию с трассировкой: имя нашего API ( People ), HTTP-метод, URI и, самое главное, что клиент отправляет запрос на сервер.
Trace.recordRpcname( name, requestContext.getMethod() ) Trace.recordBinary( "http.uri", requestContext.getUri().toString() ) Trace.record( Annotation.ClientSend() )
ZipkinResponseFilter делает обратное к ZipkinRequestFilter и извлечь трассировки Id из заголовков запроса ( ZipkinResponseFilter.scala ):
package com.example.zipkin.client import javax.ws.rs.client.ClientResponseFilter import javax.ws.rs.client.ClientRequestContext import javax.ws.rs.client.ClientResponseContext import javax.ws.rs.ext.Provider import com.twitter.finagle.tracing.Trace import com.twitter.finagle.tracing.Annotation import com.twitter.finagle.tracing.SpanId import com.twitter.finagle.http.HttpTracing import com.twitter.finagle.tracing.TraceId import com.twitter.finagle.tracing.Flags import com.twitter.finagle.tracing.Tracer @Provider class ZipkinResponseFilter( val name: String, val tracer: Tracer ) extends ClientResponseFilter { def filter( requestContext: ClientRequestContext, responseContext: ClientResponseContext ): Unit = { val spanId = SpanId.fromString( requestContext.getHeaders().getFirst( HttpTracing.Header.SpanId ).toString() ) spanId foreach { sid => val traceId = SpanId.fromString( requestContext.getHeaders().getFirst( HttpTracing.Header.TraceId ).toString() ) val parentSpanId = requestContext.getHeaders().getFirst( HttpTracing.Header.ParentSpanId ) match { case s: String => SpanId.fromString( s.toString() ) case _ => None } val sampled = requestContext.getHeaders().getFirst( HttpTracing.Header.Sampled ) match { case s: String => s.toString.toBoolean case _ => true } val flags = Flags( requestContext.getHeaders().getFirst( HttpTracing.Header.Flags ).toString.toLong ) Trace.setId( TraceId( traceId, parentSpanId, sid, Option( sampled ), flags ) ) } if( Trace.isActivelyTracing ) { Trace.record( Annotation.ClientRecv() ) } } }
Строго говоря, в нашем примере нет необходимости извлекать Trace Id из запроса, поскольку оба фильтра должны выполняться одним потоком. Но последняя строка очень важна: она отмечает конец нашего следа, говоря, что клиент получил ответ .
Trace.record( Annotation.ClientRecv() )
На самом деле остается сам трассировщик ( Zipkin.scala ):
package com.example.zipkin import com.twitter.finagle.stats.DefaultStatsReceiver import com.twitter.finagle.zipkin.thrift.ZipkinTracer import com.twitter.finagle.tracing.Trace import javax.ws.rs.ext.Provider object Zipkin { lazy val tracer = ZipkinTracer.mk( host = "localhost", port = 9410, DefaultStatsReceiver, 1 ) }
Если в этот момент вы не понимаете, что означают все эти трассы и пролеты, просмотрите эту страницу документации , вы получите базовое понимание этих концепций.
На данный момент на стороне клиента ничего не осталось, и мы можем перейти на сторону сервера. Наш сервер JAX-RS 2.0 будет предоставлять единую конечную точку ( PeopleRestService.java ):
package com.example.server.rs; import java.util.Arrays; import java.util.Collection; import java.util.concurrent.Callable; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; import com.example.model.Person; import com.example.zipkin.Zipkin; @Path( "/people" ) public class PeopleRestService { @Produces( { "application/json" } ) @GET public Collection< Person > getPeople() { return Zipkin.invoke( "DB", "FIND ALL", new Callable< Collection< Person > >() { @Override public Collection<person> call() throws Exception { return Arrays.asList( new Person( "Tom", "Bombdil" ) ); } } ); } }
Как мы упоминали ранее, мы смоделируем доступ к базе данных и сгенерируем дочернюю трассировку с помощью оболочки Zipkin.invoke (которая выглядит очень просто, Zipkin.scala ):
package com.example.zipkin import java.util.concurrent.Callable import com.twitter.finagle.stats.DefaultStatsReceiver import com.twitter.finagle.tracing.Trace import com.twitter.finagle.zipkin.thrift.ZipkinTracer import com.twitter.finagle.tracing.Annotation object Zipkin { lazy val tracer = ZipkinTracer.mk( host = "localhost", port = 9410, DefaultStatsReceiver, 1 ) def invoke[ R ]( service: String, method: String, callable: Callable[ R ] ): R = Trace.unwind { Trace.pushTracerAndSetNextId( tracer, false ) Trace.recordRpcname( service, method ); Trace.record( new Annotation.ClientSend() ); try { callable.call() } finally { Trace.record( new Annotation.ClientRecv() ); } } }
Как мы видим, в этом случае сам сервер становится клиентом для какого-либо другого сервиса (базы данных).
Последняя и самая важная часть сервера — перехватывать все HTTP- запросы, извлекать из них Trace Id, чтобы можно было связать больше данных с трассировкой (аннотировать трассировку). В Apache CXF это очень легко сделать, предоставив собственный invoker ( ZipkinTracingInvoker.scala ):
package com.example.zipkin.server import org.apache.cxf.jaxrs.JAXRSInvoker import com.twitter.finagle.tracing.TraceId import org.apache.cxf.message.Exchange import com.twitter.finagle.tracing.Trace import com.twitter.finagle.tracing.Annotation import org.apache.cxf.jaxrs.model.OperationResourceInfo import org.apache.cxf.jaxrs.ext.MessageContextImpl import com.twitter.finagle.tracing.SpanId import com.twitter.finagle.http.HttpTracing import com.twitter.finagle.tracing.Flags import scala.collection.JavaConversions._ import com.twitter.finagle.tracing.Tracer import javax.inject.Inject class ZipkinTracingInvoker extends JAXRSInvoker { @Inject val tracer: Tracer = null def trace[ R ]( exchange: Exchange )( block: => R ): R = { val context = new MessageContextImpl( exchange.getInMessage() ) Trace.pushTracer( tracer ) val id = Option( exchange.get( classOf[ OperationResourceInfo ] ) ) map { ori => context.getHttpHeaders().getRequestHeader( HttpTracing.Header.SpanId ).toList match { case x :: xs => SpanId.fromString( x ) map { sid => val traceId = context.getHttpHeaders().getRequestHeader( HttpTracing.Header.TraceId ).toList match { case x :: xs => SpanId.fromString( x ) case _ => None } val parentSpanId = context.getHttpHeaders().getRequestHeader( HttpTracing.Header.ParentSpanId ).toList match { case x :: xs => SpanId.fromString( x ) case _ => None } val sampled = context.getHttpHeaders().getRequestHeader( HttpTracing.Header.Sampled ).toList match { case x :: xs => x.toBoolean case _ => true } val flags = context.getHttpHeaders().getRequestHeader( HttpTracing.Header.Flags ).toList match { case x :: xs => Flags( x.toLong ) case _ => Flags() } val id = TraceId( traceId, parentSpanId, sid, Option( sampled ), flags ) Trace.setId( id ) if( Trace.isActivelyTracing ) { Trace.recordRpcname( context.getHttpServletRequest().getProtocol(), ori.getHttpMethod() ) Trace.record( Annotation.ServerRecv() ) } id } case _ => None } } val result = block if( Trace.isActivelyTracing ) { id map { id => Trace.record( new Annotation.ServerSend() ) } } result } @Override override def invoke( exchange: Exchange, parametersList: AnyRef ): AnyRef = { trace( exchange )( super.invoke( exchange, parametersList ) ) } }
По сути, единственное, что делает этот код, — это извлечение Trace Id из запроса и его привязка к текущему потоку. Также обратите внимание, что мы связываем дополнительные данные с трассировкой, отмечающей участие сервера .
Trace.recordRpcname( context.getHttpServletRequest().getProtocol(), ori.getHttpMethod() ) Trace.record( Annotation.ServerRecv() )
Чтобы увидеть трассировку в реальном времени, давайте запустим наш сервер (обратите внимание, что sbt должен быть установлен ), предполагая, что все компоненты Zipkin и Apache Zookeeper уже запущены и работают:
sbt 'project server' 'run-main com.example.server.ServerStarter'
тогда клиент:
sbt 'project client' 'run-main com.example.client.ClientStarter'
и, наконец, откройте веб-интерфейс Zipkin по адресу http: // localhost: 8080 . Мы должны увидеть что-то подобное (в зависимости от того, сколько раз вы запускали клиент):
В качестве альтернативы, мы можем создавать и запускать толстые JAR-файлы, используя плагин sbt-assembly :
sbt assembly java -jar server/target/zipkin-jaxrs-2.0-server-assembly-0.0.1-SNAPSHOT.jar java -jar client/target/zipkin-jaxrs-2.0-client-assembly-0.0.1-SNAPSHOT.jar
Если щелкнуть какую-либо конкретную трассировку, будет показана более подробная информация, очень похожая на цепочку базы данных client <-> server <-> .
Еще больше деталей отображается, когда мы нажимаем на конкретный элемент в дереве.
Наконец, бонусной частью является график зависимости компонентов / услуг.
Как мы видим, все данные, связанные с трассировкой, находятся здесь и подчиняются иерархической структуре. Обнаружены и показаны корневая и дочерняя трассы, а также временные рамки для цепочек отправки / получения клиента и приема / отправки сервера . Наш пример довольно наивен и прост, но даже в этом случае он демонстрирует, насколько мощной и полезной является трассировка распределенной системы. Спасибо Зипкину, ребята.
Полный исходный код доступен на GitHub .