作者

王成,腾讯云研发工程师,Kubernetes contributor,从事数据库产品容器化、资源管控等工作,关注 Kubernetes、Go、云原生领域。

概述

进入 K8s 的世界,会发现有很多方便扩展的 Interface,包括 CSI, CNI, CRI 等,将这些接口抽象出来,是为了更好的提供开放、扩展、规范等能力。

K8s 持久化存储经历了从 in-tree Volume 到 CSI Plugin(out-of-tree) 的迁移,一方面是为了将 K8s 核心主干代码与 Volume 相关代码解耦,便于更好的维护;另一方面则是为了方便各大云厂商实现统一的接口,提供个性化的云存储能力,以期达到云存储生态圈的开放共赢。

本文将从持久卷 PV 的 创建(Create)、附着(Attach)、分离(Detach)、挂载(Mount)、卸载(Unmount)、删除(Delete) 等核心生命周期,对 CSI 实现机制进行了解析。

相关术语

Term Definition
CSI Container Storage Interface.
CNI Container Network Interface.
CRI Container Runtime Interface.
PV Persistent Volume.
PVC Persistent Volume Claim.
StorageClass Defined by provisioner(i.e. Storage Provider), to assemble Volume parameters as a resource object.
Volume A unit of storage that will be made available inside of a CO-managed container, via the CSI.
Block Volume A volume that will appear as a block device inside the container.
Mounted Volume A volume that will be mounted using the specified file system and appear as a directory inside the container.
CO Container Orchestration system, communicates with Plugins using CSI service RPCs.
SP Storage Provider, the vendor of a CSI plugin implementation.
RPC Remote Procedure Call.
Node A host where the user workload will be running, uniquely identifiable from the perspective of a Plugin by a node ID.
Plugin Aka “plugin implementation”, a gRPC endpoint that implements the CSI Services.
Plugin Supervisor Process that governs the lifecycle of a Plugin, MAY be the CO.
Workload The atomic unit of "work" scheduled by a CO. This MAY be a container or a collection of containers.

本文及后续相关文章都基于 K8s v1.22

流程概览

PV 创建核心流程:

  • apiserver 创建 Pod,根据 PodSpec.Volumes 创建 Volume;
  • PVController 监听到 PV informer,添加相关 Annotation(如 pv.kubernetes.io/provisioned-by),调谐实现 PVC/PV 的绑定(Bound);
  • 判断 StorageClass.volumeBindingModeWaitForFirstConsumer 则等待 Pod 调度到 Node 成功后再进行 PV 创建,Immediate 则立即调用 PV 创建逻辑,无需等待 Pod 调度;
  • external-provisioner 监听到 PV informer, 调用 RPC-CreateVolume 创建 Volume;
  • AttachDetachController 将已经绑定(Bound) 成功的 PVC/PV,经过 InTreeToCSITranslator 转换器,由 CSIPlugin 内部逻辑实现 VolumeAttachment 资源类型的创建;
  • external-attacher 监听到 VolumeAttachment informer,调用 RPC-ControllerPublishVolume 实现 AttachVolume;
  • kubelet reconcile 持续调谐:通过判断 controllerAttachDetachEnabled || PluginIsAttachable 及当前 Volume 状态进行 AttachVolume/MountVolume,最终实现将 Volume 挂载到 Pod 指定目录中,供 Container 使用;

从 CSI 说起

CSI(Container Storage Interface) 是由来自 Kubernetes、Mesos、Docker 等社区 member 联合制定的一个行业标准接口规范(https://github.com/container-storage-interface/spec),旨在将任意存储系统暴露给容器化应用程序。

CSI 规范定义了存储提供商实现 CSI 兼容的 Volume Plugin 的最小操作集和部署建议。CSI 规范的主要焦点是声明 Volume Plugin 必须实现的接口。

先看一下 Volume 的生命周期:

  1. CreateVolume +------------+ DeleteVolume
  2. +------------->| CREATED +--------------+
  3. | +---+----^---+ |
  4. | Controller | | Controller v
  5. +++ Publish | | Unpublish +++
  6. |X| Volume | | Volume | |
  7. +-+ +---v----+---+ +-+
  8. | NODE_READY |
  9. +---+----^---+
  10. Node | | Node
  11. Stage | | Unstage
  12. Volume | | Volume
  13. +---v----+---+
  14. | VOL_READY |
  15. +---+----^---+
  16. Node | | Node
  17. Publish | | Unpublish
  18. Volume | | Volume
  19. +---v----+---+
  20. | PUBLISHED |
  21. +------------+
  22. The lifecycle of a dynamically provisioned volume, from
  23. creation to destruction, when the Node Plugin advertises the
  24. STAGE_UNSTAGE_VOLUME capability.

从 Volume 生命周期可以看到,一块持久卷要达到 Pod 可使用状态,需要经历以下阶段:

CreateVolume -> ControllerPublishVolume -> NodeStageVolume -> NodePublishVolume

而当删除 Volume 的时候,会经过如下反向阶段:

NodeUnpublishVolume -> NodeUnstageVolume -> ControllerUnpublishVolume -> DeleteVolume

上面流程的每个步骤,其实就对应了 CSI 提供的标准接口,云存储厂商只需要按标准接口实现自己的云存储插件,即可与 K8s 底层编排系统无缝衔接起来,提供多样化的云存储、备份、快照(snapshot)等能力。

多组件协同

为实现具有高扩展性、out-of-tree 的持久卷管理能力,在 K8s CSI 实现中,相关协同的组件有:

组件介绍

  • kube-controller-manager:K8s 资源控制器,主要通过 PVController, AttachDetach 实现持久卷的绑定(Bound)/解绑(Unbound)、附着(Attach)/分离(Detach);
  • CSI-plugin:K8s 独立拆分出来,实现 CSI 标准规范接口的逻辑控制与调用,是整个 CSI 控制逻辑的核心枢纽;
  • node-driver-registrar:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),它使用 kubelet 插件注册机制向 kubelet 注册插件,需要请求 CSI 插件的 Identity 服务来获取插件信息;
  • external-provisioner:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的创建(Create)、删除(Delete);
  • external-attacher:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的附着(Attach)、分离(Detach);
  • external-snapshotter:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的快照(VolumeSnapshot)、备份恢复等能力;
  • external-resizer:是一个由官方 K8s sig 小组维护的辅助容器(sidecar),主要功能是实现持久卷的弹性扩缩容,需要云厂商插件提供相应的能力;
  • kubelet:K8s 中运行在每个 Node 上的控制枢纽,主要功能是调谐节点上 Pod 与 Volume 的附着、挂载、监控探测上报等;
  • cloud-storage-provider:由各大云存储厂商基于 CSI 标准接口实现的插件,包括 Identity 身份服务、Controller 控制器服务、Node 节点服务;

组件通信

由于 CSI plugin 的代码在 K8s 中被认为是不可信的,因此 CSI Controller Server 和 External CSI SideCar、CSI Node Server 和 Kubelet 通过 Unix Socket 来通信,与云存储厂商提供的 Storage Service 通过 gRPC(HTTP/2) 通信:

RPC 调用

从 CSI 标准规范可以看到,云存储厂商想要无缝接入 K8s 容器编排系统,需要按规范实现相关接口,相关接口主要为:

  • Identity 身份服务:Node Plugin 和 Controller Plugin 都必须实现这些 RPC 集,协调 K8s 与 CSI 的版本信息,负责对外暴露这个插件的信息。
  • Controller 控制器服务:Controller Plugin 必须实现这些 RPC 集,创建以及管理 Volume,对应 K8s 中 attach/detach volume 操作。
  • Node 节点服务:Node Plugin 必须实现这些 RPC 集,将 Volume 存储卷挂载到指定目录中,对应 K8s 中的 mount/unmount volume 操作。

相关 RPC 接口功能如下:

创建/删除 PV

K8s 中持久卷 PV 的创建(Create)与删除(Delete),由 external-provisioner 组件实现,相关工程代码在:【https://github.com/kubernetes-csi/external-provisioner】

首先,通过标准的 cmd 方式获取命令行参数,执行 newController -> Run() 逻辑,相关代码如下:

  1. // external-provisioner/cmd/csi-provisioner/csi-provisioner.go
  2. main() {
  3. ...
  4. // 初始化控制器,实现 Volume 创建/删除接口
  5. csiProvisioner := ctrl.NewCSIProvisioner(
  6. clientset,
  7. *operationTimeout,
  8. identity,
  9. *volumeNamePrefix,
  10. *volumeNameUUIDLength,
  11. grpcClient,
  12. snapClient,
  13. provisionerName,
  14. pluginCapabilities,
  15. controllerCapabilities,
  16. ...
  17. )
  18. ...
  19. // 真正的 ProvisionController,包装了上面的 CSIProvisioner
  20. provisionController = controller.NewProvisionController(
  21. clientset,
  22. provisionerName,
  23. csiProvisioner,
  24. provisionerOptions...,
  25. )
  26. ...
  27. run := func(ctx context.Context) {
  28. ...
  29. // Run 运行起来
  30. provisionController.Run(ctx)
  31. }
  32. }

接着,调用 PV 创建/删除流程:

PV 创建:runClaimWorker -> syncClaimHandler -> syncClaim -> provisionClaimOperation -> Provision -> CreateVolume

PV 删除:runVolumeWorker -> syncVolumeHandler -> syncVolume -> deleteVolumeOperation -> Delete -> DeleteVolume

由 sigs.k8s.io/sig-storage-lib-external-provisioner 抽象了相关接口:

  1. // 通过 vendor 方式引入 sigs.k8s.io/sig-storage-lib-external-provisioner
  2. // external-provisioner/vendor/sigs.k8s.io/sig-storage-lib-external-provisioner/v7/controller/volume.go
  3. type Provisioner interface {
  4. // 调用 PRC CreateVolume 接口实现 PV 创建
  5. Provision(context.Context, ProvisionOptions) (*v1.PersistentVolume, ProvisioningState, error)
  6. // 调用 PRC DeleteVolume 接口实现 PV 删除
  7. Delete(context.Context, *v1.PersistentVolume) error
  8. }

Controller 调谐

K8s 中与 PV 相关的控制器有 PVController、AttachDetachController。

PVController

PVController 通过在 PVC 添加相关 Annotation(如 pv.kubernetes.io/provisioned-by),由 external-provisioner 组件负责完成对应 PV 的创建/删除,然后 PVController 监测到 PV 创建成功的状态,完成与 PVC 的绑定(Bound),调谐(reconcile)任务完成。然后交给 AttachDetachController 控制器进行下一步逻辑处理。

值得一提的是,PVController 内部通过使用 local cache,高效实现了 PVC 与 PV 的状态更新与绑定事件处理,相当于在 K8s informer 机制之外,又自己维护了一个 local store 进行 Add/Update/Delete 事件处理。

首先,通过标准的 newController -> Run() 逻辑:

  1. // kubernetes/pkg/controller/volume/persistentvolume/pv_controller_base.go
  2. func NewController(p ControllerParameters) (*PersistentVolumeController, error) {
  3. ...
  4. // 初始化 PVController
  5. controller := &PersistentVolumeController{
  6. volumes: newPersistentVolumeOrderedIndex(),
  7. claims: cache.NewStore(cache.DeletionHandlingMetaNamespaceKeyFunc),
  8. kubeClient: p.KubeClient,
  9. eventRecorder: eventRecorder,
  10. runningOperations: goroutinemap.NewGoRoutineMap(true /* exponentialBackOffOnError */),
  11. cloud: p.Cloud,
  12. enableDynamicProvisioning: p.EnableDynamicProvisioning,
  13. clusterName: p.ClusterName,
  14. createProvisionedPVRetryCount: createProvisionedPVRetryCount,
  15. createProvisionedPVInterval: createProvisionedPVInterval,
  16. claimQueue: workqueue.NewNamed("claims"),
  17. volumeQueue: workqueue.NewNamed("volumes"),
  18. resyncPeriod: p.SyncPeriod,
  19. operationTimestamps: metrics.NewOperationStartTimeCache(),
  20. }
  21. ...
  22. // PV 增删改事件监听
  23. p.VolumeInformer.Informer().AddEventHandler(
  24. cache.ResourceEventHandlerFuncs{
  25. AddFunc: func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
  26. UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.volumeQueue, newObj) },
  27. DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
  28. },
  29. )
  30. ...
  31. // PVC 增删改事件监听
  32. p.ClaimInformer.Informer().AddEventHandler(
  33. cache.ResourceEventHandlerFuncs{
  34. AddFunc: func(obj interface{}) { controller.enqueueWork(controller.claimQueue, obj) },
  35. UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.claimQueue, newObj) },
  36. DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.claimQueue, obj) },
  37. },
  38. )
  39. ...
  40. return controller, nil
  41. }

接着,调用 PVC/PV 绑定/解绑逻辑:

PVC/PV 绑定:claimWorker -> updateClaim -> syncClaim -> syncBoundClaim -> bind

PVC/PV 解绑:volumeWorker -> updateVolume -> syncVolume -> unbindVolume

AttachDetachController

AttachDetachController 将已经绑定(Bound) 成功的 PVC/PV,内部经过 InTreeToCSITranslator 转换器,实现由 in-tree 方式管理的 Volume 向 out-of-tree 方式管理的 CSI 插件模式转换。

接着,由 CSIPlugin 内部逻辑实现 VolumeAttachment 资源类型的创建/删除,调谐(reconcile) 任务完成。然后交给 external-attacher 组件进行下一步逻辑处理。

相关核心代码在 reconciler.Run() 中实现如下:

  1. // kubernetes/pkg/controller/volume/attachdetach/reconciler/reconciler.go
  2. func (rc *reconciler) reconcile() {
  3. // 先进行 DetachVolume,确保因 Pod 重新调度到其他节点的 Volume 提前分离(Detach)
  4. for _, attachedVolume := range rc.actualStateOfWorld.GetAttachedVolumes() {
  5. // 如果不在期望状态的 Volume,则调用 DetachVolume 删除 VolumeAttachment 资源对象
  6. if !rc.desiredStateOfWorld.VolumeExists(
  7. attachedVolume.VolumeName, attachedVolume.NodeName) {
  8. ...
  9. err = rc.attacherDetacher.DetachVolume(attachedVolume.AttachedVolume, verifySafeToDetach, rc.actualStateOfWorld)
  10. ...
  11. }
  12. }
  13. // 调用 AttachVolume 创建 VolumeAttachment 资源对象
  14. rc.attachDesiredVolumes()
  15. ...
  16. }

附着/分离 Volume

K8s 中持久卷 PV 的附着(Attach)与分离(Detach),由 external-attacher 组件实现,相关工程代码在:【https://github.com/kubernetes-csi/external-attacher】

external-attacher 组件观察到由上一步 AttachDetachController 创建的 VolumeAttachment 对象,如果其 .spec.Attacher 中的 Driver name 指定的是自己同一 Pod 内的 CSI Plugin,则调用 CSI Plugin 的ControllerPublish 接口进行 Volume Attach。

首先,通过标准的 cmd 方式获取命令行参数,执行 newController -> Run() 逻辑,相关代码如下:

  1. // external-attacher/cmd/csi-attacher/main.go
  2. func main() {
  3. ...
  4. ctrl := controller.NewCSIAttachController(
  5. clientset,
  6. csiAttacher,
  7. handler,
  8. factory.Storage().V1().VolumeAttachments(),
  9. factory.Core().V1().PersistentVolumes(),
  10. workqueue.NewItemExponentialFailureRateLimiter(*retryIntervalStart, *retryIntervalMax),
  11. workqueue.NewItemExponentialFailureRateLimiter(*retryIntervalStart, *retryIntervalMax),
  12. supportsListVolumesPublishedNodes,
  13. *reconcileSync,
  14. )
  15. run := func(ctx context.Context) {
  16. stopCh := ctx.Done()
  17. factory.Start(stopCh)
  18. ctrl.Run(int(*workerThreads), stopCh)
  19. }
  20. ...
  21. }

接着,调用 Volume 附着/分离逻辑:

Volume 附着(Attach):syncVA -> SyncNewOrUpdatedVolumeAttachment -> syncAttach -> csiAttach -> Attach -> ControllerPublishVolume

Volume 分离(Detach):syncVA -> SyncNewOrUpdatedVolumeAttachment -> syncDetach -> csiDetach -> Detach -> ControllerUnpublishVolume

kubelet 挂载/卸载 Volume

K8s 中持久卷 PV 的挂载(Mount)与卸载(Unmount),由 kubelet 组件实现。

kubelet 通过 VolumeManager 启动 reconcile loop,当观察到有新的使用 PersistentVolumeSource 为CSI 的 PV 的 Pod 调度到本节点上,于是调用 reconcile 函数进行 Attach/Detach/Mount/Unmount 相关逻辑处理。

  1. // kubernetes/pkg/kubelet/volumemanager/reconciler/reconciler.go
  2. func (rc *reconciler) reconcile() {
  3. // 先进行 UnmountVolume,确保因 Pod 删除被重新 Attach 到其他 Pod 的 Volume 提前卸载(Unmount)
  4. rc.unmountVolumes()
  5. // 接着通过判断 controllerAttachDetachEnabled || PluginIsAttachable 及当前 Volume 状态
  6. // 进行 AttachVolume / MountVolume / ExpandInUseVolume
  7. rc.mountAttachVolumes()
  8. // 卸载(Unmount) 或分离(Detach) 不再需要(Pod 删除)的 Volume
  9. rc.unmountDetachDevices()
  10. }

相关调用逻辑如下:

Volume 挂载(Mount):reconcile -> mountAttachVolumes -> MountVolume -> SetUp -> SetUpAt -> NodePublishVolume

Volume 卸载(Unmount):reconcile -> unmountVolumes -> UnmountVolume -> TearDown -> TearDownAt -> NodeUnpublishVolume

小结

本文通过分析 K8s 中持久卷 PV 的 创建(Create)、附着(Attach)、分离(Detach)、挂载(Mount)、卸载(Unmount)、删除(Delete) 等核心生命周期流程,对 CSI 实现机制进行了解析,通过源码、图文方式说明了相关流程逻辑,以期更好的理解 K8s CSI 运行流程。

可以看到,K8s 以 CSI Plugin(out-of-tree) 插件方式开放存储能力,一方面是为了将 K8s 核心主干代码与 Volume 相关代码解耦,便于更好的维护;另一方面在遵从 CSI 规范接口下,便于各大云厂商根据业务需求实现相关的接口,提供个性化的云存储能力,以期达到云存储生态圈的开放共赢。

PS: 更多内容请关注 k8s-club

相关资料

  1. CSI 规范
  2. Kubernetes 源码
  3. kubernetes-csi 源码
  4. kubernetes-sig-storage 源码
  5. K8s CSI 概念
  6. K8s CSI 介绍

关于我们

更多关于云原生的案例和知识,可关注同名【腾讯云原生】公众号~

福利:

  1. ①公众号后台回复【手册】,可获得《腾讯云原生路线图手册》&《腾讯云原生最佳实践》~
  2. ②公众号后台回复【系列】,可获得《15个系列100+篇超实用云原生原创干货合集》,包含Kubernetes 降本增效、K8s 性能优化实践、最佳实践等系列。

【腾讯云原生】云说新品、云研新术、云游新活、云赏资讯,扫码关注同名公众号,及时获取更多干货!!

如何接入 K8s 持久化存储?K8s CSI 实现机制浅析的更多相关文章

  1. Part_three:Redis持久化存储

    redis持久化存储 Redis是一种内存型数据库,一旦服务器进程退出,数据库的数据就会丢失,为了解决这个问题,Redis提供了两种持久化的方案,将内存中的数据保存到磁盘中,避免数据的丢失. 1.RD ...

  2. 通过Heketi管理GlusterFS为K8S集群提供持久化存储

    参考文档: Github project:https://github.com/heketi/heketi MANAGING VOLUMES USING HEKETI:https://access.r ...

  3. k8s的持久化存储PV&&PVC

    1.PV和PVC的引入 Volume 提供了非常好的数据持久化方案,不过在可管理性上还有不足. 拿前面 AWS EBS 的例子来说,要使用 Volume,Pod 必须事先知道如下信息: 当前 Volu ...

  4. 从零开始入门 K8s | 应用存储和持久化数据卷:核心知识

    作者 | 至天 阿里巴巴高级研发工程师 一.Volumes 介绍 Pod Volumes 首先来看一下 Pod Volumes 的使用场景: 场景一:如果 pod 中的某一个容器在运行时异常退出,被 ...

  5. 从零开始入门 K8s | 应用存储和持久化数据卷:存储快照与拓扑调度

    作者 | 至天 阿里巴巴高级研发工程师 一.基本知识 存储快照产生背景 在使用存储时,为了提高数据操作的容错性,我们通常有需要对线上数据进行 snapshot ,以及能快速 restore 的能力.另 ...

  6. 4.深入k8s:容器持久化存储

    从一个例子入手PV.PVC Kubernetes 项目引入了一组叫作 Persistent Volume Claim(PVC)和 Persistent Volume(PV)的 API 对象用于管理存储 ...

  7. k8s集群,使用pvc方式实现数据持久化存储

    环境: 系统 华为openEulerOS(CentOS7) k8s版本 1.17.3 master 192.168.1.244 node1 192.168.1.245 介绍: 在Kubernetes中 ...

  8. k8s 网络持久化存储之StorageClass(如何一步步实现动态持久化存储)

    StorageClass的作用: 创建pv时,先要创建各种固定大小的PV,而这些PV都是手动创建的,当业务量上来时,需要创建很多的PV,过程非常麻烦. 而且开发人员在申请PVC资源时,还不一定有匹配条 ...

  9. 【原创】K8S使用ceph-csi持久化存储之RBD

    一.集群和组件版本 K8S集群:1.17.3+Ceph集群:Nautilus(stables)Ceph-CSI:release-v3.1snapshotter-controller:release-2 ...

随机推荐

  1. VUE003. 解决data中使用vue-i18n不更新视图问题(computed属性)

    案例 在国际化开发中,有一部分需要国际化的文字是由数据驱动的储存在data中,然而VUE的data存在很多无法实时更新视图的问题,比如v-for循环的标签,当数据层次过深,通过源数据数组的索引改变它的 ...

  2. word文档转成图片

    1:先把word文档转成pdf格式  这个是在word中转成pdf格式,保存好 2:再把pdf格式转成图片 在这个链接中打开https://smallpdf.com/cn/pdf-converter, ...

  3. Linux 文本相关命令(1)

    Linux 文本相关命令(1) 前言 最近线上环境(Windows Server)出现了一些问题,需要分析一下日志.感觉 Windows 下缺少了一些 Linux 系统中的小工具,像在这波操作中用到的 ...

  4. 【PHP数据结构】链表的相关逻辑操作

    链表的操作相对顺序表(数组)来说就复杂了许多.因为 PHP 确实已经为我们解决了很多数组操作上的问题,所以我们可以很方便的操作数组,也就不用为数组定义很多的逻辑操作.比如在 C 中,数组是有长度限制的 ...

  5. Centos 7 设置 SFTP

    近期要给服务器设置一个SFTP用户,可以上传删除修改的SFTP,但是禁止该用户SSH登录.这里记录下来 先升级 来源: https://blog.csdn.net/fenglailea/article ...

  6. php 日期相关的类 DateInterval DateTimeZone DatePeriod

    * DateInterval <?php /** * Created by PhpStorm. * User: Mch * Date: 7/18/18 * Time: 21:30 */ $dat ...

  7. filter_var() 验证邮箱、ip、url的格式 php

    验证邮箱格式的正确与否:你的第一解决方案是什么呢? 不管你们怎么思考的:反正我首先想到的就是字符串查找看是否有@符号: 但是对于结尾的.com或者.net 亦或者.cn等等越来越多的域名验证感觉棘手: ...

  8. [转载]linux下配置mariadb支持中文

    转载网址:http://www.cnblogs.com/vingi/articles/4302330.html 修改/etc/mysql/my.cnfOn MySQL 5.5 I have in my ...

  9. Python接口自动化测试概念以及意义

    接口定义: 接口普遍有两种意思,一种是API(Application Program Interface),应用编程接口,它是一组定义.程序及协议的集合,通过API接口实现计算机软件之间的相互通信.而 ...

  10. Gaussion

    # Kernel density estimation import numpy as np import matplotlib.pyplot as plt from scipy.stats impo ...