Docker+Django+Gunicorn+Nginx+Mysql容器化部署(下)

一、前言

在上章内容中,简单介绍了使用docker部署项目的优势以及如何在Linux环境下安装docker,本章内容将主要讲解如何构建容器来部署项目。

二、Docker的组成

  • 镜像: 一个只读模板,其中配置了容器运行所需要的环境数据
  • 容器: 使用镜像创建的实例,可以简单理解为是一个虚拟机,要注意的是容器与容器之间是相互隔离的,容器与外界操作环境之间也是相互隔离的
  • 仓库: 类似NginxMysql这样的常用组件,如果我们每次部署项目时都得自己编写镜像配置文件来构建镜像,那不是很麻烦?所以早有人将这些常用组件镜像上传至公共的远程仓库中,我们只要拉取就行,当然我们也可以上传自己的镜像到仓库中,是不是感觉和Github很类似!目前docker默认的公共仓库是docker官方维护的Dockerhub

上述镜像和容器的关系,我们可以简单的理解为:镜像是一个对象类型,容器是这个对象类型的实例。

三、构建Django的容器

在上章内容中我们提到过,如果项目由多个组件组成,我们最好是为每个组件都构建一个容器,使其能单独运行,方便复用。此处我们需要部署的项目由Django+Nginx+Mysql三个组件组成,我们需要为这三个组件分别单独构建一个容器。首先是为Django构建容器。

构建镜像

我们先创建一个Django项目,这个项目我们就暂时命名为room,我们在其中创建一个叫做deployment(可以随意命名)的文件夹,用于存放我们部署使用的配置文件,因为我们一般开始时会区分测试与正式环境,所以我们再在deployment文件夹下创建一个dev文件夹,用于存放测试环境下的部署配置文件,项目的最终目录结构如下:

|—— room
| |——deployment  # 存放部署使用的配置文件
| | |——dev  # 存放部署测试环境使用的配置文件
| | | |——app  # django相关
| | | | |——Dockerfile  # django项目的镜像配置文件
| | | | |——startapp.sh  # 启动django项目的脚本文件
| | | |——gunicron  # gunicorn相关
| | | | |——gunicorn.conf.py  # gunicorn的配置文件
| | | |——nginx  # nginx相关
| | | | |——nginx.conf  # nginx的配置文件
| | | |——docker-compose.yml  # 容器的配置文件
| | |——wait-for-it.sh  # 控制服务顺序执行的脚本文件
| |——room
| |——static
| |——templates
| |——manage.py

可以看到,目录结构中存在两个sh文件,这两个sh文件的作用如下:

  • startapp.sh: 编写了启动Django项目的命令行,因为运行项目时需要执行多条命令,我们将这些命令都写入一个shell脚本中,我们后面会通过执行这个sh文件来运行项目
  • wait-for-it.sh: 有些服务启动需要一定的时间,而其他服务需要等这些服务启动完成后才能执行,此文件的作用就是确保服务可以按顺序启动

编写Django项目的镜像配置文件

我们在项目中的Dockerfile的文件中写入如下内容:

# 从仓库拉取带有python3.8的Linux环境
FROM python:3.8

# 镜像维护者的姓名和邮箱地址
MAINTAINER albertlii  <albertlii@163.com>

# 因为墙的关系,有时安装库可能会很慢或者失败,所以推荐使用国内镜像源
# 此处是创建一个指向阿里云镜像源的参数,以便后面引用
ARG pip_url=http://mirrors.aliyun.com/pypi/simple/
ARG pip_host=mirrors.aliyun.com

# 定义一个变量,其值为项目在docker容器中的工作目录
ARG work_home=/room

# 设置python环境变量
ENV PYTHONUNBUFFERED 1

# 容器默认的时间是UTC时间,此处设置为上海时间
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

# 在容器中创建room文件夹
RUN mkdir ${work_home}

# 将容器中的room文件夹设置为工作目录
WORKDIR ${work_home}

# 将当前目录内容复制到容器的room目录,因为build中指定的context环境目录为project这一层,所以当前目录即项目目录
COPY . ${work_home}

# 更新pip
RUN pip install pip -U

# 安装所有依赖库
RUN pip install -r requirements.txt -i ${pip_url} --trusted-host ${pip_host}

# 将deployment/dev下的startapp.sh 文件拷贝到容器的根目录下
COPY deployment/dev/app/startapp.sh /startapp.sh
# 将deployment下的wait-for-it.sh文件拷贝到容器的根目录下
COPY deployment/wait-for-it.sh /wait-for-it.sh

# 修改文件权限 ,为sh文件增加可执行
RUN chmod +x /startapp.sh
RUN chmod +x /wait-for-it.sh

下面对上述的指令语句做一些简单介绍:

  • FROM python:3.8: 从仓库拉取一个包含Python3.8Linux操作环境
  • WORKDIR: 这个指令是针对容器的,功能是将容器中的指定目录设置为工作目录
  • COPY: 将宿主机中的指定目录拷贝到容器中的指定目录中,docker中还有一个ADD指令与COPY指令功能相似,但是ADD指令在拷贝后会自动解压压缩包,COPY指令不会
  • RUN: 这个指令是针对容器的,主要是在容器中执行你想要执行的指令

Dockerfile是默认的配置文件名称,docker在创建镜像时会默认去指定路径(即指定的上下文环境目录)中寻找名为Dockerfile的配置文件。当然,我们也可以修改名称,只需要我们重新手动指定一下配置文件的路径即可,在下面内容中我们会讲到。

startapp.sh文件内容如下:

#!/bin/sh

python manage.py migrate
python manage.py collectstatic --noinput
gunicorn -c ./deployment/dev/gunicorn/gunicorn.conf.py room.wsgi:application

wait-for-it.sh是直接从Github上下载的,链接如下:
https://github.com/vishnubob/wait-for-it

至此,Django相关的镜像配置到此结束,我们还缺少NginxMysql的镜像配置。但是,我们在文章开头就提到过,类似NginxMysql这类常用的镜像,早就有人帮我上传到公共仓库中,我们只需要直接从公共仓库中拉取即可,不需要在自己手动编写,所以我们真正需要自己自定义的镜像文件,只有我们的Django项目。

使用Docker-compose

前面我们多次提到不要将多个组件放在一个容器中,而是分别为每个组件创建一个容器。而当我们要构建和运行多个组件的容器时,我们如果每个容器都去使用命令单独去构建和运行岂不是很麻烦?此时我们就可以使用Docker-compose来解决这一问题。Docker-compose可以同时编排多个容器,将多个容器的构建与启动操作放到一起执行。我们在docker-compose.yml文件中写入如下内容:

version: "3"

# 定义用于共享的数据卷
volumes:
  # 定一个名称为static-volumn的数据卷
  static-volume:
  # 定一个名称为media-volumn的数据卷
  media-volume:

# 定义两个网络,只有处于同一网络下的容器才能通讯
networks:
  web_network:
    driver: bridge
  db_network:
    driver: bridge

services:
  app:
    # 除正常工作外,容器会在任何时候重启,比如遭遇 bug、进程崩溃、docker 重启等情况
    restart: always
    build:
      # 构建镜像的上下文环境所在目录,相对当前docker-compose.yml的位置
      context: ../../
      # 指定用于构建镜像的Dockerfile(ps: 命名不需要一定为Dockerfile)
      dockerfile: ./deployment/dev/app/Dockerfile
    # 设置容器名字
    container_name: room_dev
    image: room_dev
    command: "/wait-for-it.sh mysql:3306 -- /startapp.sh"
    # 数据卷,使宿主机的内容可以映射到容器中,当宿主机指定目录的内容更新时,容器中的指定目录中的内容也会相应更新
    # 所以使用数据卷时,一定要注意路径是否正确,否则会出现文件找不到或不存在的情况
    volumes:
      - ../../:/room
      - static-volume:/room/collected_static
      - media-volume:/room/media
    # 暴露端口,但不暴露给宿主机,只给连接的服务访问
    expose:
      - "8000"
    networks:
      - web_network
      - db_network

  mysql:
    image: mysql:5.7
    restart: always
    container_name: mysql
    hostname: mysql  # host的名字,在django中需要使用
    environment:
      - MYSQL_HOST=localhost  # mysql的主机
      - MYSQL_PORT=3306  # mysql的端口号
      - MYSQL_ROOT_PASSWORD=123456  # root用户的密码
      - MYSQL_DATABASE=room_dev  # 数据库的名字
      - MYSQL_USER=dev  # 用户名
      - MYSQL_PASSWORD=123456  # dev用户的密码
      - MYSQL_ALLOW_EMPTY_PASSWORD=no # 设置是否允许root用户的密码为空
    command:
      --default-authentication-plugin=mysql_native_password
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_general_ci
      --explicit_defaults_for_timestamp=true
    volumes:
      # mysql的数据目录
      - ../../../room_dev_db:/var/lib/mysql
    ports:
      - "3306:3306"
    networks:
      - db_network

  nginx:
    image: nginx:latest
    restart: always
    container_name: nginx
    # 宿主机和容器的端口映射
    ports:
      - "8001:8001"
    volumes:
      - static-volume:/collected_static
      - media-volume:/room_media_dev
      - ./nginx:/etc/nginx/conf.d  # 修改nginx的配置
    networks:
      - web_network

Docker-compose的安装请参考上章内容

上述文件内容有些多,我们逐一分析。在文件开头,有个version,代表docker-compose.yml的版本,目前最新版为3,我们不需要改动它。然后文件下面的内容大致分为volumesnetworksappmysqlnginx五个模块。其中appmysqlnginx都是容器的配置,app容器使用的镜像是我们上述的自定义镜像,mysqlnginx容器使用的镜像则是直接从公共仓库中拉取的镜像,我们先看下容器中的配置项:

  • restart: 除正常工作外,容器会在任何时候重启,比如遭遇 bug、进程崩溃、docker 重启等情况。
  • command: 运行容器所需要执行的操作,这边是直接执行一个shell脚本,当然也可以直接执行命令,比如python manage.py runserver 127.0.0.1:8000
  • container_name: 设置容器的名字
  • image: 镜像的名字,在app这种使用自定义镜像的情况中,表示的是设置自定义镜像的名字。在mysqlnginx这种从仓库中拉取镜像的情况中,表示的是要从仓库中获取的镜像的名字,而不是重新设置镜像名字
  • expose: 容器对外暴露端口,因为容器与外界隔离是连端口一起隔离的,但是web服务是需要调用端口的,因此需要将容器的端口对外暴露,但是要注意此命令不会对宿主机(即外界的系统操作环境)暴露端口,仅仅是对其他容器暴露端口
  • ports:定义了宿主机与容器之间的端口映射,写法是宿主机的端口号:容器的端口号,例如8000:5000即表示访问宿主机的8000端口就是访问容器的5000端口,与expose一样起到对外暴露端口的作用,但是ports是针对宿主机而expose是针对其他容器的
  • build: 用于指定构建镜像的上下文路径
    • context: 指定构建镜像的上下文环境所在目录,是相对配置文件docker-compose.yml的所在目录,../../表示docker-compose.yml所在目录的上级目录的上级目录,即最外层的room目录,注意上述镜像配置文件中COPY命令所涉及到的宿主机目录就是由context指定的,例如COPY deployment/dev/app/startapp.sh /startapp.sh中,因为context指向最外层的room目录,所以deployment/dev/app/startapp.sh指的是最外层room目录下的对应文件
    • dockerfile: 指定用于构建镜像的配置文件,即我们的前面编写的Dockerfile文件,其根路径就是上述context指定的路径,即最层的room./deployment/dev/app/Dockerfile即表示最外层room目录下对应的文件
  • depends_on: 一个容器需要依赖另一个容器,例如上述配置中app容器需要依赖mysql容器,只有mysql容器启动后,app容器才能正常使用。但是mysql容器第一次启动后,初始化需要一定的时间,而depends_on并不会去等待mysql初始化完成,再去启动app,这时app启动会因为mysql没有初始化完成而失败,所以这时我们可以使用上述提到的wait-for-it.sh脚本,使用方法是/wait-for-it.sh 容器名:容器端口号 -- 后续执行的命令,例如/wait-for-it.sh mysql:3306 -- /startapp.sh

因为我们使用到了mysql,所以django中的配置需要做如下修改:

# *************************
# 数据库相关
# *************************
DATABASES = {
    # 默认使用mysql数据库
    "default": {
        "ENGINE": "django.db.backends.mysql",  # 数据库引擎
        "NAME": "room_dev",  # 数据库名
        "USER": "dev",  # 用户名
        "PASSWORD": "123456",  # 密码
        "HOST": "mysql",  # HOST,配置文件中的hostname
        "POST": 3306,  # 端口
        "OPTIONS": {"charset": "utf8mb4"},
    },
}

在上述容器配置项介绍中,除了volumesnetworks外,对于其他配置项我们已经有了一些了解。下面我们单独讲解一下volumesnetworks,尤其是volumes,其在容器中起到至关重要的作用。

volumes

volumes我们一般称为容器的。因为容器与容器以及容器与宿主机是相互隔离的,但有时我们又需要它们可以互相连通,例如当宿主机上的项目代码更新了,容器中的项目代码也需要更新,如果我们每次都去手动更新,岂不是很麻烦?另外,当容器被删除时,容器中的数据也会丢失,如果数据库的容器被误删,这导致的结果也不是我们可以接受的。而正好解决这些问题!
大致可以分为两种使用形式,

第一种:使用绝对路径

app:
    ......
    volumes:
      - ../../:/room

上述配置中,:是分割符,:的左边../../是宿主机的目录(这个路径是相对docker-compose.yml所在的目录,此处为docker-compose.yml所在的目的上级目录的上级目录,即最外层的room目录),:的右边/room是容器中的目录,此时表示宿主机的../../与容器的/room目录是连通的,当宿主机中的指定目录中的项目代码更新,容器中的/room目录中的代码也会更新。

mysql:
    ......
    volumes:
      - ../../../room_dev_db:/var/lib/mysql

我们再看容器mysql中的配置,../../../room_dev_db表示在最外层room目录外新建了一个room_dev_db文件夹与mysql容器中的/var/lib/mysql(mysql存放数据的地方)目录连通。因为一开始room_dev_db目录是空的,所以/var/lib/mysql中的数据会映射到room_dev_db中,这样在宿主机中就持有了数据库中的数据,即使我们不小心误删了mysql容器,我们在宿主机中也有数据库的数据,不会造成数据丢失,当我们再次创建mysql容器时,数据会自动恢复。

第二种:使用卷标

# 定义用于共享的数据卷
volumes:
  # 定一个名称为static-volumn的数据卷
  static-volume:
  # 定一个名称为media-volumn的数据卷
  media-volume:

在文件的头部,我们定义了两个卷,分别名为static-volumemedia-volume。这两个卷目前是空的,没有任何数据。然后在app容器中有一段配置:

app:
    ......
    volumes:
      ......
      - static-volume:/room/collected_static

static-volume:/room/collected_static中,:左边的static-volume是我们在一开始在宿主机中定义的卷,:右边的/room/collected_static指的是容器中的目录,但此时的static-volume只是个卷的名字,不是具体的宿主机中的路径,此处将容器中的/room/collected_static的内容映射到宿主机的原来的空static-volume卷中,使得static-volume持有/room/collected_static中的数据,而static-volume的具体存储,则默认由宿主机上的docker统一管理。

nginx:
    ......
    volumes:
      - static-volume:/collected_static

nginx容器中有一段static-volume:/collected_static,因为在上面app容器中已经使static-volume卷拥有了数据,使其不再是空的状态,此处是将static-volume卷中的数据映射到nginx容器的/collected_static目录中,使得nginx容器也可以访问static资源,起到了数据共享的作用,这样appnginx就是共用static-volume这个卷,起到数据共享的作用。

在上述介绍数据卷时,我们几次提到数据卷的空状态非空状态,其实从中可以看出,挂载空卷非空卷是有区别的,所以我们需要记住以下数据卷的重要特性:

  • 容器启动时,如果挂载一个空的数据卷到容器中的一个非空目录中,那么这个目录下的文件会被复制到数据卷中;
  • 如果挂载一个非空数据卷到容器中的一个目录中,那么容器中的目录中会显示数据卷中的数据;如果原来容器中的目录中有数据,那么这些原始数据会被隐藏掉

举个例子,就以collected_static的数据卷static-volume来说,只要卷初始化完成后,容器原始的 collected_static目录就被隐藏起来不再使用了,新增的文件也只存在于static-volume卷中,容器中是没有的。

networks

# 定义两个网络,只有处于同一网络下的容器才能通讯
networks:
  web_network:
    driver: bridge
  db_network:
    driver: bridge

app:
    ……
    networks:
      - web_network
      - db_network

mysql:
    ……
    networks:
      - db_network

nginx:
    ……
    networks:
       - web_network

docker中可以给每个容器定义其工作网络,定义网络后可以隔离容器的网络环境,容器只有在相同的网络之中才能进行通讯。此处一共定义了两个网络web_networkdb_network。在配置文件中可以看出,appnginx都处于web_network网络,且appmysql处于db_network网络,nginxmysql因为所处网络不同,无法通讯。

四、总结

关于使用Docker+Django+Gunicorn+Mysql+Nginx部署项目的介绍就到此结束了,博主对于Docker的使用也只是停留在基础层面,如有不足之处,欢迎指正。有兴趣的同学可以关注公众号「Code满满」或者是博客「李益的小站」。另外此处推荐一个比较详细的系列文章:《Django+Docker容器化部署》,本文亦参考了此系列文章。

最后附上gunicornnginx的配置文件内容:

  • gunicorn
# -*- coding: utf-8 -*-
import multiprocessing
from pathlib import Path

BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent.parent

debug = False

# ============================================================
# gunicorn要切换到的目的工作目录
# ============================================================

chdir = str(BASE_DIR)

# ============================================================
# server socket相关
# ============================================================

# 指定绑定的ip和端口
bind = "0.0.0.0:8000"

# 服务器中排队等待的最大连接数,建议值64-2048,超过2048时client连接会得到一个error
backlog = 2048

# ============================================================
# 调试相关
# ============================================================

# 当代码有修改时,自动重启workers,适用于开发环境
reload = False

# 以守护进程形式来运行Gunicorn进程,其实就是将这个服务放到后台去运行
daemon = False

# ============================================================
# worker进程相关
# ============================================================

# 用于处理工作的进程数
workers = multiprocessing.cpu_count() * 2 + 1

# worker进程的工作方式,有sync、eventlet、gevent、tornado、gthread, 默认是sync,
# django使用gevent容易造成阻塞, 使用gthread的方式好一些
worker_class = 'gthread'

# 指定每个工作进程开启的线程数
threads = multiprocessing.cpu_count() * 2

# 访问超时时间
timeout = 30

# 接收到restart信号后,worker可以在graceful_timeout时间内,继续处理完当前requests
graceful_timeout = 60

# server端保持连接时间
keepalive = 30

# ============================================================
# 日志相关
# ============================================================

"""日志文件格式,其每个选项的含义如下:
h          remote address
l          '-'
u          currently '-', may be user name in future releases
t          date of the request
r          status line (e.g. ``GET / HTTP/1.1``)
s          status
b          response length or '-'
f          referer
a          user agent
T          request time in seconds
D          request time in microseconds
L          request time in decimal seconds
p          process ID
"""
access_log_format = '%(t)s %(h)s "%(r)s" %(s)s %(b)s "%(f)s" "%(L)s"'

LOG_DIR = Path(BASE_DIR, 'log')
if not LOG_DIR.exists():
    LOG_DIR.mkdir(parents=True)

# 错误日志输出等级,访问日志的输出等级无法设置
loglevel = "error"

# 正常的日志文件路径,'-'表示输出到终端
accesslog = '-'

# 错误日志文件路径,'-'表示输出到终端
errorlog = str(LOG_DIR / 'gunicorn_error.log')

# ============================================================
# 进程名相关
# ============================================================

# 设置进程名称,默认是gunicorn
proc_name = 'gunicorn_room'
  • nginx
#user root;
# 将请求分发到upstream池中的服务器中
upstream app {
    ip_hash;
    server app:8000;
}

server {
    # nginx监听的端口
    listen       8001;
    server_name localhost;
    # 限制用户上传文件大小
    client_max_body_size 5M;

    location /media  {
        alias /room_media_dev;
    }

    # 访问一些其他静态文件,直接交给nginx处理,可以指向django项目中配置的STATIC_ROOT路径
    # 这里的 /static 的意思就是你的域名加上/static/就访问里面的那个路径/项目了
    # alias:直接查询指定路径
    location /static  {
        alias /collected_static/;
    }

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

推荐阅读更多精彩内容