python aiompq集群_Python项目容器化实践(七) - lyanna的Kubernetes配置文件

原标题:Python项目容器化实践(七) - lyanna的Kubernetes配置文件

接下来2篇解释我刚写出Kubernetes版本的lyanna配置文件,同时还要需要补充2个知识: DaemonSet和StatefulSet。

DaemonSet

通过资源对象的名字就能看出它的用法:用来部署Daemon(守护进程)的,DaemonSet确保在全部(或者一些)节点上运行一个需要长期运行着的Pod的副本。主要场景如日志采集、监控等。

在lyanna的项目中,执行异步arq消息的任务进程使用了它(k8s/arq.yaml):

kind: DaemonSet

apiVersion: apps/v1

metadata:

name: lyanna-arq

labels:

app.kubernetes.io/name: lyanna-arq

spec:

selector:

matchLabels:

app.kubernetes.io/name: lyanna-arq

template:

metadata:

labels:

app.kubernetes.io/name: lyanna-arq

spec:

containers:

- image: dongweiming/lyanna:latest

name: lyanna-web

command: ['sh', '-c', './arq tasks.WorkerSettings']

env:

- name: REDIS_URL

valueFrom:

configMapKeyRef:

name: lyanna-cfg

key: redis_url

- name: MEMCACHED_HOST

valueFrom:

configMapKeyRef:

name: lyanna-cfg

key: memcached_host

- name: DB_URL

valueFrom:

configMapKeyRef:

name: lyanna-cfg

key: db_url

- name: PYTHONPATH

value: $PYTHONPATH:/usr/local/src/aiomcache:/usr/local/src/tortoise:/usr/local/src/arq:/usr/local/src

简单地说就是启动一个进程执行 arq tasks.WorkerSettings,这里面有4个要说的地方

labels。lyanna项目用的Label键的名字一般都是 app.kubernetes.io/name,表示应用程序的名字,这是官方推荐使用的标签,具体的可以看延伸阅读链接1

image。由于是线上部署,所以不再使用build本地构建,而是用打包好的镜像,这里用的是 dongweiming/lyanna:latest,是我向https://hub.docker.com/注册的账号下上传的镜像,其中配置了Github集成,每次push代码会按规则自动构建镜像。

env。设置环境变量,这里用到了ConfigMap,之后会专门说,大家先略过,另外要注意使用了PYTHONPATH,预先写好的。

command。 sh -c ./arq tasks.WorkerSettings是启动的命令,参数是一个列表,要求能找到第一个参数作为可执行命令,我这里常规的是用 sh -c开头去执行,arq这个文件是修改Dockerfile添加的:

❯ cat Dockerfile

...

WORKDIR /app

COPY . /app

COPY --from=build /usr/local/bin/gunicorn /app/gunicorn

COPY --from=build /usr/local/bin/arq /app/arq

其实就是在build阶段安装包之后把生成的可执行文件拷贝到 /app下备用。

StatefulSet

先说「有状态」和「无状态」。在Deployment里面无论启动多少Pod,它们的环境和做的事情都是一样的,请求到那个Pod上都可以正常被响应。在请求过程中不会对Pod产生额外的数据,例如持久化数据。这就是「无状态」。而StatefulSet这个资源对象针对的就是有状态的应用,比如MySQL、Redis、Memcached等,因为你在Pod A上写入数据(例如添加了一个文件),如果没有数据同步,在另外一个Pod B里面是看不到这个数据的;而Pod A被销毁重建之后数据也不存在了。当然别担心,实际环境中会通过之前说的PV/PVC或者其他方法把这些需要持久化的数据存储到数据卷中,保证无论怎么操作Pod都不影响数据。

StatefulSet另外的特点是它可以控制Pod的启动顺序,还能给每个Pod的状态设置唯一标识(在Pod名字后加0,1,2这样的数字),当然对于部署、删除、滚动更新等操作也是有序的。

Memcached

在lyanna项目中Memcached和Mariadb使用了StatefulSet,先说Memcached(k8s/memcached.yaml):

apiVersion: apps/v1

kind: StatefulSet

metadata:

name: lyanna-memcached

labels:

app.kubernetes.io/name: memcached

spec:

replicas: 3 # StatefulSet有3副本

revisionHistoryLimit: 10 # 只保留最新10次部署记录,再远的就退不回去了

selector:

matchLabels:

app.kubernetes.io/name: memcached

serviceName: lyanna-memcached

template:

metadata:

labels:

app.kubernetes.io/name: memcached

spec:

containers:

- command: # 容器中启动Memcached的命令,值是一个列表,按照之前部署的参数来的

- memcached

- -o

- modern

- -v

- -I

- 20m

image: memcached:latest # 使用最新的官方memcached镜像

imagePullPolicy: IfNotPresent

livenessProbe:

failureThreshold: 3

initialDelaySeconds: 10

periodSeconds: 10

successThreshold: 1

tcpSocket:

port: memcache

timeoutSeconds: 5

name: lyanna-memcached

ports:

- containerPort: 11211

name: memcache

protocol: TCP

readinessProbe:

failureThreshold: 3

initialDelaySeconds: 5

periodSeconds: 10

successThreshold: 1

tcpSocket:

port: memcache

timeoutSeconds: 1

resources: # 限定Pod使用的CPU和MEM资源

requests:

cpu: 50m # 1m = 1/1000CPU

memory: 64Mi

securityContext: # 限定运行容器的用户,默认是root

runAsUser: 1001

dnsPolicy: ClusterFirst

restartPolicy: Always

securityContext:

fsGroup: 1001

terminationGracePeriodSeconds: 30

updateStrategy:

type: RollingUpdate

---

apiVersion: v1

kind: Service

metadata:

labels:

app.kubernetes.io/name: memcached

name: lyanna-memcached

spec:

clusterIP: None

ports:

- name: memcache

port: 11211

protocol: TCP

targetPort: memcache

selector:

app.kubernetes.io/name: memcached

sessionAffinity: ClientIP

在配置文件中写了一些注释,每个服务大家可以理解他是一个「微服务」,包含一个StatefulSet/Deployment和一个Service,应用通过访问Service域名的方式访问它。在一个yaml里面能写多个配置,中间用 ---隔开即可。

Memcached是内存数据库,进程死掉缓存就丢失了,所以里面没有mount数据卷相关的配置,我使用StatefulSet它主要是考虑每个Pod内存中的数据是不一样的,另外注意服务定义中有一句 sessionAffinity:ClientIP,让请求根据客户端的IP地址做会话关联: 他每次都访问这个Pod。

再重点说一下配置文件中用到的2种探针。探针是由kubelet对容器执行的定期诊断,它是k8s提供的应用程序健康检查方案:

livenessProbe。指示容器是否正在运行。如果存活探测失败,则kubelet会杀死容器,容器将按照重启策略(restartPolicy)重启。如果容器不提供存活探针,表示容器成功通过了诊断。

readinessProbe。指示容器是否准备好服务请求。如果就绪探测失败,Service不会包含这个Pod,请求也就不会发到这个Pod上来。初始延迟之前的就绪状态默认为失败,如果容器不提供就绪探针,则默认状态为 Success。

大家理解了吧?简单地说,livenessProbe是看容器是否正常,readinessProbe是看应用是否正常。

MariaDB

接着说数据库,首先说MySQL和MariaDB的区别:

MySQL先后被Sun和Oracle收购,MySQL之父Ulf Michael Widenius离开了Sun之后,由于对这种商业公司不信任等原因,新开了分支(名字叫做MariaDB)发展MySQL。MariaDB跟MySQL在绝大多数方面是兼容的,对于开发者来说,几乎感觉不到任何不同。目前MariaDB是发展最快的MySQL分支版本,新版本发布速度已经超过了Oracle官方的MySQL版本。

MySQL和MariaDB都有各自应用大户,所以目前不需要考虑MariaDB替代MySQL的问题,我这次选择「纯」开源版本的MariaDB主要是我瓣一直在用,而我用的云服务器上面只能选择MySQL,正好借着k8s的机会使用MariaDB。

MariaDB显然是最适合用StatefulSet了,由于它要定义主从,配置文件(k8s/optional/mariadb.yaml)很长,所以分开来演示。先看一下PV部分:

kind: PersistentVolume

apiVersion: v1

metadata:

name: mariadb-master

labels:

type: local

spec:

storageClassName: lyanna-mariadb-master

capacity:

storage: 5Gi

accessModes:

- ReadWriteOnce

hostPath:

path: /var/lib/mariadb

persistentVolumeReclaimPolicy: Retain

---

kind: PersistentVolume

apiVersion: v1

metadata:

name: mariadb-slave

labels:

type: local

spec:

storageClassName: lyanna-mariadb-slave

capacity:

storage: 5Gi

accessModes:

- ReadWriteOnce

hostPath:

path: /var/lib/redis-slave

persistentVolumeReclaimPolicy: Retain

定义了2个PersistentVolume分别给Master/Slave用,它们都使用了hostPath挂载到宿主机(其实就是minikube虚拟机),空间5G,访问模式是ReadWriteOnce,表示只能被单个节点以读/写模式挂载,这也是必然的,数据库文件被多个节点同时写会让文件损坏的。通过persistentVolumeReclaimPolicy制定回收策略,默认是Delete(删除),我改成了Retain(保留): 保留数据,需要管理员手工清理。

接着是ConfigMap部分,k8s中通过ConfigMap方式配置数据库配置(my.cnf中的项):

apiVersion: v1

kind: ConfigMap

metadata:

labels:

app: mariadb

app.kubernetes.io/component: master

name: lyanna-mariadb-master

data:

my.cnf: |-

[mysqld]

skip-name-resolve

explicit_defaults_for_timestamp

basedir=/data/mariadb

port=3306

socket=/data/mariadb/tmp/mysql.sock

tmpdir=/data/mariadb/tmp

max_allowed_packet=16M

bind-address=0.0.0.0

pid-file=/data/mariadb/tmp/mysqld.pid

log-error=/data/mariadb/logs/mysqld.log

character-set-server=UTF8

collation-server=utf8_general_ci

[client]

port=3306

socket=/data/mariadb/tmp/mysql.sock

default-character-set=UTF8

[manager]

port=3306

socket=/data/mariadb/tmp/mysql.sock

pid-file=/data/mariadb/tmp/mysqld.pid

---

apiVersion: v1

kind: ConfigMap

metadata:

labels:

app.kubernetes.io/name: mariadb

app.kubernetes.io/component: slave

name: lyanna-mariadb-slave

data:

my.cnf: |-

[mysqld]

skip-name-resolve

explicit_defaults_for_timestamp

basedir=/data/mariadb

port=3306

socket=/data/mariadb/tmp/mysql.sock

tmpdir=/data/mariadb/tmp

max_allowed_packet=16M

bind-address=0.0.0.0

pid-file=/data/mariadb/tmp/mysqld.pid

log-error=/data/mariadb/logs/mysqld.log

character-set-server=UTF8

collation-server=utf8_general_ci

[client]

port=3306

socket=/data/mariadb/tmp/mysql.sock

default-character-set=UTF8

[manager]

port=3306

socket=/data/mariadb/tmp/mysql.sock

pid-file=/data/mariadb/tmp/mysqld.pid

通过配置项可以感受到Pod会发生状态变化的文件都在 /data/mariadb下。我对MariaDB配置没有什么经验,这部分主要是从helm/charts/stable/mariadb里找的。

我没有用官方MariaDB镜像,而是用了bitnami/mariadb,主要是为了容易地实现主从复制集群。先看Matser部分:

apiVersion: apps/v1

kind: StatefulSet

metadata:

labels:

app.kubernetes.io/name: mariadb

app.kubernetes.io/component: master

name: lyanna-mariadb-master

spec:

replicas: 1

revisionHistoryLimit: 10

selector:

matchLabels:

app.kubernetes.io/name: mariadb

app.kubernetes.io/component: master

serviceName: lyanna-mariadb-master

template:

metadata:

labels:

app.kubernetes.io/name: mariadb

app.kubernetes.io/component: master

spec:

containers:

- env:

- name: MARIADB_USER

valueFrom:

configMapKeyRef:

key: user

name: lyanna-cfg

- name: MARIADB_PASSWORD

valueFrom:

configMapKeyRef:

key: password

name: lyanna-cfg

- name: MARIADB_DATABASE

valueFrom:

configMapKeyRef:

key: database

name: lyanna-cfg

- name: MARIADB_REPLICATION_MODE

value: master

- name: MARIADB_REPLICATION_USER

value: replicator

- name: MARIADB_REPLICATION_PASSWORD

valueFrom:

configMapKeyRef:

key: replication-password

name: lyanna-cfg

- name: MARIADB_ROOT_PASSWORD

value: passwd

image: bitnami/mariadb:latest

imagePullPolicy: IfNotPresent

livenessProbe:

exec:

command:

- sh

- -c

- exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD

failureThreshold: 3

initialDelaySeconds: 120

periodSeconds: 10

successThreshold: 1

timeoutSeconds: 1

name: mariadb

ports:

- containerPort: 3306

name: mysql

protocol: TCP

readinessProbe:

exec:

command:

- sh

- -c

- exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD

failureThreshold: 3

initialDelaySeconds: 30

periodSeconds: 10

successThreshold: 1

timeoutSeconds: 1

volumeMounts:

- mountPath: /data/mariadb

name: data

restartPolicy: Always

securityContext:

fsGroup: 1001

runAsUser: 1001

terminationGracePeriodSeconds: 30

volumes:

- configMap:

defaultMode: 420

name: lyanna-mariadb-master

name: config

updateStrategy:

type: RollingUpdate

volumeClaimTemplates:

- metadata:

labels:

app.kubernetes.io/name: mariadb

app.kubernetes.io/component: master

name: data

spec:

accessModes:

- ReadWriteOnce

resources:

requests:

storage: 5Gi

volumeMode: Filesystem

storageClassName: lyanna-mariadb-master

数据库主从是分别的StatefulSet,每个StatefulSet都只有一个副本,这个配置中需要着重说的有4点:

env。主从都是StatefulSet,那么Pod里面怎么知道自己要跑那种数据库实例呢?就靠环境变量,所以Master的环境变量包含 MARIADB_USER、 MARIADB_PASSWORD、 MARIADB_DATABASE、MARIADB_REPLICATION_MODE、 MARIADB_REPLICATION_USER、 MARIADB_REPLICATION_PASSWORD和 MARIADB_ROOT_PASSWORD,有些是要在我们自定义的ConfigMap中获取,有些是写死的常量

探针。livenessProbe和readinessProbe都用的是 mysqladmin status来检查数据库状态

volumeMounts。数据库就是通过volumeMounts项找挂载到哪里,mountPath表示要挂载到容器的路径,name是使用的挂载PVC名字

volumes。配置的挂载,前面配置的数据库设置项都是由于他生效的

volumeClaimTemplates。PVC的模板,基于volumeClaimTemplates数组会自动生成PVC(PersistentVolumeClaim)对象,它的名字要和volumeMounts里面的name一致才能对应上,由于访问模式是ReadWriteOnce的,所以PVC和PV是一一对应的。

接着看从(Slave),其实它就是Label、name之类的值换个名字,限于篇幅问题只展示env这不同于Master的部分:

...

spec:

containers:

- env:

- name: MARIADB_REPLICATION_MODE

value: slave

- name: MARIADB_MASTER_HOST

value: lyanna-mariadb

- name: MARIADB_MASTER_PORT_NUMBER

valueFrom:

configMapKeyRef:

key: port

name: lyanna-cfg

- name: MARIADB_MASTER_USER

valueFrom:

configMapKeyRef:

key: user

name: lyanna-cfg

- name: MARIADB_MASTER_PASSWORD

valueFrom:

configMapKeyRef:

key: password

name: lyanna-cfg

- name: MARIADB_REPLICATION_USER

value: replicator

- name: MARIADB_REPLICATION_PASSWORD

valueFrom:

configMapKeyRef:

key: replication-password

name: lyanna-cfg

- name: MARIADB_MASTER_ROOT_PASSWORD

value: passwd

...

接着看一下名字是lyanna-cfg的ConfigMap,这里面包含了数据库、Redis、Memcached相关的设置项,这些想需要通过环境变量的方式传到对应容器中(k8s/config.yaml):

apiVersion: v1

kind: ConfigMap

metadata:

name: lyanna-cfg

data:

port: "3306"

database: test

user: lyanna

password: lyanna

memcached_host: lyanna-memcached

replication-password: lyanna

redis_sentinel_host: redis-sentinel

redis_sentinel_port: "26379"

db_url: mysql://lyanna:lyanna@lyanna-mariadb:3306/test?charset=utf8Redis Sentinel

类似部署MariaDB用的主从方案最大的问题是Master宕机了,不能实现自动主从切换,所有在实际的应用中还是直接连接的主服务器,从服务器更多的是数据备份的作用,如果真的Master出错了能手动调整ConfigMap让应用直接使用从服务器的数据。当然这部分可以优化,但我的博客实际上用的是云数据库,所以先跑起来再说。

而用Redis做Master-Slave也有这个问题,所以官方推荐Redis Sentinel这种高可用性(HA)解决方案: Sentinel监控集群状态并能够实现自动切换,我们只要不断地从Sentinel哪里获得现在的Master是谁就可以了。

在学习k8s过程里面我发现k8s世界更多的是做基础支持,对于高可用、备份方案这类现实世界更真实的需求没什么官方成熟、完善的支持。我现在使用的是k8s官方例子中的Redis Sentinel集群用法,具体的可以看延伸阅读链接2: 《Reliable, Scalable Redis on Kubernetes》,不过它的文档写的很简陋且不符合国情(你懂得),且这个例子看起来比较古老,我对其做了一些调整。

构建镜像

例子中使用的镜像是 k8s.gcr.io/redis:v1,但其实这个镜像是通过例子的image目录下的代码构建出来的,所以我针对国内源的问题修改了下具体的可以看lyanna项目下的k8s/sentinel目录下的内容,为此,我需要构建一个新的镜像(dongweiming/redis-sentinel)并上传到hub.docker.com:

❯ docker build -t dongweiming/redis-sentinel:latest .

❯ docker push dongweiming/redis-sentinel用ReplicaSet替代ReplicationController

官方都这么推荐好久,可以这个例子还是使用RC,所以为此我改进成了ReplicaSet,不过为了省事我没有改成StatefulSet,未来有时间再搞吧。

让lyanna支持Redis Sentinel

原来在lyanna的代码中使用 DB_URL、 REDIS_URL这样的设置项,而现在迁到容器里面,我的思路是用上面那个叫lyanna-cfg的ConfigMap把设置项通过环境变量传进容器,启动应用时会读取这些环境变量,另外也要支持Redis Sentinel,所以改成这样(config.py):

DB_URL = os.getenv('DB_URL', 'mysql://root:@localhost:3306/test?charset=utf8')

REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379')

DEBUG = os.getenv('DEBUG', '').lower in ('true', 'y', 'yes', '1')

MEMCACHED_HOST = os.getenv('MEMCACHED_HOST', '127.0.0.1')

# Redis sentinel

REDIS_SENTINEL_SERVICE_HOST = None

REDIS_SENTINEL_SERVICE_PORT = 26379

try:

from local_settings import * # noqa

except ImportError:

pass

# 这部分要加在`from local_settings import *`之后

redis_sentinel_host = os.getenv('REDIS_SENTINEL_SVC_HOST') or REDIS_SENTINEL_SERVICE_HOST # noqa

if redis_sentinel_host:

redis_sentinel_port = os.getenv('REDIS_SENTINEL_SVC_PORT',

REDIS_SENTINEL_SERVICE_PORT)

from redis.sentinel import Sentinel

sentinel = Sentinel([(redis_sentinel_host, redis_sentinel_port)],

socket_timeout=0.1)

redis_host, redis_port = sentinel.discover_master('mymaster')

REDIS_URL = f'redis://{redis_host}:{redis_port}'

另外,lyanna是一个aio项目,redis驱动用的是aioredis,它底层用的是hiredis(Redis C客户端的Python封装),它是不支持sentinel的,所以需要额外引入redis-py这个库(requirements.txt)

看看代码

说了这么多,看看具体代码吧。架构分三步,首先是一个Pod,里面有2个容器: 一个Master和一个Sentinel:

apiVersion: v1

kind: Pod

metadata:

labels:

name: redis

redis-sentinel: "true"

role: master

name: redis-master

spec:

containers:

- name: master

image: dongweiming/redis-sentinel:latest

env:

- name: MASTER

value: "true"

ports:

- containerPort: 6379

resources:

limits:

cpu: "0.1"

volumeMounts:

- mountPath: /redis-master-data

name: data

- name: sentinel

image: dongweiming/redis-sentinel:latest

env:

- name: SENTINEL

value: "true"

ports:

- containerPort: 26379

volumes:

- name: data

hostPath:

path: /var/lib/redis

这2个容器都有对应的环境变量MASTER和SENTINEL,但是注意监听端口不同(master 6379/sentinel 26379),而且Master会把容器的/redis-master-data(Redis数据存储目录,具体逻辑可以看k8s/sentinel目录下的代码)挂载到本地/var/lib/redis,让数据持久化。

然后是Sentinel服务:

apiVersion: v1

kind: Service

metadata:

labels:

name: sentinel

role: service

name: redis-sentinel

spec:

ports:

- port: 26379

targetPort: 26379

selector:

redis-sentinel: "true"

服务并不直接提供Redis服务,这是一个Sentinel服务,lyanna请求它获得现在的Master IP和端口,然后拼 REDIS_URL访问,具体的可以看前面提的config.py中的改动。

然后是2个ReplicaSet,先看Master的:

apiVersion: apps/v1

kind: ReplicaSet

metadata:

name: redis

spec:

replicas: 2

selector:

matchLabels:

name: redis

template:

metadata:

labels:

name: redis

role: master

spec:

containers:

- name: redis

image: dongweiming/redis-sentinel:latest

ports:

- containerPort: 6379

volumeMounts:

- mountPath: /redis-master-data

name: data

volumes:

- name: data

hostPath:

path: /var/lib/redis

总体和前面的name为redis-master的Pod中master部分一样,唯一不同的是: 这个ReplicaSet中的2个副本都没有环境变量MASTER,所以可以理解它们是Slave!

再看Sentinel的ReplicaSet:

apiVersion: apps/v1

kind: ReplicaSet

metadata:

name: redis-sentinel

spec:

replicas: 2

selector:

matchLabels:

redis-sentinel: "true"

template:

metadata:

labels:

name: redis-sentinel

redis-sentinel: "true"

role: sentinel

spec:

containers:

- name: sentinel

image: dongweiming/redis-sentinel:latest

env:

- name: SENTINEL

value: "true"

ports:

- containerPort: 26379

Service的后端Pod(服务的selector为redis-sentinel: "true")包含这个ReplicaSet里面2个Pod,以及前面的name为redis-master的Pod中的sentinel,这三个Pod都有SENTINEL变量但是没有放在同一个ReplicaSet的设计是为了在初始化时让Sentinel服务先生效再启动ReplicaSet里面的2个Pod(这部分逻辑在k8s/sentinel/run.sh里面)。

我再深入的解释下这个问题吧。Replica里的2个Pod是靠svc/redis-sentinel获取IP和端口的,但问题是这个服务就是靠这些Pod才能接受请求,这就有了「没有鸡就下不了蛋,没有蛋生不了鸡」的问题。那么svc中久需要有一(多)个用另外的方法获得IP和端口才可以。svc是Pod之间的通信,另外一种方法就是让Pod内部2个容器内部直接通信,所以在run.sh里面会尝试 redis-cli-h $(hostname-i)INFO,那么name为redis-master的Pod中的sentinel就能和Master容器直接通信了。其实看Sentinel Pod日志也能看到这个过程:

❯ kubectl logs redis-sentinel-5p84q |head -5

Could not connect to Redis at 10.101.31.21:26379: Connection refused

Could not connect to Redis at 172.17.0.7:6379: Connection refused

Connecting to master failed. Waiting...

# Server

redis_version:4.0.14

# 👆 首先尝试从服务10.101.31.21:26379获取失败,由于容器所在的Pod的网络是共享的,所以尝试了访问自己这个IP的6379端口也失败

# 👇先从服务10.101.31.21:26379获取失败,再连自己连成功了,就没第二个Connection refused

❯ kubectl logs redis-master -c sentinel |head -5

Could not connect to Redis at 10.101.31.21:26379: Connection refused

# Server

redis_version:4.0.14

现在为什么这么搞了吧?

后记

贴了好长的配置,大家慢慢理解吧~

全部k8s配置可以看lyanna项目下的k8s目录

https://kubernetes.io/zh/docs/concepts/overview/working-with-objects/common-labels/

https://github.com/kubernetes/examples/tree/master/staging/storage/redis

责任编辑:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值