为什么我们要替换Docker?

 

为什么选择Dockerless?

首先,我需要解释一下这个系列的标题:”Dokerless”。我们为什么不用Docker呢?或者我们为什么要用Docker呢?难道就没有别的选择了吗?IT社区在2013年第一次尝试容器的时候是否就已经提出了一个完美的解决方案呢?

答案并不完全是肯定的。容器工业界竭尽全力避免虚拟化技术出现的情形,尽管拥有libvirt这样出色的工具,但我们并没有一套每个虚拟化供应商都会遵循的标准。我们应该认可Docker公司的努力,作为一家公司它发起并积极支持了许多围绕容器技术标准化的倡议。

自2015年以来,我们有了开放容器计划(OCI)以及容器运行和容器镜像管理的规范。此外,我们还有容器运行时接口(CRI)和容器网络接口(CNI)。如果你很难理解所有新的容器标准是如何在一起工作的,那么就看一下Scott McCarty的理解容器标准,他甚至演示了如何在不同容器引擎之间转换这些接口,并且没有丢失任何功能。

如果你可以在Kubernetes集群中用CRI-O或其它引擎来替换Docker Daemon,而且作为开发人员又不会注意到其中的差异,那么究竟哪个引擎在运行又有什么关系呢?就像容器让你不再关心主机服务器一样,容器标准也让你可以不用关心容器本身的管理。毕竟,你是否真地关心过AWS EC2究竟是在Xen还是KVM上运行呢?你只是需要一个虚拟机或者一个容器而已。

但是,那些负责Kubernetes集群维护、扩展和升级的工程师就非常关心这些选项了。例如,他们可能会担心使用Docker的一些安全问题。另外,他们可能认为没有必要在集群的每个节点上运行额外的守护进程。Docker可能已经使容器非常流行,但这并不意味着它是最好的容器技术。如果你对各种容器引擎之间的差异很感兴趣,可以从这个对比入手:Kubernetes Container Runtimes

作为一个开发者,如果你开始依赖一些标准或者一些公司(以免这些公司搞砸标准的开发和支持),那么你可以为本地开发自由地选择开发工具。如果所有工具最终都使用相同的API,那么你就没有必要局限于某种特定的工具了。那么,”为什么不用Docker呢?”,答案是因为我们没有必要再使用Docker。那么我们该用什么来代替它呢?

Buildah和Podman

我们将会分别使用BuildahPodman来管理容器镜像和容器本身。这两个工具都是由RedHat开发,大多数RedHat的Linux发行版都搭载了这些安装包。更重要的是,从RedHat Enterprise Linux 8开始,将不会再有直接使用Docker的安装包。当然,你仍然可以选择从Docker库中直接来安装Docker。

注意,当我说”由RedHat开发”时,我并不是说这些工具只是由RedHat开发的。Buildah和Podman都是开源项目,源代码托管在Github上的容器组织中。的确,Redhat雇佣员工来开发这些工具,但其实还有一个更大的开发者社区来维护和扩展这两个项目。

使用Buildah和Podman的弊端

在我们开始讨论为什么使用Buildan和Podman之前,让我先明确指出与Docker相比使用Buildah和Podman的明显的缺点:

  1. 目前它们只能在Linux上运行。对于Windows和MacOS,没有像Docker那样方便的包装器。在Windows下可以使用wsl2,所以问题可能会好一些。
  2. Podman没有Docker Compose的替代品。有一种方法可以在本地运行兼容kubernetes的Pod YAMLs,但还不成熟。不过,这种情况可能会改变。我们将在本系列的最后一篇文章中研究Pod YAML。
  3. 它们仍然非常新,还在快速开发中!

如果你是Windows或MacOS用户,你肯定会怀念Docker客户端工具的舒适,因为你可能不得不手动启动一个Linux虚拟机来运行Buildah和Podman。

如果你有一个非常复杂的Docker Compose设置,或者你使用Docker Compose来部署生产环境,那么你就没有新工具集来替换它了。

但是,除了这两点,你几乎不会注意到从Docker转换后的差异。你会得到什么样的回报么?

从Docker转向Buildah和Podman的原因

有一个Docker的替代品

当你开始使用一组新的工具来管理容器时,你很快就会意识到Docker只是一个工具。你会看到没有”Docker容器”,只有”容器”,没有”Docker镜像”,只有”容器镜像”。能够思考技术和原则的根本,并避免将你的思想锁定在单一的技术中是很重要的。

就像一个优秀的程序员能够意识到他最喜欢的语言只是众多编程语言中的一种,我们应该能意识到Docker只是与容器交互的一种方式而已。了解并尝试其他选择是很重要的,它会给你一个不同的视角,丰富你的知识。

了解真正的容器是什么

一旦你停止思考”Docker”并开始思考”容器”,你将不可避免地要学习容器到底是什么,并获得更深层次的知识,也能理解诸如“docker run”或”podman exec”等命令真正做了什么。如果你是一个网页开发者,那么这些知识可能对你的日常工作没有帮助。是否想更深入地挖掘事物运作的底层知识,完全取决于个人。我的观点是,理解所有事物的工作原理和整合对每个工程师来说都是一项非常重要的技能。

为未来做准备

当你试图在大脑中将”Docker”替换为”容器”时,整个行业也在做同样的事情。正如前面提到的,CRI-O逐渐成为Kubernetes的默认运行时,不同的公司也推出了针对容器的不同工具集。Redhat发起Buildah,而谷歌推动了Kaniko,总是有一些不太知名的工具会在某个时候成为主流。

你可以在Docker身上下大赌注,并且只使用这一个工具。或者,你可以了解行业中正在发生的情况,并做好Docker不再是默认工具的准备,它将会被下一代容器技术工具取代。

什么是容器镜像?

在学习Buildah之前,让我们先通过文章系统管理员容器入门来了解一下容器镜像。通过该篇文章,我们了解到容器镜像是一个包含两个内容的TAR文件:

  1. 容器根文件系统。简单地讲,它是一个目录,包含容器中所有的常规目录,如/usr、/home等。
  2. Json文件,它是一个定义了如何运行根文件系统的配置文件。它包括该执行哪些命令,设置哪些环境变量等。

容器镜像的内容按照OCI镜像规范定义,如果你想了解更多关于容器镜像结构的信息,该规范就是最好的选择。这听起来可能很疯狂,但是你的容器镜像并非一定要使用镜像规范,而镜像规范也可以用到其他事情上。

什么是Buildah?

Buildah是一个容器镜像构建工具,可以生成兼容OCI的镜像。Buildah作为一个单一的二进制文件发布,由GO语言编写。大多数现代Linux的发行版本都包含了Buildah的软件包,安装只需遵循官方说明即可。

Buildah只能用来处理镜像。它的工作只是构建容器镜像并将其推送到注册中心,而没有涉及守护进程。Buildah也不需要根权限来构建镜像。这使得Buildah非常适合作为CI/CD管道的一部分来使用:你可以轻松地在容器中运行Buildah,而无需授予该容器任何根权限。

对于基于容器的CI系统(比如Gitlab CI和Docker执行器),很多时候只是为了能够构建一个容器镜像,就需要搭建一个完整的Docker环境。对于我个人来讲,这有点太繁重了。Buildah就完全没有必要这样做,因为对它来说事情只有需要做好和完全不应该做的区别。

在Open Shift中的BuildConfigurations场景中,Buildah看起来非常有用。从OpenShift 4.0开始,BuildConfgs将依赖于Buildah而不是Docker,因此不需要共享任何套接字或者在OpenShift平台中拥有特权容器。毫无置疑,这让在最流行的容器平台之一中构建容器镜像的方法变得更加安全和清晰。

Buildah构建的镜像可以直接由Docker使用。它们不是“Buildah镜像”,而只是“容器镜像”,它们遵循OCI规范,这也是Docker兼容的格式。那么我们该如何使用Buildah构建一个镜像呢?

使用Buildahfile

开个玩笑,Buildah并没有使用Buildahfile文件。实际上,Buildah可以只使用Dockerfiles,从Docker到Buildah的转变也变得异常简单。

在mkdev中,我们使用Mattermost作为消息传递平台的核心。重要的是,我们能够在本地运行Mattermost,从而能够轻松地在web应用程序和消息传递系统之间完成开发整合。

尽管Mattermost已经提供了官方的Docker镜像,但是我们仍然不得不构建自己的镜像,因为我们更喜欢配置的方式,并且也为了更容易地运行Mattermost的临时测试实例。同时,我们也想预先安装一些导师们所依赖的插件。于是我们修改了官方的Dockerfile文件,并将其提供给Buildah:

FROM alpine:3.9


# 设置ENV变量
ENV PATH="/opt/mattermost/bin:${PATH}"
ENV MM_VERSION=5.8.0
# 为config设置默认值
ENV MMDBCON=localhost:5432 \
    MMDBKEY=XXXXXXXXXXXX \
    MMSMTPUSERNAME=postfix \
    MMSMTPPASSWORD=secrets \
    MMSMTPSALT=XXXXXXXXXXXX \
    MMGITHUBSECRET=secret \
    MMGITHUBHOOK=localhost
# 设置Mattermost版本的Build参数
ARG PUID=2000
ARG PGID=2000
# 安装所需要的软件包
RUN apk add --no-cache \
 ca-certificates \
 curl \
 jq \
 libc6-compat \
 libffi-dev \
 linux-headers \
 mailcap \
 netcat-openbsd \
 xmlsec-dev \
 && rm -rf /tmp/*
## 获取Mattermost
RUN mkdir -p /opt/mattermost/data /opt/mattermost/plugins /opt/mattermost/client/plugins \
    && cd /opt \
    && curl https://releases.mattermost.com/$MM_VERSION/mattermost-team-$MM_VERSION-linux-amd64.tar.gz | tar -xvz \
    && curl -L https://github.com/mattermost/mattermost-plugin-github/releases/download/v0.7.1/github-0.7.1.tar.gz -o /tmp/github.tar.gz \
    && cd /opt/mattermost/plugins \
    && tar -xvf /tmp/github.tar.gz
COPY files/entrypoint.sh /
COPY files/mattermost.json /opt/mattermost/config/config.json
RUN chmod +x /entrypoint.sh \
    && addgroup -g ${PGID} mattermost \
    && adduser -D -u ${PUID} -G mattermost -h /mattermost -D mattermost \
    && chown -R mattermost:mattermost /opt/mattermost /opt/mattermost/plugins /opt/mattermost/client/plugins
USER mattermost
# 设置entrypoint和command
ENTRYPOINT ["/entrypoint.sh"]
WORKDIR /opt/mattermost
CMD ["mattermost"]
# 暴露容器端口8000
EXPOSE 8065
# 为挂载点声明volumes
VOLUME ["/opt/mattermost/data", "/opt/mattermost/logs", "/opt/mattermost/config", "/opt/mattermost/plugins", "/opt/mattermost/client/plugins"]

如果它看起来和常规的Dockerfile一样,那是因为它就只是一个常规的Dockerfile。让我们开始运行Buildah:

buildah bud -t docker.io/mkdevme/mattermost:5.8.0 .

接下来的输出类似于docker build命令。生成的镜像将存储在本地,我们可以通过运行buildah images命令进行查看。Buildah一个不错的特点就是镜像都是用户特制的,这意味着只有创建这个镜像的用户能看到并使用它。如果以其他系统用户的身份运行buildah images,我们将看不到任何内容。这与Docker不同,docker images总是为所有用户列出相同的镜像集。

生成镜像后,可以将其推送到注册中心。Buildah支持多种传输方式来推送镜像,一些传输方式例如:docker-daemon,如果你仍然在本地运行Docker,并且希望Docker能够看到这个镜像;docker,如果你希望将镜像推送到与Docker API兼容的远程注册中心;还有一些其它的非Docker传输:oci、容器存储、dir等等。

如果你选择Docker hub作为注册中心的话,你完全可以使用Buildah进行镜像推送。通过使用Buildah,我们可以不用考虑Docker镜像的一些条款。这就像操作一个Git存储库,我们可以把它推送到GitHub、GitLab或BitBucket,同样,我们也可以将容器镜像推送到DockerHub、Quay、AWSECR等不同的注册中心。

查看镜像信息

Buildah支持的其中一个传输方式就是dir。当你将镜像推送到dir(文件系统上的一个目录)时,Buildah将在那里存储tarball的层,配置你的镜像以及JSON清单文件。这只对调试有帮助,尤其是查看镜像的内部结构的时候。

创建一些目录并运行buildah push IMAGE dir:/$(pwd),我并期望你真的去构建一个Mattermost镜像,你可以构建其他任何的镜像。如果你什么也没有并且也不想构建,那么你可以通过buildah pull从docker hub中心拉取任意一个镜像。

一旦拉取完成,你将看到一个名为96c6e3522e18ff696e9c40984a8467ee15c8cf80c2d32ffc184e79cdfd4070f6的文件,这实际上是一个tarball。你可以解压缩这个文件到你选择的任何地方,并查看这个镜像层中的所有文件。你还会看到一个imagemanifest.json文件,如果是Mattermost镜像的话,它看起来像这样:

{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:57ea4e4c7399849779aa80c7f2dd3ce4693a139fff2bd3078f87116948d1991b",
    "size": 1262
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar",
      "digest": "sha256:6bb94ea9af200b01ff2f9dc8ae76e36740961e9a65b6b23f7d918c21129b8775",
      "size": 2832039
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar",
      "digest": "sha256:96c6e3522e18ff696e9c40984a8467ee15c8cf80c2d32ffc184e79cdfd4070f6",
      "size": 162162411
    }
  ]
}

镜像清单由OCI规范描述。如果仔细查看上面的示例,你就会发现它定义了两个层(vnd.oci.image.layer.v1.tar)和一个配置文件(vnd.oci.image.config.v1+json)。我们可以看到这个配置有一个摘要57ea4e4c7399849779aa80c7f2dd3ce4693a139fff2bd3078f87116948d1991b。我们也有这个文件,尽管它看起来像镜像层文件,但它实际上是镜像的配置文件。

这可能有点令人困惑,但请记住,这种结构是为其他软件存储和处理而创建的,而不是让我们通过人眼来阅读的。如果你需要快速查找镜像中存储配置的文件,请先查看manifest.json:

{
  "created": "2019-05-12T16:13:28.951120907Z",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "User": "mattermost",
    "ExposedPorts": {
      "8065/tcp": {}
    },
    "Env": [
      "PATH=/opt/mattermost/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "MM_VERSION=5.8.0",
      "MMDBCON=localhost:5432",
      "MMDBKEY=XXXXXXXXXX",
      "MMSMTPUSERNAME=postfix",
      "MMSMTPPASSWORD=secrets",
      "MMSMTPSALT=XXXXXXXXXX",
      "MMGITHUBSECRET=secret",
      "MMGITHUBHOOK=localhost"
    ],
    "Entrypoint": [
      "/entrypoint.sh"
    ],
    "Cmd": [
      "mattermost"
    ],
    "Volumes": {
      "/opt/mattermost/client/plugins": {},
      "/opt/mattermost/config": {},
      "/opt/mattermost/data": {},
      "/opt/mattermost/logs": {},
      "/opt/mattermost/plugins": {}
    },
    "WorkingDir": "/opt/mattermost"
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:f1b5933fe4b5f49bbe8258745cf396afe07e625bdab3168e364daf7c956b6b81",
      "sha256:462e838baed1292fb825d078667b126433674cdc18c1ba9232e2fb8361fc8ac2"
    ]
  },
  "history": [
    {
      "created": "2019-05-11T00:07:03.358250803Z",
      "created_by": "/bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6ae03397b99ea77f2e9ee901c5c59e59f76f93adbb4035913 in / "
    },
    {
      "created": "2019-05-11T00:07:03.510395965Z",
      "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]",
      "empty_layer": true
    },
    {
      "created": "2019-05-12T16:13:28.951120907Z"
    }
  ]
}

因此,整个容器镜像就只有一堆tarball和json文件!

你说Dockerless,但你仍然依赖Dockerfile!

Buildah的创建者有意不引入新的DSL来定义容器镜像。他提供了两种定义镜像的方法:一个Dockerfile或者一系列Buildah命令。我们很快就会学到第二种方法,但我必须警告你,我不认为Dockerfiles会很快消失。除了名字本身,可能没有什么东西能与他们相提并论。想象一下,投身到Dockerless,却发现自己仍然在写Dockerfiles!

我希望它们被称为“Containerfiles”或者“Imagefiles”。这样社区就不会那么尴尬了。但是到目前为止,惯例仍然是将这个文件命名为Dockerfile,我们只能忍受这个现状。

通过Buildah直接构建镜像

通过Buildah构建镜像的第二种方法是直接使用Buildah命令。Buildah构建镜像是从一个基本镜像创建一个新的容器,然后在这个容器中运行命令。在运行完所有命令之后,你可以提交这个容器为镜像文件。让我们以这种方式构建一个镜像,然后讨论是否以及何时这样做才能比编写一个Dockerfile更方便。

首先,我们需要从现有的镜像开始创建一个新的容器:

buildah from centos:7

如果镜像还不存在,它会从注册中心被提取出来,就像使用Docker一样。buildah命令行将返回启动容器的名称,通常是”IMAGEname-working-container“,在我们的示例中是“centos-working-container”。我们需要记住它,并在将来的所有命令中都使用相同的名称。

我们可以这个容器中运行buildah run命令:

buildah run centos-working-container -- yum install unzip -y

我们还可以使用buildah config命令为未来的镜像配置各种OCI兼容的选项,例如环境变量:

buildah config -e ENVIRONMENT=test centos-working-container

我们还可以将完整的容器文件系统挂载到构建服务器中,并使用主机上的工具直接操作它。当我们不希望仅仅为了执行构建操作而在镜像中安装某些工具时,这非常有用。但要记住,在这种情况下,我们需要确保所有希望构建你镜像的机器需要安装这些工具,这也有点破坏了构建脚本的可移植性。

buildah mount centos-working-container

相应的,Buildah会提供挂载文件系统的位置,例如/home/fodoj/.local/share/containers/storage/overlay/DIGEST/merged。作为测试,我们可以在那里创建一个文件:

touch hello-from-host /home/fodoj/.local/share/containers/storage/overlay/DIGEST/merged/home/

一旦我们对镜像感到满意,我们就可以提交了:

buildah mount centos-working-container

同时移除工作容器:

buildah rm centos-working-container

请注意,尽管Buildah的确运行了容器,但这除了对构建镜像之外别无用处。Buildah不是容器引擎的替代品,它只提供一些原语来调试构建镜像的过程!

Podman可以看到Buildah构建的镜像,这将是下一篇文章的主题。现在,如果你想验证文件hello-from-host是否真的存在,可以运行以下命令:

image=$(buildah from my-first-buildah-image)
ls $(buildah mount $image)/home
$> hello-from-host

这将创建另外一个工作容器。挂载该容器并显示/home目录的内容。如果你想使用Buildah而不Dockerfile来构建镜像,我们其实也是这么做的。你应该编写一个shell脚本来调用所有的命令,提交镜像并删除工作容器,而不是使用Dockerfile。这就是mkdev的“Buildahfile”文件,实际上只是一个shell脚本:

#!/bin/bash
set -x
mkdev=$(buildah from centos:7)
buildah run "$mkdev" -- curl -L http://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm -o epel-release-latest-7.noarch.rpm
buildah run "$mkdev" -- curl -L https://downloads.wkhtmltopdf.org/0.12/0.12.5/wkhtmltox-0.12.5-1.centos7.x86_64.rpm -o wkhtmltopdf.rpm
buildah run "$mkdev" -- curl "https://s3.amazonaws.com/aws-cli/awscli-bundle.zip" -o "awscli-bundle.zip"
buildah run "$mkdev" -- rpm -ivh epel-release-latest-7.noarch.rpm
buildah run "$mkdev" -- yum install centos-release-scl -y
buildah run "$mkdev" -- yum install unzip postgresql-libs postgresql-devel ImageMagick \
                       autoconf bison flex gcc gcc-c++ gettext kernel-devel make m4 ncurses-devel patch \
                       rh-ruby25 rh-ruby25-ruby-devel rh-ruby25-rubygem-bundler rh-ruby25-rubygem-rake \
                       rh-postgresql96-postgresql openssl-devel libyaml-devel libffi-devel readline-devel zlib-devel \
                       gdbm-devel ncurses-devel gcc72-c++ \
                       python-devel git cmake python2-pip chromium chromedriver which -y
buildah run "$mkdev" -- pip install ansible boto3 botocore
buildah run "$mkdev" -- yum install wkhtmltopdf.rpm -y
buildah run "$mkdev" -- ln -s /usr/local/bin/wkhtmltopdf /bin/wkhtmltopdf
buildah run "$mkdev" -- unzip awscli-bundle.zip
buildah run "$mkdev" -- ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws
buildah run "$mkdev" -- yum clean all && rm -rf /var/cache/yum
git archive -o app.tar.gz --format=tar.gz HEAD
buildah add "$mkdev" app.tar.gz /app/
buildah add "$mkdev" infra/app/build/entrypoint.sh /entrypoint.sh
buildah config --workingdir /app "$mkdev"
buildah run "$mkdev" -- scl enable rh-ruby25 "bundle install"
rm app.tar.gz
buildah config --port 3000 "$mkdev"
buildah config --entrypoint '[ "/entrypoint.sh" ]' "$mkdev"
buildah run "$mkdev" -- chmod +x /entrypoint.sh
buildah config --cmd "bundle exec rails s -b '0.0.0.0' -P /tmp/mkdev.pid" "$mkdev"
buildah config --env LC_ALL="en_US.UTF-8" "$mkdev"
buildah run "$mkdev" -- rm -rf /app/
buildah commit "$mkdev" "docker.io/mkdevme/app:dev"
buildah rm "$mkdev"

如果你已经构建过一个很好的容器镜像,那么这个脚本可能对你来说可能非常愚蠢。让我来解释一下脚本里发生的一些事情:

  1. 我们使用Centos 7作为基础镜像,因为我们在生产环境中使用了Centos7。即使我们还没有在生产环境中运行容器,也应该尽可能地使开发环境接近生产环境。
  2. 我们确实安装了大量的软件包,包括AWS CLI、Chromium、Software Collections等等。我们之所以这样做,是因为我们在开发环境和CI系统中使用了新构建的镜像。这两个位置都需要额外的工具来运行集成测试(Chromium)或执行一些打包和部署任务(AWS CLI和Ansible)。Software Collections在我们的生产环境中使用,在所有其他环境中使用相同的Ruby版本也很重要。
  3. 最后我们删除了应用程序本身的代码。对于这个用例,我们实际上并不需要将代码放在镜像中。在开发环境和CI系统中,我们都需要最新版本的代码,而不是嵌入到镜像中的东西。

我们将这个脚本存储在应用程序repo中,就像我们将Dockerfile保存在那里一样。一旦我们决定要在生产环境中的容器中运行mkdev,我们就可以根据环境做来修改这个脚本,从而完成不同的事情。

只有当你的构建服务器能够运行shell脚本时,你才可以使用此方法。这不是一个问题,例如Windows有WSL。你的主机系统不一定是基于Linux的,只要它能够在内部运行某种Linux!MacOS用户可以有一天在没有额外的Linux虚拟机的情况下会使用它吗?谁知道呢,让我们期待Buildah开发者正在实现这个功能吧。

Buildah内部是如何工作的呢?

Podman和Buildah的内部工作方式非常相似。它们都使用Linux内核特性,特别是用户命名空间和网络命名空间,以便可以在没有任何root权限的情况下运行容器。我不会在这篇文章中讨论这个问题,但是如果你已经等不及了,那就开始阅读下面的资源吧:

 

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

认真的柯南

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

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

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

打赏作者

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

抵扣说明:

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

余额充值