2021年11月

如何搜索 grafana 的所有仪表盘(dashboard) 和面板(panel)的标题(title)

Grafana 提供了很多各种各样的图表, 被广泛用在各种需要仪表盘(dashboard)的场合. 它默认提供的搜索, 可以搜所有的文件夹名字和 dashboard 的名字. 可是有时候我们只看到别人给了一个 panel 的截图, 如何根据 panel的名字找到对应的 dashboard 呢? 当你有上千个 dashboard 的时候, 这种情况变得很困难.


最好的方式是 Grafana 提供这样一个插件, 可以搜索所有的文件夹(folder)/仪表盘(dashboard)/面板(panel)的名字, 但是我们现在没有找到.
其次, 这些文件夹(folder)/仪表盘(dashboard)/面板(panel)都定义在 grafana 背后的一个数据库中, 如果这个数据库的表提供了全文索引, 也能方便我们搜索. 但是现在也没有.

那么有没有可能我们把所有的 文件夹(folder)/仪表盘(dashboard)/面板(panel) 的标题放到一个支持全文索引的产品里面, 然后提供一个索引搜索的可能呢?

这种方式是可行的. 首先, Grafana 提供了一整套的 API, 我们可以通过 API 列出所有的文件夹, 然后列出每个文件夹下的所有 dashboard, 然后查询每个 dashboard 的每个 panel , 这样我们就能获得 文件夹(folder)/仪表盘(dashboard)/面板(panel) 的所有标题了. 接着, 我们把获得的标题放到一个支持全文索引的产品, 就能支持全面搜索了.

这里我们给出一个 JavaScript 的脚本, 当你打开你的 Grafana 之后, 然后登录, 然后打开开发者工具, 选择 Console tab, 然后运行这个脚本. 当脚本运行完之后, 在运行导出脚本, 它会打印所有要导出的 json 内容, 然后把这些 json 内容复制到文本编辑器, 然后保存到本地.

获得所有标题的内容

使用这个脚本
https://github.com/manecocomph/scripts/blob/main/JavaScript/grafana/exportAllTitles.js
当上面的脚本运行完之后, 可以看到上面的文件结尾处在注释的地方有个导出脚本, 运行导出脚本. 导出完之后, 复制保存到本地.
export.png

导入到 MongoDB, 并设置全文索引

安装 MongoDB 和 webUI. 更改下面的 volume 到你的本地文件夹

sudo docker network create mongo-network
sudo docker run --network mongo-network --restart always -p 27017:27017 --volume /home/supra/work/data/mongo/grafana:/data/db --name mongodb -d mongo
sudo docker run --network mongo-network --restart always -e ME_CONFIG_MONGODB_SERVER=mongodb -p 8081:8081 --name mongoui mongo-express

可以选择 webUI 导入, 或者使用下面的命令行在 docker 容器内导入(更改 db 名字和 collection 名字, 以及文件名)

mongoimport --db dbName --collection collectionName --file fileName.json 

设置全文索引, 如何查询

db.grafana.createIndex({title: "text", folder: "text", panels: "text", });
db.grafana.find({$text: {$search: "EricTian"}});

导入 ElasticSearch & Kibana

安装 ES & Kibana

sudo docker network create elastic-network
sudo docker run --restart always --name es01 --network elastic-network -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -d docker.elastic.co/elasticsearch/elasticsearch:7.15.2
sudo docker run --restart always --name kib01 --network elastic-network -p 5601:5601 -e "ELASTICSEARCH_HOSTS=http://es01:9200" -d docker.elastic.co/kibana/kibana:7.15.2

打开 http://host:5601/ 然后导入数据文件, 然后查询

导入 Splunk (最方便)

安装 Splunk

sudo docker run --rm -p 8000:8000 -e "SPLUNK_START_ARGS=--accept-license"  -e "SPLUNK_PASSWORD=admin" --name splunk splunk/splunk

打开 http://host:8000/ 输入用户名/密码 admin/admin 然后选择导入数据文件, 然后查询

使用 Java 访问 https 的站点 TLS 协商中的 SNI 问题

上周有同事说他们的 Java 应用连一个新的服务, 总是有连接问题, 但是切换到老的服务一起都正常. 经过查看日志, tcp 抓包, Loader Balancer 验证, 我们发现问题的根源是 TLS 协商中 client_hello 中涉及的 SNI 扩展问题.


问题再现

这个 Java 应用连接一个 Elastic Search 服务, 这个 ES (Elastic Search) 的 endpoint 只支持 https 协议, 平时一切运行正常. 最近这个 ES 服务重新在 K8S 集群上面重新做了一个, 同样只支持 https 协议. 但是在 Java 应用这边, 把 endpoint 改成新的 URL, 可是总是报错. 一开始开发人员以为是新的 ES 可能慢, 或者网络问题, 于是加大了 client 端的 connect timeout & read timeout 时间. 即便增加了很多, 问题还是没有改善.

原因分析

首先, 我们看了出错日志, 发现是连接超时, 也就是连接本身就有问题. 然后我们在客户端抓包, 发现以下情形: 首先 TCP 的三次握手是没有任何问题的. 然后开始协商 TLS 协议, 客户端发送 client hello 包, 服务端 ack client hello, 然后就直接 reset 了这个连接. 然后一切就结束了. 所以, 可以看到是 TLS 协商的问题.
image.png

从这里推断, 我们猜测是客户端发送的 client_hello 里面的一些信息, 在服务端认为不可接受, 所以直接就 reset 了. 首先我们猜测客户端发送的这些加密方法(cipher suites) 在服务端没有一个被接受的. 客户端说我使用 TLSv1.2 协议, 发送了如下的加密方法:
image (1).png
我们查看了这些加密方法, 这些方法正是: io.netty.handler.ssl.SslUtils.java 里面默认的加密方法:
defaultcipher.png
这些加密方法是默认所有加密端都必须接受的, 所以这个不是问题.

又咨询了做加密证书的同事, 他们说这些加密方法服务端肯定是接受的, 都是默认的. 他们猜测这些连接被 reset 的原因很有可能是 SNI 导致的, 对于在K8S新加的 endpoint, 这些都要求客户端在发送 client_hello 的时候, 必须发送 SNI 扩展, 否则直接 reset 连接.

于是重新查看了 TCP dump, 发现确实没有发送 SNI 扩展项. 这就是导致问题的根源.

HTTP 协议中的 host header

在讲 TLS 协议的 SNI 之前, 我们先说一下 HTTP 协议中的 host header. 在早期的 HTTP 协议中, 是没有 host header 的. 一个 HTTP 请求大概是这样的:

GET /index.html HTTP/1.0

在那个时候, 网站还没有那么多, 一般每个网站都有一个独立的 IP. 后来网站越来越多, IPv4逐渐被用光, IPv6 还没被大规模使用, 于是兴起了虚拟主机(Virtual Host), 每个 IP 后边可能有多个网站. 为了区分不同的网站, 于是 HTTP 协议就加了一个 host header 用来区分不同的网站. 于是一个 HTTP 请求大概是这样的:

GET /index.html HTTP/1.0
Host www.tianxiaohui.com

所以, 概括起来就是: Host header 解决了一个 IP 多个网站的问题.

TLS 协议 client_hello 中的 SNI 扩展

如果使用 https 协议, 那么在 TCP 3次握手之后, 就是 TLS 协商, 如果一个 IP 上面有多个网站, 都有不同的证书, 那么如何在 TLS 协商阶段就能区分不同的网站呢? 于是 TLS 协议添加了 SNI (Server Name Indication) 扩展, 在 SNI 扩展里面设置 server_name 的字段值, 通过这个字段值, 服务端在开始握手的时候, 就能知道它要访问具体那个网站.
sni.png

Java 支持 SNI

Java 从 JDK 7 开始支持 SNI. https://docs.oracle.com/javase/7/docs/technotes/guides/security/enhancements-7.html

Server Name Indication (SNI) for JSSE client: The Java SE 7 release supports the Server Name Indication (SNI) extension in the JSSE client. SNI is described in RFC 4366. This enables TLS clients to connect to virtual servers.

Java 中常见的2种连接关于 SNI 的实验

我们的应用使用的是 JDK 8, 那么为什么还缺少 SNI 呢? 这要归因于那段代码使用的是 Netty 库, Netty 对于 TLS 的处理是使用的 JDK 底层 API, 需要自己处理 SNI 这个参数.

通常在 Java 中我们会遇到如下两种 https连接方式: 1) 使用 Java 的 URL连接; 2) 使用 Netty 的连接. 于是我们实验了这2种方式, 在使用Java 的 URL 连接的时候, 它默认是带 SNI 的. 如果使用 Netty 的方式, 就需要手工设置 SslParameter 里面设置主机名. 否则 Netty 不会发送 SNI. 通过 tcp 抓包的方式, 我们可以验证这些信息.
实验代码:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLParameters;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.ssl.SslProvider;

public class Test {
    public static void main(String[] args) throws IOException, InterruptedException {
        //Test.testHttpsUrlSni();
        Test.testNettyHttpsSni();
    }

    public static void testHttpsUrlSni() throws IOException {
        URL u = new URL("https://www.tianxiaohui.com/");
        HttpsURLConnection http = (HttpsURLConnection) u.openConnection();
        http.setAllowUserInteraction(true);
        http.setRequestMethod("GET");
        http.connect();

        try (InputStream is = http.getInputStream();
                BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {

            StringBuilder stringBuilder = new StringBuilder();
            String line = null;
            while (null != (line = reader.readLine())) {
                stringBuilder.append(line + "\n");
            }
            System.out.println(stringBuilder.toString());
        }

        System.out.println("***************Https testing completed **************");
    }

    public static void testNettyHttpsSni() throws SSLException, InterruptedException {
        EventLoopGroup childGroup = new NioEventLoopGroup();

        // SSL Parameters to set SNI TLS Extension

        SSLParameters sslParameters = new SSLParameters();
        sslParameters.setServerNames(Collections.singletonList(new SNIHostName("facebook.com")));

        // Build SSLContext for Client
        SslContext sslContext = SslContextBuilder.forClient().sslProvider(SslProvider.JDK).build();

        // SSLEngine with SSL Parameters for SNI
        SSLEngine sslEngine = sslContext.newEngine(ByteBufAllocator.DEFAULT);
        // sslEngine.setSSLParameters(sslParameters);//是否设置在这里

        // SSL Handler
        SslHandler sslHandler = new SslHandler(sslEngine);

        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(childGroup).channel(NioSocketChannel.class).handler(new SimpleChannelInboundHandler<>() {
            @Override
            protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
                // JUST RANDOM CODE TO MAKE TLS REQUEST
                FullHttpRequest fullHttpRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/",
                        Unpooled.EMPTY_BUFFER);
                ctx.writeAndFlush(fullHttpRequest);
            }
        });

        Channel ch = bootstrap.connect(new InetSocketAddress("tianxiaohui.com", 443)).sync().channel();
        ch.pipeline().addFirst(sslHandler);
    }

关于 HTTP header Content Security Policy (CSP)

为什么需要 CSP

允许用户提交数据的网站总是会遇到跨站脚本(Cross-Site Scripting)和各种数据注入的安全问题, 通常这些脚本都会发送消息给第三方搜集数据或者加载第三方站点的各种资源来进一步扩大攻击面. 那么如何保证我们自己的站点不要加载或使用第三方资源呢?

什么是 CSP

Content Security Policy 就是专门用来发现和缓解上面提到的各种潜在的安全问题的解决方案. 它限制浏览器可以使用的各种资源(JavaScript, css, 图片, video, 字体, frame, audio等)的 URL. 比如只允许使用本站点或信任站点的 JavaScript 或 CSS.

CSP 的实现

服务器端返回的 response 里的 Content-Security-Policy header 用来实现 CSP.
Content-Security-Policy: default-src 'self' trusted.com *.trusted.com
或者通过 HTML 里面的 meta 元素来实现:

<meta http-equiv="Content-Security-Policy" content="default-src 'self' trusted.com *.trusted.com">

Content-Security-Policy 的值称之为 policy. 每个 policy 定义一种或多种资源的这种资源被允许的来源. default-src 用来设置默认的policy, 当没有设置某种资源的限制时, 就使用 default-src的 policy.

一些例子:

Content-Security-Policy: default-src 'self'
Content-Security-Policy: default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com
Content-Security-Policy: default-src 'self' *.mailsite.com; img-src *

更多例子参考:
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

当你只是想测试一下你的 policy 时(比如, 你不确定你的站点现在共使用了哪些第三方的资源时), 你可以使用这个 header 去测试, 它只会报告给你, 不会真正限制时候第三方资源:

Content-Security-Policy-Report-Only: policy

如何上报

可以在 policy 里面设置 report-to 或 report-uri (deprecated) 如:

Content-Security-Policy: ...; report-uri https://endpoint.com; report-to groupname

当浏览器不支持 CSP 时, 它会降级到同源策略 (CORS)来保护站点.

当遇到违反 CSP 的情况时候, console 会报错:
sampel.png

错误的使用 CompletionService 导致的内存泄漏

有开发人员发现他们的某些 server 上的业务时间偶尔变长, 再看 gc overhead 指标, 发现已经超过10%, 怀疑内存泄漏. 通过对 GC 日志的分析, heap dump的分析, 我们找到泄漏的根本原因.


  1. 首先看数据趋势, 可以看到业务时间从某个时间点之后变长, CPU 跟着增加, CPU 增加的原因就在于 GC 的耗时增加;
    perfSimple.png
  2. 看 verbose GC log 的具体日志, 我们可以看到 CMS GC 老年代的频率很高, 耗时也很多. 从34s 到52s 就要做3次老年代的 GC, 说明每次回收都无法获得足够多的空闲内存, 只能加快回收.

    2021-11-17T09:39:34.726-0700: 1459219.285: [CMS-concurrent-mark-start]
    2021-11-17T09:39:37.142-0700: 1459221.701: [CMS-concurrent-mark: 2.408/2.416 secs] [Times: user=2.90 sys=0.04, real=2.42 secs] 
    ...
    2021-11-17T09:39:37.434-0700: 1459221.993: [CMS-concurrent-sweep-start]
    2021-11-17T09:39:39.055-0700: 1459223.614: [CMS-concurrent-sweep: 1.616/1.621 secs] [Times: user=1.90 sys=0.00, real=1.62 secs] 
    2021-11-17T09:39:39.055-0700: 1459223.614: [CMS-concurrent-reset-start]
    2021-11-17T09:39:39.059-0700: 1459223.618: [CMS-concurrent-reset: 0.004/0.004 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    2021-11-17T09:39:41.063-0700: 1459225.622: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1348577K(1433600K)] 1792358K(2170880K), 0.1895637 secs] [Times: user=0.75 sys=0.00, real=0.19 secs] 
    ...
    2021-11-17T09:39:41.253-0700: 1459225.811: [CMS-concurrent-mark-start]
    2021-11-17T09:39:43.632-0700: 1459228.191: [CMS-concurrent-mark: 2.375/2.379 secs] [Times: user=2.84 sys=0.01, real=2.38 secs] 
    2021-11-17T09:39:43.632-0700: 1459228.191: [CMS-concurrent-preclean-start]
    ...
    2021-11-17T09:39:44.000-0700: 1459228.559: [CMS-concurrent-sweep-start]
    2021-11-17T09:39:45.590-0700: 1459230.149: [CMS-concurrent-sweep: 1.590/1.590 secs] [Times: user=1.81 sys=0.01, real=1.60 secs] 
    2021-11-17T09:39:45.590-0700: 1459230.149: [CMS-concurrent-reset-start]
    2021-11-17T09:39:45.594-0700: 1459230.153: [CMS-concurrent-reset: 0.004/0.004 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
    ...
    2021-11-17T09:39:52.122-0700: 1459236.681: [CMS-concurrent-mark-start]
  3. 捕获一个 heap dump, 从 heap dump 的 leak 分析可以看到 ClassLoader 保持的内存过多, 通常情况下 ClassLoader 只是引用类定义, 但是它可以通过 static 字段引用实例对象. 下面的 ClassLoader 使用了660M+ 内存, 具有很大的可疑. 如果我们不是很确定, 可以有2个办法来确定: 1) 重启服务器, 等服务器稳定运行在做一个 heap dump, 对比二者的这个 ClassLoader 使用的内存数量, 是不是差别比较大. 2) 增大 heap size, 让它一直运行, 直到再出现问题, 这时候这个 ClassLoader 使用的内存会更大.
    leak.png
  4. 从这个 ClassLoader 出发, 我们找到了对象的聚集地: ClassLoader -> BulkGet*Processer Class -> completionService -> CompletionQueue.
    accumulate.pngrefs.png
  5. 那么为什么这个 CompletionQueue 里这么多对象呢? 为什么不能回收?
    原来在代码 BulkGet*Processer 类里面, 定义了一个静态的 CompletionService, 当有 request 要处理的时候, 就把 Task submit 到这个 completionService 里面, 可是这个没有去拿结果. 可是 CompletionService 是要求把结果都要通过 poll 或 take 取出来, 然后才能从 completionQueue 结果集里面去除这个结果. 因为没有去拿这个结果, 结果导致它的 CompletionQueue 的结果越来越多, 最后把老年代占满.