04 | 理解进程(3):为什么我在容器中的进程被强制杀死了?

本文仅作为学习记录,非商业用途,侵删,如需转载需作者同意。

今天说下为啥容器中的进程被强制杀死了,帮助理解更好的管理进程,让容器中的进程可以优雅的退出。

为啥优雅的停止很重要呢?
实际生产环境中,不少应用在退出的时候需要做一些清理工作,比如清理一些远端的链接,或者清除一些本地的临时数据。

这样的清理工作,可以尽可能避免远端或者本地的错误发生,比如减少丢包等问题。
这些退出清理的工作,通常在SIGTERM 这个信号用户注册的 handler 里进行的。

如果进程是被kill -9 ,就是进程收到了 SIGKILL 信号,那应用程序就没有机会做清理的工作了,不能优雅的退出,就会增加应用的出错率。

一、场景再现

Kubernetes或者Docker 停止一个容器,最后都会用到Containerd 这个服务。

而 Containerd 在停止容器的时候,就会向容器的 init 进程发送一个 SIGTERM 信号。
init 进程退出之后,容器内的其他进程也都会立刻退出。
不同的是,init 进程收到的是 SIGTERM 信号,其他进程收到的是 SIGKILL 信号。


SIGKILL 信号是不能被捕获的(catch)的,也就是用户不能注册自己的 handler,而SIGTERM 信号却允许用户注册自己的handler。

那么,当容器退出的时候,如何才能让容器中的进程都收到 SIGTERM 信号,而不是 SIGKILL 信号?


使用如下的代码 执行下 make images,运行一个容器,重现一下问题:
https://github.com/chengyli/training/tree/main/init_proc/fwd_sig

使用 strace 工具来监控,当用 docker stop 停止这个容器的时候,就能看到容器里的 init 进程和另外一个进程收到的信号情况。

strace 命令的使用:
https://www.cnblogs.com/machangwei-8/p/10388883.html

下面例子中,15909 就是容器里的 init 进程,15959 就是容器里的另外一个进程。

如下看到,init 进程(15909)收到的是 SIGTERM 信号,另外一个进程(15959)收到的是SIGKILL 信号。


# ps -ef | grep c-init-sig
root     15857 14391  0 06:23 pts/0    00:00:00 docker run -it registry/fwd_sig:v1 /c-init-sig
root     15909 15879  0 06:23 pts/0    00:00:00 /c-init-sig
root     15959 15909  0 06:23 pts/0    00:00:00 /c-init-sig
root     16046 14607  0 06:23 pts/3    00:00:00 grep --color=auto c-init-sig

# strace -p 15909
strace: Process 15909 attached
restart_syscall(<... resuming interrupted read ...>) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---
write(1, "received SIGTERM\n", 17)      = 17
exit_group(0)                           = ?
+++ exited with 0 +++

# strace -p 15959
strace: Process 15959 attached
restart_syscall(<... resuming interrupted read ...>) = ?
+++ killed by SIGKILL +++

二、知识详解:信号的两个系统调用

信号:就是Linux 进程收到的一个通知。

进程对信号的处理包括两个问题:

  • 进程如何发送信号(kill 系统调用)
  • 进程收到信号后如何处理(signal 系统调用)

kill() 函数定义:


NAME
       kill - send signal to a process

SYNOPSIS
       #include <sys/types.h>
       #include <signal.h>

       int kill(pid_t pid, int sig);
  • sig,代表需要发送哪个信号,比如sig的值是15,就是指发送SIGTERM
  • pid,发送信号给哪个进程

signal()系统调用,它可以给信号注册handler,定义如下


NAME
       signal - ANSI C signal handling

SYNOPSIS
       #include <signal.h>
       typedef void (*sighandler_t)(int);
       sighandler_t signal(int signum, sighandler_t handler);

signum 就是信号编号,例如数值15,就是信号 SIGTERM;参数 handler 是一个函数指针参数,用来注册用户的信号 handler

进程对信号的处理,有三个选择:调用系统缺省行为、捕获、忽略
这里的选择,就是程序中如何去调用 signal()这个系统调用。

  • 1、缺省:代码中不做任何对 signal()相关的系统调用的处理,接收到信号,进程默认执行内核中的对应信号的缺省代码。

例如SIGTERM 信号的缺省行为就是进程退出(terminate)
内核对信号的缺省行为一般有:退出(termin)、暂停(stop)、忽略(ignore)三种行为中的一种。

  • 2、捕获:在代码中为某个信号,调用signal()注册自己的handler。这样运行的进程接收到信号时,就不会去执行内核中的缺省代码,而是会执行通过signal()注册的handler。

例如下面的示例:
代码中为 SIGTERM这个信号注册了一个handler,
handler中只做了一个打印的操作,这样接收到 SIGTERM 信号,进程就不会退出,会显示出来"received SIGTERM"。


void sig_handler(int signo)
{
  if (signo == SIGTERM) {
          printf("received SIGTERM\n");
  }
}

int main(int argc, char *argv[])

{
...
  signal(SIGTERM, sig_handler);
...
}
  • 3、忽略:让进程忽略一个信号,就是通过 signal()这个系统调用,为这个信号注册一个特殊的handler SIG_IGN

int main(int argc, char *argv[])
{
...
  signal(SIGTERM, SIG_IGN);
...
}

这样操作的效果:进程接收到 SIGTERM信号后,程序不会退出,也不打印日志,没有任何反应。

重复下信号处理的三个选择:
缺省行为,捕获,忽略

捕获 是不是就可以优雅的退出了?
程序里的handler做一些退出前的清理工作,优雅退出。


SIGKILL和SIGSTOP 信号是两个特权信号,他们不可以被捕获和忽略。 这个特点也反应在signal()调用上。


# cat reg_sigkill.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>

typedef void (*sighandler_t)(int);

void sig_handler(int signo)
{
            if (signo == SIGKILL) {
                        printf("received SIGKILL\n");
                        exit(0);
            }
}
 
int main(int argc, char *argv[])
{
            sighandler_t h_ret;

            h_ret = signal(SIGKILL, sig_handler);
            if (h_ret == SIG_ERR) {
                        perror("SIG_ERR");
            }
            return 0;
}

# ./reg_sigkill
SIG_ERR: Invalid argument

上面的示例代码,用signal()为 SIGKILL 注册 handler,那么它就会返回 SIG_ERR,不允许我们做捕获操作。


下面提供一个代码用signal() 对SIGTERM 这个信号,分别做了忽略,捕获,和默认的缺省行为的测试。每一次都用kill() 系统调用向进程发送 SIGTERM 信号。
根据输出结果,可以验证进程对 SIGTERM 信号的选择。


#include <stdio.h>
#include <signal.h>

typedef void (*sighandler_t)(int);

void sig_handler(int signo)
{
        if (signo == SIGTERM) {
                printf("received SIGTERM\n\n");
                // Set SIGTERM handler to default
                signal(SIGTERM, SIG_DFL);
        }
}

int main(int argc, char *argv[])
{
        //Ignore SIGTERM, and send SIGTERM
        // to process itself.

        signal(SIGTERM, SIG_IGN);
        printf("Ignore SIGTERM\n\n");
        kill(0, SIGTERM);

        //Catch SIGERM, and send SIGTERM
        // to process itself.
        signal(SIGTERM, sig_handler);
        printf("Catch SIGTERM\n");
        kill(0, SIGTERM);

 
        //Default SIGTERM. In sig_handler, it sets
        //SIGTERM handler back to default one.
        printf("Default SIGTERM\n");
        kill(0, SIGTERM);

        return 0;
}
2.1总结

总结下上面用到的两个系统调用:

kill():两个输入参数,进程号和信号。把指定的信号,发送给指定的进程。

signal():决定了进程收到指定的信号后如何处理。
SIG_DFL 参数把对应信号恢复为缺省的 handler
也可以使用自定义的函数作为 handler
或者用 SIG_IGN 参数让进程忽略信号。

对于 SIGKILL 信号,如果调用 signal()函数,为它注册定义的handler ,系统就会拒绝。

三、解决问题

问题:为啥在停止一个容器的时候,容器 init 进程收到的是SIGTERM 信号,容器中其他进程却会收到 SIGKILL 信号?

解释:
Linux 进程收到 SIGTERM 信号并且使进程退出,这个Linux 内核对处理进程退出的入口点就是 do_exit() 函数。
do_exit() 函数中会释放进程的相关资源,比如内存,文件句柄,信号量等。

做完这些工作之后,会调用一个exit_notify() 函数,用来通知这个进程相关的父子进程。

对于容器来说,还要考虑 Pid Namespace 里的其他进程,这里调用的就是zap_pid_ns_processes() 函数。
在这个函数中,如果init 进程处于退出状态,它会向Namespace 中的其他进程都发送一个 SIGKILL 信号。

在这里插入图片描述

内核代码如下:


    /*
         * The last thread in the cgroup-init thread group is terminating.
         * Find remaining pid_ts in the namespace, signal and wait for them
         * to exit.
         *
         * Note:  This signals each threads in the namespace - even those that
         *        belong to the same thread group, To avoid this, we would have
         *        to walk the entire tasklist looking a processes in this
         *        namespace, but that could be unnecessarily expensive if the
         *        pid namespace has just a few processes. Or we need to
         *        maintain a tasklist for each pid namespace.
         *
         */

        rcu_read_lock();
        read_lock(&tasklist_lock);
        nr = 2;
        idr_for_each_entry_continue(&pid_ns->idr, pid, nr) {
                task = pid_task(pid, PIDTYPE_PID);
                if (task && !__fatal_signal_pending(task))
                        group_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_MAX);
        }

因此,上面的问题的原因就清晰了。

SIGKILL 信号是个特权信号,既是Linux为kernal 和超级用户去删除任意进程保留的,不能被忽略也不能被捕获。

因此进程在收到 SIGKILL 信号之后,就立刻退出,没有机会再去调用一些释放资源的handler。

SIGTERM 是可以被捕获的,因此就可以在定义的handler中操作一些资源释放的操作,容器中的程序也更希望在停止容器的时候收到 SIGTERM 信号而不是 SIGKILL 信号。


如何才能让容器中的除init外的程序接收到 SIGTERM 信号呢?

答案:
让容器 init 进程来转发 SIGTERM 信号。
比如: Docker Container 里使用的 tini 作为 init 进程,tini 的代码中就会调用 sigtimedwait() 这个函数来查看自己收到的信号,然后调用 kill() 函数把信号发送给子进程。

因此总结下最初的问题:

解决办法:在容器的 init 进程中对收到的信号做个转发,发送到容器中的其他子进程,这样容器中的所有进程在停止时,都会收到 SIGTERM ,而不是SIGKILL 信号。

四、重点小结

信号在接收处理的三个选择:忽略,捕获,缺省

SIGTERM 可以被忽略和捕获,SIGKILL 不能被忽略和捕获。

我们需要在停止容器的时候,让容器中的应用收到 SIGTERM,而不是 SIGKILL。
可以在容器的 init 进程中对收到的信号做个转发,发送到容器中的其他进程。

思考题


#include <stdio.h>
#include <signal.h>

typedef void (*sighandler_t)(int);

void sig_handler(int signo)
{
        if (signo == SIGTERM) {
                printf("received SIGTERM\n\n");
                // Set SIGTERM handler to default
                signal(SIGTERM, SIG_DFL);
        }
}

int main(int argc, char *argv[])
{
        //Ignore SIGTERM, and send SIGTERM
        // to process itself.

        signal(SIGTERM, SIG_IGN);
        printf("Ignore SIGTERM\n\n");
        kill(0, SIGTERM);

        //Catch SIGERM, and send SIGTERM
        // to process itself.
        signal(SIGTERM, sig_handler);
        printf("Catch SIGTERM\n");
        kill(0, SIGTERM);

 
        //Default SIGTERM. In sig_handler, it sets
        //SIGTERM handler back to default one.
        printf("Default SIGTERM\n");
        kill(0, SIGTERM);

        return 0;
}

答案:
第一个是注册了Sig Ignore,所以第一个kill会被忽略。第二个是注册了自己的handler,所以会打印出receive SIGTERM,第三个是因为第二个程序里注册了default handler,所以是默认行为。

五、留言

留言1、

简单总结了下,子进程被kill杀死的原因是,父进程在退出时,执行do_exit中,由于是cgroup_init 组的进程,因此向所有的子进程发送了sigkill信号。而导致这个的原因是,一般情况下,容器起来的第一个进程都不是专业的init进程,没有考虑过这些细节问题。由于正常情况下,父进程被终结,信号不会传递到子进程,exit时也不会给子进程发终结命令。这会导致多进程容器在关闭时,无法被终止。为了保证容器能够被正常终结。设计者在do_exit中做文章,使用sigkill这个不可屏蔽信号,而是为了能够在没有任何前提条件的情况下,能够把容器中所有的进程关掉。而一个优雅的解决方法是,使用一个专业的init进程作为容器的第一个进程,来处理相关业务。实现容器的优雅关闭。当然,如果子进程也把SigTerm做了劫持,那也是有可能导致容器无法关闭。

留言2:

问题:老师,我做了个测试,现象有点迷惑,我打开两个终端,用sleep进行测试,方法和现象如下:

  1. 在第一个终端启动sleep,在另外一个终端通过命令去kill,能通过sigterm正常杀掉进程。
    .# strace sleep 30000
    execve("/usr/bin/sleep", [“sleep”, “30000”], [/* 25 vars */]) = 0

    — SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1505, si_uid=0} —
    +++ killed by SIGTERM +++

  2. 启动一个容器里面的命令是sleep 30000,用strace跟踪进程,我使用kill,杀不掉sleep进程,然后通过docker stop发现,先是发送sigterm信号,没有成功,最后被强制杀掉了:
    .# strace -p 2207
    strace: Process 2207 attached
    restart_syscall(<… resuming interrupted nanosleep …>) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
    — SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} —
    restart_syscall(<… resuming interrupted restart_syscall …>
    ) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
    — SIGWINCH {si_signo=SIGWINCH, si_code=SI_USER, si_pid=0, si_uid=0} —
    restart_syscall(<… resuming interrupted restart_syscall …>
    ) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
    +++ killed by SIGKILL +++

我有点迷惑,老师能解释一下为什么在宿主机或者用docker不能用sigterm来杀死容器的进程吗?

回答:
对于第二个问题,我假设sleep进程在宿主机上的pid是2207, 你还是可以先查看"cat /proc/2207/status | grep SigCgt", 我的理解是SIGTERM handler应该还是没有注册,那么即使从宿主机上发送SIGTERM给这个容器里的1号进程,那么也是不能杀死的。

"docker stop"在停止容器的时候,先给容器里的1号进程发送SIGTERM, 如果不起作用,那么等待30秒后会发送SIGKILL。我想这个是你看到的现象了。

至于为什么即使在宿主机机上向容器1号进程发送SIGTERM,在1号进程没有注册handler的情况下,不能被杀死的问题 (思考题), 原因是这样的:

开始要看内核里的那段代码,“ !(force && sig_kernel_only(sig))”,
虽然由不同的namespace发送信号, 虽然force是1了,但是sig_kernel_only(sig)对于SIGTERM来说还是0, 这里是个&&, 那么 !(1 && 0) = 1。

#define sig_kernel_only(sig) siginmask(sig, SIG_KERNEL_ONLY_MASK)
#define SIG_KERNEL_ONLY_MASK (
rt_sigmask(SIGKILL) | rt_sigmask(SIGSTOP))

留言3:

胖容器的init进程其实是一个bash脚本run.sh, 由它来启动jvm的程序。
但是run.sh本身没有注册SIGTERM handler, 也不forward SIGTERM给子进程jvm。
当stop容器的时候,run.sh先收到一个SIGTERM, run.sh没有注册SIGTERM, 所以呢对SIGTERM没有反应,contaienrd过30秒,会发SIGKILL给run.sh, 这样run.sh退出do_exit(),在退出的时候同样给子进程jvm程序发送了SIGKILL而不是SIGTERM。其实呢,jvm的程序是注册了SIGTERM handler的,但是没有机会调用handler了。

留言4:

问题:老师,容器的最佳实践一般都是一个容器即一个进程,一般如果按照这种做法,就只需要在应用程序进程中对sigterm信号做捕获并处理就行了吧,无需转发吧
回答:是的

留言5:

strace 主要用来查看程序调用了哪些系统调用已经收到什么信号。

留言6:

问题:不太明白zap_pid_ns_processes()这个函数为啥是发送SIGKILL信号,不能设计成发送SIGTERM么,如果是term信号,岂不是就没有容器中子进程中收到sigkill信号的问题了么

回答:好问题!
不过只有SIGKILL才可以强制杀进程。如果namespace中有进程忽略了SIGTERM,那么就会有进程残留了。

留言7:

问题:老师,请教一个问题,tini 会把其他所有的信号都转发给它的子进程,假如我的子进程又创建了子进程(也就是tini的孙子进程),tini会把信号转发给孙子进程吗?

回答:
我们可以从tini转发信号的代码看一下。如果 “kill_process_group” 没有设置, 为0时,这也是tini缺省的配置,那么SIGTERM只会转发给子进程,而子子进程就不会收到转发的SIGTERM。当子进程退出的时候,子子进程就会收到SIGKILL。

而如果kill_process_group > 0的时候,同时子进程与子子进程在同一个process group的时候 (缺省fork出来的子进程会和父进程在同一个process group), 那么子子进程就会收到SIGTERM

if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo))

留言8:

问题:
老师,这里的逻辑我还没有理顺。

  1. 你说的容器init 进程,是不是就是容器的第一个进程?还有是不是如果我使用docker , 容器的第一个进程一定不是我自己的进程,而是tini 进程?

  2. 上文所SIGTerm 发送后,触发do exit 函数,SIGkill 其实是在内核往容器内的其他子进程发送的。那当我在init 进程配置了Sig term handler 截取信号转发sigterm 以后,do exit 函数还会被调用吗?如果不被调用,do exit 里其他的退出逻辑怎么被执行呢?如果被调用,怎么就不执行sigkill 了呢?

回答:
作者回复:
1
是的, init进程就是容器里的第一个进程。容器里的第一个进程大部分情况应该是我们自己的进程,除非有容器用户有意识的去使用tini进程作为init进程。

2
很好的问题。
init 进程自己退出,还是会调用do_exit()的。所以呢,为了保证子进程先收到转发的SIGTERM, 类似tini的做法是,自己在收到SIGTERM的时候不退出,转发SIGTERM给子进程,子进程收到SIGTERM退出之后会给父进程发送SIGCHILD, tini是收到SIGCHILD之后主动整个程序退出。

留言9:

-> 之前我们一直都是应用程序作为PID1来运行的,好像也没啥问题

信号对容器中进程的影响的多少,也有多方面的原因,比如程序本身对错误的容忍度比较高, 容器建立删除的频率不高,那么也就看不出有什么影响。

如果你的程序的容器化程度较高,几乎是一个容器一个进程的程度,那么不需要考虑用tini来做改动。

我觉得容器里的init进程,应该是具备这些信号处理的能力:
1 至少转发SIGTERM给容器里其他的关键子进程。
2 能够接受到外部的SIGTERM信号而退出,(这里可以是通过注册SIGTERM handler, 也可以像tini一样先转发SIGTERM 给子进程,然后收到SIGCHILD后自己主动退出)
3 具有回收zombie进程的能力。

留言10
问题:想问下老师,那k8s里的优雅关闭选项是否就是做了这个操作

回答:k8s在delete pod的时候,通过containerd先向容器发送SIGTERM.
这个graceful shutdown是需要容器中的进程自己来处理SIGTERM

留言11
问题:老师,我有个疑问哈,tini没有注册SIGTERM,按照前面将的,内核是不会把这个信号发送给tini进程的,为啥它又能接收所有的信号(除了SIGHILD)并转发给子进程呢?我对这块的理解的不是很清晰,望指教。

回答:很好的问题!
因为在tini里调用的sigtimedwait()系统调用,直接把发送给tini的信号先截了下来,这时候tini有没有SIGTERM的handler就没有关系了。

留言12
问题:我的容器的init进程是通过bash拉起的Linux守护进程,然后守护进程会创建子进程一个MySQL实例,为了优雅退出,我该如何改写init进程呢?

回答:你这里的“Linux守护进程”指的是mysqld吗?如果是只是想mysqld graceful shutdown, 可以用tini来启动mysqld, 不过还需要看你的bash里有没有做其他的工作。

留言13
问题:init进程收到 SIGTERM,是在 exit_notify 中回收子进程的 pid 资源吗?( 应该肯定也是在这儿发送推出信号给父进程的吧! )

回答:是的,在exit_notify中会做一次wait()子进程。

留言14
问题:
A:你说的进程被强制杀死,主要是指这个进程是init的子进程吧?如果我的应用不是多进程的应用,不会产生子进程,那就没有被强制杀死的问题了?

B:在平常写代码子进程的时候,我没有注意过写sigterm15这个情况的处理,比如Java或者tomcat,Python启动的,如果没写这类的hander,那么收到sigterm信号,是不是也不会处理,最后被强制杀死?

回答:
A,
是的,但进程的容器,在容器停止的时候,不会有这个问题。

B,
如果用python启动,但是没有转发信号,那么容器结束的时候,子进程就会被强制杀死。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 这可能是因为进程在被杀死之前有一部分任务还未完成,导致该进程被操作系统暂时挂起。此时虽然该进程已经被杀死,但是它留下的一些未完成的任务可能会继续运行,这就导致了进程没有完全消失的情况。这种情况下,需要等待这些任务完成后,才能完全结束该进程。 ### 回答2: 进程被手动杀死后仍然没有终止的原因可能有以下几种情况: 1. 进程具有在杀死后自动重启的属性。有些应用程序或服务具有自动重启的机制,即使手动杀死进程,系统会自动重新启动该进程,使其继续运行。 2. 进程具有子进程或相关进程。某些进程可能会创建子进程或与其他进程建立依赖关系。当你手动杀死一个进程时,可能只是杀死了主进程,而子进程或相关进程可能还在继续运行,导致进程没有完全终止。 3. 进程处于僵尸状态。进程在终止时会向父进程发送终止信号,但如果父进程没有正确处理终止信号,可能会导致进程变成僵尸状态。僵尸进程不再执行任何代码,但仍然占用系统资源,直到父进程正确处理终止信号,将其清理掉。 4. 进程被其他进程或系统占用。在某些情况下,进程可能会被其他进程或系统占用,导致无法立即终止。这可能是由于进程的资源被其他进程锁定,或者进程正在执行某些需要较长时间完成的操作,例如写入文件或网络通信等。 如果以上情况都不是造成进程无法终止的原因,可能是系统存在其他问题,例如操作系统的错误或软件bug等。可以尝试重新启动系统或检查系统日志以查找进一步的线索。 ### 回答3: 进程是计算机正在运行的程序的实例。当手动杀死一个进程时,我们实际上是发送了一个信号给该进程,告诉它结束运行。然而,进程是否立即终止取决于操作系统的调度和处理信号的方式。 首先,操作系统可能需要一些时间来处理终止信号。这可能涉及到一些清理工作,比如关闭文件、释放内存等。这个过程可能需要一些时间,而在此期间进程仍然会继续运行。 其次,操作系统根据调度算法来确定进程的运行顺序。即使收到终止信号,进程也可能会等待到它被调度执行之前才真正终止。这意味着即使发送了终止信号,进程仍然可能继续运行一段时间。 此外,还有一些特殊情况可能导致进程没有立即终止。例如,进程可能处于忙碌状态,正在执行一些耗时的任务,当它完成这些任务之前,即使收到了终止信号,也不会立即停止。 总结起来,虽然手动杀死进程会发送终止信号,但进程并不会立即停止。这是因为操作系统需要处理信号和资源释放,以及调度执行的相关因素。因此,用手动杀死进程的方式来强制终止一个进程可能需要一些时间,不是立即生效的。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值