如何创建灵活的服务为CoreOS集群与Fleet单元文件

介绍

CoreOS安装利用了许多工具,使群集和Docker包含的服务易于管理。 etcd涉及衔接的独立的节点,并提供对全球的数据的区域,大多数的实际的服务管理和管理任务涉及与工作fleet守护程序。

先前的指导 ,我们过了的基本用法fleetctl命令操纵的服务和集群成员。 在该指南,我们触及简要单元上文件船队用于定义服务,但这些被简化用于提供一个工作服务学习的例子fleetctl

在本指南中,我们将探索fleet单元文件,深入了解如何创建和一些技术,使您的服务在生产中更加健壮。

先决条件

为了完成本教程中,我们将假定你是我们中所述配置的CoreOS集群集群指南 这将给你留下三个服务器命名为:

  • coreos-1
  • coreos-2
  • coreos-3

虽然本教程的大部分内容将集中在单元文件创建,这些机器将用于后来演示某些指令的调度影响。

我们还假设您已经阅读我们的指导如何使用fleetctl 你应该有一个工作知识fleetctl ,这样就可以提交和使用这些单元文件与集群。

完成这些要求后,请继续阅读本指南的其余部分。

单元文件段和类型

由于服务管理方面的fleet主要依靠每一个本地系统对systemd初始化系统, systemd单元文件用于定义服务。

虽然服务是使用CoreOS配置的最常见单元类型,但实际上还可以定义其他单元类型。 这些是那些可用于常规的子集systemd单元文件。 这些类型中的每一个由类型标识被用作文件的Stapling,如example.service

  • 服务 :这是单元文件的最常见的类型。 它用于定义可以在集群中的一台计算机上运行的服务或应用程序。
  • 插座 :定义关于插座或插座类文件的详细信息。 这些包括网络套接字,IPC套接字和FIFO缓冲区。 这些用于呼叫服务以在文件上看到流量时启动。
  • 设备 :定义有关在udev设备树可用设备的信息。 Systemd将根据udev规则在内核设备的各个主机上根据需要创建这些文件。 这些通常用于订购问题,以确保设备在尝试安装之前可用。
  • 安装 :定义有关的安装点设备的信息。 这些以它们引用的安装点命名,斜线替换为斜线。
  • 挂载 :定义一个挂载点。 它们遵循与安装单元相同的命名约定,并且必须伴随相关的安装单元。 这些用于描述按需和并行安装。
  • 计时器 :定义与另一个单元相关联的计时器。 当达到此文件中定义的时间点时,将启动相关单元。
  • 路径 :定义可用于基于路径的活化被监控的路径。 当对某条路径进行更改时,可用于启动另一个单元。

虽然这些选项都可用,但服务单位将最常使用。 在本指南中,我们将仅讨论服务单元配置。

单位文件是以点和一个上述Stapling结尾的简单文本文件。 在内部,它们由部分组织。 对于fleet ,大多数单位的文件将具有以下一般格式为:

[Unit]
generic_unit_directive_1
generic_unit_directive_2

[Service]
service_specific_directive_1
service_specific_directive_2
service_specific_directive_3

[X-Fleet]
fleet_specific_directive

节头和单元文件中的其他内容区分大小写。 [Unit]部分用于定义有关单位的一般信息。 所有单位类型通用的选项通常放在这里。

[Service]部分用于设置特定于服务单位的指令。 上面的大多数(但不是全部)单元类型具有用于单元类型特定信息的相关部分。 退房通用systemd单元文件手册页的链接不同的单位类型看更多的资料。

[X-Fleet]部分用于设置单位为使用调度要求fleet 使用此部分,您可以要求某些条件为真,以便在主机上调度单元。

建设主要服务

在本节中,我们将在我们所描述的单元文件的变体开始对CoreOS运行的服务的基本指南 该文件称为apache.1.service ,将是这样的:

[Unit]
Description=Apache web server service

# Requirements
Requires=etcd.service
Requires=docker.service
Requires=apache-discovery.1.service

# Dependency ordering
After=etcd.service
After=docker.service
Before=apache-discovery.1.service

[Service]
# Let processes take awhile to start up (for first run Docker containers)
TimeoutStartSec=0

# Change killmode from "control-group" to "none" to let Docker remove
# work correctly.
KillMode=none

# Get CoreOS environmental variables
EnvironmentFile=/etc/environment

# Pre-start and Start
## Directives with "=-" are allowed to fail without consequence
ExecStartPre=-/usr/bin/docker kill apache
ExecStartPre=-/usr/bin/docker rm apache
ExecStartPre=/usr/bin/docker pull username/apache
ExecStart=/usr/bin/docker run --name apache -p ${COREOS_PUBLIC_IPV4}:80:80 \
username/apache /usr/sbin/apache2ctl -D FOREGROUND

# Stop
ExecStop=/usr/bin/docker stop apache

[X-Fleet]
# Don't schedule on the same machine as other Apache instances
X-Conflicts=apache.*.service

我们先从[Unit]部分。 这里,基本思想是描述单元并且放置依赖性信息。 我们从一组要求开始。 我们对这个例子使用了严格的要求。 如果我们想fleet试图启动其他服务,但失败不会停止,我们可以用Wants的指令来代替。

然后,我们明确列出了需求的顺序。 这是重要的,这样先决条件服务在需要时可用。 这也是我们自动启动sidekick etcd宣布我们将要建设的服务的方式。

对于[Service]部分中,我们关掉服务启动超时。 第一次在主机上运行服务时,容器将必须从Docker注册表中下拉,这将计入启动超时。 这默认为90秒,这通常有足够的时间,但使用更复杂的容器,可能需要更长的时间。

然后我们将killmode设置为none。 这是使用,因为正常的击杀方式(控制组)将有时会导致容器取出命令失败(由Docker试图尤其是当--rm选项)。 这可能会导致下次重新启动时出现问题。

我们拉环境文件,使我们有机会获得COREOS_PUBLIC_IPV4 ,如果在创建过程中启用专用网络中, COREOS_PRIVATE_IPV4环境变量。 这些对于配置Docker容器以使用其特定主机的信息非常有用。

ExecStartPre线用来推倒以前的所有遗留克鲁夫特,以确保执行环境是干净的。 我们使用=-对前两项,表明systemd应该忽略并继续,如果这些命令将失败。 因此,Docker将尝试杀死和删除以前的容器,但不会担心,如果它找不到任何。 最后一个预启动用于确保正在运行容器的最新版本。

实际的启动命令引导Docker容器并将其绑定到主机的公共IPv4接口。 这使用环境文件中的信息,并使其轻松切换接口和端口。 该进程在前台运行,因为如果正在运行的进程结束,容器将退出。 stop命令试图正常停止容器。

[X-Fleet]部分包含一个简单的条件下,部队fleet安排了尚未运行另一个Apache服务的计算机上的服务。 这是一种通过强制重复服务在单独的计算机上启动来使服务高度可用的简单方法。

建筑主要服务的基本建设

在上面的例子中,我们讨论了一个相当基本的配置。 然而,我们可以从中学到很多经验教训,以帮助我们建立一般的服务。

构建主服务时要记住的一些行为:

  • 相关性和排序独立的逻辑 :铺陈你的依赖与Requires=Wants=依赖,如果依赖无法实现你正在建设单位是否应该失败指令。 分离出独立的订购After=Before=行,这样,如果需求发生变化,你可以轻松地调整。 将依赖列表与顺序分离可以帮助您在依赖性问题的情况下进行调试。
  • 处理与一个单独的进程服务注册 :您的服务应该与注册etcd 。利用服务发现和动态配置功能,这允许然而 ,这应该由一个独立的“搭档”容器处理,以保持逻辑分离。 这将允许您从外部角度更准确地报告服务的运行状况,这是其他组件将需要的。
  • 要知道你的服务超时的可能性 :考虑调整TimeoutStartSec ,以允许更长的启动时间指令。 将此设置为“0”将禁用启动超时。 这通常是必要的,因为有时Docker必须提取一个镜像(在首次运行或更新被找到),这可能增加了大量的时间来初始化服务。
  • 调整KillMode如果你的服务不干净,止损 :须注意的KillMode选择,如果你的服务或容器似乎不清洁停止。 将此设置为“无”有时可以解决您的容器在停止后不会被删除的问题。 这在您命名容器时尤其重要,因为如果上一次运行时遗留了同名的容器,则Docker将失败。 退房的KillMode文档的详细信息
  • 清理启动前环境 :与以上项目,确保在每次启动清理以前的Docker容器。 您不应该假定服务的先前运行已按预期退出。 这些清理线应使用=-符允许如果不需要清理他们默默地失败。 虽然你应该停止使用容器docker stop正常,你应该使用docker kill清理过程中。
  • 拉和使用服务的便携主机特定的信息 :如果你需要你的服务绑定到特定的网络接口,拉中/etc/environment文件,以获得进入COREOS_PUBLIC_IPV4 ,如果配置, COREOS_PRIVATE_IPV4 如果你需要知道机器的主机名运行你的服务,使用%H systemd说明。 要了解更多有关可能说明符,检查出systemd符文档 [X-Fleet]段内,只有%n%N%i%p符会工作。

建立Sidekick宣布服务

现在,我们有一个好主意,当建立一个主要服务时,要记住,我们可以开始看一个传统的“sidekick”服务。 这些搭档服务与一个主服务相关联,并且被用作外部点与注册服务etcd

这个文件,因为它是在主机文件中引用,被称为apache-discovery.1.service ,看起来像这样:

[Unit]
Description=Apache web server etcd registration

# Requirements
Requires=etcd.service
Requires=apache.1.service

# Dependency ordering and binding
After=etcd.service
After=apache.1.service
BindsTo=apache.1.service

[Service]

# Get CoreOS environmental variables
EnvironmentFile=/etc/environment

# Start
## Test whether service is accessible and then register useful information
ExecStart=/bin/bash -c '\
  while true; do \
    curl -f ${COREOS_PUBLIC_IPV4}:80; \
    if [ $? -eq 0 ]; then \
      etcdctl set /services/apache/${COREOS_PUBLIC_IPV4} \'{"host": "%H", "ipv4_addr": ${COREOS_PUBLIC_IPV4}, "port": 80}\' --ttl 30; \
    else \
      etcdctl rm /services/apache/${COREOS_PUBLIC_IPV4}; \
    fi; \
    sleep 20; \
  done'

# Stop
ExecStop=/usr/bin/etcdctl rm /services/apache/${COREOS_PUBLIC_IPV4}

[X-Fleet]
# Schedule on the same machine as the associated Apache service
X-ConditionMachineOf=apache.1.service

我们以与我们做主服务相同的方式启动sidekick服务。 我们描述单元在移动到依赖关系信息和排序逻辑之前的目的。

这里的第一个新项目是BindsTo=指令。 此指令使本机遵循发送到所列单元的启动,停止和重新启动命令。 基本上,这意味着我们可以通过这两个曾经被装载到操控主机同时管理这些单位的fleet 这是一个单向机制,所以控制sidekick不会影响主单元。

对于[Service]部分中,我们再次源/etc/environment文件,因为我们需要它持有的变量。 ExecStart=在这种情况下指令基本上是一个简短bash脚本。 它尝试使用所暴露的接口和端口连接到主要服务。

如果连接成功,则etcdctl命令用于设置内的主机的公共IP地址的关键/services/apacheetcd 此值是包含有关服务的信息的JSON对象。 关键是设定,所以如果本机意外关闭,陈旧的服务信息将不会被留在在30秒内到期etcd 如果连接失败,则会立即删除密钥,因为该服务无法验证为可用。

该循环包括20秒的休眠命令。 这意味着,每20秒(30秒前etcd密钥超时),这个单位再检查是否主机可用并重置键。 这基本上刷新密钥上的TTL,以便它将被认为有效另外30秒。

在这种情况下,停止命令只会导致手动删除密钥。 这将导致由于当主设备的停止命令被镜像到本机删除服务注册BindsTo=指令。

[X-Fleet]节中,我们需要确保本机启动在同一台服务器主机上。 虽然这不会让设备上的服务到远程机器的可用性报告,对于重要的是BindsTo=指令才能正常工作。

基本建设Sidekick服务

在建立这个伙伴关系时,我们可以看到我们应该记住的一些事情作为这些类型的单位的一般规则:

  • 检查主机的实际可用性 :实际检查主机的状态是很重要的。 不要认为主机是可用的,只是因为sidekick已经初始化。 这取决于主机的设计和功能,但是更加稳健的检查,您的注册状态将更可信。 检查可以是任何有意义的单元,从检查/health端点尝试与客户端连接到一个数据库中。
  • 河套注册逻辑要定期重新检查 :检查服务的可用性在启动是重要的,但它也是必不可少的,你重新检查定期。 这可以捕获意外的服务故障的实例,特别是如果他们不知何故导致容器不停止。 循环之间的暂停必须根据您的需要进行调整,通过衡量快速发现对主机上附加负载的重要性。
  • Sidekick的装置意外故障可能导致陈旧发现信息: 与ETCD上注册失败自动注销登记时,使用TTL标志 etcd 为了避免服务的注册和实际状态之间的冲突,您应该让您的密钥超时。 使用上面的循环结构,您可以在超时间隔之前刷新每个键,以确保该键永远不会在sidekick运行时过期。 循环中的睡眠间隔应设置为略小于超时间隔,以确保此功能正确。
  • 注册有用的信息与ETCD,不只是一个确认 :在你的一个陪衬的第一次迭代,你可能只是有兴趣与准确登记etcd机组启动时。 然而,这是一个错过的机会,为其他服务提供大量有用的信息。 虽然你可能不是现在需要这些信息,为您打造其它组件与读取值的能力就会变得更加有用etcd适合自己的配置。 etcd服务是一个全球性的key-value存储,所以不要忘记通过提供关键信息利用这一点。 在JSON对象中存储细节是传递多个信息的好方法。

在牢记这些考虑因素,你就可以开始构建健壮的注册单位,将能够智能地确保etcd有正确的信息。

Fleet特定的注意事项

虽然fleet单元文件是大部分没有与传统不同的systemd单元文件,还有一些附加的功能和陷阱。

最明显的区别是增加了一个称为部分的[X-Fleet]可用于直接fleet如何做出调度决策。 可用的选项有:

  • 的X ConditionMachineID:这可以用于指定确切机加载单元。 提供的值是完整的机器ID。 这个值可以从集群中的个体成员通过检查被检索/etc/machine-id文件,或通过fleetctl通过发出list-machines -l命令。 整个ID字符串是必需的。 如果您运行的数据库目录保存在特定计算机上,则可能需要这样做。 除非你有特定的理由使用它,尽量避免它,因为它降低了单位的灵活性。
  • 的X ConditionMachineOf:这个指令可以用来装有指定的单位在同一台机器上安排此单位。 这对于s​​idekick单元或用于将相关联的单元合并在一起是有帮助的。
  • 的X冲突 :这是上面的声明相反,因为它指定了该单位不能如期旁边单元文件。 这对于通过启动同一服务的多个版本来容易地配置高可用性很有用,每个版本在不同的机器上。
  • 的X ConditionMachineMetadata:这是用于指定根据可用的机器的元数据的调度要求。 在的“元数据”列fleetctl list-machines输出,可以看到已设置为每个主机的元数据。 要设置的元数据,通过它在你的cloud-config初始化服务器实例时文件。
  • 全球 :这是一个特殊的指令,需要指明这是否应在集群中的所有机器上安排一个boolean参数。 只有元数据条件可以与此指令一起使用。

这些附加指令使管理员在定义如何在可用机器上运行服务时具有更大的灵活性和能力。 这些都是之前将它们传递给特定计算机的评估systemd期间实例fleetctl load阶段。

这给我们带来了接下来的事情要注意与相关单位工作时的fleet fleetctl实用程序不评估之外依赖性要求[X-Fleet]单元文件的部分。 在同伴的单位时,这导致了一些有趣的问题fleet

这意味着,尽管fleetctl工具将采取必要步骤以目标单元获取到所需的状态,通过提出,装载步进,并根据需要根据给定的命令开始的过程,它不会对的依赖性要这样做那个单位。

所以,如果你有两个主和搭档单位提交,但并没有载入,在fleet ,打字fleetctl start main.service将加载,然后尝试启动main.service单位。 然而,由于sidekick.service单元尚未装载,并且因为fleetctl不会评估相关性信息,使通过装载和起动过程的依赖单位, main.service单元将失败。 这是因为,一旦本机的systemd实例处理main.service单元,它将不能够找到sidekick.service它的计算结果的依赖性。 sidekick.service单位从未装在机器上。

与同伴单位打交道时避免这种情况,你可以在同一时间手动启动服务,而不是依靠BindsTo=指令使老搭档成运行状态:

fleetctl start main.service sidekick.service

另一个选择是确保当主单元运行时,副单元至少装载。 装载阶段是其中选择一种机器和单元文件被提交给本地systemd实例。 这将确保依赖性得到满足,而且BindsTo=指令就能够正确执行带来了第二单元:

fleetctl load main.service sidekick.service
fleetctl start main.service

在您的相关单位没有正确响应您的事件要记住这一点fleetctl命令。

实例和模板

其中一个最有力的概念与工作时fleet是单位的模板。

单元模板依赖于一个特点systemd称为“实例”。 这些是在运行时通过处理模板单元文件创建的实例化单元。 模板文件的大部分与常规单位文件非常相似,只有少量修改。 然而,当正确使用时,这些是非常强大的。

模板文件可以由被识别@在其文件名。 虽然常规服务采用这种形式:

unit.service

模板文件可以如下所示:

unit@.service

当一个单元被从模板实例化,其实例标识符被置于之间@.serviceStapling。 此标识符是管理员选择的唯一字符串:

unit@instance_id.service

基座单元名称可以从由单元文件内访问%p说明符。 同样地,给定的实例标识符可以与被访问%i

主单元文件作为模板

这意味着,而不是创建一个名为您的主机文件apache.1.service与我们前面看到的内容,你可以创建一个模板叫apache@.service ,看起来像这样:

[Unit]
Description=Apache web server service on port %i

# Requirements
Requires=etcd.service
Requires=docker.service
Requires=apache-discovery@%i.service

# Dependency ordering
After=etcd.service
After=docker.service
Before=apache-discovery@%i.service

[Service]
# Let processes take awhile to start up (for first run Docker containers)
TimeoutStartSec=0

# Change killmode from "control-group" to "none" to let Docker remove
# work correctly.
KillMode=none

# Get CoreOS environmental variables
EnvironmentFile=/etc/environment

# Pre-start and Start
## Directives with "=-" are allowed to fail without consequence
ExecStartPre=-/usr/bin/docker kill apache.%i
ExecStartPre=-/usr/bin/docker rm apache.%i
ExecStartPre=/usr/bin/docker pull username/apache
ExecStart=/usr/bin/docker run --name apache.%i -p ${COREOS_PUBLIC_IPV4}:%i:80 \
username/apache /usr/sbin/apache2ctl -D FOREGROUND

# Stop
ExecStop=/usr/bin/docker stop apache.%i

[X-Fleet]
# Don't schedule on the same machine as other Apache instances
X-Conflicts=apache@*.service

正如你所看到的,我们已经修改了apache-discovery.1.service依赖是apache-discovery@%i.service 这将意味着,如果我们有一个名为本机文件的一个实例apache@8888.service ,这将需要一个名为搭档apache-discovery@8888.service %i已取代实例标识符。 在这种情况下,我们使用标识符来保存有关我们的服务运行方式的动态信息,特别是Apache服务器可用的端口。

为了使这项工作,我们正在改变docker run暴露容器的端口主机上的端口参数。 在静态单元文件,我们使用的参数是${COREOS_PUBLIC_IPV4}:80:80 ,其中映射端口80的容器的给主机的端口80的公共IPv4接口上。 在这个模板文件中,我们与这个替代${COREOS_PUBLIC_IPV4}:%i:80 ,因为我们正在使用的实例标识符告诉我们使用什么端口。 对实例标识符的聪明选择意味着您的模板文件中更大的灵活性。

Docker名称本身也被修改,因此它还使用基于实例ID的唯一容器名称。 请记住,Docker容器不能使用@符号,所以我们不得不选择从本机的文件不同的名称。 我们修改在Docker容器上操作的所有指令。

[X-Fleet]部分中,我们还修改了调度信息来识别,而不是我们之前使用静态类型的实例化的这些单位。

Sidekick单位作为模板

我们可以运行类似的过程,以适应我们的sidekick单位模板。

我们新的伙伴单位将被称为apache-discovery@.service ,将是这样的:

[Unit]
Description=Apache web server on port %i etcd registration

# Requirements
Requires=etcd.service
Requires=apache@%i.service

# Dependency ordering and binding
After=etcd.service
After=apache@%i.service
BindsTo=apache@%i.service

[Service]

# Get CoreOS environmental variables
EnvironmentFile=/etc/environment

# Start
## Test whether service is accessible and then register useful information
ExecStart=/bin/bash -c '\
  while true; do \
    curl -f ${COREOS_PUBLIC_IPV4}:%i; \
    if [ $? -eq 0 ]; then \
      etcdctl set /services/apache/${COREOS_PUBLIC_IPV4} \'{"host": "%H", "ipv4_addr": ${COREOS_PUBLIC_IPV4}, "port": %i}\' --ttl 30; \
    else \
      etcdctl rm /services/apache/${COREOS_PUBLIC_IPV4}; \
    fi; \
    sleep 20; \
  done'

# Stop
ExecStop=/usr/bin/etcdctl rm /services/apache/${COREOS_PUBLIC_IPV4}

[X-Fleet]
# Schedule on the same machine as the associated Apache service
X-ConditionMachineOf=apache@%i.service

我们已经经历了相同的步骤,要求和绑定到主单元进程的实例化版本,而不是静态版本。 这将使实例化的副单元与正确的实例化的主单元匹配。

curl的命令,当我们正在检查该服务的实际可用性,我们与即时ID代替静态端口80,这样它连接到正确的位置。 这是必要的,因为我们在我们的主单元的Docker命令中改变了端口曝光映射。

我们还修改了“口”被记录到etcd以便它使用同样的实例ID。 这种变化,在所设置的JSON数据etcd完全是动态的。 它将拾取主机名,IP地址和运行服务的端口。

最后,我们改变条件在[X-Fleet]再次节。 我们需要确保此进程在与主单元实例相同的机器上启动。

从模板实例化单位

要从模板文件实际实例化单位,您有几个不同的选项。

双方fleetsystemd可以处理符号链接,这让我们创建一个具有完整实例ID的模板文件,像这样的链接的选项:

ln -s apache@.service apache@8888.service
ln -s apache-discovery@.service apache-discovery@8888.service

这将创建两个环节,称为apache@8888.serviceapache-discovery@8888.service 每一种都所需要的信息fleetsystemd立即运行这些单位。 但是,它们指向模板,以便我们可以在一个地方进行任何所需的更改。

然后,我们可以提交,加载或启动这些服务与fleetctl是这样的:

fleetctl start apache@8888.service apache-discovery@8888.service

如果你不希望使符号链接来定义你的情况下,另一种选择是对他们自己提交模板到fleetctl ,就像这样:

fleetctl submit apache@.service apache-discovery@.service

您可以从实例化这些模板单位从内部右fleetctl只需在运行时分配的实例标识符。 例如,您可以通过键入以下内容获得相同的服务:

fleetctl start apache@8888.service apache-discovery@8888.service

这消除了对符号链接的需要。 一些管理员喜欢链接机制,因为这意味着您随时可以使用实例文件。 它也可以让你在一个目录传递给fleetctl让一切则立即启动。

举例来说,在工作目录,你可能有一个子目录叫做templates您的模板文件和子目录称为instances的实例链接的版本。 你甚至可以有一个叫做static的非模板单位。 你可以这样做:

mkdir templates instances static

然后,您可以将您的静态文件为static和模板文件到templates

mv apache.1.service apache-discovery.1.service static
mv apache@.service apache-discovery@.service templates

从这里,您可以创建所需的实例链接。 让我们运行我们的服务端口555566667777

cd instances
ln -s ../templates/apache@.service apache@5555.service
ln -s ../templates/apache@.service apache@6666.service
ln -s ../templates/apache@.service apache@7777.service
ln -s ../templates/apache-discovery@.service apache-discovery@5555.service
ln -s ../templates/apache-discovery@.service apache-discovery@6666.service
ln -s ../templates/apache-discovery@.service apache-discovery@7777.service

然后,您可以通过键入以下内容一次启动所有实例:

cd ..
fleetctl start instances/*

这可以非常有用启动您的服务快速。

结论

您应该对如何建立单元文件一个体面的理解fleet由这点。 通过采取一些单位的文件中可用的动态功能的优势,可以确保你的服务是均匀分布的,接近他们的依赖,并注册了有用的信息etcd

后来指导 ,我们将介绍如何配置容器使用您使用注册信息etcd 这可以帮助您的服务建立实际部署环境的工作知识,以便将请求传递到后端中的相应容器。

赞(52) 打赏
未经允许不得转载:优客志 » 系统运维
分享到:

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏