Titan笔记

  • 首页
  • Java
  • 数据结构
  • C语言
  • Web
  • 杂谈
  • 移动开发
  • 逸笔挥墨
Titan笔记
分享学习,研究与开发的点滴记忆
  1. 首页
  2. 云原生
  3. Kubernetes
  4. 正文

K8s中Pod的基本概念

2022年12月17日 94点热度 0人点赞 0条评论

前言

这篇文章是在公司K8s读书会集体学习的时候总结和分享的一些内容,参考了Kubernetes官方文档和一些有关的书籍。

什么是 Pod?

Kubernetes 中的 Pod 是最小的可部署的计算单元,是 Kubernetes 项目的原子调度单位。Pod 是由一组共享 Linux namespace、cgroup 和一些其他的隔离资源组成,用来隔离容器,并且每个独立的应用可能会进一步实施隔离。

Pod 是 Kubernetes 项目中最小的 API 对象,在 Kubernetes 中可以创建和管理,拥有一组 Linux namespace、cgroup和一些其他的隔离资源,类似于用来隔离 Docker 容器的技术,而且在 Pod 的上下文中,每个独立的应用可能会进一步实施隔离。

类比 Docker 概念的术语,Pod 就像是共享namespace和cgroup的一组 Docker(一个或多个),它们共享存储、网络、以及怎样运行这些容器的声明,作为 Kubernetes 项目中最小的可部署的计算单元,Pod 是 Kubernetes 项目的原子调度单位。

K8s中Pod的基本概念插图 K8s中Pod的基本概念插图1

我们为什么需要Pod?

在前面提到过一个问题:K8s相比Docker Swarm到底好在哪里?

Swarm中的成组调度问题

书中给了这样的一个例子:

已知 rsyslogd 由三个进程组成,一个 imklog 模块,一个 imuxsock 模块,一个 rsyslogd 自己的 main 函数主进程。

这三个进程一定要运行在同一台机器上,否则,它们之间基于 Socket 的通信和文件交换,都会出现问题。

现在,我要把 rsyslogd 这个应用给容器化,由于受限于容器的“单进程模型”,这三个模块必须被分别制作成三个不同的容器。

而在这三个容器运行的时候,它们设置的内存配额都是 1 GB。

容器的“单进程模型”,并不是指容器里只能运行“一个”进程,而是指容器没有管理多个进程的能力。

这是因为容器里 PID=1 的进程就是应用本身,其他的进程都是这个 PID=1 进程的子进程。

可是,用户编写的应用,并不能够像正常操作系统里的 init 进程或者 systemd 那样拥有进程管理的功能。

比如,你的应用是一个 Java Web 程序(PID=1),然后你执行 docker exec 在后台启动了一个 Nginx 进程(PID=3)。

可是,当这个 Nginx 进程异常退出的时候,你该怎么知道呢?这个进程退出后的垃圾收集工作,又应该由谁去做呢?

假设我们的 Kubernetes 集群上有两个节点:node-1 上有 3 GB 可用内存,node-2 有 2.5 GB 可用内存。

这时,假设我要用 Docker Swarm 来运行这个 rsyslogd 程序。

为了能够让这三个容器都运行在同一台机器上,我就必须在另外两个容器上设置一个 affinity=main(与 main 容器有亲密性)的约束,即:它们俩必须和 main 容器运行在同一台机器上。

这样,这三个容器都会进入 Swarm 的待调度队列。

然后,main 容器和 imklog 容器都先后出队并被调度到了 node-2 上(这个情况是完全有可能的)。

可是,当 imuxsock 容器出队开始被调度时,Swarm 就有点懵了:node-2 上的可用资源只有 0.5 GB 了,并不足以运行 imuxsock 容器;可是,根据 affinity=main 的约束,imuxsock 容器又只能运行在 node-2 上。

这就是一个典型的成组调度(gang scheduling)没有被妥善处理的例子。

K8s是怎么解决的

Pod 是 Kubernetes 里的原子调度单位。这就意味着,Kubernetes 项目的调度器,是统一按照 Pod 而非容器的资源需求进行计算的。

所以,像 imklog、imuxsock 和 main 函数主进程这样的三个容器,正是一个典型的由三个容器组成的 Pod。

Kubernetes 项目在调度时,自然就会去选择可用内存等于 3 GB 的 node-1 节点进行绑定,而根本不会考虑 node-2,也就是说其一定不会被调度到 node-2 节点上。

K8s中的容器设计模式

Pod 共享 Network Namespace

在 Kubernetes 项目里,Pod 的实现需要使用一个中间容器,这个容器叫作 Infra 容器。

在 Pod 中,Infra 容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过 Join Network Namespace 的方式,与 Infra 容器关联在一起。

K8s中Pod的基本概念插图2 K8s中Pod的基本概念插图3

如上图所示,这个 Pod 里有两个用户容器 A 和 B,还有一个 Infra 容器。

在 Kubernetes 项目里,Infra 容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,

叫作:k8s.gcr.io/pause。这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器,解压后的大小也只有 100~200 KB 左右

而在 Infra 容器“Hold 住”Network Namespace 后,用户容器就可以加入到 Infra 容器的 Network Namespace 当中了。

所以,如果你查看这些容器在宿主机上的 Namespace 文件(这个 Namespace 文件的路径,我已经在前面的内容中介绍过),它们指向的值一定是完全一样的。(/proc/pid/ns/net)

这也就意味着,对于 Pod 里的容器 A 和容器 B 来说:

它们可以直接使用 localhost 进行通信;它们看到的网络设备跟 Infra 容器看到的完全一样;

一个 Pod 只有一个 IP 地址,也就是这个 Pod 的 Network Namespace 对应的 IP 地址;

其他的所有网络资源,都是一个 Pod 一份,并且被该 Pod 中的所有容器共享;

Pod 的生命周期只跟 Infra 容器一致,而与容器 A 和 B 无关。

Pod 共享 Volume

同一个 Pod 中的多个容器能够共享 Pod 级别的存储卷 Volume,K8s把所有 Volume 的定义都设计在 Pod 层级

一个 Volume 对应的宿主机目录对于 Pod 来说就只有一个,Pod 里的容器只要声明挂载这个 Volume,就一定可以共享这个 Volume 对应的宿主机目录

pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: two-containers
spec:
  restartPolicy: Never
  volumes:
  - name: shared-data
    hostPath:      
      path: /data
  containers:
  - name: nginx-container
    image: nginx
    volumeMounts:
    - name: shared-data
      mountPath: /usr/share/nginx/html
  - name: debian-container
    image: debian
    volumeMounts:
    - name: shared-data
      mountPath: /pod-data
    command: ["/bin/sh"]
    args: ["-c", "echo Hello from the debian container > /pod-data/index.html"]

在这个例子中,debian-container 和 nginx-container 都声明挂载了 shared-data 这个 Volume。

而 shared-data 是 hostPath 类型。所以,它对应在宿主机上的目录就是:/data。

而这个目录,其实就被同时绑定挂载进了上述两个容器当中。

Pod 这种“超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器

Init Container

Init Container相比普通的容器有着这样的特点:

1、在 Pod 中,所有 Init Container 定义的容器,都会比 spec.containers 定义的用户容器先启动。

2、Init Container 容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。

如果 Pod 的 Init 容器失败,Kubernetes 会不断地重启该 Pod,直到 Init 容器成功为止。然而,如果 Pod 对应的 restartPolicy 值为 Never,它不会重新启动。

我们现在有一个 Java Web 应用的 WAR 包,它需要被放在 Tomcat 的 webapps 目录下运行起来。

pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: javaweb-2
spec:
  initContainers:
  - image: geektime/sample:v2
    name: war
    command: ["cp", "/sample.war", "/app"]
    volumeMounts:
    - mountPath: /app
      name: app-volume
  containers:
  - image: geektime/tomcat:7.0
    name: tomcat
    command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
    volumeMounts:
    - mountPath: /root/apache-tomcat-7.0.42-v2/webapps
      name: app-volume
    ports:
    - containerPort: 8080
      hostPort: 8001 
  volumes:
  - name: app-volume
    emptyDir: {}

Init Container 类型的 WAR 包容器启动后,将会把应用的 WAR 包拷贝到 /app 目录下,然后退出

这个 /app 目录,挂载了一个名叫 app-volume 的 Volume

Tomcat 容器,同样声明了挂载 app-volume 到自己的 webapps 目录下

等 Tomcat 容器启动时,它的 webapps 目录下就一定会存在 sample.war 文件:这个文件正是 WAR 包容器启动时拷贝到这个 Volume 里面的,而这个 Volume 是被这两个容器共享的

Sidecar

上面这个例子中,WAR容器和Tomcat的“组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。

顾名思义,sidecar 指的就是我们可以在一个 Pod 中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。

比如,在我们的这个应用 Pod 中,Tomcat 容器是我们要使用的主容器,而 WAR 包容器的存在,只是为了给它提供一个 WAR 包而已。所以,我们用 Init Container 的方式优先运行 WAR 包容器,扮演了一个 sidecar 的角色。

关于Sidecar举的另外一个例子:

比如,我现在有一个应用,需要不断地把日志文件输出到容器的 /var/log 目录中。这时,我就可以把一个 Pod 里的 Volume 挂载到应用容器的 /var/log 目录上。

然后,我在这个 Pod 里同时运行一个 sidecar 容器,它也声明挂载同一个 Volume 到自己的 /var/log 目录上。

这样,接下来 sidecar 容器就只需要做一件事儿,那就是不断地从自己的 /var/log 目录里读取日志文件,转发到 MongoDB 或者 Elasticsearch 中存储起来。这样,一个最基本的日志收集工作就完成了。

跟第一个例子一样,这个例子中的 sidecar 的主要工作也是使用共享的 Volume 来完成对文件的操作。

Pod的一些基本概念

Pod,而不是容器,才是 Kubernetes 项目中的最小编排单位。

将这个设计落实到 API 对象上,容器(Container)就成了 Pod 属性里的一个普通的字段。

那么,一个很自然的问题就是:到底哪些属性属于 Pod 对象,而又有哪些属性属于 Container 呢?

凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的

凡是跟容器的 Linux Namespace 相关的属性,也一定是 Pod 级别的

这个原因也很容易理解:Pod 的设计,就是要让它里面的容器尽可能多地共享 Linux Namespace,仅保留必要的隔离和限制能力。这样,Pod 模拟出的效果,就跟虚拟机里程序间的关系非常类似了。

Pod的属性子段

NodeSelector:是一个供用户将 Pod 与 Node 进行绑定的字段

pod.yml

apiVersion: v1
kind: Pod
...
spec:
 nodeSelector:
   disktype: ssd

 

这样的一个配置,意味着这个 Pod 永远只能运行在携带了“disktype: ssd”标签(Label)的节点上;否则,它将调度失败。(这里有个疑问:K8s的节点如何定义自己的标签?)

NodeName:一旦 Pod 的这个字段被赋值,Kubernetes 项目就会被认为这个 Pod 已经经过了调度,调度的结果就是赋值的节点名字。

HostAliases:定义了 Pod 的 hosts 文件(比如 /etc/hosts)里的内容

pod.yml

apiVersion: v1
kind: Pod
...
spec:
  hostAliases:
  - ip: "10.1.2.3"
    hostnames:
    - "foo.remote"
    - "bar.remote"
...

需要指出的是,在 Kubernetes 项目中,如果要设置 hosts 文件里的内容,一定要通过这种方法。否则,如果直接修改了 hosts 文件的话,在 Pod 被删除重建之后,kubelet 会自动覆盖掉被修改的内容。

shareProcessNamespace:这个字段如果为true,就意味着这个 Pod 里的容器要共享 PID Namespace(容器之间能互相看到Pid)

tty和std:在 Pod 的 YAML 文件里声明开启它们,等同于设置了 docker run 里的 -it(-i 即 stdin,-t 即 tty)参数,可以使用标准输入输出和容器进行交互

ImagePullPolicy:

  • Always:默认值,当容器的镜像是类似于 nginx 或者 nginx:latest 这样的名字时,ImagePullPolicy 也会被认为 Always。即每次创建 Pod 都重新拉取一次镜像(当远程仓库的镜像信息与本地相同则跳过)
  • Never:从来不拉取新镜像
  • IfNotPresent:优先使用本地镜像,当本地不存在,则拉取远程仓库镜像;当本地存在,无论远程仓库镜像信息与本地是否相同都使用本地的

Lifecycle:用于Hook容器状态的变化

pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: lifecycle-demo
spec:
  containers:
  - name: lifecycle-demo-container
    image: nginx
    lifecycle:
      postStart:
        exec:
          command: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"]
      preStop:
        exec:
          command: ["/usr/sbin/nginx","-s","quit"]

Pod 对象在 Kubernetes 中的生命周期

Pod 生命周期的变化,主要体现在 Pod API 对象的 Status 部分,这是它除了 Metadata 和 Spec 之外的第三个重要字段。

K8s中Pod的基本概念插图4

其中,pod.status.phase,就是 Pod 的当前状态,它有如下几种可能的情况:

取值 描述
Pending Pod 的 YAML 文件已经提交给了 Kubernetes,API 对象已经被创建并保存在 Etcd 中,但有一个或者多个容器尚未创建亦未运行。比如,调度不成功。此阶段包括等待 Pod 被调度的时间和通过网络下载镜像的时间。
Running Pod 已经绑定到了某个节点,Pod 中所有的容器都已被创建。至少有一个容器仍在运行,或者正处于启动或重启状态。
Succeeded Pod 里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。
Failed Pod 中的所有容器都已终止,并且至少有一个容器是因为失败终止。也就是说,容器以非 0 状态退出或者被系统终止。
Unknown 因为某些原因无法取得 Pod 的状态。这种情况通常是因为与 Pod 所在主机通信失败,意味着 Pod 的状态不能持续地被 kubelet 汇报给 kube-apiserver,这很有可能是主从节点(Master 和 Kubelet)间的通信出现了问题

Pod 有一个 PodStatus 对象,其中包含一个 PodConditions 数组。

K8s官方文档中对PodConditions的解释

  • PodScheduled:Pod 已经被调度到某节点;
  • ContainersReady:Pod 中所有容器都已就绪;
  • Initialized:所有的 Init 容器 都已成功完成;
  • Ready:Pod 可以为请求提供服务,并且应该被添加到对应服务的负载均衡池中

英文文档中有一个新的PodCondition:

  • PodHasNetwork: (alpha feature; must be enabled explicitly) the Pod sandbox has been successfully created and networking configured.

疑问:书中有提到Unschedulable状态,翻阅K8s文档中没有看到这个状态?

Projected Volume (PV)

到目前为止,Kubernetes 支持的 Projected Volume 一共有四种:

  1. Secret
  2. ConfigMap
  3. Downward API
  4. ServiceAccountToken

Secret

它的作用,是帮你把 Pod 想要访问的加密数据,存放到 Etcd 中。然后,你就可以通过在 Pod 的容器里挂载 Volume 的方式,访问到这些 Secret 里保存的信息了。

Secret 最典型的使用场景,存放数据库的 Credential 信息

pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: test-projected-volume 
spec:
  containers:
  - name: test-secret-volume
    image: busybox
    args:
    - sleep
    - "86400"
    volumeMounts:
    - name: mysql-cred
      mountPath: "/projected-volume"
      readOnly: true
  volumes:
  - name: mysql-cred
    projected:
      sources:
      - secret:
          name: user
      - secret:
          name: pass

 

$ cat ./username.txt
admin
$ cat ./password.txt
c1oudc0w!

$ kubectl create secret generic user --from-file=./username.txt
$ kubectl create secret generic pass --from-file=./password.txt

pod.yml

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  user: YWRtaW4=
  pass: MWYyZDFlMmU2N2Rm

 

Secret会被挂载到/projected-volume/{name}上,像这样通过挂载方式进入到容器里的 Secret,一旦其对应的 Etcd 里的数据被更新,这些 Volume 里的文件内容,同样也会被更新。其实,这是 kubelet 组件在定时维护这些 Volume。

ConfigMap

它与 Secret 的区别在于,ConfigMap 保存的是不需要加密的、应用所需的配置信息。

而 ConfigMap 的用法几乎与 Secret 完全相同:你可以使用 kubectl create configmap 从文件或者目录创建 ConfigMap,也可以直接编写 ConfigMap 对象的 YAML 文件。

Download API

能够让 Pod 里的容器能够直接获取到这个 Pod API 对象本身的信息

  1. 使用fieldRef可以声明使用:
  • spec.nodeName - 宿主机名字
  • status.hostIP - 宿主机IP
  • metadata.name - Pod 的名字
  • metadata.namespace - Pod 的 Namespace
  • status.podIP - Pod 的 IP
  • spec.serviceAccountName - Pod 的 Service Account 的名字
  • metadata.uid - Pod 的 UID
  • metadata.labels['<KEY>'] - 指定<KEY>的 Label 值
  • metadata.annotations['<KEY>'] - 指定<KEY>的 Annotation 值
  • metadata.labels - Pod 的所有 Label
  • metadata.annotations - Pod 的所有 Annotation
  1. 使用 resourceFieldRef 可以声明使用:
  • 容器的CPU limit
  • 容器的CPU request
  • 容器的memory limit
  • 容器的memory request

ServiceAccount

ServiceAccount 为 Pod 中的进程提供身份信息。

当真人用户访问集群(例如使用kubectl命令)时,apiserver 会将用户认证为一个特定的 User Account(目前通常是admin,除非自定义了集群配置)

Pod 容器中的进程也可以与 apiserver 联系。 当它们在联系 apiserver 的时候,它们会被认证为一个特定的 Service Account(例如default),可以在 pod 中使用自动挂载的 service account 凭证来访问 API

容器健康检查和恢复机制

容器健康检查

在 Kubernetes 中,你可以为 Pod 里的容器定义一个健康检查“探针”(Probe)。

这样,kubelet 就会根据这个 Probe 的返回值决定这个容器的状态,而不是直接以容器镜像是否运行(来自 Docker 返回的信息)作为依据。

这种机制,是生产环境中保证应用健康存活的重要手段。

pod.yml

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness
  name: test-liveness-exec
spec:
  containers:
  - name: liveness
    image: busybox
    args:
    - /bin/sh
    - -c
    - touch /tmp/healthy; sleep 30; rm -rf /tmp/healthy; sleep 600
    livenessProbe:
      exec:
        command:
        - cat
        - /tmp/healthy
      initialDelaySeconds: 5
      periodSeconds: 5

 

在这个 Pod 中,我们定义了一个有趣的容器。它在启动之后做的第一件事,就是在 /tmp 目录下创建了一个 healthy 文件,以此作为自己已经正常运行的标志。而 30 s 过后,它会把这个文件删除掉。

与此同时,我们定义了一个这样的 livenessProbe(健康检查)。

它的类型是 exec,这意味着,它会在容器启动后,在容器里面执行一条我们指定的命令,比如:“cat /tmp/healthy”。

这时,如果这个文件存在,这条命令的返回值就是 0,Pod 就会认为这个容器不仅已经启动,而且是健康的。

这个健康检查,在容器启动 5 s 后开始执行(initialDelaySeconds: 5),每 5 s 执行一次(periodSeconds: 5)。

恢复机制

在K8s集群里面实验性的创建这个Pod,发现Pod的状态一直是Running状态

如果注意到 RESTARTS 字段从 0 到 1 的变化,就明白原因了:

这个异常的容器已经被 Kubernetes 重启了。在这个过程中,Pod 保持 Running 状态不变。

K8s的Restart,实际上是重新创建了一个容器来替换异常的容器。

这个功能就是 Kubernetes 里的 Pod 恢复机制,也叫 restartPolicy。它是 Pod 的 Spec 部分的一个标准字段(pod.spec.restartPolicy),默认值是 Always

  • Always:在任何情况下,只要容器不在运行状态,就自动重启容器;
  • OnFailure: 只在容器 异常时才自动重启容器;
  • Never: 从来不重启容器。

Pod 的恢复过程,永远都是发生在当前节点上,而不会跑到别的节点上去。

事实上,一旦一个 Pod 与一个节点(Node)绑定,除非这个绑定发生了变化(pod.spec.node 字段被修改),否则它永远都不会离开这个节点。

这也就意味着,如果这个宿主机宕机了,这个 Pod 也不会主动迁移到其他节点上去。

  • 只要 Pod 的 restartPolicy 指定的策略允许重启异常的容器(比如:Always),那么这个 Pod 就会保持 Running 状态,并进行容器重启。否则,Pod 就会进入 Failed 状态 。
  • 对于包含多个容器的 Pod,只有它里面所有的容器都进入异常状态后,Pod 才会进入 Failed 状态。在此之前,Pod 都是 Running 状态。此时,Pod 的 READY 字段会显示正常容器的个数

PodPreset

pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: website
  labels:
    app: website
    role: frontend
spec:
  containers:
    - name: website
      image: nginx
      ports:
        - containerPort: 80

 

podPreset.yml

apiVersion: settings.k8s.io/v1alpha1
kind: PodPreset
metadata:
  name: allow-database
spec:
  selector:
    matchLabels:
      role: frontend
  env:
    - name: DB_PORT
      value: "6379"
  volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
    - name: cache-volume
      emptyDir: {}

 

pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: website
  labels:
    app: website
    role: frontend
  annotations:
    podpreset.admission.kubernetes.io/podpreset-allow-database: "resource version"
spec:
  containers:
    - name: website
      image: nginx
      volumeMounts:
        - mountPath: /cache
          name: cache-volume
      ports:
        - containerPort: 80
      env:
        - name: DB_PORT
          value: "6379"
  volumes:
    - name: cache-volume
      emptyDir: {}

PodPreset 里定义的内容,只会在 Pod API 对象被创建之前追加在这个对象本身上,而不会影响任何 Pod 的控制器的定义,比如Deployment。

如果你定义了同时作用于一个 Pod 对象的多个 PodPreset,Kubernetes 项目会帮你合并(Merge)这两个 PodPreset 要做的修改。

而如果它们要做的修改有冲突的话,这些冲突字段就不会被修改。

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
标签: 暂无
最后更新:2022年12月25日

Titan

不为岁月流逝蹉跎,不为潮流的势头去附和

点赞
< 上一篇
下一篇 >

文章评论

您需要 登录 之后才可以评论
最新 热点 随机
最新 热点 随机
Docker配置IPv6容器网络支持 什么是Elastic Stack,ELK的发展历程 K8s中Pod的基本概念 Pushkin AI - 基于OpenAI-ChatGPT / GPT3的问答机器人 云原生 - 浅谈容器基础与K8S架构设计 腾讯Serverless体验,使用TypeScript编写并部署云函数
Docker配置IPv6容器网络支持
Hadoop安装与环境配置入门 [数据结构]结构体练习之复数运算 [Java] 在Java中优雅地进行文件IO操作 Appium移动测试入门指南(一)- Appium概述 MySQL ORDER BY,GROUPBY 与各种JOIN [数据结构] 括号符的匹配
分类
  • Android
  • C语言
  • Elasticsearch
  • Hadoop
  • Hive
  • Java
  • JavaWeb
  • Kubernetes
  • Linux运维之道
  • Mybatis学习笔记
  • Python
  • SpringCloud
  • Web
  • Web前端
  • Web后端
  • 云原生
  • 并发编程
  • 开发工具
  • 数据库
  • 数据结构
  • 杂谈
  • 移动开发
  • 移动测试
  • 诗词歌赋
  • 软件测试
  • 逸笔挥墨
  • 随摘
标签聚合
Mybatis学习笔记 Apache-Hive 链式存储 Java Python 数据结构 JavaWeb 二叉树

COPYRIGHT © 2013-2021 Titan. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

豫ICP备20001822号-1

豫公网安备 41010502004418号