深入剖析Kubernetes学习笔记-07 | 白话容器基础(三):深入理解容器镜像

目录

一、什么是 Mount Namespace?

二、案例- 创建子进程时开启指定的 Namespace。

三、容器如何拥有自己的文件系统?

3.1 chroot<案例>

3.2 docker和rootfs


一、什么是 Mount Namespace?

Namespace 的作用是“隔离”,它让应用进程只能看到该 Namespace 内的“世界”;而 Cgroups 的作用是“限制”

个人理解Mount Namespace 是在容器中就要宿主机内核挂载新的文件系统。这样容器的文件系统就独立于宿主机的文件系统了,文件系统的隔离才能让容器真正独立于宿主机。

容器里的进程看到的文件系统又是什么样子的呢?

为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容器里通过执行 "ls /" 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件。

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等:

二、案例- 创建子进程时开启指定的 Namespace。

#define _GNU_SOURCE
#include <sys/mount.h> 
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
  "/bin/bash",
  NULL
};
 
int container_main(void* arg)
{  
  printf("Container - inside the container!\n");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}
 
int main()
{
  printf("Parent - start a container!\n");
  int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
  waitpid(container_pid, NULL, 0);
  printf("Parent - container stopped!\n");
  return 0;
}

在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。

k8s]# gcc -o ns ns.c
k8s]# ll
-rwxr-xr-x 1 root root 7255 Sep  2 10:19 ns
-rw-r--r-- 1 root root  729 Sep  2 10:19 ns.c
 k8s]# ./ns 
Parent - start a container!
Container - inside the container!

/bin/bash” 运行后 shell 就运行在了 Mount Namespace 的隔离环境中。

查看/tmp 下的内容

 k8s]# ls /tmp
gpcc_pg_log_stage  keyring-Faa36R  lost+found  nmon.old.log  orbit-root          pulse-lzRhYLP1W3A3  sock.log        ulimit.log
hsperfdata_root    keyring-fXGt3x  nmon.log    orbit-gdm     pulse-kdauMp8kUjsW  pulse-nBjXbjEgxOG7  ssh-EXOpB39240

显然这是宿主机tmp下的内容

为啥没有隔离呢 ?

这是因为Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

总不能每个目录都使用mount namespace然后再挂载一遍? 这个太麻烦了..如何解决呢?

创建新进程时,

1、声明要启用 Mount Namespace

2、容器进程执行前重新挂载需要的目录

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

mount(“none”, “/tmp”, “tmpfs”, 0, “”)  容器以 tmpfs(内存盘)格式,重新挂载了 /tmp 目录。

重新编译ns.c,并运行

 k8s]# ./ns 
Parent - start a container!
Container - inside the container!
 k8s]# ls /tmp

tmp目录下是空的。这代表重新挂载成功了。

使用mount查看 /tmp 目录 

 tmpfs 方式单独挂载

 k8s]#  mount -l | grep tmpfs
tmpfs on /dev/shm type tmpfs (rw)

再回到宿主机:

k8s]#  mount -l | grep tmpfs
tmpfs on /dev/shm type tmpfs (rw)
 k8s]# ls /tmp
gpcc_pg_log_stage  keyring-Faa36R  lost+found  nmon.old.log  orbit-root          pulse-lzRhYLP1W3A3  sock.log        ulimit.log
hsperfdata_root    keyring-fXGt3x  nmon.log    orbit-gdm     pulse-kdauMp8kUjsW  pulse-nBjXbjEgxOG7  ssh-EXOpB39240

为什么还能看到挂载tmp目录?

由于宿主机的挂载类型是 shared,必须先重新挂载根目录。

mount("", "/", NULL, MS_PRIVATE, "");

 再一次在宿主机mount -l 来检查一下这个挂载。你会发现它是不存在的

# 在宿主机上
$ mount -l | grep tmpfs

这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效 

这里有一个问题很容易忽略:

同样的在子进程的shell里输入ps,top等命令,我们还是可以看得到所有进程。说明并没有完全隔离。这是因为,像free, top这些命令会去读/proc文件系统,所以,因为/proc文件系统在父进程和子进程都是一样的,所以这些命令显示的东西都是一样的

proc如何与宿主机隔离呢?

1 使用 lxcfs,尝试回答一下。top 是从 /prof/stats 目录下获取数据,所以道理上来讲,容器不挂载宿主机的该目录就可以了。lxcfs就是来实现这个功能的,做法是把宿主机的 /var/lib/lxcfs/proc/memoinfo 文件挂载到Docker容器的/proc/meminfo位置后。容器中进程读取相应文件内容时,LXCFS的FUSE实现会从容器对应的Cgroup中读取正确的内存限制。从而使得应用获得正确的资源约束设定。kubernetes环境下,也能用,以ds 方式运行 lxcfs ,自动给容器注入正确的 proc 信息。

三、容器如何拥有自己的文件系统?

每当创建一个新容器时,容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?

可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。就如上面容器中挂载的tmp 目录。

3.1 chroot<案例>

全称是change root file system”,即改变进程的根目录到你指定的位置。

挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc

k8s]# ls /
0      bin   cgroup  dev  etc       greenplum-cc-web  home     ktahis1  ktauas1  lib64       media  mnt  nohup.out  oracle  redis  sbin     selinux  sys      tmp  var
aplog  boot  data    dfs  gpmaster  hadoop            ktabrm1  ktappt1  lib      lost+found  misc   net  opt        proc    root   scripts  srv      test001  usr

进入容器之后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同。

案例

现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。

1、创建一个 test 目录和几个 lib 文件夹:

$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T

2、把 bash 命令拷贝到 test 目录对应的 bin 路径下

$ cp -v /bin/{bash,ls} $HOME/test/bin

3、把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令:

$ T=$HOME/test
$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
$ for i in $list; do cp -v "$i" "${T}${i}"; done

4、执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录:

$ chroot $HOME/test /bin/bash

5、执行 "ls /",就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。

6、了能够让容器的这个根目录看起来更“真实,在这个容器的根目录下挂载一个完整操作系统的文件系统。比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,在容器里通过执行 "ls /" 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件。

chroot 的进程来说,它并不会感受到自己的根目录已经被“修改”成 $HOME/test 了。

这种视图被修改的原理和Linux Namespace 很类似。

--update 2022年3月11日18:23:48  这就像docker中制作某一个layer一样。

3.2 docker和rootfs

对Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程

  1. 启用 Linux Namespace 配置;

  2. 设置指定的 Cgroups 参数;

  3. 切换进程的根目录(Change Root)。

这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用(pivot是一个公司,MPP 数据库GreenPlum就是这个公司的产品),如果系统不支持,才会使用 chroot。

3.3 容器只有OS 的躯壳,没有操作系统的“灵魂”

rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像

rootfs 只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”

对于容器来说,这个操作系统的“灵魂”又在哪里呢?

同一台机器上的所有容器,都共享宿主机操作系统的内核。

四、什么是容器的“一致性”呢?

由于云端与本地服务器环境不同,应用的打包过程,一直是使用 PaaS 时最“痛苦”的一个步骤。

但有了容器之后,更准确地说,有了容器镜像(即 rootfs)之后,这个问题被非常优雅地解决了。

由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如 Golang 的 Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。

有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。

这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

4.1 开发、升级应用,都要重复制作一次 rootfs 吗?

比如,我现在用 Ubuntu 操作系统的 ISO 做了一个 rootfs,然后又在里面安装了 Java 环境,用来部署我的 Java 应用。那么,我的另一个同事在发布他的 Java 应用时,显然希望能够直接使用我安装过 Java 环境的 rootfs,而不是重复这个流程。

传统的做法:

制作 rootfs 的时候,每做一步“有意义”的操作,就保存一个 rootfs 出来,这样其他同事就可以按需求去用他需要的 rootfs 了。

但是,这个解决办法并不具备推广性。原因在于,一旦你的同事们修改了这个 rootfs,新旧两个 rootfs 之间就没有任何关系了。这样做的结果就是极度的碎片化。

那么,既然这些修改都基于一个旧的 rootfs,我们能不能以增量的方式去做这些修改呢?这样做的好处是,所有人都只需要维护相对于 base rootfs 修改的增量内容,而不是每次修改都制造一个“fork”。

4.2 Docker 公司的创新

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。

4.3 什么是UnionFS?

最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

做个实验

有A B C三个目录

A下有a x两个文件

B下有b x两个文件

用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上

mount -t aufs -o dirs=./A:./B none ./C 

再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一起:

可以看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,并且 x 文件只有一份。这,就是“合并”的含义。此外,如果你在目录 C 里对 a、b、x 文件做修改,这些修改也会在对应的目录 A、B 中生效。

我本地测试是报错了:

查找了下原因,是因为Centos默认不支持aufs,于是我在想宿主机都不支持aufs,那docker是如何做到的呢?

原来是现在最新的 docker 版本中默认两个系统都是使用的 overlay2。之前centos有一段时间使用了device mapper,但其性能不佳,所以很多文章不要在centos上面使用docker,现在看来,还是可以使用的。因为docker现在默认采用的overlay技术。

 4.4 深入理解docker镜像的layer(类似于增量rootfs)

第一部分,只读层。

它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是 whiteout,我下面马上会讲到)。

这时,我们可以分别查看一下这些层的内容:

$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
etc sbin usr var
$ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
run
$ ls /var/lib/docker/aufs/diff/a524a729adadedb900...
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

可以看到,这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。

第二部分,可读写层。

它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。

可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?

为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。

比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。

所以,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这就是增量 rootfs 的好处。

第三部分,Init 层。

它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。

所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

 例子: 

运行docker run -d ubuntu:latest sleep 3600就可以自动下载ubuntu的镜像,这个所谓的“镜像”,实际上就是一个Ubuntu操作系统的rootfs,它的内容是Ubuntu操作系统的所有文件和目录。不过,与之前我们讲述的rootfs稍微不同的是,Docker镜像使用的rootfs,往往由多个“层”组成:

1
2
3
4
5
6
7
8
9
root@fdm:~# docker image inspect ubuntu:latest |sed -n '/Layers/,/]/p'
            "Layers": [
                "sha256:2fb7bfc6145d0ad40334f1802707c2e2390bdcfc16ca636d9ed8a56c1101f5b9",
                "sha256:c8dbbe73b68c96e3252f8191226b700d4f4b284154624fa40a2e6a0c42712a0d",
                "sha256:1f6b6c7dc482cab1c16d3af058c5fa1782e231cac9aab4d9e06b3f7d77bb1a58",
                "sha256:2c77720cf318a4c7eaee757162e6bfc364c3ed83a96a525bc20c548e0f75f1af"
            ]
root@fdm:~# docker inspect busybox:latest -f '{{json .RootFS.Layers}}'
["sha256:683f499823be212bf04cb9540407d8353803c25d0d9eb5f2fdb62786d8b95ead"]

这个Ubuntu镜像,实际上由四个层组成。这四个层就是四个增量rootfs,每一层都是Ubuntu操作系统文件与目录的一部分;而在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点上。而busybox就只有一个层。

以下测试是在busybox镜像上面做的测试。

那这些文件到底存放在哪个目录呢?这里可以查看/proc/mounts,可以看到是存放在/var/lib/docker/aufs/mnt/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1

1
2
3
4
root@fdm:~# cat /proc/mounts| grep 'aufs/mnt'
none on /var/lib/docker/aufs/mnt/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1 type aufs (rw,relatime,si=9c85c7461d7bafc4,dio,dirperm1)
root@fdm:~# ls /var/lib/docker/aufs/mnt/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

可以看到这个目录下,就是一个类似完整的操作系统的目录。我们可以使用si这个ID,你就可以在/sys/fs/aufs下查看被联合挂载在一起的各个层的信息:

1
2
3
4
5
6
7
8
9
root@fdm:~# ll /sys/fs/aufs/si_9c85c7461d7bafc4/br[0-9]
-r--r--r-- 1 root root 4096 Oct 14 09:38 /sys/fs/aufs/si_9c85c7461d7bafc4/br0
-r--r--r-- 1 root root 4096 Oct 14 09:38 /sys/fs/aufs/si_9c85c7461d7bafc4/br1
-r--r--r-- 1 root root 4096 Oct 14 09:38 /sys/fs/aufs/si_9c85c7461d7bafc4/br2
root@fdm:~# 
root@fdm:~# cat /sys/fs/aufs/si_9c85c7461d7bafc4/br[0-9]
/var/lib/docker/aufs/diff/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1=rw
/var/lib/docker/aufs/diff/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1-init=ro+wh
/var/lib/docker/aufs/diff/8c6d77459b5ddf3bfaee756737581c4b94b985c51ed3935b1c18ce575f7f4118=ro+wh

我们可以看到,镜像的层都放置在/var/lib/docker/aufs/diff目录下,然后被联合挂载在/var/lib/docker/aufs/mnt里面。

先删除/etc/group这个文件,

复制

1
2
3
4
5
6
7
8
9
### 进入busybox
root@fdm:~# docker exec -it busybox sh
/ # rm -f /etc/group
/ # ls /etc/group
ls: /etc/group: No such file or directory

### 查看 rw 可写层,会发现group变成了.wh.group文件。
root@fdm:~# ls /var/lib/docker/aufs/diff/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1/etc/.wh.group
f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1/etc/.wh.group

注意,之前/var/lib/docker/aufs/diff/f16fcd089ac75b5319eb85a0cb2caf541893d770ad1adf343790698c5624bcd1/etc是没有group这个文件的,只不少在删除的时候,docker会把这个文件从只读层复制至可写层之后,再重命名为.wh.group

总结

在今天的分享中,我着重介绍了 Linux 容器文件系统的实现方式。而这种机制,正是我们经常提到的容器镜像,也叫作:rootfs。它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大。

通过结合使用 Mount Namespace 和 rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。当然,这个功能的实现还必须感谢 chroot 和 pivot_root 这两个系统调用切换进程根目录的能力。

而在 rootfs 的基础上,Docker 公司创新性地提出了使用多个增量 rootfs 联合挂载一个完整 rootfs 的方案,这就是容器镜像中“层”的概念。

通过“分层镜像”的设计,以 Docker 镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个 GB 的虚拟机磁盘镜像的协作要敏捷得多。

更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,得到的内容都完全一致,可以完全复现这个镜像制作者当初的完整环境。这,就是容器技术“强一致性”的重要体现。

而这种价值正是支撑 Docker 公司在 2014~2016 年间迅猛发展的核心动力。容器镜像的发明,不仅打通了“开发 - 测试 - 部署”流程的每一个环节,更重要的是:

容器镜像将会成为未来软件的主流发布方式。

思考题

  1. 既然容器的 rootfs(比如,Ubuntu 镜像),是以只读方式挂载的,那么又如何在容器里修改 Ubuntu 镜像的内容呢?(提示:Copy-on-Write)

上面的读写层通常也称为容器层,下面的只读层称为镜像层,所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。知道这一点,就不难理解镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。

  1. 除了 AuFS,你知道 Docker 项目还支持哪些 UnionFS 实现吗?你能说出不同宿主机环境下推荐使用哪种实现吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

MyySophia

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

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

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

打赏作者

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

抵扣说明:

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

余额充值