Prometheus 为你的系统保驾护航

“You can’t manage what you don’t measure”
— W. Edwards Deming

近年来,以docker 为首的容器技术在IT领域尤其是在云计算和微服务应用领域掀起了一股狂潮,成为当下特别流行的一种技术,说明容器技术正好满足了当今软件领域中的一些迫切需求,它主要解决了下面的一些问题

  • 它将应用及依赖的运行环境打包成镜像,消除了线上线下环境的差异,保证了应用环境的一致性;

  • 作为一种轻量级的虚拟化技术,它以很小的代价却提供了不错的资源隔离和限制能力,可以更加细粒度的分配资源,极大提高了资源的使用率;

  • 还有它构建一次,到处运行,提高了容器的跨平台性的同时,大大简化了持续集成、测试和发布的过程……

容器监控的解决方案

在容器时代,传统的那些耳熟能详的监控软件如Zabbix 不能提供方便的容器化服务的监控体验,相反的许多新生的开源监控项目则将对容器监控的支持放到了关键特性的位置,如InfluxDB Prometheus 等获得了广泛的认可。

在DockerCon EU 2015上,Swisscom AG的云方案架构师Brian Christner阐述了“Docker监控”的概况,分享了这方面的最佳实践和Docker stats API的指南,并对比了三个流行的监控方案:cAdvisor、“cAdvisor + InfluxDB + Grafana”以及Prometheus。其中Prometheus是整体化的开源监控软件,但它本身对容器信息的收集能力以及图表展示能力相比其他专用开源组件较弱,通常在实际实施的时候依然会将它组合为『cAdvisor + Prometheus』或『cAdvisor + Prometheus + Grafana』的方式使用。

Prometheus作为天生的容器监控的项目,特别适合作为度量数据的存储和查询,所以我们选择了以Prometheus为核心的容器监控系统的解决方案。

我们后端大量采用Java 和Node.js 两种开发语言,Java 后端采用Spring Cloud 支撑的一套微服务架构,我们需要将开源的监控软件与本公司的实际技术相结合。坐而言不如起而行,接下来我们会介绍监控系统的搭建和使用、Prometheus基础和自定义监控数据详细介绍『cAdvisor + Prometheus + Grafana』这种解决方案在本公司的本土化过程。

监控组件

一般性能监控系统会包含5大组件:

  • 探针:安装在应用中收集应用性能的包
  • 收集器:收集探针发送过来的数据或者主动拉取应用性能数据的工具
  • 存储介质:存储收集到的应用性能数据的介质
  • 展示器:将应用性能数据按照使用者的要求展示的工具
  • 预警器:当某一监测值超过预定阈值向devops成员发出预警

监控组件简要介绍

prometheus

Prometheus是一个开源的时序数据收集和处理的服务软件,它其实包含监控组件中的:收集器,存储和展示器,它所需的探针有相应的exporter和client library exporter组件提供,预警器可以由配套的Alertmanager 软件提供,它既可以监控各个主机的CPU、内存、文件等资源的使用情况,也可以监控服务的健康状况、网络流量,它既可以监控mysql、redis的使用状况,也可以定制监控信息,它是一个整体化解决方案的监控软件。

cAdvisor

cAdvisor会收集、聚集、处理并导出运行中容器的信息,它可以为容器用户提供了了解运行时容器资源使用和性能特征的方法,安装后你可以通过web界面直接看到相应机器上容器中应用的资源使用和性能特征。它包含了监控组件中的前4大组件,但是它只能监控容器资源的使用和性能,因为它叫container advisor。

Grafana

Grafana是一个开源的领先的能非常漂亮的展示时序数据分析结果的软件。它是使用js所写的一个软件,支持多种数据源,支持多种图形面板展示,它也提供了预警功能,是一个非常棒的图形展示组件。

exporter

下面介绍的是各种度量数据的 exporters

  • node_exporter: 监控一个主机的CPU、内存等资源的使用状况

  • blackbox_exporter:监控各个服务的健康状况

  • mysqld_exporter: 监控mysql的使用状况

  • redis_exporter: 监控redis的使用状况

  • SNMP_exporter: 监控网络流量

到现在为止我们已经将整个容器监控系统搭建起来,我们的监控系统具有监控各个服务器CPU、内存、文件等资源使用状况,服务器中每个容器的CPU、内存、文件等资源使用状况以及对我们开发的应用进行健康检查的能力。我们做的非常的完美,是的,我们开发的应用终于可以上线了。这一切看似完美的背后仍然存在一点小小的缺憾,比如我想知道各个微服务的被调用情况,每次请求的延迟,每次请求正确与否,一段时间内的请求量……;仔细观察,我们还会发现一个问题,就是在Prometheus 的配置文件中我们监控的地址都是固定的,我们知道在docker容器集群中,这些地址是随时变动的,还有我们增加一个服务就需要去更改Prometheus 的配置文件,that is too bad!我需要Prometheus 可以动态更改监控的地址,动态增加监控的服务,we need more!

Prometheus的数据模型

Prometheus从目标主机或者从push-gateway 拉取数据,存在本地文件系统Alertmanager 根据recordRules配置文件计算聚合数据或者根据alertRules 计算是否发送预警。Grafana可以直接使用Prometheus 提供的Api 发送promQL查询时序数据绘图。

Prometheus将相同的 metrics(指标名称) 和 labels(一个或多个标签) 组成一条时间序列

metrics 一般是给监测对象起一个名字。

labels 一般是给监测对象提供一些额外信息的键值对,对一条时间序列不同维度的识别,promQL将通过这些标签很容易的过滤和聚合这些时间序列数据。

api_http_requests_total{method="POST", handler="/messages"}

存入数据库中时还会自动为它添加一个时间戳标记,所以一个时序序列是大量不同时间的相同指标相同标签的数据集合。
如果以传统数据库的理解来看这条语句,则可以考虑 http_requests_total是表名,标签是字段,而timestamp是主键,还有一个float64字段是值了。(Prometheus里面所有值都是按float64存储)

Prometheus的指标类型

Prometheus的客户端软件包中提供了4 种核心的指标类型,这四种类型仅仅在客户端存在区别,在服务端存储时转换为无类型的时间序列。

  • Counter
    累加器或者称作计数器,统计的指标值只能增加,不能减少,增加值不一定为1,可以用于请求的总数、访问时间的总和。
  • Gauge
    指数器,指示当前统计的指标值的大小,值可以增大也可以减小,主要用户统计当前cpu 的温度、最近一次访问的耗时。
  • Histogram
    直方图,统计指标值分散在不同区间的个数。相当于针对Gauge 做了一次再加工,统计的时候就将分散在不同区间的个数统计好了。比如统计每次访问耗时的数据分布情况,用Histogram 可以统计小于200 ms的访问次数,小于300 毫秒的次数,小于500 毫秒的次数等等
  • Summary
    概述,它的作用我们通过一个例子来说明:比如我们监测的指标值为每次请求的响应时间,用Summary 可以统计5min 内95% 的请求的响应平均用时,5min 内80% 的请求的响应用时……。我们也可以统计10 min内60% 的请求的响应的平均用时……其实Summary 也是针对Counter 或者 Gauge 做的再次加工,只是在记录到数据库之前它计算好了再存入数据库。它和Histogram 针对同一监测指标的区别是Summary 将次数作为横坐标, Histogram 是将次数作为纵坐标。

通过上面的介绍我们知道最基本的类型其实就是Counter 和Gauge 两种,其他的类型都是在它们基础上的再加工。了解了这4中类型,我们才能选择正确的类型统计我们需要监测的数据

promQL基础

我们使用过sql,我们都能感受到sql语言查询功能的强大之处,promQL的查询功能也异常强大,它也具有运算、过滤、分组等功能,它还提供大量的内置函数,可以让我们更加容易对时序数据进行操作。当我们学会了promQL我们就能很顺利的将我们想要展示的数据按照我们的要求完美的展示出来。
promQL是非常简单的,在开始学习promQL之前我们先看一些简单的promQL查询表达式。

栗子

http_requests_total

返回监测指标名为http_requests_total时间戳为当前时间的时序序列(它们的标签可能不同,所以结果可能有多条)

http_requests_total{job="apiserver", handler="/api/comments"}

返回标签job=”apiserver”, handler=”/api/comments”,监测指标名为http_requests_total时间戳为当前时间的时序序列。大括号相当于sql 的 where

http_requests_total{job="apiserver", handler="/api/comments"}[5m]

返回5分钟前到当前时间的标签为job=”apiserver”, handler=”/api/comments”,监测指标名为http_requests_total的时序序列。中括号相当于对时间加了一个维度的限制

http_requests_total{job=~"server$"}

返回标签job的值为以server结尾的指标名为http_requests_total的时序序列。=后面接一个正则表达式,表示此标签的值匹配后面的正则表达式,那么!就是不匹配后面的正则

http_requests_total offset 5m

返回的是5分钟之前的请求总数

下面会介绍4个函数的使用

rate(http_requests_total[5m])

http_requests_total记录的是请求的总数的时序序列,http_requests_total[5m]记录的是5分钟内请求的总数的时序序列,rate是计算平均率的函数既计算每秒的平均增加数。所以这个promQL的作用是计算5分钟内平均每秒的请求数。如果我们记录指标有三种,

http_requests_total{instance=“127.0.0.1”,job="prometheus"}
http_requests_total{instance=“127.0.0.2”,job="prometheus"}
http_requests_total{instance=“127.0.0.2”,job="monitor"}

那么结果rate(http_requests_total[5m])得到的结果也是三种,因为他们的标签不一样,属于3种时间序列,所以我们结果会有3种。如果我想得到这三个时间序列的每秒总请求数则可以

sum(rate(http_requests_total[5m]))

sum()是求和函数,可以将不同维度的时间序列聚合,得到的结果只有一种时间序列。现在我想将127.0.0.1和127.0.0.2这两个实例的请求数分别聚合既我想得到每台机器的每秒请求总数,可以使用promQL的分组功能

sum(rate(http_requests_total[5m])) by (instance)

这样我们得到的结果又两种时间序列。现在我想得到3中时间序列中每秒请求数最多的一个该怎么计算呢?如下

topk(1,http_requests_total[5m])

如果我想知道http_requests_total有多少种指标,可以

count(http_requests_total)

结果返回值为3

promQL高级

我们已经对promQL已经有了一定程度的了解,但是如果我们想使用的更加得心应手,则还需要对promQL有更加深入的了解。

数据类型

在Prometheus中有3种数据类型

  • Instant vector 即时向量,你可以看一个时间点的时序序列,它反映的是一个时间点不同标签的值组成的时间序列,如
http_requests_total
  • Range vector 范围向量,你可以看做带有中括号时间限制的时序序列,它反映的是一段时间范围内的值组成的时间序列,时间单位可以是:s、m、h、d、w、y,如
rate(http_requests_total[5m])
  • Scalar 标量,你可以把它看成一个float64位的数值

隐式标签

http_requests_total = {__name__ = 'http_requests_total'}

以上2个查询表达式是等价的

向量匹配

promQL中的数据类型是可以相互运算的即可以+,-,*,/……,它们的运算分为3种

  • 标量 oprator 标量
  • 向量 oprator 标量
  • 向量 oprator 向量

前面2种我们是很容易理解的,第3种它内部是如何计算的呢?接着基础篇的例子

http_requests_total - http_requests_total offset 5m

这个例子返回的是5分钟内请求的数量,返回的结果又几种呢?3种。因为它是根据标签来进行匹配的,即操作符两边的标签完全相同(不包括隐式标签,以__开头)的两个时间序列才进行运算。

自定义监控数据

需求是永无止境的,我们一定要在正确的时间做正确的事情。Prometheus 作为容器监控系统,它的功能是强大的,我们可以通过它提供的客户端开发包,自定义我们需要监控的性能数据;我们也可以通过它提供的file_sd_configs 动态配置监控的地址,动态增加监控的服务,这正好解决了前面章节我们提出的2 个明确的需求。下面就让我们开启这一场神奇的解决问题和coding 之旅吧。

自定义监控数据的目标

我们自定义监控数据的目标是在15s 内发现系统的稳定性问题并主动的去解决问题。其实就是在最短的时间内发现由于应用发布、应用内部bug、应用崩溃自重启等等带来的系统的稳定性问题。最终我们搭建好的监控系统在实际的生产环境中是15s 去拉取一次度量指标,这个时间最短可以调到1s 一次,所以说我们的系统是一个准实时或者说是一个实时的监控系统,它带来解决问题的一个巨大的革新。

我们后端的微服务采用的是SpringCloud 支撑的微服务架构,我们的网关为Zuul ,所以任何请求都会经过Zuul来做转发,我们监控的思路是,在Zuul 网关层增加一个过滤器,记录每个请求的模块,每个请求的路径,每个请求的方法,每个请求的响应时间,每个请求的次数。然后将这些数据导入到Prometheus,Grafana 再将Prometheus 中的数据以图表的形式展示出来。

网关里自己实现一个filter 记录下面三种数据格式, 也可以考虑使用 spring boot actuator

http_response_time_milliseconds_count{method="ytx_sso_users",module="ytx_sso",status="200",method_type="GET",} 166.0  
http_response_time_milliseconds_sum{method="ytx_sso_users",module="ytx_sso",status="200",method_type="GET",} 110186.0  
http_total_request_size{status="200",module="cms_content",} 2162.0

http_response_time_milliseconds_count统计每个接口的调用次数
http_response_time_milliseconds_sum统计每个接口响应时间的总和
http_total_request_size统计每个模块的调用总次数
以上三种都是Counter类型,前两种通过Summarry统计的,最后一种使用Counter统计的。
labels标签:method记录访问的路径,module记录访问的模块,status记录响应的状态码,method_type记录请求的类型。

代码见

public class MetricFilter implements Filter {
    @Autowired
    private CollectorRegistry collectorRegistry;

    Summary responseTimeInMs;
    Counter totalRequest;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        responseTimeInMs = Summary
            .build()
            .name("http_response_time_milliseconds")
            .labelNames("method", "module", "status", "method_type")
            .help("Request completed time in milliseconds")
            .register(collectorRegistry);

        totalRequest = Counter
            .build()
            .name("http_total_request_size")
            .labelNames("status", "module")
            .help("total http request size")
            .register(collectorRegistry);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        String uri = httpRequest.getRequestURI().replace("/", ".");
        Long end = 0L;
        Long start = 0L;
        try {
            start = System.currentTimeMillis();
            chain.doFilter(request, response);
            end = System.currentTimeMillis();
        } finally {
            doMetrics(httpRequest, httpResponse, uri, end, start);
        }

    }

    private void doMetrics(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String uri, Long end,
        Long start) {
        try {
            Pair<String, String> pair = getMethodAndModuleName(uri);
            if (pair != null) {
                int status = switchStatus(httpResponse.getStatus());
                long time = end - start;
                responseTimeInMs.labels(pair.getLeft(), pair.getRight(),
                    String.valueOf(status), httpRequest.getMethod()).observe(time);
                totalRequest.labels(String.valueOf(status), pair.getRight()).inc();
            }
        } catch (Exception e) {
            // ignore exception
        }
    }

    private Pair<String, String> getMethodAndModuleName(String name) {
        if (filterByMethodName(name)) {
            return null;
        }

        Matcher matcher = Pattern.compile("_api_(.*?)_(.*?)_([a-zA-Z0-9_]*)")
            .matcher(name.replaceAll("[^a-zA-Z0-9_]", "_")
                         .replaceAll("_\\d{1,9}", "_id"));
        if (matcher.find()) {
            String module = matcher.group(1) + "_" + matcher.group(2);
            String method = module + "_" + StringUtils.removeEnd(matcher.group(3), "_");
            return new ImmutablePair<>(method, module);
        }

        return null;
    }


    private boolean filterByMethodName(String name) {
        if (name.contains("health") || name.contains("swagger") || name.contains("prometheus")
            || name.contains("metrics") || name.contains("springboot-admin")
            || name.contains("star-star") || name.contains("api-docs")) {
            return true;
        }
        return false;
    }

    @Override
    public void destroy() {
    }


    private Integer switchStatus(Integer status) {
        if (status >= 200 && status < 300) {
            return 200;
        } else if (status >= 400 && status < 500) {
            return 400;
        } else if (status >= 500) {
            return 500;
        }
        return 100;
    }
}

在grafana中展示数据

grafana的使用可以到grafana 官网学习,有时间我们会详细介绍。

我们仅仅讨论怎样书写promQL表达式将我们的数据按照我们要求完美的展现出来,比如我想统计1分钟内每个服务的可用性。promQL如下:

(1+sum(http_total_request_size{status="200"}) by (module)-sum(http_total_request_size{status="200"} offset 30s) by (module)) * 100/ (1+sum(http_total_request_size{status=~"200|500"})by (module) -sum(http_total_request_size{status=~"200|500"} offset 30s)by (module) )

每个服务的吞吐率:

sum(rate(http_response_time_milliseconds_count{kubernetes_namespace="$namespace"}[1m])) by (module)

还有错误调用Top10

topk(10,sum(http_response_time_milliseconds_count{status!="200"}) by(method,method_type,status)  - sum(http_response_time_milliseconds_count{status!="200"} offset 1m) by(method,method_type,status))

等等,在这里就不一一列举

现在一个实时的监控系统已经搭建起来,它带来的优势显而易见,我们可以实时观测各个服务的健康状况,一目了然。同时它带来了解决问题方式的改变,以往没有实时监控的时候,我们的服务某个接口报错一定需要等到用户反馈或者调用方反馈,我们才能发现并被动的解决问题。现在我们可以清晰的看到那个接口调用报错,在用户或者调用方还没有反馈之前就已经发现了问题并主动的解决掉问题。这带来解决问题方式的一个革新。

配置将Prometheus 动态发现 Zuul

因为Zuul 的实例有多个,我们可以通过 Kubernetes 的服务发现完成

      - job_name: 'zuul'
        metrics_path: '/prometheus'
        kubernetes_sd_configs:
          - role: pod
        relabel_configs:
          - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
            action: replace
            regex: (.+):(?:\d+);(\d+)
            replacement: ${1}:${2}
            target_label: __address__
          - source_labels: [__meta_kubernetes_pod_name]
            action: keep
            regex: zuul-server-(.+)

也可以自己实现一套file_sd,输出的格式如下所示

[{"targets":["192.168.77.153:8041","192.168.77.164:8041","192.168.77.156:8041"]}]

配置

file_sd_configs:    #file dynamic discovery
  - files:
    - /config/config.json
      refresh_interval: 30s

修改配置文件以后,reload 一下配置,在 status 菜单下的targets 标签下可以观察到Prometheus 动态拉取到了Zuul 的地址

Prometheus查询性能的优化

当我们以为一切都万事大吉的时候,在使用过程中,我们发现了一个有点严重的问题,就是Grafana 的Dashboard 监控面板需要很长一段时间才能将我们需要的监测结果给显示出来,
这是非常致命的,因为我们这个Prometheus 是一个实时监控平台,如果需要花费3-5 分钟才将数据显示出来就失去了实时性的意义了,所以对我们来说这是一个必须要解决的问题。下面我们将整个解决问题的过程呈现出来供大家参考。
Prometheus 使用的是本地磁盘存储,它非常耗内存和cpu,生产上的数据是相当庞大的,当我们写了一个性能非常不好promQL时,Prometheus将进行大量的计算,虽然Prometheus 已经针对时序数据的计算做了优化,但是仍然阻止不了我们写出性能很差的查询语句。

第一阶段优化

我们一开始给指标名起名的格式为:指标功能模块名访问方法路径,没有打label,最后数据展示的时候我们大量使用{name =~ “.api.get.*“}去正则过滤,最后导致的结果是查询非常慢。
最后我们的改进措施是,将模块名、请求方法、返回状态吗、请求类型都设置为标签,猜测应该是有类似索引的优化。

第二阶段的优化

上面的优化措施过后,我们的查询性能大大提高,在开发和测试环境中已经非常流畅的能显示出图片,但是当我们的机器到了生产上面之后,我们发现生产的数据量很巨大,Grafana的显示速度还算能接受,但是Prometheus那台机器的cpu负载间歇性突增。
我们发现可能是我们生产上是抓取的时间间隔缩短,同时抓取的数据量非常庞大,每一次发送请求都会进行大量的计算,导致cpu负载间歇性突增。为了改善性能,我们第一步将大量我们没有使用到的自定义监测数据删掉不予记录,prometheus抓取的数据大大减少;第二步我们将可以在客户端聚合的数据先在客户端聚合好,然后在存入prometheus,而不是每一次请求都让prometheus去计算聚合一次,比如统计每个模块的返回值200和500状态码的调用总次数
优化后

sum(http_total_request_size{status=~"200|500"})by (module)

优化前

sum(http_response_time_milliseconds_count{status="200|500"}) by (module)

真的非常完美。

写在最后

经过以上的搭建过程和开发过程,我们已经非常完美的搭建了一个实时的容器性能监控系统,能够很好的完成我们对生产上应用的监控。在本篇文章中还有很多方面没有详述,如Prometheus 的配置、Grafana 的使用、预警的设置、Grafana 变量模板的使用……,大家可以自己去研究探索。我们是银天下DevOps 团队,如果你有任何疑问和技术问题,欢迎与我们联系!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容