An overview of the

We are learning Kubernetes in addition to understand its related concepts, we also need to deeply understand the implementation mechanism of the whole Kubernetes, if you can also understand its source code, that we are more familiar with Kubernetes. I will explain the entire implementation mechanism of Kubernetes in terms of how Kubernetes generates a Deployment resource, along with a source code interpretation. The source version is 1.22

Previous articles – kubernetes apply command source code parsing – an overview of the whole process

  • Kubectl perform apply command source code parsing
  • Kube-apiserver accepts the source code for creating deploy resource requests
  • Kube-controller-manager handles the informer listening for creating deployment resources
  • How does kube-controller-Manager handle data when creating deployment
  • Kube-scheduler resource scheduling principle is analyzed in depth

The previous article mentioned the creation of a Depoyment resource

  1. Kubectl sends the request to Apiserver, which stores the requested resource information into ETCD.
  2. The controller-manager actually controls the resource. Deployment-controller-manager and Replicaset-controller-manager work together to generate pod resources.
  3. The POD resources are stored after ETCD. After receiving the resource change event, Kube-Scheduler conducts scheduling calculation on the resource and writes the information into ETCD again after determining the scheduling node.
  4. How does the pod finally start on the corresponding node? And what happened in the process of getting started? This article will examine how K8S generates the final desired POD on the scheduling node.

The principle of analysis

Yaml kubectl apply -f deploy.yaml kubectl apply -f deploy.yaml And that’s what Kubelet is designed to do. Kubelet is also responsible for monitoring the status of resources on the entire node, maintaining static pods, etc. This article focuses on how Kubelet created the POD. To figure this out, consider the following questions:

  1. What exactly does this POD contain?
  2. How does Kubelet create based on the pod information?
  3. How does it implement this configuration creation at the code level?

Pod information

Relationship between pod and Container

Before explaining the POD information, let’s describe the relationship between a POD and a Container. Pod is a concept in Kubernetes, which is the smallest unit for container scheduling. A POD is an abstraction of higher latitude than a Container. A POD can have multiple Containers. Container provides more of a runtime lifecycle management, while POD also includes a service lifecycle management.

Information that pod can provide

Going back to the deployment information mentioned in our previous article, the pod information kubelet will eventually generate is based on the information in the Apps.template in Deployment.

  ## POD template
      creationTimestamp: null
      ## the labels must be the same as the labels selected above
        app: nginx
    The expected state of pod
      Container configuration
      - image: nginx
        ## Mirror pull policy
        imagePullPolicy: Always
        name: nginx
        - containerPort: 80
          protocol: TCP
        resources: {}
        ## Interrupt log
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      # # DNS strategy
      dnsPolicy: ClusterFirst
      The restart policy is used with LIVENESS
      restartPolicy: Always
      The scheduler name for this pod
      schedulerName: default-scheduler
      ## Security context
      securityContext: {}
      ## Maximum wait time when removing a POD.
      terminationGracePeriodSeconds: 30
Copy the code

As we can see from the above, there is relatively little information in the SPEC, which is mainly based on the containers information, while in fact pod can be configured with much more information. Actually we can in POD

  • Configure the POD QoS
  • Configuration volume
  • Configuring a POD Account
  • Configure alive, ready and start probes
  • Configure pod initialization

Here I stopped to detail, everyone can refer to the official document: kubernetes. IO/useful/docs/tas…

How does Kubelet create a pod

Kubelet does more than just create pods. Most of the time kubelet maintains the running state of each pod on the node and interacts that state information with Apiserver. Kubelet is a large SyncLoop that listens for pod events. Multiple small syncloops for node,network,pleg, etc state management.

How to create pod is to get the event to add pod according to this big SyncLoop and hand the event to the worker to execute. The process of event execution also needs to go through various managers to update the state and finally reach a desired state. The whole process can be viewed as the following steps:

  1. Get POD information
  2. SyncPod
  3. Runtime generation

In addition, some components involved in the whole process can be referred to the following figure:

Get POD information

  1. Sort all pods by creation date, ensuring that the pod created first is processed first
  2. I’m going to add it topodManagerbecausepodManagerKubelet’s Source of Truth, where all managed Pods will appear. ifpodManagerIf a pod is not found in the
  3. If it is a Mirror pod, call its separate method
  4. Verify that pod can run on this node. If not, reject it
  5. Assign pod to worker for asynchronous processing
  6. inprobeManagerIf Readiness and LiVENESS health checks are defined in THE POD, start Goroutine for periodic testing


  1. If it is to remove pod, execute immediately and return
  2. Check whether POD can run on the local node, mainly for permission check (whether it can use the host network mode, whether it can run under privileged authority, etc.). If you do not have permission, delete the old pod locally and return an error message
  3. If it is static Pod, create or update the corresponding mirrorPod
  4. Create a pod data directory to store the volume and Plugin information
  5. If PV is defined, wait for all volume mounts to complete (volumeManager does this in the background)
  6. If you have image Secrets, go to Apiserver to get the corresponding secrets data
  7. Call the SyncPod method of the Container Runtime to implement the actual container creation logic

Runtime generation

  1. Pasue mirror
  2. Does the network model change
  3. There is no change in the exposed port number
  4. Mirror pull policy
  5. The environment variable

During the above creation process, you will find that the relevant information in the POD can be matched. So there’s a difference in the order in which this information is created, so why is that order that way? You can think about that.

Source code analysis

This diagram shows the code architecture of Kubelet and gives us a general idea of how Kubelet is implemented. Source code this piece I start from the process, and create the process of two perspectives to analyze. Because the two processes echo each other.

Start the analysis

For all source code analysis, I recommend starting with startup analysis, where we know what modules Kubelet starts and what they do.

This diagram is a startup flowchart for Kubernets version 1.16 and can be used as a reference.

This article also focuses on the pod listener SyncLoop() function step by step to start the creation of the pod depends on this function to trigger the listener event.

// cmd/kubelet/kubelet.go
// here is kubelet's main function
func main(a) {

	command := app.NewKubeletCommand()
	defer logs.FlushLogs()

	iferr := command.Execute(); err ! =nil {
		os.Exit(1)}}// cmd/kubelet/app/server.go
// Generate a kubelet command, which will have a Run method
// NewKubeletCommand creates a *cobra.Command object with default parameters
func NewKubeletCommand(a) *cobra.Command {
	cleanFlagSet := pflag.NewFlagSet(componentKubelet, pflag.ContinueOnError)
	kubeletFlags := options.NewKubeletFlags()
	kubeletConfig, err := options.NewKubeletConfiguration()
	// programmer error
	iferr ! =nil {
		klog.ErrorS(err, "Failed to create a new kubelet configuration")
		os.Exit(1) } cmd := &cobra.Command{ ... }}// cmd/kubelet/app/server.go
// Here is the basic configuration and check for kubelet startup
// The core method is RunKubelet
func run(ctx context.Context, s *options.KubeletServer, kubeDeps *kubelet.Dependencies, featureGate featuregate.FeatureGate) (err error){...iferr := RunKubelet(s, kubeDeps, s.RunOnce); err ! =nil {
		return err
	return nil

// cmd/kubelet/app/server.go
// The createAndInitKubelet method is called to initialize the kubelet component, and the startKubelet method is called to start the kubelet component.
// RunKubelet is responsible for setting up and running a kubelet. It is used in three different applications:
// 1 Integration tests
// 2 Kubelet binary
// 3 Standalone 'kubernetes' binary
// Eventually, #2 will be replaced with instances of #3
func RunKubelet(kubeServer *options.KubeletServer, kubeDeps *kubelet.Dependencies, runOnce bool) error{...// This function initializes kubelet, each controller, and all modules that Kubelet depends on
	k, err := createAndInitKubelet(&kubeServer.KubeletConfiguration,
	iferr ! =nil {
		return fmt.Errorf("failed to create kubelet: %w", err)
  // process pods and exit.
	if runOnce {
		if_, err := k.RunOnce(podCfg.Updates()); err ! =nil {
			return fmt.Errorf("runonce failed: %w", err)
		klog.InfoS("Started kubelet as runonce")}else {
    // Start kubelet service
		startKubelet(k, podCfg, &kubeServer.KubeletConfiguration, kubeDeps, kubeServer.EnableServer)
		klog.InfoS("Started kubelet")}return nil

// cmd/kubelet/app/server.go
// startKubelet by calling to start all modules in kubelet and the main process, and then start the HTTP server required by kubelet
func startKubelet(k kubelet.Bootstrap, podCfg *config.PodConfig, kubeCfg *kubeletconfiginternal.KubeletConfiguration, kubeDeps *kubelet.Dependencies, enableServer bool) {
	// start the kubelet
	go k.Run(podCfg.Updates())

	// start the kubelet server
	if enableServer {
		go k.ListenAndServe(kubeCfg, kubeDeps.TLSOptions, kubeDeps.Auth)
	if kubeCfg.ReadOnlyPort > 0 {
		go k.ListenAndServeReadOnly(net.ParseIP(kubeCfg.Address), uint(kubeCfg.ReadOnlyPort))
	if utilfeature.DefaultFeatureGate.Enabled(features.KubeletPodResources) {
		go k.ListenAndServePodResources()
Copy the code
// kubernetes/pkg/kubelet/kubelet.go
// This function starts each of the previously initialized modules, focusing on SyncLoop
// Run starts the kubelet reacting to config updates
func (kl *Kubelet) Run(updates <-chan kubetypes.PodUpdate){...// call kl.syncLoop to listen for pod changes
	kl.syncLoop(updates, kl)

// kubernetes/pkg/kubelet/kubelet.go
func (kl *Kubelet) syncLoop(updates <-chan kubetypes.PodUpdate, handler SyncHandler){...for{...// The syncLoopIteration method listens for multiple channels. When it finds data on any channel, it sends it to the handler, where it dispatches tasks by calling dispatchWork
		if! kl.syncLoopIteration(updates, handler, syncTicker.C, housekeepingTicker.C, plegCh) {break}... }}// kubernetes/pkg/kubelet/kubelet.go
// Listen for the pod creation event and the corresponding handling method
func (kl *Kubelet) syncLoopIteration(configCh <-chan kubetypes.PodUpdate, handler SyncHandler,
	syncCh <-chan time.Time, housekeepingCh <-chan time.Time, plegCh <-chan *pleg.PodLifecycleEvent) bool {
	select {
	case u, open := <-configCh:
		switch u.Op {
		case kubetypes.ADD:
  return true
Copy the code

Create POD process

Here is a diagram showing the functions used in the pod creation process for your reference only.

When creating a POD, syncLoop will listen for event changes. Which module instance will listen for event changes and which module instance will handle event changes? What is the specific processing process?

We will build on the previous startup analysis by using syncLoopiteration

Listen for POD changes (syncLoopIteration)

Let’s look at the arguments passed by syncLoopIteration

// kubernetes/pkg/kubelet/kubelet.go
// Listen for the pod creation event and the corresponding handling method
func (kl *Kubelet) syncLoopIteration(configCh <-chan kubetypes.PodUpdate, handler SyncHandler,
	syncCh <-chan time.Time, housekeepingCh <-chan time.Time, plegCh <-chan *pleg.PodLifecycleEvent) bool {
	select {
	case u, open := <-configCh:
		switch u.Op {
		case kubetypes.ADD:
  return true
Copy the code

From here we can take a look at configCh and SyncHandler respectively

  • ConfigCh: This information source is provided by the PodConfig submodule in the kubeDeps object. This module will watch the changes of the POD information from three different sources simultaneously (file, HTTP, apiserver). Once the POD information from one source has been updated (create/update/delete), The updated POD information and the updated operation will appear in this channel.
  • SyncHandler contains the HandlePodAddtions method for handling pod new events
The POD event listens to configCh

Kubelet initialization: How is configCh generated

// kubernetes/pkg/kubelet/kubelet.go
// makePodSourceConfig creates a config.PodConfig from the given
// KubeletConfiguration or returns an error.
func makePodSourceConfig(kubeCfg *kubeletconfiginternal.KubeletConfiguration, kubeDeps *Dependencies, nodeName types.NodeName, nodeHasSynced func(a) bool) (*config.PodConfig, error){...// source of all configuration
	cfg := config.NewPodConfig(config.PodConfigNotificationIncremental, kubeDeps.Recorder)
	return cfg, nil

// kubernetes/pkg/kubelet/config/config.go
/ / create podConfig
// NewPodConfig creates an object that can merge many configuration sources into a stream
// of normalized updates to a pod configuration.
func NewPodConfig(mode PodConfigNotificationMode, recorder record.EventRecorder) *PodConfig {
	updates := make(chan kubetypes.PodUpdate, 50)
	storage := newPodStorage(updates, mode, recorder)
	podConfig := &PodConfig{
		pods:    storage,
		mux:     config.NewMux(storage),
		updates: updates,
		sources: sets.String{},
	return podConfig

Copy the code

Once you understand the structure of podConfig and the initialization process, how does Kubelet listen for events to be converted to podConfig? Informer is also used here, so I won’t go into it again. You can refer to:…

Pod New Handles (HandlePodAddtions)

We can find out what handler was initialized when Kubelet was created and what logic the corresponding method was. Handler is actually an interface, and Kubelet satisfies the requirements of this interface, so Kubelet is also a handler instance.

// kubernetes/pkg/kubelet/kubelet.go
// HandlePodAdditions is the callback in SyncHandler for pods being added from
// a config source.
func (kl *Kubelet) HandlePodAdditions(pods []*v1.Pod) {
	start := kl.clock.Now()
	for _, pod := range pods {
		existingPods := kl.podManager.GetPods()
		// Always add the pod to the pod manager. Kubelet relies on the pod
		// manager as the source of truth for the desired state. If a pod does
		// not exist in the pod manager, it means that it has been deleted in
		// the apiserver and no action (other than cleanup) is required.

		if kubetypes.IsMirrorPod(pod) {
			kl.handleMirrorPod(pod, start)

		if! kl.podIsTerminated(pod) {// Only go through the admission process if the pod is not
			// terminated.

			// We failed pods that we rejected, so activePods include all admitted
			// pods that are alive.
			activePods := kl.filterOutTerminatedPods(existingPods)

			// Check if we can admit the pod; if not, reject it.
			ifok, reason, message := kl.canAdmitPod(activePods, pod); ! ok { kl.rejectPod(pod, reason, message)continue
		mirrorPod, _ := kl.podManager.GetMirrorPodByPod(pod)
    DispatchWork encapsulates received parameters as UpdatePodOptions and calls UpdatePod.
		kl.dispatchWork(pod, kubetypes.SyncPodCreate, mirrorPod, start)
Copy the code
Issued by the task

The main purpose of dispatchWorker is to send an action (create/update/delete) to Pod to podWorkers.

// kubernetes/pkg/kubelet/kubelet.go
// dispatchWork starts the asynchronous sync of the pod in a pod worker.
// If the pod has completed termination, dispatchWork will perform no action.
func (kl *Kubelet) dispatchWork(pod *v1.Pod, syncType kubetypes.SyncPodType, mirrorPod *v1.Pod, start time.Time){...// Run the sync in an async worker.
		Pod:        pod,
		MirrorPod:  mirrorPod,
		UpdateType: syncType,
		OnCompleteFunc: func(err error) {
			iferr ! =nil {
Copy the code
Update events

The main function of the podWorkers submodule is to handle update events for each Pod, such as Pod creation, deletion, and update. The basic idea taken by podWorkers is to create a separate Goroutine for each Pod and a channel for update events. The Goroutine blocks for events in the channel and processes the acquired events. The podWorkers object itself is responsible for delivering update events.

// UpdatePod apply the new setting to the specified pod.
// If the options provide an OnCompleteFunc, the function is invoked if the update is accepted.
// Update requests are ignored if a kill pod request is pending.
func (p *podWorkers) UpdatePod(options *UpdatePodOptions){...ifpodUpdates, exists = p.podUpdates[uid]; ! exists {// We need to have a buffer here, because checkForUpdates() method that
		// puts an update into channel is called from the same goroutine where
		// the channel is consumed. However, it is guaranteed that in such case
		// the channel is empty, so buffer of size 1 is enough.
		podUpdates = make(chan UpdatePodOptions, 1)
		p.podUpdates[uid] = podUpdates

		// Creating a new pod worker either means this is a new pod, or that the
		// kubelet just restarted. In either case the kubelet is willing to believe
		// the status of the pod for the first pod worker sync. See corresponding
		// comment in syncPod.
		go func(a) {
			defer runtime.HandleCrash()
Copy the code
Synchronize pods with syncPodFn (managePodLoop)

ManagePodLoop calls the syncPodFn method to synchronize pods. Syncfn is actually kubelet.syncPod. After completing this sync action, the wrapUp function is called, which does several things:

  • Insert this POD information into kubelet’s workQueue and wait for the next periodic sync of the POD state
  • Add the most recent update that was accumulated during sync to goroutine’s event channel for immediate processing.
func (p *podWorkers) managePodLoop(podUpdates <-chan UpdatePodOptions) {
	var lastSyncTime time.Time
	for update := range podUpdates {
		p.wrapUp(update.Pod.UID, err)
Copy the code
Complete the preparation before creating the container (SyncPod)

ManagePodLoop inserts pod updates into the workQueue, and Kubelet will have a Goroutine to listen on the queue for pod creation. When I created Kubelet, There is a function newPodWorkers(klet.syncPod, Kubedeps.recorder, klet.workqueue, klet.resyncInterval, backOffPeriod, klet.podcache). So let’s just look at this function right here.

// kubernetes/pkg/kubelet/kubelet.go
func (kl *Kubelet) syncPod(o syncPodOptions) error {
	// pull out the required options
	pod := o.pod
	mirrorPod := o.mirrorPod
	podStatus := o.podStatus
	updateType := o.updateType

	// if we want to kill a pod, do it now!
  // Determine whether to delete pod
	ifupdateType == kubetypes.SyncPodKill { ... }...// Check whether pod can run on this node
	runnable := kl.canRunPod(pod)
	if! runnable.Admit { ... }// Update the pod status
	// Update status in the status manager
	kl.statusManager.SetPodStatus(pod, apiPodStatus)

  // If the pod is not running, kill it
	// Kill pod if it should not be running
	if! runnable.Admit || pod.DeletionTimestamp ! =nil || apiPodStatus.Phase == v1.PodFailed {
	// Load the network plug-in
	// If the network plugin is not ready, only start the pod if it uses the host network
	iferr := kl.runtimeState.networkErrors(); err ! =nil && !kubecontainer.IsHostNetworkPod(pod) {

	// Create Cgroups for the pod and apply resource parameters
	// to them if cgroups-per-qos flag is enabled.
	pcm := kl.containerManager.NewPodContainerManager()
	// If pod has already been terminated then we need not create
	// or update the pod's cgroup
	if! kl.podIsTerminated(pod) {// Create and update pod cgroups
		if! (podKilled && pod.Spec.RestartPolicy == v1.RestartPolicyNever) {if! pcm.Exists(pod) { ... }}}// Create Mirror Pod for Static Pod if it doesn't already exist
  // If it is static pod, create the corresponding mirror pod, which can be queried when the client calls apiserver.
	if kubetypes.IsStaticPod(pod) {
	// Create a data directory
	// Make data directories for the pod
	iferr := kl.makePodDataDirs(pod); err ! =nil{... }/ / mount the volume
	// Volume manager will not mount volumes for terminated pods
	if! kl.podIsTerminated(pod) {// Wait for volumes to attach/mount
		iferr := kl.volumeManager.WaitForAttachAndMount(pod); err ! =nil{... }}// Get secret information
	// Fetch the pull secrets for the pod
	pullSecrets := kl.getPullSecretsForPod(pod)
	// Call containerRuntime's SyncPod method to create the container
	// Call the container runtime's SyncPod callback
	result := kl.containerRuntime.SyncPod(pod, podStatus, pullSecrets, kl.backOff)
	kl.reasonCache.Update(pod.UID, result)
	iferr := result.Error(); err ! =nil{...return nil
	return nil
Copy the code
Create a container

It is the SyncPod function of the containerRuntime (PKG /kubelet/kuberuntime) submodule that creates the pod container entity.

// kubernetes/pkg/kubelet/kuberuntime/kuberuntime_manager.go
/ SyncPod syncs the running pod into the desired pod by executing following steps:
// 1. Compute sandbox and container changes.
// 2. Kill pod sandbox if necessary.
// 3. Kill any containers that should not be running.
// 4. Create sandbox if necessary.
// 5. Create ephemeral containers.
// 6. Create init containers.
// 7. Create normal containers.
func (m *kubeGenericRuntimeManager) SyncPod(pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, backOff *flowcontrol.Backoff) (result kubecontainer.PodSyncResult) {
	// Step 1: Compute sandbox and container changes.
	podContainerChanges := m.computePodActions(pod, podStatus)

	// Step 2: Kill the pod if the sandbox has changed.
	if podContainerChanges.KillPod {
	} else {
		// Step 3: kill any running containers in this pod which are not to keep.
		for containerID, containerInfo := range podContainerChanges.ContainersToKill {
			if err := m.killContainer(pod, containerID,, containerInfo.message, containerInfo.reason, nil); err ! =nil{... }}}...// Step 4: Create a sandbox for the pod if necessary.
	podSandboxID := podContainerChanges.SandboxID
	ifpodContainerChanges.CreateSandbox { ... }}...// Step 5: start ephemeral containers
	// These are started "prior" to init containers to allow running ephemeral containers even when there
	// are errors starting an init container. In practice init containers will start first since ephemeral
	// containers cannot be specified on pod creation.
	if utilfeature.DefaultFeatureGate.Enabled(features.EphemeralContainers) {
		for _, idx := range podContainerChanges.EphemeralContainersToStart {
			start("ephemeral container", ephemeralContainerStartSpec(&pod.Spec.EphemeralContainers[idx]))

	// Step 6: start the init container.
	ifcontainer := podContainerChanges.NextInitContainerToStart; container ! =nil {
		// Start the next init container.
		if err := start("init container", containerStartSpec(container)); err ! =nil {

		// Successfully started the container; clear the entry in the failure
		klog.V(4).InfoS("Completed init container for pod"."containerName", container.Name, "pod", klog.KObj(pod))

	// Step 7: start containers in podContainerChanges.ContainersToStart.
	for _, idx := range podContainerChanges.ContainersToStart {
		start("container", containerStartSpec(&pod.Spec.Containers[idx]))
Copy the code
Start the container

StartContainer starts the container, which is actually the API of the runtime. The implementation logic will be different depending on which runtime you are using.

// startContainer starts a container and returns a message indicates why it is failed on error.
// It starts the container through the following steps:
// * pull the image
// * create the container
// * start the container
// * run the post start lifecycle hooks (if applicable)
func (m *kubeGenericRuntimeManager) startContainer(podSandboxID string, podSandboxConfig *runtimeapi.PodSandboxConfig, spec *startSpec, pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, podIP string, podIPs []string) (string, error) {
	container := spec.container

	// Step 1: pull the image.
	imageRef, msg, err := m.imagePuller.EnsureImageExists(pod, container, pullSecrets, podSandboxConfig)
	iferr ! =nil{... }// Step 2: create the container.
	// For a new container, the RestartCount should be 0
	restartCount := 0
	containerStatus := podStatus.FindContainerStatusByName(container.Name)

	// Step 3: start the container.
	err = m.runtimeService.StartContainer(containerID)
	iferr ! =nil{... }...// Step 4: execute the post start hook.
	ifcontainer.Lifecycle ! =nil&& container.Lifecycle.PostStart ! =nil {
		kubeContainerID := kubecontainer.ContainerID{
			Type: m.runtimeName,
			ID:   containerID,
    // The main function of the runner.Run method is that when the business container is up,
    // A container hook(PostStart and PreStop) is executed to do some preprocessing.
    // Only after the Container hook is successfully executed, the specific service will run. Otherwise, the container is abnormal.
		msg, handlerErr := m.runner.Run(kubeContainerID, pod, container, container.Lifecycle.PostStart)
		ifhandlerErr ! =nil{... }}return "".nil
Copy the code


This article mainly describes kubelet from listening to the container scheduling to the node to create a container process, Kubelet finally call CRI interface to create the container. Let’s go over the main points of this article again

  1. The pod is created by listening to events through the Informer and delivering tasks through syncLoop processing. In fact, podWorker handles the final tasks
  2. Kubelet creates pods not only to start containers, but also to take into account configurations like volume,secret, etc
  3. Kubelet implementation process, through the Goroutine loop listening processing. In addition to listening for POD events, there are many mangers that do status listening.


With Kubelet finally creating pod, this series of articles comes to a close. In the next stage, I will find time to write some extras, such as the principle analysis of ETCD. In the Kubernetes Paoding Computer series, there will inevitably be some loose parts in the article, but also hope that we will forgive, we learn the essence (if any), to its dross. If you are interested, you can close my official account: Gungunxi. My wechat account is Lcomedy2021

Reference documentation

  • Qiankunli. Making. IO / 2018/12/31 /…