自定義Docker監(jiān)控:Glances API+Go Iris-websocket+Vue

前言

目前docker資源監(jiān)控方案很多骇笔,各種高大上:Prometheus(配合xxx-exporter...)+Grafana或cAdvisor+InfluxDB+Grafana等等
但都只適合大型生產(chǎn)環(huán)境或使用了K8s情況下渠牲,對于我只希望在測試環(huán)境獲取docker宿主機(jī)及容器的資源使用情況的需求來看讳苦,確實(shí)小題大做纽甘。有沒有輕便的方案呢桥状?自己動(dòng)手,利用glances api加一些自定義代碼構(gòu)建自己需要的圖形化界面也是可以的绞幌,這種雖然土,但更實(shí)際则披。

為什么說這種是土但更實(shí)際

因?yàn)橹恢С謫蝹€(gè)宿主機(jī)及其容器共缕、不支持分布式及集群、沒有告警(當(dāng)然經(jīng)過二次開發(fā)支持也不是不可能收叶,但已經(jīng)有高大上系列了骄呼,沒必要再造輪子)
但在測試環(huán)境,主要的資源監(jiān)控需求來自于我需要在性能測試時(shí)獲取應(yīng)用所在容器及宿主機(jī)資源”隨著時(shí)間變動(dòng)“情況判没,glances已經(jīng)滿足資源獲取需求蜓萄,但是缺少grafana那種“時(shí)間×資源”視圖展示方式。
因?yàn)橹恍枰獪y試時(shí)查看資源曲線澄峰,因此不需要做持久化嫉沽。這里采用使用GlancesApi、配合websocket推送及前端畫圖(vue俏竞、echarts)畫出需要的折線圖绸硕。

Glances安裝

  • glances是python編寫的一個(gè)用于資源監(jiān)控的可運(yùn)行模塊,最近的版本已經(jīng)開始支持docker及其容器魂毁。
    它使用了python的psutil玻佩、docker等工具進(jìn)行資源情況獲取,使用了bottle作為簡單的web頁面展示席楚。
  • 安裝要求
    宿主機(jī)是CentOS系統(tǒng)咬崔,在宿主機(jī)上安裝python及pip
  • 安裝方式
    yum -y install gcc gcc-c++ autoconf pcre pcre-devel make automake
    yum install python-devel.x86_64
    pip install glances
    pip install docker
    pip install bottle
    運(yùn)行 glances,可以直接在控制臺展示宿主機(jī)烦秩、容器的各個(gè)資源利用情況(cpu垮斯、內(nèi)存、swap只祠、網(wǎng)絡(luò)):
    image2019-7-15 10_2_22.png

    運(yùn)行g(shù)lances -w 可以在瀏覽器端展示詳情的信息:
    image2019-7-15 10_6_28.png

    也提供api接口兜蠕,比如:
    image2019-7-15 10_7_56.png

自定義WEB UI編寫

采用Go做Api調(diào)用者,進(jìn)行資源信息收集抛寝,前端用Vue框架的echarts組件進(jìn)行直線圖的繪制熊杨。
為什么不直接調(diào)用glances的api?
因?yàn)檫@種方式下如果多個(gè)瀏覽器訪問盗舰,將會并發(fā)產(chǎn)生很多個(gè)api調(diào)用猴凹,增加此Host機(jī)器的負(fù)載。
此處使用Go運(yùn)行一個(gè)背景進(jìn)程岭皂,每5s調(diào)用glances API獲取資源信息,利用websocket進(jìn)行廣播:不管多少個(gè)瀏覽器(websocket 連接)訪問該頁面沼头,只會5s產(chǎn)生1次API調(diào)用爷绘。
大致流程圖如下(退出邏輯沒有畫):

dd.png

效果如下(第1版:Host的cpu书劝、mem、swap使用率土至、主要進(jìn)程收集购对,容器的mem使用量、cpu使用率收集)
1.gif

代碼參考

后端代碼(go-iris)
// -------------這個(gè)在main文件中聲明并在app定義后進(jìn)行調(diào)用---------------------
func setupWebsocket(app *iris.Application) {
   // create our echo websocket server
   ws := websocket.New(websocket.Config{
      ReadBufferSize:  1024,
      WriteBufferSize: 1024, 
   })
   ws.OnConnection(dockerMonitor.HandleGlances)
   app.Get("/echo", ws.Handler())
}
// --------下面是另一個(gè)文件-----------
package dockerMonitor
 
import (
   "github.com/kataras/iris/websocket"
   "github.com/levigross/grequests"
   "fmt"
   "time"
   "sync"
   "strings"
) 
type AllMetrics struct {
   Now string `json:"now"`
   Docker DockerMetrics `json:"docker"`
   Network []interface{} `network`
   QuickLook HostQuickLook `json:"quicklook"`
   ProcessList []ProcessInfo `json:"processlist"`
}
type DockerMetrics struct {
   Version interface{} `json:"version"`
   Containers []ContainerMetrics `json:"containers"`
 
}
type  ContainerMetrics struct {
   Id         string                 `json:"Id"`
   Name       string                 `json:"name"`
   Key        string                 `json:"key"`
   Status     string                 `json:"Status"`
   Io         map[string]interface{} `json:"io"`
   Image      []string               `json:"Image"`
   CpuPercent float64                `json:"cpu_percent"`
   MemoryUsage float64 `json:"memory_usage"`
   Network    map[string]interface{} `json:"network"`
}
type HostQuickLook struct {
   MemoryPercent float64 `json:"mem"`
   SwapPercent float64 `json:"swap"`
   CpuPercent float64 `json:"cpu"`
   Percpu []PerCpuMetrics `json:"percpu"`
}
type PerCpuMetrics struct {
   CpuNumber int `json:"cpu_number"`
   SoftIrq float64 `json:"softirq"`
   Iowait float64 `json:"iowait"`
   GuestNice float64 `json:"guest_nice"`
   System float64 `json:"system"`
   Guest float64 `json:"guest"`
   Idle float64 `json:"idle"`
   User float64 `json:"user"`
   Irq float64 `json:"irq"`
   Total float64 `json:"total"`
   Steal float64 `json:"steal"`
   Nice float64 `json:"nice"`
}

type ProcessInfo struct {
   Name string `json:"name"`
   Username string `json:"username"`
   Status string `json:"status"`
   Ppid int `json:"ppid"`
   Pid int `json:"pid"`
   NumThreads int `json:"num_threads"`
   CpuPercent float64 `json:"cpu_percent"`
   MemoryPercent float64 `json:"memory_percent"`
   Cmdline []string `json:"cmdline"`
}
 // 全局變量
var wsMtx sync.Mutex
var    wsStatus bool
var stopFlag chan int=make(chan int)
func glancesDocker(c websocket.Connection, hostport string) {
   rOpt:=&grequests.RequestOptions{
      RequestTimeout:30*time.Second,
   }
   // fmt.Println(wsStatus)
   wsMtx.Lock()
   if !wsStatus {
      wsStatus=true
      wsMtx.Unlock()
   dockerloop:
      for {
         select {
         case <-stopFlag:
            fmt.Println("stop Emit msg...")
            break dockerloop
         default:
            apiurl:=fmt.Sprintf("http://%s/api/3/all", hostport)
            rsp, err := grequests.Get(apiurl, rOpt)
            if err != nil {
               fmt.Printf("%v\n", err)
               break dockerloop
            }
            defer rsp.Close()
            allMetrics:= AllMetrics{}
            rsp.JSON(&allMetrics)
            c.To(websocket.All).Emit("glances", allMetrics)
            time.Sleep(5 * time.Second)
         }
      }
      wsMtx.Lock()
      wsStatus=false
      wsMtx.Unlock()
   }else{
      wsMtx.Unlock()
   }
}
 
 
func HandleGlances(c websocket.Connection){
   // fmt.Println(stopFlag)
   c.On("glances",func(msg string) {    // "192.168.60.91:61208"
      hostport:=""
      msgSlc:=strings.Split(msg,";")
      if len(msgSlc)==2 && strings.TrimSpace(msgSlc[1])!=""{
         hostport=msgSlc[1]
      }
      go glancesDocker(c,hostport)
      if msg=="STOP"{
         stopFlag<-1
      }
   })
}
前端繪制相關(guān)折線圖(Vue陶因,elementUI骡苞,iview,echarts)
<template>
    <div>
       <el-row>
          <Drawer :closable="false" width="1000"  v-model="showDrawer">
            <h2 style="text-align:center">宿主機(jī)進(jìn)程列表(Top100)</h2>
            <Table :columns="processCols" :data="processList" height="900"></Table>
          </Drawer>
        <el-col :span="12">
        <b>選擇宿主機(jī):</b>
        <el-radio-group v-model="HostAddr" @change="changeHostAddr()" >
            <el-radio :label="`192.168.60.91:61208`" border>192.168.60.91:61208</el-radio>
            <el-radio :label="`192.168.60.110:61208`" border>192.168.60.110:61208</el-radio>
        </el-radio-group>
        <ButtonGroup shape="circle" style="margin-left:100px">
        <Button type="primary" @click="openWebsocket()">
            <Icon type="ios-analytics"/>請求資源
        </Button>
        <Button type="primary" @click="closeWebsocket()">
            <Icon type="ios-square"/> 停止獲取
        </Button>
        </ButtonGroup>
        </el-col>
        <el-col :span="12"  >
          <div style="float:right;margin: 9px">
          <Button type="info" @click="showDrawer=true" ghost>查看宿主機(jī)進(jìn)程列表</Button>
          </div>
        </el-col>
         </el-row>
        <el-divider>HOST資源監(jiān)控</el-divider>
        <div id="host_metrics" style="width:100%;height:500px;border:1px solid #ccc;padding:10px;"></div>
        <el-divider>各個(gè)容器資源監(jiān)控</el-divider>
      <el-row>
        <el-col :span="6">
          <h2>選擇需要檢查的容器(不超過6個(gè))</h2>
        <el-checkbox-group v-model="curContainers"  @change="changeContainer()">
            <el-checkbox v-for="(contr,i) in containers" :key="i" :label="contr" ></el-checkbox>
        </el-checkbox-group>
      </el-col>
      <el-col :span="18">
         <div id="docker_cpu" style="width:100%;height:500px;border:1px solid #ccc;padding:10px;"></div>
         <div id="docker_mem" style="width:100%;height:500px;border:1px solid #ccc;padding:10px;"></div>
      </el-col>
      </el-row>
    </div>
</template>
 
<script>
import echarts from "echarts";
import { constants } from "fs";
import { setInterval, setTimeout } from "timers";
import expandRow from "./擴(kuò)展Glances.vue";
export default {
  name: "basecharts",
  components: {},
  data() {
    return {
      showDrawer: false,
      transFlag: false,
      HostAddr: null,
      hostMetrics: null,
      dockerCpuMetrics: null,
      dockerMemMetrics: null,
      containers: [],
      curContainers: [],
      dockersTime: [],
      dockersCpu: [],
      dockersMem: [],
      host_time: [],
      host_cpu_pct: [],
      host_mem_pct: [],
      host_swap_pct: [],
      processCols: [
        {
          type: "expand",
          width: 30,
          render: (h, params) => {
            return h(expandRow, {
              props: {
                row: params.row
              }
            });
          }
        },
        { title: "Name", key: "name", sortable: true },
        { title: "Username", key: "username", sortable: true },
        { title: "Status", key: "status", sortable: true },
        { title: "進(jìn)程id", key: "pid" },
        { title: "父進(jìn)程id", key: "ppid", sortable: true },
        { title: "NumThreads", key: "num_threads" },
        { title: "Cpu(%)", key: "cpu_percent", sortable: true },
        {
          title: "Memory(%)",
          key: "memory_percent",
          sortable: true,
          width: 130
        }
      ],
      processList: []
    };
  },
  mounted() {
    this.initWebSocketHost();
    this.hostMetrics = echarts.init(document.getElementById("host_metrics"));
    this.hostMetrics.setOption({
      //初始化
      title: { text: "Host的資源使用率(%)" },
      tooltip: { trigger: "axis" },
      legend: {
        data: ["CPU使用占比\x25", "內(nèi)存使用占比\x25", "SWAP使用占比\x25"]
      },
      xAxis: { type: "category", data: [] },
      yAxis: {},
      series: [
        {
          name: "CPU使用占比\x25",
          type: "line",
          data: [],
          lineStyle: { width: 3 },
          label: { show: false, formatter: "{c}%" }
        },
        {
          name: "內(nèi)存使用占比\x25",
          type: "line",
          data: [],
          lineStyle: { width: 3 },
          label: { show: false, formatter: "{c}%" }
        },
        {
          name: "SWAP使用占比\x25",
          type: "line",
          data: [],
          lineStyle: {
            type: "solid", //solid,dashed
            width: 2,
            color: "rgb(17, 93, 232)"
          },
          label: {
            show: false,
            formatter: "{c}%",
            color: "rgb(17, 93, 232)",
            position: "bottom"
          }
        }
      ]
    });
    this.dockerCpuMetrics = echarts.init(document.getElementById("docker_cpu"));
    this.dockerCpuMetrics.setOption({
      //初始化
      title: { text: "docker容器的CPU使用率(%)" },
      tooltip: { trigger: "axis" },
      legend: {
        right: "right",
        orient: "vertical",
        data: []
      },
      xAxis: {
        type: "category",
        data: []
      },
      yAxis: {},
      series: []
    });
    this.dockerMemMetrics = echarts.init(document.getElementById("docker_mem"));
    this.dockerMemMetrics.setOption({
      //初始化
      title: { text: "docker容器的Mem量(MiB)" },
      tooltip: { trigger: "axis" },
      legend: {
        right: "right",
        orient: "vertical",
        data: []
      },
      xAxis: {
        type: "category",
        data: []
      },
      yAxis: {},
      series: []
    });
    let othis = this;
    window.addEventListener("resize", function() {
      othis.hostMetrics.resize();
      othis.dockerCpuMetrics.resize();
      othis.dockerMemMetrics.resize();
    });
  },
  methods: {
    openWebsocket() {
      if (!this.HostAddr) {
        this.$alert("請選擇目標(biāo)主機(jī)");
        return;
      }
      this.WebSocketHostSend(
        "iris-websocket-message:glances;0;START;" + this.HostAddr
      );
      this.transFlag = true;
    },
    closeWebsocket() {
      this.WebSocketHostSend("iris-websocket-message:glances;0;STOP");
      this.transFlag = false;
    },
    initWebSocketHost() {
      //初始化weosocket
      var serverhost = document.location.host;
      const wsuri = `ws://${serverhost}/echo`;
      this.websockHost = new WebSocket(wsuri);
      this.websockHost.onopen = this.onopenWebSocketHost;
      this.websockHost.onerror = this.onerrorWebSocketHost;
      this.websockHost.onclose = this.oncloseWebSocketHost;
      this.websockHost.onmessage = this.onmessageWebSocketHost;
    },
    onopenWebSocketHost() {
      console.log("open iris-websocket...");
    },
    oncloseWebSocketHost() {
      console.log("close iris-websocket...");
    },
    onerrorWebSocketHost() {
      this.initWebSocketHost(); //連接建立失敗重連
    },
    onmessageWebSocketHost(e) {
      var jsonD = e.data.replace(/iris-websocket-message:glances;\d+;/, "");
      var jsonData = JSON.parse(jsonD);
      var t = new Date(jsonData.now);
      var cpuPercent = jsonData.quicklook.cpu;
      var memPercent = jsonData.quicklook.mem;
      var swapPercent = jsonData.quicklook.swap;
      this.host_time.push(
        t.getHours() + ":" + t.getMinutes() + ":" + t.getSeconds()
      );
      this.host_cpu_pct.push(cpuPercent);
      this.host_mem_pct.push(memPercent);
      this.host_swap_pct.push(swapPercent);
      this.hostMetrics.setOption({
        series: [
          { name: "CPU使用占比\x25", data: this.host_cpu_pct },
          { name: "內(nèi)存使用占比\x25", data: this.host_mem_pct },
          { name: "SWAP使用占比\x25", data: this.host_swap_pct }
        ],
        xAxis: { data: this.host_time }
      });
      var containerList = jsonData.docker.containers;
      this.containers = [];
      for (var ct of containerList) {
        this.containers.push(ct.name);
      }
      var dockersCpuData = [];
      var dockersMemData = [];
      for (var i in this.curContainers) {
        for (var ct of containerList) {
          if (ct.name == this.curContainers[i]) {
            if (!this.dockersCpu[i]) {
              this.dockersCpu[i] = [];
            }
            this.dockersCpu[i].push(ct.cpu_percent);
            if (!this.dockersMem[i]) {
              this.dockersMem[i] = [];
            }
            this.dockersMem[i].push(ct.memory_usage / 1024.0 / 1024.0);
          }
        }
        dockersCpuData.push({
          name: this.curContainers[i],
          data: this.dockersCpu[i],
          type: "line"
        });
        dockersMemData.push({
          name: this.curContainers[i],
          data: this.dockersMem[i],
          type: "line"
        });
      }
      if (this.curContainers.length > 0) {
        this.dockersTime.push(
          t.getHours() + ":" + t.getMinutes() + ":" + t.getSeconds()
        );
      }
      this.dockerCpuMetrics.setOption({
        legend: { data: this.curContainers },
        series: dockersCpuData,
        xAxis: { data: this.dockersTime }
      });
      this.dockerMemMetrics.setOption({
        legend: { data: this.curContainers },
        series: dockersMemData,
        xAxis: { data: this.dockersTime }
      });
      if (!this.showDrawer) {
        // 面板打開時(shí)楷扬,不更新數(shù)據(jù)
        var oProcesses = jsonData.processlist.slice(0, 100);
        var tmpP = {};
        this.processList = [];
        for (var op of oProcesses) {
          if (!op.cmdline || op.cmdline.length === 0) {
            continue;
          }
          tmpP = {};
          tmpP.name = op.name;
          tmpP.username = op.username;
          tmpP.status = op.status;
          tmpP.ppid = op.ppid;
          tmpP.pid = op.pid;
          tmpP.num_threads = op.num_threads;
          tmpP.cpu_percent = op.cpu_percent;
          tmpP.memory_percent = parseInt(op.memory_percent * 100) / 100.0;
          tmpP.cmd = op.cmdline[0];
          tmpP.cmdargs = op.cmdline.slice(1).join(" ");
          this.processList.push(tmpP);
        }
      }
    },
    WebSocketHostSend(Data) {
      this.websockHost.send(Data); //數(shù)據(jù)發(fā)送
    },
    changeContainer() {
      if (this.curContainers.length > 6) {
        this.$alert("為了數(shù)據(jù)清晰解幽,不要選擇超過6個(gè)容器");
        this.curContainers = this.curContainers.slice(0, -1);
      }
      this.dockersTime = [];
      this.dockersCpu = [];
      this.dockersMem = [];
    },
    cleanDockerMetrics() {
      this.dockerCpuMetrics.setOption({
        legend: { data: [] },
        series: [],
        xAxis: { data: [] }
      });
      this.dockerMemMetrics.setOption({
        legend: { data: [] },
        series: [],
        xAxis: { data: [] }
      });
    },
    changeHostAddr() {
      this.closeWebsocket();
      this.cleanDockerMetrics();
      this.containers = [];
      this.curContainers = [];
      this.dockersTime = [];
      this.dockersCpu = [];
      this.dockersMem = [];
      this.host_time = [];
      this.host_cpu_pct = [];
      this.host_mem_pct = [];
      this.host_swap_pct = [];
      this.processList = [];
    }
  }
};
</script> 
<style scoped>
.content-title {
  clear: both;
  font-weight: 400;
  line-height: 50px;
  margin: 10px 0;
  font-size: 22px;
  color: #1f2f3d;
}
</style>

子模塊文件-擴(kuò)展Glances.vue

<style scoped>
    .expand-row{
        margin-bottom: 2px;
    }
</style>
<template>
    <div>
        <el-row class="expand-row">
           {{ row.cmd }}
        </el-row>
        <el-row>
           {{ row.cmdargs }}
        </el-row>
    </div>
</template>
<script>
    export default {
        name: "expandRow",
        props: {
            row: Object
        }
    };
</script>
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市烘苹,隨后出現(xiàn)的幾起案子躲株,更是在濱河造成了極大的恐慌,老刑警劉巖镣衡,帶你破解...
    沈念sama閱讀 221,198評論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件霜定,死亡現(xiàn)場離奇詭異,居然都是意外死亡廊鸥,警方通過查閱死者的電腦和手機(jī)望浩,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評論 3 398
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來惰说,“玉大人磨德,你說我怎么就攤上這事≈唬” “怎么了剖张?”我有些...
    開封第一講書人閱讀 167,643評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長揩环。 經(jīng)常有香客問我搔弄,道長,這世上最難降的妖魔是什么丰滑? 我笑而不...
    開封第一講書人閱讀 59,495評論 1 296
  • 正文 為了忘掉前任顾犹,我火速辦了婚禮,結(jié)果婚禮上褒墨,老公的妹妹穿的比我還像新娘炫刷。我一直安慰自己,他們只是感情好郁妈,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,502評論 6 397
  • 文/花漫 我一把揭開白布浑玛。 她就那樣靜靜地躺著,像睡著了一般噩咪。 火紅的嫁衣襯著肌膚如雪顾彰。 梳的紋絲不亂的頭發(fā)上极阅,一...
    開封第一講書人閱讀 52,156評論 1 308
  • 那天,我揣著相機(jī)與錄音涨享,去河邊找鬼筋搏。 笑死,一個(gè)胖子當(dāng)著我的面吹牛厕隧,可吹牛的內(nèi)容都是我干的奔脐。 我是一名探鬼主播,決...
    沈念sama閱讀 40,743評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼吁讨,長吁一口氣:“原來是場噩夢啊……” “哼髓迎!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起挡爵,我...
    開封第一講書人閱讀 39,659評論 0 276
  • 序言:老撾萬榮一對情侶失蹤竖般,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后茶鹃,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體涣雕,經(jīng)...
    沈念sama閱讀 46,200評論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,282評論 3 340
  • 正文 我和宋清朗相戀三年闭翩,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了挣郭。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,424評論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡疗韵,死狀恐怖兑障,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蕉汪,我是刑警寧澤流译,帶...
    沈念sama閱讀 36,107評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站者疤,受9級特大地震影響福澡,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜驹马,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,789評論 3 333
  • 文/蒙蒙 一革砸、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧糯累,春花似錦算利、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,264評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春允耿,著一層夾襖步出監(jiān)牢的瞬間借笙,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,390評論 1 271
  • 我被黑心中介騙來泰國打工较锡, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人盗痒。 一個(gè)月前我還...
    沈念sama閱讀 48,798評論 3 376
  • 正文 我出身青樓蚂蕴,卻偏偏與公主長得像,于是被迫代替她去往敵國和親俯邓。 傳聞我的和親對象是個(gè)殘疾皇子骡楼,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,435評論 2 359