部署微服务

本文详细探讨了五种微服务部署模式:编程语言特定的发布包模式、虚拟机部署、容器部署、Kubernetes部署和Serverless部署。每种模式都有其优缺点,适合不同阶段和技术需求。Kubernetes成为大型系统首选,Serverless则为特定场景提供解决方案。

部署一般涉及到两个互相关联的概念:流程和架构。

  • 部署流程包括一些由开发人员和运维人员执行的步骤,以便将软件投入到生产环境。
  • 部署架构定义了该软件运行的环境结构。

下图说明了重量级、长生命周期的物理机已被越来越多轻量级、短声明周期的技术所抽象:
在这里插入图片描述

下面结合自己的工作经历,回顾下部署流程和架构的演进路线:

  • 早先开发人员将代码和配置脚本扔给测试人员,测试通过后,在提交代码和生产环境的配置给到运维人员,由运维人员进行手动或者借助脚本的半自动化部署。此时运维人员一般要登录应用服务器的控制台(例如Weblogic、WebSphere)来部署应用程序,他们会非常关心这些机器,就像对待宠物一样。
  • 后来很多项目甚至电信核心的系统都用开源的轻量级的Web容器Tomcat替换掉昂贵的应用程序服务器,同时虚拟机开始取代物理机。
  • 在后来就是采用CC或者Jenkins之类的持续集成工具应用于开发、测试和生产环境,小项目团队一般自行负责生产环境的部署,从工具流层面逐渐实践DevOps理念。
  • 最近一般大型的系统慢慢采用容器化部署方式,部署到kubernetes环境下。此时容器是一种虚拟机之上的更轻量级的抽象层,它们被视为一次性的资源而不再是宠物,更像是奶牛,出现故障或者使用过后,它们就被丢弃和重建,而不是重新配置。

最近一些公有云厂商提供了基于kubernetes的更轻量级的Serverless部署平台,使得租户不再关注购买了多少台云主机,不再需要自己构建kubernetes环境,只需要按厂商提供的规范、开发接入并部署,后续的运维、监控都无需介入。

部署流程和架构的更新换代,与微服务架构的日益普及几乎同时发生,这绝非是一种巧合。应用程序可能会有数十种语言和框架编写的服务。因为每个服务都是一个小应用程序,这就意味着在生产环境中有数十个应用程序。因此,让系统管理员手动配置服务器和服务已不再可行。如果要大规模部署微服务,则需要高度自动化的部署流程和基础设施。

下图显示了一个生产环境的抽象视图:生产环境使开发人员能够配置和管理他们的服务。使用部署流水线部署新版本的服务,以及用户访问这些服务实现的功能。
image
生产环境比较实现四个关键的功能:

  1. 服务管理接口:使开发人员能够创建、更新和配置服务。提供可供命令行和图形部署工具调用的Rest API。
  2. 运行时服务管理:确保始终运行着所需数量的服务实例。
  3. 监控:让开发人员能够了解服务正在做入门,包括日志文件和各种监控指标。如果出现问题,必须提醒开发人员。也就是所谓的可观测性。
  4. 请求路由:将用户的请求路由到服务。

1 部署模式

下面讲解5种基本的部署模式,每个模式在特定的时期都是阶段性正确的。其中所说的不足也仅表示站在今天的技术角度去看而已,不存在优劣之分。

1.1 编程语言特定的发布包模式

对于特定语言的应用程序,都需要安装必要的运行环境。例如JDK或JRE。

下图说明了理想情况下,部署流水线自动构建可执行的JAR文件并将其部署到生产环境中。在生产环境中,每个服务受理都是运行在安装了JDK或JRE的计算机上的JVM中:
image

服务实例通常是单个进程,可以采用一台主机一个服务实例的方式,它的好处在于:

  1. 服务实例之间的隔离;
  2. 不会又资源的需求或者版本依赖之类的冲突;
  3. 单个服务只会最大化地消耗本地机器的资源;
  4. 可以直接监控、管理和部署每个服务实例;

当然这种方式会导致资源利用率不足,此时可以采用同一台计算机上部署多个服务实例:
在这里插入图片描述

也可以在单个进程中运行多个服务实例。例如下图中单个Tomcat上运行了多个Java服务:
image

当然这种多实例部署的方式,其优缺点刚好和一台主机一个实例的相反。

优点
  • 快速部署:将服务复制到主机并快速启动它。对于Java语言编写的服务,只要复制Jar或者War文件。对于Go语言的,只要复制可执行文件。在任何一种情况下,需要通过网络复制的字节数相对较小。

    此外,启动服务耗时也较短。如果服务运行在自己独占的进程,则启动它。如果在同一个Web容器中部署了多个实例,则可以将其动态部署到Web容器中,或重启Web容器。通常情况下,启动速度都较快。

  • 高效的资源利用率:多个服务实例共享机器及其操作系统。若干多个服务实例在同一进程中运行,则资源利用率更高。

不足
  • 缺乏对技术栈的封装:运维团队必须了解部署每个服务的具体细节,例如不同的语言和框架,特定的版本等;开发人员与运维人员需要分享这些细节。这种沟通的复杂性增加了部署期间出错的风险。
  • 无约束服务实例消耗的资源:一个进程可能会消耗机器的所有CPU或内存,争用其他进程和操作系统的资源。
  • 在同一台计算机上运行多个服务实例时缺少隔离:行为不当的服务实例可能会影响其他服务实例。
  • 很难自动判断放置服务实例的位置:每台机器都有一组固定的CPU、内存等资源,而每个服务实例都需要一定的资源。如何以一种有效使用机器而不会使它过载的方式将服务实例分配给机器非常重要。每个机器应该部署多少个服务实例,需要人为判断,缺乏自动处理能力。

1.2 虚拟机部署

将服务实例打包成虚拟机镜像,然后按一台虚拟机一个服务实例的方式部署:
image
虚拟机镜像一般由构建流水线构建而来,比如采用Packer工具,它是一个现代的虚拟机镜像构建器,支持多种虚拟化技术,包括Amazon EC2, CloudStack, DigitalOcean, Docker, Google Compute Engine, Microsoft Azure, QEMU, VirtualBox, VMware等。

优点
  • 虚拟机镜像封装了技术栈:虚拟机镜像包含服务及其所有的依赖项。消除了错误来源,确保正确安装和设置服务运行所需的软件,像黑盒一样,封装服务的技术栈。在不同虚拟机之间可迁移性较强,但镜像格式也存在平台或厂商绑定的问题。
  • 隔离的服务实例:每个服务实例完全隔离。
  • 使用成熟的云技术基础设施:如果部署到公有云上,则可以利用云厂商成熟且高度自动化的云计算基础设施。例如跨虚拟机的流量负载均衡和自动扩展。
缺点
  • 资源利用率低:每个服务实例拥有一整台虚拟机的开销,包括其操作系统。一般的云厂商提供有限的虚拟机配置组合,例如4G 2核、8G 4核,对于6G的服务来说,就只能买稍微超配的8G的配置,这种对于一般稍重的Java服务来说不是太大的问题,但对于部署轻量级的Node.js或者Go来说,就比较浪费资源了。
  • 部署速度相对较慢:从构建虚拟机到部署虚拟机以及启动虚拟机,每个阶段都以分钟计算。
  • 系统管理的额外开销:我们不得不管理虚拟机的底层的操作系统和版本以及补丁升级之类的工作。而后面所讲的serverless就消除了这种系统管理的工作。

1.3 容器部署

容器是一种更现代、更轻量级的部署机制,是一种操作系统级别的虚拟化机制。通过不同的namespace来实现从进程、网络、文件系统等多个维度的隔离。每个容器就是一个沙箱:
image

下图显示了将服务部署为容器的过程。在构建时,部署流水线使用容器镜像构建工具(如Jenkins),读取服务代码和镜像描述,以创建容器镜像并将其存储在镜像仓库中。在运行时,从镜像仓库中拉取容器镜像,并用于创建容器。
image

Docker镜像的一般流程:

  1. 编写Dockerfile:
FROM openjdk:8u171-jre-alpine
RUN apk --no-cache add curl
CMD java ${JAVA_OPTS} -jar ftgo-restaurant-service.jar
HEALTHCHECK --start-period=30s --
       interval=5s CMD curl http://localhost:8080/actuator/health || exit 1
COPY build/libs/ftgo-restaurant-service.jar .
  1. 构建镜像
cd ftgo-restaurant-service
../gradlew assemble
docker build -t ftgo-restaurant-service .
  1. 上传镜像
docker tag ftgo-restaurant-service registry.acme.com/ftgo-restaurant-
     service:1.0.0.RELEASE
     
docker push registry.acme.com/ftgo-restaurant-service:1.0.0.RELEASE
  1. 启动镜像容器
docker  run -d \
--name ftgo-restaurant-service  \
-p 8082:8080  \
-e SPRING_DATASOURCE_URL=... -e SPRING_DATASOURCE_USERNAME=...  \
-e SPRING_DATASOURCE_PASSWORD=... \
registry.acme.com/ftgo-restaurant-service:1.0.0.RELEASE
优点
  • 封装技术栈,可以用容器的API实现对服务的管理。
  • 服务实例是隔离的。
  • 服务实例的资源受到限制。

由于镜像的分层机制,所有构建和启动镜像都比较快,当容器启动时,所运行的就是服务。

缺点

需要承担大量的容器镜像管理工作,必须给操作系统和运行时打补丁。需要管理容器基础设施以及容器运行可能需要的虚拟机基础设施。

docker或者docker compose的问题在于它仅限于一台机器。要可靠地部署服务,必须使用Docker编排框架,例如kubernetes,它将一组计算机转换为资源池。
image

1.4 kubernetes部署服务

下图是kubernetes的架构图,本文不对kubernetes做过多的介绍,想了解kubenetes的,推荐《kubernetes in Action》一书。
在这里插入图片描述
kubernetes集群由管理集群的主节点和运行服务的普通节点组成。开发人员和部署流水线通过API服务器与kubernetes交互,API服务器与主节点上运行的其他集群管理软件一起运行。应用程序容器在节点上运行,每个节点运行一个kubelet(它管理应用程序容器),以及一个kube-proxy(它将应用程序请求路由到pod),可以直接使用代理,也可以通过配置Linux内核中内置的iptables路由规则间接地完成路由工作。

kubernetes部署步骤:

  1. 创建Deployment
apiVersion: extensions/v1beta1
kind: Deployment
 metadata:
  name: ftgo-restaurant-service
spec:
 replicas: 2
  template:
   metadata:
     labels:
spec:
  containers:
  - name: ftgo-restaurant-service
    image: msapatterns/ftgo-restaurant-service:latest
    imagePullPolicy: Always
    ports:
    - containerPort: 8080
      name: httpport
    env:
     - name: JAVA_OPTS
       value: "-Dsun.net.inetaddr.ttl=30"
     - name: SPRING_DATASOURCE_URL
       value: jdbc:mysql://ftgo-mysql/eventuate
     - name: SPRING_DATASOURCE_USERNAME
       valueFrom:
         secretKeyRef:
           name: ftgo-db-secret
           key: username
     - name: SPRING_DATASOURCE_PASSWORD
       valueFrom:
          secretKeyRef:
           name: ftgo-db-secret
           key: password
      - name: SPRING_DATASOURCE_DRIVER_CLASS_NAME
        value: com.mysql.jdbc.Driver
      - name: EVENTUATELOCAL_KAFKA_BOOTSTRAP_SERVERS
        value: ftgo-kafka:9092
      - name: EVENTUATELOCAL_ZOOKEEPER_CONNECTION_STRING
        value: ftgo-zookeeper:2181
    livenessProbe:
        httpGet:
           path: /actuator/health
           port: 8080
        initialDelaySeconds: 60
        periodSeconds: 20
    readinessProbe:
          httpGet:
             path: /actuator/health
             port: 8080
           initialDelaySeconds: 60
           periodSeconds: 20
kubectl apply -f ftgo-restaurant-service/src/deployment/kubernetes/ftgo-restaurant-service.yml
     
kubectl create secret generic ftgo-db-secret \
  --from-literal=username=mysqluser --from-literal=password=mysqlpw
  1. 创建Service
apiVersion: v1
kind: Service
metadata:
  name: ftgo-restaurant-service
 spec:
  ports:
  - port: 8080
     targetPort: 8080
  selector:
     app: ftgo-restaurant-service
---
kubectl apply -f ftgo-restaurant-service-service.yml
  1. 创建一个API 网关(供外部访问)
apiVersion: v1
kind: Service
metadata:
  name: ftgo-api-gateway
spec:
  type: NodePort
  ports:
  - nodePort: 30000
     port: 80
    targetPort: 8080
  selector:
    app: ftgo-api-gateway
---

虽然k8s支持滚动升级,但在升级的过程中或多或少地会对用户找出一定的影响,为了解决这个问题并使新版本的服务更可靠,我们需要一分为二地看问题:部署这个环节意味着服务在生产环境中运行;发布这个环节意味着使服务可用于接收用户请求,处理生产流程。

服务网格Istio

服务网格是一种网络基础设施,它负责处理服务与其他服务、服务与外部应用程序之间的所有网络通信,提供基于规则的负载均衡和流量路由,可以安全地同时运行多个版本的服务。

采用服务网格之后,灰度发布将变得更加容易:

  1. 将新版本部署到生产环境中,而不向其路由任何最终用户请求;
  2. 在生产环境进行测试;
  3. 将其发布给少量最终用户;
  4. 逐步将其发布给越来越多的用户,直到它处理所有生产流量为止;
  5. 任何时候出现问题,请恢复旧版本,否则,一旦确定新版本正常工作,就可以删除旧版本。

下图显示了Istio的架构。Istio服务网格逻辑上分为数据面板和控制面板。

  • 数据面板由一组智能代理(Envoy)组成,代理部署为sidecar,调解和控制微服务之间所有的网络通信。
  • 控制面板负责管理和配置代理来路由流量,以及在运行时执行策略。
    image
    Pilot从底层基础设施(例如:k8s)中获取有关已部署服务的信息并配置数据平台。Mixer负责执行配额和收集遥测信息等策略,并将其报告给监控基础设施服务器。Envoy代理服务将流量路由到服务中并路由到服务外。每个服务实例多有一个Envoy代理服务器。
使用Istio部署服务

可以使用istoctl或者kubectl命令配置Istio.

istio对k8s和pod的一些要求:

  • k8s服务端口(name)必须使用[-suffix>]的Istio命名约定,其中portocol是http、HTTPS、grpc、mongo或redis。如果端口未命名,则Istio会将端口视为TCP端口,并且不会应用基于规则的路由。
  • 一个pod应该由一个app标签,用于标识服务,以支持Istio分布式跟踪。
  • 为了同时运行多个版本的服务,k8s部署的名称必须包含版本。例如ftgo-consumer- service-v1, ftgo-consumer-service-v2。部署的pod应该由一个 version标签,例如version:v1,用来指定版本,以便Istio可以路由到特定版本。
1 创建服务
apiVersion: v1
kind: Service
metadata:
  name: ftgo-consumer-service
spec:
    ports:
    - name: http 
      port: 8080
      targetPort: 8080
    selector:
       app: ftgo-consumer-service
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
 name: ftgo-consumer-service-v2
 spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: ftgo-consumer-service
        version: v2
    spec:
       containers:
        - image: image: ftgo-consumer-service:v2
...
istioctl kube-inject -f ftgo-consumer-service/src/deployment/kubernetes/ftgo-
     consumer-service.yml | kubectl apply -f -

可以在pod描述中看到注入了一个init容器和一个envoy容器。

$ kubectl describe po ftgo-consumer-service-7db65b6f97-q9jpr
Name:           ftgo-consumer-service-7db65b6f97-q9jpr
Namespace:      default
...
Init Containers:
  istio-init:
     Image: 
   ...
Containers:
  ftgo-consumer-service:
      Image: 
        ...
 istio-proxy:
   Image:msapatterns/ftgo-consumer-service:latest
 docker.io/istio/proxyv2:0.8.0
  ...
2 创建到v1版本的路由规

下图显示了用于将所有流量路由到v1的consumer service的路由规则。它由一个VirtualService和一个DestinationRule组成,VirtualService将流量路由到v1子集,DestinationRule将v1子集定义为标有version:v1的pod。
image
VirtualService定义如何路由一个或者多个主机名的请求。
定义VirtualService:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ftgo-consumer-service
spec:
  hosts:
  - ftgo-consumer-service
  http:
    - route:
      - destination:
          host: ftgo-consumer-service
          subset: v1 #引用到RestinationRule

定义DestinationRule:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: ftgo-consumer-service
spec:
  host: ftgo-consumer-service
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
       version: v2

此DestinationRule定义了两个pod子集:v1和v2。

3 部署v2版本
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: ftgo-consumer-service-v2
 spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: ftgo-consumer-service
        version: v2
...
4 将测试流量路由到v2版本

更新VirtualService,将http头部带有testuser的header的测试用户的流量路由到v2。

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
       name: ftgo-consumer-service
spec:
  hosts:
  - ftgo-consumer-service
  http:
    - match:
      - headers:
          testuser:
            regex: "^.+$"
      route:
      - destination:
          host: ftgo-consumer-service
          subset: v2
     - route:
      - destination:
          host: ftgo-consumer-service
          subset: v1
5 把生产流量路由到v2版本
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
    name: ftgo-consumer-service
spec:
    hosts:
        - ftgo-consumer-service
    http:
        - route:
            - destination:
                host: ftgo-consumer-service
                subset: v1
            weight: 95
            - destination:
                host: ftgo-consumer-service
                subset: v2
            weight: 5

1.5 Serverless部署

serverless使得我们不用准备计算资源,也不用负责系统管理。

常用的商用的serverless框架包括:

  • AWS Lambda
  • Google Cloud Functions
  • Azure Functions
  • 阿里云
  • 腾讯云

开源的有:

  • Apache Openwhisk
  • fission for kubernetes
  • kubeless

在WS Re:Invent 2014上,Amazon的CTIO: Werner Vogels在发布AWS Lambda时使用了一个惊人的短语,他说:“魔术正发生在函数、事件和数据的交叉点”。

要部署服务,需将应用程序打包为ZIP或者Jar文件,将其上传到AWS Lambda,并指定响应请求(事件)的函数名称。

开发lambda 函数:

 public interface RequestHandler<I, O> {
            public O handleRequest(I input, Context context);
}

调用 lambda 函数的方式:

  • HTTP请求
  • AWS服务生成的事件
  • 定时调度
  • 直接使用API调用

使用lambda 函数的好处:

  • 有许多AWS服务可供集成
  • 消除许多系统管理任务
  • 弹性伸缩
  • 基于使用情况的定价

使用lambda 函数的弊端:

  • 长尾延迟:启动比较耗时。
  • 基于有限事件与请求的编程模型:不适合用于部署长时间运行的服务。

Restaurant service部署到AWS Lambda的部署架构:
image

AWS Lambda版本的Restaurant service

表现层由请求处理程序类组成,他们实现Lambda函数。它们调用业务层,业务层以传统方式编写,包括服务类、实体和存储库:
image

请求处理程序类的设计:
image

public class FindRestaurantRequestHandler extends AbstractAutowiringHttpRequestHandler {
    @Autowired
    private RestaurantService restaurantService;

    @Override
    protected Class<?> getApplicationContextClass() {
        return CreateRestaurantRequestHandler.class;
    }

    @Override
    protected APIGatewayProxyResponseEvent handleHttpRequest(APIGatewayProxyRequestEvent request, Context context) {
        long restaurantId;
        try {
            restaurantId = Long.parseLong(request.getPathParameters().get("restaurantId"));
        } catch (NumberFormatException e) {
            return makeBadRequestResponse(context);
        }

        Optional<Restaurant> possibleRestaurant = restaurantService.findById(restaurantId);
        return possibleRestaurant.map(this::makeGetRestaurantResponse)
            .orElseGet(() -> makeRestaurantNotFoundResponse(context, restaurantId));
    }

    private APIGatewayProxyResponseEvent makeBadRequestResponse(Context context) { 
            ...
    }

    private APIGatewayProxyResponseEvent
        makeRestaurantNotFoundResponse(Context context, long restaurantId) { 
        ... 
    }

    private APIGatewayProxyResponseEvent
        makeGetRestaurantResponse(Restaurant restaurant) { 
            ... 
   }
}

public abstract class AbstractAutowiringHttpRequestHandler extends AbstractHttpHandler {
    private static ConfigurableApplicationContext ctx;
    private ReentrantReadWriteLock ctxLock = new ReentrantReadWriteLock();
    private boolean autowired = false;

    protected synchronized ApplicationContext getAppCtx() {
        ctxLock.writeLock().lock();

        try {
            if (ctx == null) {
                ctx = SpringApplication.run(getApplicationContextClass());
            }
            return ctx;
        } finally {
            ctxLock.writeLock().unlock();
        }
    }

    @Override
    protected void beforeHandling(APIGatewayProxyRequestEvent request, Context context) {
        super.beforeHandling(request, context);
        if (!autowired) {
            getAppCtx().getAutowireCapableBeanFactory().autowireBean(this);
            autowired = true;
        }
    }

    protected abstract Class<?> getApplicationContextClass();
}


public abstract class AbstractHttpHandler
    implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
    private Logger log = LoggerFactory.getLogger(this.getClass());

    @Override
    public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) {
        log.debug("Got request: {}", input);

        try {
            beforeHandling(input, context);
            return handleHttpRequest(input, context);
        } catch (Exception e) {
            log.error("Error handling request id: {}", context.getAwsRequestId(), e);
            return buildErrorResponse(new AwsLambdaError("Internal Server Error", "500", context.getAwsRequestId(),
                "Error handling request: " + context.getAwsRequestId() + " " + input.toString()));
        }
    }

    protected void beforeHandling(APIGatewayProxyRequestEvent request, Context context) {
        // do nothing
    }

    protected abstract APIGatewayProxyResponseEvent handleHttpRequest(APIGatewayProxyRequestEvent request,
        Context context);
}
把服务打包为ZIP文件:

Gradle任务:

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from configurations.runtime
    }
}

使用Serverless框架部署Lambda函数:
service: ftgo-application-lambda
 provider:
  name: aws
  runtime: java8
  timeout: 35
  region: ${env:AWS_REGION}
  stage: dev
  environment:
   SPRING_DATASOURCE_DRIVER_CLASS_NAME: com.mysql.jdbc.Driver
   SPRING_DATASOURCE_URL: ...
   SPRING_DATASOURCE_USERNAME: ...
   SPRING_DATASOURCE_PASSWORD: ...
 
 package:
   artifact: ftgo-restaurant-service-aws-lambda/build/distributions/
     ftgo-restaurant-service-aws-lambda.zip
 functions:
   create-restaurant:
    handler: net.chrisrichardson.ftgo.restaurantservice.lambda
     .CreateRestaurantRequestHandler
    events:
      - http:
          path: restaurants
          method: post
  find-restaurant:
    handler: net.chrisrichardson.ftgo.restaurantservice.lambda
     .FindRestaurantRequestHandler
    events:
      - http:
          path: restaurants/{restaurantId}
method: get

使用serverless deploy命名部署。

小结

对于一些小型规模的项目,采用特定语言的发版模式,结合jenkins之类的持续集成工具一般都能满足项目需求。

对于大型的项目,在有自建kubernetes集群和运维能力的前提下,推荐采用kubernetes的容器化部署模式。

至于serverless,个人认为现阶段的应用场景和驱动力都还不够。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

正说杂谈

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值