浅尝:ETCD集群部署


背景说明

前置知识

什么是ETCD

ETCD 是 Core OS 基于 Raft 协议开放的分布式 key-value 存储,可用于服务发现,共享配置以及一致性保障(数据库选主,分布式锁等)。

主要功能

  • key-value存储
  • 监听机制
  • key 的过期及续约机制,用于监控和服务发现
  • 原子 Compare And Swap 和Compare And Delete,用于分布式锁和 leader 选举。(对key设置或者删除需要满足一定的条件才能执行,条件如下)
    • prevExist: key当前赋值前是否存在
    • prevvalue: key 当前赋值前的值
    • prevlndex: key Index

使用场景

  • 服务注册与发现(使用较多,与 zookeeper 类似)

    • 强一致性、高可用的服务存储目录。

    • 可以对注册的 key 设置 ttl ,到期后 key 会自动删除,定时保持服务的心跳以达到健康检查的效果。

  • 基于监听机制的分布式异步系统
    • 在分布式系统中,最常用的一种组件间通信方式就是消息发布与订阅。
    • 创建一个消息中心,生产者在这个消息中心发布消息,消费者订阅他们关心的主题,一旦主题有消息发布,就会实时通知订阅者。
    • 应用在启动的时候主动从 etcd 获取一次配置信息,同时,在 etcd 节点上注册一个Watcher 并等待,以后每次配置有更新的时候,etcd 都会实时通知订阅者,以此达到获取最新配置信息的目的。
  • key-value存储,应用程序可以读取和写入 etcd 中的数据
    • 采用 KV 型数据存储,一般情况下比关系型数据库快。
    • 支持动态存储(内存)以及静态存储(磁盘)。
    • 分布式存储,可集成为多节点集群。
    • 存储方式,采用类似目录结构(B+tree)。 只有叶子节点才能真正存储数据,相当于文件。 叶子节点的父节点一定是目录,目录不能存储数据。

SSL证书

集群证书与工具说明

ETCD集群均在 TLS 环境下运行,所以需要签发私有证书。

  • CA证书
    • ca-config.json: CA证书签发配置,主要包含CA过期时间
    • ca-csr.json: CA证书签发主体信息与加密方法
    • ca.pem: CA证书文件
    • ca-key.pem: CA证书私钥
  • 对等证书
    • etcd-csr.json: ETCD成员配置、证书签发地区
    • etcd.csr: 证书公钥文件
    • etcd-key.pem: 对等证书
    • etcd.pem: 对等证书文件
  • cfssl
    • 是 CloudFlare 的 PKI/TLS 利器。
    • 它既是命令行工具,又可以用于签名,验证和捆绑 TLS 证书的 HTTP API 服务器,环境构建方面需要 Go 1.12+。
  • cfssljson
    • 是 cfssl 的配套工具,可以从 cfssl 获取 JSON 输出的二进制程序,并将证书、密钥、CSR和 bundle 写入指定位置。

附加说明

数字证书中主题(Subject)中字段的含义:

  • 一般的数字证书产品的主题通常含有如下字段:
    • 公用名称 (Common Name) 简称:CN 字段,对于 SSL 证书,一般为网站域名;而对于代码签名证书则为申请单位名称;而对于客户端证书则为证书申请者的姓名;
    • 组织名称,公司名称(Organization Name) 简称:O 字段,对于 SSL 证书,一般为网站域名;而对于代码签名证书则为申请单位名称;而对于客户端单位证书则为证书申请者所在单位名称;
    • 组织单位名称,公司部门(Organization Unit Name) 简称:OU字段
  • 证书申请单位所在地
    • 所在城市 (Locality) 简称:L 字段
    • 所在省份 (State/Provice) 简称:S 字段,State:州,省
    • 所在国家 (Country) 简称:C 字段,只能是国家字母缩写,如中国:CN

版本选型

因为当前线上 K8S 的 ETCD 版本为 3.3.10 , 而业务集群需要使用 Learner 角色实现边缘/跨区域从节点信息同步,需要 3.4.X 版本以上才可支持;

同时在 3.5.X 版本中,默认情况下新增一个 member 其状态为 learner ,在当前新 member 未变成 “voting-member” 前,是不会改变 quorum size ,同样 Misconfiguration 能够撤销保证 quorum 不会 lose 。

所以本次部署直接采用当前最新 release 版本 3.5.4 进行部署运行。


部署流程

节点信息

节点IP 节点名称 Peer通信端口 客户端交互端口 节点域名
192.168.7.91 etcd1-cn 2501 2500 etcd1-cn.nestealin.com
192.168.7.92 etcd2-cn 2501 2500 etcd2-cn.nestealin.com
192.168.7.93 etcd3-cn 2501 2500 etcd3-cn.nestealin.com

环境准备

内网解析

根据上述表格对每台ETCD新增内网解析,便于后续Api操作。

工具下载

mkdir /opt/etcd_tools && cd /opt/etcd_tools

# 证书工具
wget https://github.com/cloudflare/cfssl/releases/download/v1.6.1/cfssljson_1.6.1_linux_amd64
wget https://github.com/cloudflare/cfssl/releases/download/v1.6.1/cfssl_1.6.1_linux_amd64
wget https://github.com/cloudflare/cfssl/releases/download/v1.6.1/cfssl-certinfo_1.6.1_linux_amd64

mv cfssl_1.6.1_linux_amd64 cfssl
mv cfssl-certinfo_1.6.1_linux_amd64 cfssl-certinfo
mv cfssljson_1.6.1_linux_amd64 cfssljson
chmod 755 cfssl*

mv cfssl* /usr/local/bin/


# ETCD下载
wget https://github.com/etcd-io/etcd/releases/download/v3.5.4/etcd-v3.5.4-linux-amd64.tar.gz
tar zxvf etcd-v3.5.4-linux-amd64.tar.gz

cd etcd-v3.5.4-linux-amd64/
mv etcd* /usr/local/bin/
chmod 755 /usr/local/bin/etcd*

创建etcd数据目录

包含启动后自动创建的 etcd 数据文件目录 /data/etcd_2500/member 与集群证书目录 /data/etcd_2500/ssl .

每台节点都需要创建

mkdir -p /data/etcd_2500/ssl
cd /data/etcd_2500/ssl

自签证书

CA证书

创建 ca-config.json 签发配置

{
  "signing": {
    "default": {
      "expiry": "438000h"
    },
    "profiles": {
      "kubernetes": {
        "usages": [
            "signing",
            "key encipherment",
            "server auth",
            "client auth"
        ],
        "expiry": "438000h"
      }
    }
  }
}

创建 CA 主体配置 ca-csr.json

{
  "CN": "CA",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "CN",
      "ST": "Guangdong",
      "L": "Guangzhou",
      "O": "etcd",
      "OU": "System"
    }
  ]
}

生成 CA 证书

cfssl gencert -initca ca-csr.json | cfssljson -bare ca

2022/05/26 00:51:01 [INFO] generating a new CA key and certificate from CSR
2022/05/26 00:51:01 [INFO] generate received request
2022/05/26 00:51:01 [INFO] received CSR
2022/05/26 00:51:01 [INFO] generating key: rsa-2048
2022/05/26 00:51:01 [INFO] encoded CSR
2022/05/26 00:51:01 [INFO] signed certificate with serial number 706048808790509849515892382867387858686891109238

此时生成证书文件

ca.csr
ca.pem
ca-key.pem
集群证书

创建集群证书配置 etcd-csr.json

{
  "CN": "etcd",
  "hosts": [
    "192.168.7.91",
    "192.168.7.92",
    "192.168.7.93",
    "*.nestealin.com",
    "127.0.0.1"
  ],
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "CN",
      "ST": "Guangdong",
      "L": "Guangzhou",
      "O": "etcd",
      "OU": "System"
    }
  ]
}

根据CA证书签发集群证书

cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=etcd etcd-csr.json | cfssljson -bare etcd

2022/05/26 00:53:35 [INFO] generate received request
2022/05/26 00:53:35 [INFO] received CSR
2022/05/26 00:53:35 [INFO] generating key: rsa-2048
2022/05/26 00:53:36 [INFO] encoded CSR
2022/05/26 00:53:36 [INFO] signed certificate with serial number 252487637386301147036590697689340402714034710017
2022/05/26 00:53:36 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").

/data/etcd_2500/ssl 目录拷贝至其他节点

Node1部署

创建 service 管理文件

注意关键参数:

–initial-cluster-state: 集群首次运行,需要设置为 new .

另外注意,因为有配置 --initial-cluster 默认集群信息,不急于单台配置完成后立刻启动,会导致连不上其他节点而启动失败。

vim /etc/systemd/system/etcd_2500.service
[Unit]
Description=Etcd Server
After=network.target
After=network-online.target
Wants=network-online.target
Documentation=https://github.com/etcd-io/etcd

[Service]
Type=notify
WorkingDirectory=/usr/local/bin
ExecStart=/usr/local/bin/etcd \
  --name=etcd1-cn \
  --cert-file=/data/etcd_2500/ssl/etcd.pem \
  --key-file=/data/etcd_2500/ssl/etcd-key.pem \
  --peer-cert-file=/data/etcd_2500/ssl/etcd.pem \
  --peer-key-file=/data/etcd_2500/ssl/etcd-key.pem \
  --trusted-ca-file=/data/etcd_2500/ssl/ca.pem \
  --peer-trusted-ca-file=/data/etcd_2500/ssl/ca.pem \
  --initial-advertise-peer-urls=https://etcd1-cn.nestealin.com:2501 \
  --listen-peer-urls=https://0.0.0.0:2501 \
  --listen-client-urls=https://0.0.0.0:2500 \
  --advertise-client-urls=https://etcd1-cn.nestealin.com:2500 \
  --initial-cluster-token=etcd-cluster-0 \
  --initial-cluster=etcd1-cn=https://etcd1-cn.nestealin.com:2501,etcd2-cn=https://etcd2-cn.nestealin.com:2501,etcd3-cn=https://etcd3-cn.nestealin.com:2501 \
  --initial-cluster-state=new \
  --data-dir=/data/etcd_2500 \
  --snapshot-count=50000 \
  --auto-compaction-retention=1 \
  --max-request-bytes=10485760 \
  --quota-backend-bytes=8589934592
Restart=always
RestartSec=15
LimitNOFILE=65536
OOMScoreAdjust=-999

[Install]
WantedBy=multi-user.target

CLI管理工具: /usr/local/bin/etcdcli

简化每次操作带上endpoint等信息。

通用工具,集群内节点同样配置使用。

#!/bin/bash
# -----
# ETCD-Cli-Ops-Tools.
# -----

# Input Var
USER_COMMAND=$@

# Env Info
ETCDCTL_API=3
ETCD_CERT_PATH=/data/etcd_2500/ssl
ETCD_DOMAINS=https://etcd1-cn.nestealin.com:2500,https://etcd2-cn.nestealin.com:2500,https://etcd3-cn.nestealin.com:2500
USAGE="Usage: `basename $0` (member list|endpoint health)"

# Common Command
COMMON_COMMAND="etcdctl --endpoints=$ETCD_DOMAINS --cacert=$ETCD_CERT_PATH/ca.pem  --cert=$ETCD_CERT_PATH/etcd.pem --key=$ETCD_CERT_PATH/etcd-key.pem"

# Functions
function help() {
    echo "$USAGE"
    echo "-------------"
    echo "Usage_Sample:"
    echo "Member List: `basename $0` member list"
    echo "Endpoint Health: `basename $0` endpoint health"
    echo "Put Data For Test: `basename $0` put /test/ok 11"
    echo "Get Data For Test: `basename $0` get /test/ok"
    echo "Delete Data For Test: `basename $0` del /test/ok"
}

# Entrance
case $USER_COMMAND in

    (-h|--help|help)
        help
        ;;
    (*)
        $COMMON_COMMAND $USER_COMMAND
        ;;
esac

Node2部署

  1. 下载etcd二进制、证书工具及证书文件至相应目录。
  2. 运行配置与Node1大同小异,将节点信息换成本机即可。
vim /etc/systemd/system/etcd_2500.service
[Unit]
Description=Etcd Server
After=network.target
After=network-online.target
Wants=network-online.target
Documentation=https://github.com/etcd-io/etcd

[Service]
Type=notify
WorkingDirectory=/usr/local/bin
ExecStart=/usr/local/bin/etcd \
  --name=etcd2-cn \
  --cert-file=/data/etcd_2500/ssl/etcd.pem \
  --key-file=/data/etcd_2500/ssl/etcd-key.pem \
  --peer-cert-file=/data/etcd_2500/ssl/etcd.pem \
  --peer-key-file=/data/etcd_2500/ssl/etcd-key.pem \
  --trusted-ca-file=/data/etcd_2500/ssl/ca.pem \
  --peer-trusted-ca-file=/data/etcd_2500/ssl/ca.pem \
  --initial-advertise-peer-urls=https://etcd2-cn.nestealin.com:2501 \
  --listen-peer-urls=https://0.0.0.0:2501 \
  --listen-client-urls=https://0.0.0.0:2500 \
  --advertise-client-urls=https://etcd2-cn.nestealin.com:2500 \
  --initial-cluster-token=etcd-cluster-0 \
  --initial-cluster=etcd1-cn=https://etcd1-cn.nestealin.com:2501,etcd2-cn=https://etcd2-cn.nestealin.com:2501,etcd3-cn=https://etcd3-cn.nestealin.com:2501 \
  --initial-cluster-state=new \
  --data-dir=/data/etcd_2500 \
  --snapshot-count=50000 \
  --auto-compaction-retention=1 \
  --max-request-bytes=10485760 \
  --quota-backend-bytes=8589934592
Restart=always
RestartSec=15
LimitNOFILE=65536
OOMScoreAdjust=-999

[Install]
WantedBy=multi-user.target

Node3部署

与Node2做法一致

[Unit]
Description=Etcd Server
After=network.target
After=network-online.target
Wants=network-online.target
Documentation=https://github.com/etcd-io/etcd

[Service]
Type=notify
WorkingDirectory=/usr/local/bin
ExecStart=/usr/local/bin/etcd \
  --name=etcd3-cn \
  --cert-file=/data/etcd_2500/ssl/etcd.pem \
  --key-file=/data/etcd_2500/ssl/etcd-key.pem \
  --peer-cert-file=/data/etcd_2500/ssl/etcd.pem \
  --peer-key-file=/data/etcd_2500/ssl/etcd-key.pem \
  --trusted-ca-file=/data/etcd_2500/ssl/ca.pem \
  --peer-trusted-ca-file=/data/etcd_2500/ssl/ca.pem \
  --initial-advertise-peer-urls=https://etcd3-cn.nestealin.com:2501 \
  --listen-peer-urls=https://0.0.0.0:2501 \
  --listen-client-urls=https://0.0.0.0:2500 \
  --advertise-client-urls=https://etcd3-cn.nestealin.com:2500 \
  --initial-cluster-token=etcd-cluster-0 \
  --initial-cluster=etcd1-cn=https://etcd1-cn.nestealin.com:2501,etcd2-cn=https://etcd2-cn.nestealin.com:2501,etcd3-cn=https://etcd3-cn.nestealin.com:2501 \
  --initial-cluster-state=new \
  --data-dir=/data/etcd_2500 \
  --snapshot-count=50000 \
  --auto-compaction-retention=1 \
  --max-request-bytes=10485760 \
  --quota-backend-bytes=8589934592
Restart=always
RestartSec=15
LimitNOFILE=65536
OOMScoreAdjust=-999

[Install]
WantedBy=multi-user.target

集群启动

分别在每台节点执行如下命令

systemctl daemon-reload
service etcd_2500 start
service etcd_2500 status

集群验证

ETCDCTL方式

# 环境声明
ETCD_CERT_PATH=/data/etcd_2500/ssl
PORT=2500
ETCD1=etcd1-cn.nestealin.com
ETCD2=etcd2-cn.nestealin.com
ETCD3=etcd3-cn.nestealin.com

# 成员信息,增加 --write-out=table / -w table 参数可以表格形式输出
ETCDCTL_API=3 etcdctl \
--endpoints=https://$ETCD1:$PORT,https://$ETCD2:$PORT,https://$ETCD3:$PORT \
--cacert="$ETCD_CERT_PATH/ca.pem" \
--cert="$ETCD_CERT_PATH/etcd.pem" \
--key="$ETCD_CERT_PATH/etcd-key.pem" \
member list --write-out=table


# 集群健康检查
ETCDCTL_API=3 etcdctl \
--endpoints=https://$ETCD1:$PORT,https://$ETCD2:$PORT,https://$ETCD3:$PORT \
--cacert="$ETCD_CERT_PATH/ca.pem" \
--cert="$ETCD_CERT_PATH/etcd.pem" \
--key="$ETCD_CERT_PATH/etcd-key.pem" \
endpoint health


# 写入数据
ETCDCTL_API=3 etcdctl \
--endpoints=https://$ETCD1:$PORT,https://$ETCD2:$PORT,https://$ETCD3:$PORT \
--cacert="$ETCD_CERT_PATH/ca.pem" \
--cert="$ETCD_CERT_PATH/etcd.pem" \
--key="$ETCD_CERT_PATH/etcd-key.pem" \
put /test/ok 11


# 查询数据示例
ETCDCTL_API=3 etcdctl \
--endpoints=https://$ETCD1:$PORT,https://$ETCD2:$PORT,https://$ETCD3:$PORT \
--cacert="$ETCD_CERT_PATH/ca.pem" \
--cert="$ETCD_CERT_PATH/etcd.pem" \
--key="$ETCD_CERT_PATH/etcd-key.pem" \
get /test/ok


# 删除数据示例
ETCDCTL_API=3 etcdctl \
--endpoints=https://$ETCD1:$PORT,https://$ETCD2:$PORT,https://$ETCD3:$PORT \
--cacert="$ETCD_CERT_PATH/ca.pem" \
--cert="$ETCD_CERT_PATH/etcd.pem" \
--key="$ETCD_CERT_PATH/etcd-key.pem" \
del /test/ok

CURL验证

# 检查集群版本
curl --cacert /data/etcd_2500/ssl/ca.pem --cert /data/etcd_2500/ssl/etcd.pem --key /data/etcd_2500/ssl/etcd-key.pem -sL https://etcd1-cn.nestealin.com:2501/version

# 查看member信息
curl --cacert /data/etcd_2500/ssl/ca.pem --cert /data/etcd_2500/ssl/etcd.pem --key /data/etcd_2500/ssl/etcd-key.pem -sL https://etcd1-cn.nestealin.com:2501/members

ETCDCLI验证

# 省略声明证书、endpoint地址,简化操作
etcdcli member list --write-out=table
etcdcli endpoint status -w=table

常用操作

以下操作以 ETCDCLI 方式操作为例。

增加节点

以下事例为新增一台 Learner 节点;

  1. 添加内网解析;
  2. 参照Node2部署,拷贝证书、ETCD相关二进制程序、证书工具、service文件等至相关目录。

新签证书

由于集群通信还是使用IP,所以每新增一台节点都要修改一次集群证书配置,并重新签发集群证书。

cd /data/etcd_2500/ssl
vim /data/etcd_2500/ssl/etcd-csr.json
{
  "CN": "etcd",
  "hosts": [
    "192.168.7.91",
    "192.168.7.92",
    "192.168.7.93",
    "192.168.7.35",
    "*.nestealin.com",
    "127.0.0.1"
  ],
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "CN",
      "ST": "Guangdong",
      "L": "Guangzhou",
      "O": "etcd",
      "OU": "System"
    }
  ]
}

重新签发集群证书

cfssl gencert -ca=/data/etcd_2500/ssl/ca.pem -ca-key=/data/etcd_2500/ssl/ca-key.pem -config=/data/etcd_2500/ssl/ca-config.json -profile=kubernetes /data/etcd_2500/ssl/etcd-csr.json | cfssljson -bare etcd

向集群注册新节点,在当前集群任意节点操做即可

如果是新增 member 节点,则去掉 --learner 参数即可。

# 语法格式: peer-urls声明新节点peer连接地址,member add $节点名称
etcdcli --peer-urls=https://etcd4-cn.nestealin.com:2501 member add etcd4-cn --learner
  • 操作输出

允许加入集群,此时状态为待加入,并提示启动参数需要带上 ETCD_INITIAL_CLUSTER_STATE="existing" 而非作为 new 集群方式启动。

Member 7f42839e765cb6e7 added to cluster f0d6d791690a1a29

ETCD_NAME="etcd4-cn"
ETCD_INITIAL_CLUSTER="etcd1-cn=https://etcd1-cn.nestealin.com:2501,etcd3-cn=https://etcd3-cn.nestealin.com:2501,etcd4-cn=https://etcd4-cn.nestealin.com:2501,etcd2-cn=https://etcd2-cn.nestealin.com:2501"
ETCD_INITIAL_ADVERTISE_PEER_URLS="https://etcd4-cn.nestealin.com:2501"
ETCD_INITIAL_CLUSTER_STATE="existing"
  • 检查节点加入状态

此时因为新节点尚未启动,所以状态仍为unstarted,

etcdcli member list

1bd2d3991de8cd18, started, etcd1-cn, https://etcd1-cn.nestealin.com:2501, https://etcd1-cn.nestealin.com:2500, false
2e1a4370a35a09fd, started, etcd3-cn, https://etcd3-cn.nestealin.com:2501, https://etcd3-cn.nestealin.com:2500, false
7f42839e765cb6e7, unstarted, , https://etcd4-cn.nestealin.com:2501, , true
a3358166c4af2269, started, etcd2-cn, https://etcd2-cn.nestealin.com:2501, https://etcd2-cn.nestealin.com:2500, false

启动新节点

systemctl daemon-reload
service etcd_2500 start
service etcd_2500 status

检查集群状态

节点上线正常,且为Leaner角色

etcdcli member list

1bd2d3991de8cd18, started, etcd1-cn, https://etcd1-cn.nestealin.com:2501, https://etcd1-cn.nestealin.com:2500, false
2e1a4370a35a09fd, started, etcd3-cn, https://etcd3-cn.nestealin.com:2501, https://etcd3-cn.nestealin.com:2500, false
7f42839e765cb6e7, started, etcd4-cn, https://etcd4-cn.nestealin.com:2501, https://etcd4-cn.nestealin.com:2500, true
a3358166c4af2269, started, etcd2-cn, https://etcd2-cn.nestealin.com:2501, https://etcd2-cn.nestealin.com:2500, false

修改Cli文件,增加endpoint

vim /usr/local/bin/etcdcli
# 忽略其他相同内容,追加Node4客户端连接信息
ETCD_DOMAINS=https://etcd1-cn.nestealin.com:2500,https://etcd2-cn.nestealin.com:2500,https://etcd3-cn.nestealin.com:2500,https://etcd4-cn.nestealin.com:2500

检查集群同步状态

etcdcli endpoint status

https://etcd1-cn.nestealin.com:2500, 1bd2d3991de8cd18, 3.5.4, 25 kB, true, false, 2, 13, 13,
https://etcd2-cn.nestealin.com:2500, a3358166c4af2269, 3.5.4, 20 kB, false, false, 2, 13, 13,
https://etcd3-cn.nestealin.com:2500, 2e1a4370a35a09fd, 3.5.4, 20 kB, false, false, 2, 13, 13,
https://etcd4-cn.nestealin.com:2500, 7f42839e765cb6e7, 3.5.4, 20 kB, false, true, 2, 13, 13,

删除节点

# 语法 member remove $节点ID
etcdcli member remove 12d20db544264be2

关于监控

集群自带指标暴露,只需要在Prometheus添加对应Job即可

- job_name: etcd
    static_configs:
    - targets: ['192.168.7.91:2500','192.168.7.92:2500','192.168.7.93:2500']

监控指标访问测试

curl --cacert /data/etcd_2500/ssl/ca.pem --cert /data/etcd_2500/ssl/etcd.pem --key /data/etcd_2500/ssl/etcd-key.pem -sL https://etcd1-cn.nestealin.com:2500/metrics

可能遇到的问题

如有集群数据问题启动失败,请在关闭 ETCD 后直接删除 /data/etcd_2500/member/* 下内容,待调整完后重新启动即可。


相关文档

https://wghdr.top/archives/285

https://wiki.shileizcc.com/confluence/pages/viewpage.action?pageId=60227790


文章作者: NesTeaLin
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 NesTeaLin !
  目录