pilot-agent 源码分析

文源码版本为 istio-v1.11.3

架构

pilot 共分两个主要模块,一个是 pilot-agent用来提供 pod 中的服务发现客户端,另一个是 polot-discovery 提供服务发现服务端。

其中 envoyIstio Agent 就是我们上面所讲的 pilot-agent 模块,而 Istiod 则为控制面,模块对应源码见 https://github.com/istio/istio/tree/1.11.3/pilot/cmd

pilot-agent

对于 polot-agent 它运行在每个pod中 ,并以 sidecar 方式与应用容器运行在同一个pod中,如果你使用的是 vm 的话,则可以看到vm的进程视图

# pstree -pu 24530
su(24530)───pilot-agent(24611,istio-proxy)─┬─envoy(24619)─┬─{envoy}(24620)
                                          │             ├─{envoy}(24621)
                                          │             ├─{envoy}(24622)
                                          │             ├─{envoy}(24623)
                                          │             ├─{envoy}(24624)
                                          │             ├─{envoy}(24625)
                                          │             ├─{envoy}(24627)
                                          │             ├─{envoy}(24628)
                                          │             ├─{envoy}(24629)
                                          │             ├─{envoy}(24630)
                                          │             └─{envoy}(24635)
                                          ├─{pilot-agent}(24612)
                                          ├─{pilot-agent}(24613)
                                          ├─{pilot-agent}(24614)
                                          ├─{pilot-agent}(24615)
                                          ├─{pilot-agent}(24616)
                                          ├─{pilot-agent}(24617)
                                          ├─{pilot-agent}(24618)
                                          ├─{pilot-agent}(24626)
                                          └─{pilot-agent}(24698)

从进程关系可以看到,envoy 属于 pilot-agent 的子进程,当前进程以 istio-proxy 用户身份运行。

在istio中,如果应用是以容器方式部署的话,则对象为pod,如果是以vm部署的话,则对象为 wordloadEntry。下面我们看一下容器部署的情况。

在安装 istio 应用后,每个启用注入的 pod 里都会多出一来一个名叫 istio-proxy 的容器

Containers:
nginx:
  Container ID:   docker://dea2fa5b051f74f1d5f867693543b2d9858b01b1713d70cfb1470268bb1987c9
  Image:         nginx:1.23
  Image ID:       docker-pullable://nginx@sha256:63b44e8ddb83d5dd8020327c1f40436e37a6fffd3ef2498a6204df23be6e7e94
  Port:           80/TCP
  Host Port:     0/TCP
  ...
istio-proxy:
  Container ID: docker://eb8eb3efee0aa35306fe248b19cfe3983ab896309e365ad4afac5bc4d5d8ae4b
  Image:         docker.io/istio/proxyv2:1.11.2
  Image ID:     docker-pullable://istio/proxyv2@sha256:0354daaaa62d064c046119035c20ea8a48b8e5824772110656a3898f9170969e
  Port:         15090/TCP
  Host Port:     0/TCP
  ...

这个容器是由 docker.io/istio/proxyv2:1.11.2 镜像提供的,其镜像是通过 /pilot/docker/Dockerfile.proxyv2 文件生成的的,容器里运行的是一个叫 pilot-agent 进程。而这个进程里启用了 envoy 这个代理程序。

Continue reading

kubernetes 之 client-go 工作原理源码解析

本方主要介绍有关 client_go 架构实现原理

架构介绍

我们先看一下来自官方的 client-go 架构图

整个架构图分上、下两部分,其中上部分为 client-go 的实现,而下部分是我们自己要实现的 Custom Controller,每部分由不同的组件组成,其介绍请参考 https://github.com/kubernetes/sample-controller/blob/master/docs/controller-client-go.md

有时候 Controller 也被叫做 Operator,这两个术语的混用有时让人感到迷惑。Controller 是一个通用的术语,凡是遵循 “Watch K8s 资源并根据资源变化进行调谐” 模式的控制程序都可以叫做 Controller。而 Operator 是一种专用的 Controller,用于在 Kubernetes 中管理一些复杂的有状态的应用程序。例如在 Kubernetes 中管理 MySQL 数据库的 MySQL Operator。

在实现 controller 时一般在 Informer 配置回调函数 Callbacks(ResourceEventHandlers) 来实现 Informer 和 自定义控制器 上下两部分之间的通讯,这个在上面的链接里均有介绍。如果上图理解吃力的话,也可以参考下面两张架构图。

注意这里写入 workqueue 队列的是API对象的 key, 即 namespace/name, 然后在控制循环 Control Loop 里先从 workqueue 读取这个key;然后根据key从 indexer 缓存里读取对象,如果对象不存在则说明前面是通过 DeleteFunc 写入的,则需要删除key, 否则进行其它处理,执行控制器模式里的对比“期望状态”和“实际状态”的逻辑了。

下面根据上面的架构图我们梳理一下所有的实现代码。

架构实现源码分析

这里以官方提供的 workqueue 示例为例,按照上方的架构图对其 每一个步骤 进行源码分析

入口函数为 main函数中的 go controller.Run(1, stop)

// /examples/workqueue/main.go
func main() {
	...

	// create the pod watcher
	podListWatcher := cache.NewListWatchFromClient(clientset.CoreV1().RESTClient(), "pods", v1.NamespaceDefault, fields.Everything())

	// create the workqueue
	queue := workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter())

	// 创建 indexer 和 informer
	indexer, informer := cache.NewIndexerInformer(podListWatcher, &v1.Pod{}, 0, cache.ResourceEventHandlerFuncs{}

	controller := NewController(queue, indexer, informer)

	stop := make(chan struct{})
	defer close(stop)
	go controller.Run(1, stop)

	select {}
}

这里创建的是一个 pods 类型的 ListWatch, 接着创建了一个带 限速率 功能的 workqueue(底层queue的实现对应代码为 https://github.com/kubernetes/client-go/blob/v12.0.0/util/workqueue/queue.go#L64-L88), 然后调用 cache.NewIndexInformer 来创建 indexer 和 informer。

这里的 workqueue 主要是在回调的 ResoureEventHandlers 来调用的,对应的是第 7) Enqueue Object Key 步骤。

// /examples/workqueue/main.go
func (c *Controller) Run(threadiness int, stopCh chan struct{}) {
	defer runtime.HandleCrash()

	// Let the workers stop when we are done
	defer c.queue.ShutDown()
	klog.Info("Starting Pod controller")

	// 启用 informer 服务
	go c.informer.Run(stopCh)

	// Wait for all involved caches to be synced, before processing items from the queue is started
	if !cache.WaitForCacheSync(stopCh, c.informer.HasSynced) {
		runtime.HandleError(fmt.Errorf("Timed out waiting for caches to sync"))
		return
	}

	// 业务逻辑回调 c.runWorker
	for i := 0; i < threadiness; i++ {
		go wait.Until(c.runWorker, time.Second, stopCh)
	}

	<-stopCh
}

这里首先调用 go c.informer.Run() 来启用 informer 服务,让其在单独的 goroutine 运行, 其实现对应架构图中的 1~7 步骤。

接着再调用 go wait.Until(c.runWorker, time.Second, stopCh) 来实现自定义控制器的逻辑,其对应架构图中的 8~9 步骤。

下面我们先看一下 informer 服务的实现 (https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/controller.go#L97-L125)。

// /tools/cache/controller.go
func (c *controller) Run(stopCh <-chan struct{}) {
​
    // 首先创建 Reflector
    r := NewReflector(
        c.config.ListerWatcher,
        c.config.ObjectType,
        c.config.Queue,
        c.config.FullResyncPeriod,
    )
    
​
    // 启用 Reflector 服务
    wg.StartWithChannel(stopCh, r.Run)
​
}

这里首先创建一个 Reflector 对象,并注入 podListWatcher 和 Delta Fifo Queue 队列, 其中 ObjectType 为 &v1.Pod{},接着启用 Reflector 服务(https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/reflector.go#L119-L128

// /tools/cache/reflector.go
// Run starts a watch and handles watch events. Will restart the watch if it is closed.
// Run will exit when stopCh is closed.
func (r *Reflector) Run(stopCh <-chan struct{}) {
    // 启用 ListAndWatch 
    wait.Until(func() {
        if err := r.ListAndWatch(stopCh); err != nil {
            utilruntime.HandleError(err)
        }
    }, r.period, stopCh)
}

接着看一下 r.ListAndWatch() 实现 (https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/reflector.go#L156-L307

// It returns error if ListAndWatch didn't even try to initialize watch.
func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error {
​
    // 当前为客户端首次请求资源的情况
    if err := func() error {
        go func() {
            defer func() {
                if r := recover(); r != nil {
                    panicCh <- r
                }
            }()
            
            // 1. 向 apiserver 发送请求
            // 如果支持 listerWatcher,则尝试以 chunks 的方式获取资源列表; 否则第一个列表就返回完整的响应
            
            // Attempt to gather list in chunks, if supported by listerWatcher, if not, the first
            // list request will return the full response.
            pager := pager.New(pager.SimplePageFunc(func(opts metav1.ListOptions) (runtime.Object, error) {
                return r.listerWatcher.List(opts)
            }))
            if r.WatchListPageSize != 0 {
                pager.PageSize = r.WatchListPageSize
            }
            // Pager falls back to full list if paginated list calls fail due to an "Expired" error.
            list, err = pager.List(context.Background(), options)
            close(listCh)
        }()
        
        // 2. 读取响应 以 channel通道的方式获取上面 goroutine 的响应结果
        select {
        case <-stopCh:
            return nil
        case r := <-panicCh:
            panic(r)
        case <-listCh:
        }
        if err != nil {
            return fmt.Errorf("%s: Failed to list %v: %v", r.name, r.expectedType, err)
        }
        
        // 3.1 从响应结果列表里获取版本号信息
        listMetaInterface, err := meta.ListAccessor(list)
        resourceVersion = listMetaInterface.GetResourceVersion()
​
        items, err := meta.ExtractList(list)
        if err != nil {
            return fmt.Errorf("%s: Unable to understand list result %#v (%v)", r.name, list, err)
        }
​
        // 3.2 根据上次获取的版本号同步最新记录, 更新 Store(Delta FIIO queue) 为最新内容
        if err := r.syncWith(items, resourceVersion); err != nil {
            return fmt.Errorf("%s: Unable to sync list result: %v", r.name, err)
        }
​
        // 3.3 更新最新版本号
        r.setLastSyncResourceVersion(resourceVersion)
​
        return nil
    }
​
    
    // 非首次则根据版本号来获取最新变更资源
    for {
        // give the stopCh a chance to stop the loop, even in case of continue statements further down on errors
        select {
        case <-stopCh:
            return nil
        default:
        }
​
        timeoutSeconds := int64(minWatchTimeout.Seconds() * (rand.Float64() + 1.0))
        options = metav1.ListOptions{
            ResourceVersion: resourceVersion,
            TimeoutSeconds: &timeoutSeconds,
            AllowWatchBookmarks: false,
        }
​
        // 根据版本号获取最新资源
        w, err := r.listerWatcher.Watch(options)
        if err := r.watchHandler(w, &resourceVersion, resyncerrc, stopCh); err != nil {
            if err != errorStopRequested {
                klog.Warningf("%s: watch of %v ended with: %v", r.name, r.expectedType, err)
            }
            return nil
        }
    }
}

首先,如果是首次访问 apiserver,如果支持 listerWatcher 的话,则以 chunk 的方式资源获取 ,否则一次性获取完整的资源信息,然后再从响应结果里读取当前资源信息号。

当获取资源列表后,对于以后更新的资源,则需要根据上次的版本号来监控以后变更的资源,这样就可以只监控后续变更的资源即可,大大减少数据的传输。

上面这些对应的正是架构图中的 1) List & Watch 步骤。

我们再看一下 r.watchHandler 的实现 https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/reflector.go#L318-L387

// watchHandler watches w and keeps *resourceVersion up to date.
func (r *Reflector) watchHandler(w watch.Interface, resourceVersion *string, errc chan error, stopCh <-chan struct{}) error {
​
    ...
​
loop:
    for {
        select {
        case <-stopCh:
            return errorStopRequested
        case err := <-errc:
            return err
        case event, ok := <-w.ResultChan():
            if !ok {
                break loop
            }
            if event.Type == watch.Error {
                return apierrs.FromObject(event.Object)
            }
            if e, a := r.expectedType, reflect.TypeOf(event.Object); e != nil && e != a {
                utilruntime.HandleError(fmt.Errorf("%s: expected type %v, but watch event object had type %v", r.name, e, a))
                continue
            }
            meta, err := meta.Accessor(event.Object)
            if err != nil {
                utilruntime.HandleError(fmt.Errorf("%s: unable to understand watch event %#v", r.name, event))
                continue
            }
            newResourceVersion := meta.GetResourceVersion()
            
            // 更新 Delta Fifo Queue
            switch event.Type {
            case watch.Added:
                err := r.store.Add(event.Object)
                if err != nil {
                    utilruntime.HandleError(fmt.Errorf("%s: unable to add watch event object (%#v) to store: %v", r.name, event.Object, err))
                }
            case watch.Modified:
                err := r.store.Update(event.Object)
                if err != nil {
                    utilruntime.HandleError(fmt.Errorf("%s: unable to update watch event object (%#v) to store: %v", r.name, event.Object, err))
                }
            case watch.Deleted:
                err := r.store.Delete(event.Object)
                if err != nil {
                    utilruntime.HandleError(fmt.Errorf("%s: unable to delete watch event object (%#v) from store: %v", r.name, event.Object, err))
                }
            case watch.Bookmark:
                // A `Bookmark` means watch has synced here, just update the resourceVersion
            default:
                utilruntime.HandleError(fmt.Errorf("%s: unable to understand watch event %#v", r.name, event))
            }
            *resourceVersion = newResourceVersion
            r.setLastSyncResourceVersion(newResourceVersion)
            eventCount++
        }
    }
​
    watchDuration := r.clock.Since(start)
    if watchDuration < 1*time.Second && eventCount == 0 {
        return fmt.Errorf("very short watch: %s: Unexpected watch close - watch lasted less than a second and no items received", r.name)
​
    return nil
}

这里只是对增量队列 Delta Fifo queue 里的资源进行了更新操作,其实现代码见 https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/fifo.go, 其对应的正是 2)Add Object 这一步。

对于 3)Pop Object 这个操作入口函数为 processLoop (https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/controller.go#L139-L161)

// /tools/cache/controller.go
func (c *controller) processLoop() {
	for {


		// 从 Delta Fifo Queue 读取对象
		obj, err := c.config.Queue.Pop(PopProcessFunc(c.config.Process))
		if err != nil {
			if err == FIFOClosedError {
				return
			}
			if c.config.RetryOnError {
				// This is the safe way to re-enqueue.
				c.config.Queue.AddIfNotPresent(obj)
			}
		}
	}
}

其增量队列Pop实现(https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/delta_fifo.go#L399-L445

// /tools/cache/delta_fifo.go
// Pop returns a 'Deltas', which has a complete list of all the things
// that happened to the object (deltas) while it was sitting in the queue.
func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error) {
    f.lock.Lock()
    defer f.lock.Unlock()
    for {
        for len(f.queue) == 0 {
            // 阻塞方式获取一个对象
            // When the queue is empty, invocation of Pop() is blocked until new item is enqueued.
            // When Close() is called, the f.closed is set and the condition is broadcasted.
            // Which causes this loop to continue and return from the Pop().
            if f.IsClosed() {
                return nil, FIFOClosedError
            }
​
            f.cond.Wait()
        }
        
        // f.queue 是一个slice, 这里获取首个元素后并更新这个切片
        id := f.queue[0]
        f.queue = f.queue[1:]
        if f.initialPopulationCount > 0 {
            f.initialPopulationCount--
        }
        
        // 将id作为key从items这个 map 中获取 Deltas 信息
        item, ok := f.items[id]
        if !ok {
            // Item may have been deleted subsequently.
            continue
        }
        delete(f.items, id)
        
        // 这里 process 是下一步的进入
        err := process(item)
        if e, ok := err.(ErrRequeue); ok {
            f.addIfNotPresent(id, item)
            err = e.Err
        }
        // Don't need to copyDeltas here, because we're transferring
        // ownership to the caller.
        return item, err
    }
}

这个 process 函数在这里是作为一个参数传递过来的,其声明位置为 https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/controller.go#L320-L380

// /tools/cache/controller.go
func newInformer(
    lw ListerWatcher,
    objType runtime.Object,
    resyncPeriod time.Duration,
    h ResourceEventHandler,
    clientState Store,
) Controller {
    ...
    
    cfg := &Config{
        ...
​
        Process: func(obj interface{}) error {
            // from oldest to newest
            for _, d := range obj.(Deltas) {
                switch d.Type {
                case Sync, Added, Updated:
                    if old, exists, err := clientState.Get(d.Object); err == nil && exists {
                        // 对应第 4) 和 5) 步骤
                        if err := clientState.Update(d.Object); err != nil {
                            return err
                        }
                        h.OnUpdate(old, d.Object)
                    } else {
                        if err := clientState.Add(d.Object); err != nil {
                            return err
                        }
                        h.OnAdd(d.Object)
                    }
                case Deleted:
                    if err := clientState.Delete(d.Object); err != nil {
                        return err
                    }
                    h.OnDelete(d.Object)
                }
            }
            return nil
        },
    }
        return New(cfg)
}

对应的是 Config.Process 这个函数。

对于架构图中的 4)Add Object5)Store Object & Key 对应的则是 clientState 的调用;

6)Dispatch Event Handler functions 则为 对象 h ,其是一个实现了 Resource Event Handlers 接口的结构体,可以看到它有三个方法函数 h.OnAddh.OnUpdateh.OnDelete,而这三个函数原型已在 main 函数里实现 https://github.com/kubernetes/client-go/blob/v12.0.0/examples/workqueue/main.go#L144-L217

// /examples/workqueue/main.go
func main() {
    ...
    
    // Bind the workqueue to a cache with the help of an informer. This way we make sure that
    // whenever the cache is updated, the pod key is added to the workqueue.
    // Note that when we finally process the item from the workqueue, we might see a newer version
    // of the Pod than the version which was responsible for triggering the update.
    indexer, informer := cache.NewIndexerInformer(podListWatcher, &v1.Pod{}, 0, cache.ResourceEventHandlerFuncs{
        AddFunc: func(obj interface{}) {
            key, err := cache.MetaNamespaceKeyFunc(obj)
            if err == nil {
                queue.Add(key)
            }
        },
        UpdateFunc: func(old interface{}, new interface{}) {
            key, err := cache.MetaNamespaceKeyFunc(new)
            if err == nil {
                queue.Add(key)
            }
        },
        DeleteFunc: func(obj interface{}) {
            // IndexerInformer uses a delta queue, therefore for deletes we have to use this
            // key function.
            key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj)
            if err == nil {
                queue.Add(key)
            }
        },
    }, cache.Indexers{})
    
    ...
}   

这里使用的结构体名为 cache.ResourceEventHandlerFuncs (https://github.com/kubernetes/client-go/blob/v12.0.0/tools/cache/controller.go#L186-L211)

queue.Add(key) 则对应的是步骤 7) Enqueue Object key

对于 8)Get key 则对应的是 controller.processNextItem()

// /examples/workqueue/main.go
func (c *Controller) processNextItem() bool {
	// Wait until there is a new item in the working queue
	// 对应 8 步骤,从wprkqueue 里读取一个 key
	key, quit := c.queue.Get()
	if quit {
		return false
	}
	// Tell the queue that we are done with processing this key. This unblocks the key for other workers
	// This allows safe parallel processing because two pods with the same key are never processed in
	// parallel.
	defer c.queue.Done(key)

	// Invoke the method containing the business logic
	err := c.syncToStdout(key.(string))

	// 出错,重试 5 次
	// Handle the error if something went wrong during the execution of the business logic
	c.handleErr(err, key)

	return true
}

这里 c.queue.Done() 表示当前key处理完毕。

9) Get Object for key 则对应的是 controller.syncToStdout() 函数

// /examples/workqueue/main.go
func (c *Controller) syncToStdout(key string) error {
	// 对应步骤 9,从 index 里读取对象
	obj, exists, err := c.indexer.GetByKey(key)
	if err != nil {
		klog.Errorf("Fetching object with key %s from store failed with %v", key, err)
		return err
	}

	if !exists {
		// Below we will warm up our cache with a Pod, so that we will see a delete for one pod
		fmt.Printf("Pod %s does not exist anymoren", key)
	} else {
		// Note that you also have to check the uid if you have a local controlled resource, which
		// is dependent on the actual instance, to detect that a Pod was recreated with the same name
		fmt.Printf("Sync/Add/Update for Pod %sn", obj.(*v1.Pod).GetName())
	}
	return nil
}

可以看到对 workqueue 的写入与读取全部在 Custom Controller 部分来实现的,有时候对一个对象对处理会出现失败的情况,这种情况下就需要对其 key 进行 RateLimited 了.

// handleErr checks if an error happened and makes sure we will retry later.
func (c *Controller) handleErr(err error, key interface{}) {
	if err == nil {
		// Forget about the #AddRateLimited history of the key on every successful synchronization.
		// This ensures that future processing of updates for this key is not delayed because of
		// an outdated error history.
		c.queue.Forget(key)
		return
	}

	// This controller retries 5 times if something goes wrong. After that, it stops trying.
	if c.queue.NumRequeues(key) < 5 {
		klog.Infof("Error syncing pod %v: %v", key, err)

		// Re-enqueue the key rate limited. Based on the rate limiter on the
		// queue and the re-enqueue history, the key will be processed later again.
		c.queue.AddRateLimited(key)
		return
	}

	c.queue.Forget(key)
	// Report to an external entity that, even after several retries, we could not successfully process this key
	runtime.HandleError(err)
	klog.Infof("Dropping pod %q out of the queue: %v", key, err)
}

queue.AddRateLimted(key) 表示过一段时间将当前 key重新写入 workqueue 里,同时累计当前key的重试次数, 如果重试多次(当前示例为5次)仍失败的话,则调用 runtime.HandleError(err) 处理。

c.queue.Forget 表示一旦key完成,则清除其重启记录,避免影响下次重试,可以看出来 Forget 是对重试行为的处理,这个与 c.queue.Done() 的作用是不一样的。

至此整个架构图中的每个步骤我们基本介绍完了,对于部分细节问题可能还需要花一些时间进行消化。

对于自定义控制器开发,即可以直接选择使用 Controller Runtime 库开发,也可以基于 Operator SDK. 开发,还可以基于 kubebuilder 开发框架,其中后两者都会使用 Controller Runtime 库,而 kubebuiler 作为一款开发框架,由于其对开发者极其友好,因此是目前最优先的考虑,参考  Kubebuilder’s Quick Start 了解其用法。

参考文章

https://github.com/kubernetes/sample-controller/blob/master/docs/controller-client-go.md
https://github.com/kubernetes-sigs/controller-runtime/blob/main/examples/README.md
深入解析声明式API(二):编写自定义控制器
kubebuilder之一:kubernetes operator工作原理
kubernetes client-go解析
https://gist.github.com/BruceChen7/778b7c683f27da8990d924a0a1e182e8
自定义资源对象与控制器的实现
深入浅出kubernetes之client-go
Kubernetes Controller 机制详解(一)
官方所有依赖仓库清单
https://github.com/kubernetes-sigs/kubebuilder

Golang 中网络请求使用指定网卡

当用户发起一个网络请求时,流量会通过默认的网卡接口流出与流入,但有时需要将流量通过指定的网卡进行流出流入,这时我们可能需要进行一些额外的开发工作,对其实现主要用到了 Dialer.Control 配置项。

type Dialer struct {

// If Control is not nil, it is called after creating the network
// connection but before actually dialing.
//
// Network and address parameters passed to Control method are not
// necessarily the ones passed to Dial. For example, passing "tcp" to Dial
// will cause the Control function to be called with "tcp4" or "tcp6".
Control func(network, address string, c syscall.RawConn) error
}

可以看到这是一个函数类型的参数。

环境

当前系统一共两个网卡 ens33ens160 ,ip地址分别为 192.168.3.80192.168.3.48

➜  ~ ifconfig
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
      inet 192.168.3.80 netmask 255.255.255.0 broadcast 192.168.3.255
      inet6 fe80::8091:2406:c51e:ecb9 prefixlen 64 scopeid 0x20<link>
      ether 00:0c:29:4f:05:90 txqueuelen 1000 (Ethernet)
      RX packets 4805008 bytes 826619853 (826.6 MB)
      RX errors 0 dropped 104152 overruns 0 frame 0
      TX packets 732513 bytes 284605386 (284.6 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

ens160: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
      inet 192.168.3.48 netmask 255.255.255.0 broadcast 192.168.3.255
      inet6 fe80::259a:d8d4:80a9:7fa4 prefixlen 64 scopeid 0x20<link>
      ether 00:0c:29:4f:05:9a txqueuelen 1000 (Ethernet)
      RX packets 4158530 bytes 746167179 (746.1 MB)
      RX errors 1 dropped 106875 overruns 0 frame 0
      TX packets 351616 bytes 149235606 (149.2 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
      inet 127.0.0.1 netmask 255.0.0.0
      inet6 ::1 prefixlen 128 scopeid 0x10<host>
      loop txqueuelen 1000 (Local Loopback)
      RX packets 426742 bytes 80978543 (80.9 MB)
      RX errors 0 dropped 0 overruns 0 frame 0
      TX packets 426742 bytes 80978543 (80.9 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

路由表记录

➜  ~ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.3.1     0.0.0.0         UG    100    0        0 ens33
0.0.0.0         192.168.3.1     0.0.0.0         UG    101    0        0 ens160
169.254.0.0     0.0.0.0         255.255.0.0     U     1000   0        0 ens33
192.168.3.0     0.0.0.0         255.255.255.0   U     100    0        0 ens33
192.168.3.0     0.0.0.0         255.255.255.0   U     101    0        0 ens160
​

从最后两条路由记录可以看到对于 192.168.3.0/24 这个段的流量会匹配的两个物理网卡,但由于配置的 Metric 的优先级比较的高,因此最终流量只会走网卡 ens33

Continue reading

Linux下两种 DNAT 用法的差异

前段时间使用 iptablesDNAT 实现一个业务需求的时候,遇到了一些问题这里将其整个过程记录下来。

需求

这里假设开发机地址为 192.168.3.80,要实现的需求是当用户在开发机访问一个IP地址 192.168.3.196时,将请求转发到另一台机器 192.168.3.58,很明显直接使用 DNAT 来实现即可。

问题现象

iptables 命令如下

sudo iptables -t nat -F
sudo iptables -t nat -A PREROUTING -d 192.168.3.196 -p tcp --dport 8080 -j DNAT --to-destination 192.168.3.58:8080
sudo iptables -t nat -A POSTROUTING -d 192.168.3.58 -p tcp --dport 8080 -j SNAT --to-source 192.168.3.196:8080

这时在开发机器访问

curl http://192.168.3.196:8080

发现提示错误

curl: (7) Failed to connect to 192.168.3.196 port 8080: Connection refused

奇怪了,竟然不能访问,确认路由规则是写入成功的。网上查找了一些资料好像全是这种写法,只不过用法有怕差异,这时直觉告诉我应该对 DNAT 理解不到位,遗漏了一些重要的知识点。

上面这种写法一般都是将开发机当作一个中转服务器跳板来使用,多种情况下都有一个公网ip,与我们的真正需求有一些不一样。

现在我们再以将其视为中转服务器的角色测试一次,当然这个规则不能直接使用上面的这个,需要把访问的目标ip更换成开发机器的IP地址。

sudo iptables -t nat -F
sudo iptables -t nat -A PREROUTING -d 192.168.3.80 -p tcp --dport 8080 -j DNAT --to-destination 192.168.3.58:8080
sudo iptables -t nat -A POSTROUTING -d 192.168.3.58 -p tcp --dport 8080 -j SNAT --to-source 192.168.3.80:8080

第一条是数据包出去规则,需要做DNAT,第二条是数据包回来规则,必须做一次 SNAT,否则数据包将去无回,无法响应。

这时再找一台机器访问 curl 192.168.3.80:8080,可以看到响应结果符合预期。

问题分析

现在我们基本确认了是我们的用法不对,到底是哪里出错了呢?这里我们一起看一下这张 iptables 数据流向图。

iptables Processing Flowchart

从图中可以看到,对于数据流入一共有两类,一类是外部数据包流入 ,即左侧的 Incoming Packet;另一类是本机生成的数据包流入,即右侧的 Locally generated Packet,对于对数据包的流出只有一处,即下方的 Outgoing Packet

对于数据包首个经过的表是不一样的,对于外部流入的数据包首个经过的是PREROUTING 表,而对于本地生成的数据包而言经过的是 OUTPUT 这个表,最后统一从同一个地方流出。

也就是说针对不的类型的包,经过的表是不同的,这个正是我们最上面失败的原因。

解决问题

我们要实现的场景其实是 Locally generated Packet 这类,所以使用的表应该是 OUTPUT才是正确的,现在我们清除原来的规则,重新写入新规则测试一下

sudo iptables -t nat -F
sudo iptables -t nat -A OUTPUT -d 192.168.3.196 -p tcp --dport 8080 -j DNAT --to-destination 192.168.3.58:8080

注意对于 SNAT而言,只对 INPUT/POSTROUTING有效。

再次测试 curl 192.168.3.196:8080 响应正常。

总结

针对 iptables 的 DNAT 的实现,需要根据数据包的来源不同而采用不同的处理方法,一共分外部数据包和本地数据包两类。其中对于外部数据包除了做 DNAT外,还要再做一个 SNAT 规则,否则数据包将有去无回;而对于本地数据包而言,只需要在 OUTPUT 表中做一个 DNAT 即可,并不需要SNAT,同时也不支持 SNAT。对于SNAT 只对 INPUT/POSTROUTEING 才有效。

https 是如何建立连接的

一、什么是HTTPS、TLS、SSL

HTTPS,也称作HTTP over TLS。TLS的前身是SSL,TLS 1.0通常被标示为SSL 3.1,TLS 1.1为SSL 3.2,TLS 1.2为SSL 3.3。下图描述了在TCP/IP协议栈中TLS(各子协议)和HTTP的关系。

二、HTTP和HTTPS协议的区别

1、HTTPS协议需要到证书颁发机构(Certificate Authority,简称CA)申请证书,一般免费证书很少,需要交费。

2、HTTP是超文本传输协议,信息是明文传输,HTTPS则是具有安全性的SSL加密传输协议。

3、HTTP和HTTPS使用的是完全不同的连接方式,使用的端口也不一样,前者是80,后者是443。

4、HTTP的连接很简单,是无状态的。

5、HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比HTTP协议安全。

从上面可看出,HTTPS和HTTP协议相比提供了

· 数据完整性:内容传输经过完整性校验

· 数据隐私性:内容经过对称加密,每个连接生成一个唯一的加密密钥

· 身份认证:第三方无法伪造服务端(客户端)身份

其中,数据完整性和隐私性由TLS Record Protocol保证,身份认证由TLS Handshaking Protocols实现。

三、证书

1、什么是证书呢?

2、证书中包含什么信息

证书信息:过期时间和序列号

所有者信息:姓名等

所有者公钥

3、为什么服务端要发送证书给客户端

互联网有太多的服务需要使用证书来验证身份,以至于客户端(操作系统或浏览器等)无法内置所有证书,需要通过服务端将证书发送给客户端。

4、客户端为什么要验证接收到的证书

中间人攻击

5、客户端如何验证接收到的证书

为了回答这个问题,需要引入数字签名(Digital Signature)。

将一段文本通过哈希(hash)和私钥加密处理后生成数字签名。

假设消息传递在Bob,Susan和Pat三人之间发生。Susan将消息连同数字签名一起发送给Bob,Bob接收到消息后,可以这样验证接收到的消息就是Susan发送的

当然,这个前提是Bob知道Susan的公钥。更重要的是,和消息本身一样,公钥不能在不安全的网络中直接发送给Bob。

此时就引入了证书颁发机构(Certificate Authority,简称CA),CA数量并不多,Bob客户端内置了所有受信任CA的证书。CA对Susan的公钥(和其他信息)数字签名后生成证书。

Susan将证书发送给Bob后,Bob通过CA证书的公钥验证证书签名。

Bob信任CA,CA信任Susan, 使得 Bob信任Susan,信任链(Chain Of Trust)就是这样形成的。

事实上,Bob客户端内置的是CA的根证书(Root Certificate),HTTPS协议中服务器会发送证书链(Certificate Chain)给客户端。

正式开始HTTPS的内容:

一、HTTPS的基本原理

从上面可知,HTTPS能够加密信息,以免敏感信息被第三方获取。所以很多银行网站或电子邮箱等等安全级别较高的服务都会采用HTTPS协议。

HTTPS其实是有两部分组成:HTTP +SSL/ TLS,也就是在HTTP上又加了一层处理加密信息的模块。服务端和客户端的信息传输都会通过TLS进行加密,所以传输的数据都是加密后的数据。具体是如何进行加密,解密,验证的,且看下图。

1. 客户端发起HTTPS请求

这个没什么好说的,就是用户在浏览器里输入一个HTTPS网址,然后连接到server的443端口。

2. 服务端的配置

采用HTTPS协议的服务器必须要有一套数字证书,可以自己制作,也可以向组织申请。区别就是自己颁发的证书需要客户端验证通过,才可以继续访问,而使用受信任的公司申请的证书则不会弹出提示页面(startssl就是个不错的选择,有1年的免费服务)。这套证书其实就是一对公钥和私钥。如果对公钥和私钥不太理解,可以想象成一把钥匙和一个锁头,只是全世界只有你一个人有这把钥匙,你可以把锁头给别人,别人可以用这个锁把重要的东西锁起来,然后发给你,因为只有你一个人有这把钥匙,所以只有你才能看到被这把锁锁起来的东西。

3. 传送证书

这个证书其实就是公钥,只是包含了很多信息,如证书的颁发机构,过期时间等等。

4. 客户端解析证书

这部分工作是由客户端的TLS来完成的,首先会验证公钥是否有效,比如颁发机构,过期时间等等,如果发现异常,则会弹出一个警告框,提示证书存在问题。如果证书没有问题,那么就生成一个随机值。然后用证书对该随机值进行加密。就好像上面说的,把随机值用锁头锁起来,这样除非有钥匙,不然看不到被锁住的内容。

5. 传送加密信息

这部分传送的是用证书加密后的随机值,目的就是让服务端得到这个随机值,以后客户端和服务端的通信就可以通过这个随机值来进行加密解密了。

6. 服务端解密信息

服务端用私钥解密后,得到了客户端传过来的随机值(私钥),然后把内容通过该值进行对称加密。所谓对称加密就是,将信息和私钥(随机值)通过某种算法混合在一起,这样除非知道私钥(随机值),不然无法获取内容,而正好客户端和服务端都知道这个私钥(随机值),所以只要加密算法够彪悍,私钥(随机值)够复杂,数据就够安全。

7. 传输加密后的信息

这部分信息是服务端用私钥(随机值)加密后的信息,可以在客户端被还原

8. 客户端解密信息

客户端用之前生成的私钥(随机值)解密服务端传过来的信息,于是获取了解密后的内容。整个过程第三方即使监听到了数据,也束手无策。

二、HTTPS的通信流程和握手过程

1、HTTPS对应的通信时序图:

2、HTTPS在传输数据之前需要客户端(浏览器)与服务端(网站)之间进行一次握手,在握手过程中将确立双方加密传输数据的密码信息。TLS/SSL协议不仅仅是一套加密传输的协议,更是一件经过艺术家精心设计的艺术品,TLS/SSL中使用了非对称加密,对称加密以及HASH算法。握手过程的具体描述如下:

1. 浏览器将自己支持的一套加密规则发送给网站。

客户端 -》 服务端 【Client Hello】

2.网站从中选出一组加密算法与HASH算法,并将自己的身份信息以证书的形式发回给浏览器。证书里面包含了网站地址,加密公钥,以及证书的颁发机构等信息。

服务端 -》 客户端 【Server Hello】

首先服务端选择一个随机数 Random,并选择一个加密套件算法

【Certificate, Server Key Exchange, Server Hello Done】

接着服务端继续发送 Certificate 、Server Key Exchange 和 Server Hello Done 。其中 Server Hello Done 表示服务器完成了握手步骤。

  1. Certificate:在 TLS 握手的第一个消息中,服务器发送一个证书,该证书包含了服务器的公钥,以及服务器身份信息。客户端通过验证证书来确定服务器的身份是合法的。
  2. Server Key Exchange:如果服务器没有发送证书,或者客户端不想验证证书,服务器将发送一个包含公钥的 Server Key Exchange 消息来与客户端协商密钥,从而建立安全通信。
  3. Server Hello Done:在 TLS 握手的 Server Hello 阶段,服务器告诉客户端其支持的协议及密码套件。当服务器完成协商后,它会发送 Server Hello Done 消息,告诉客户端它已经完成了所有握手步骤,等待客户端发送后续消息。

3.浏览器获得网站证书之后浏览器要做以下工作:

客户端 -》 服务端 【Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message】

  1. Client Key Exchange(客户端密钥交换):在此步骤中,客户端会发送一个消息,其中包含一个随机生成的 Pre-Master Secret,这是一个用于生成对称密钥的重要材料。Pre-Master Secret 会通过非对称密钥加密传输,以确保安全性。
  2. Change Cipher Spec(修改密码规范):此步骤并不发送任何数据,而是告知对方从此处开始使用新的对称加密算法和密钥。在客户端发送 Change Cipher Spec 消息后,任何后续传输的数据都将使用新的加密方式和密钥进行加密。
  3. Encrypted Handshake Message(加密握手消息):在此步骤中,握手中的所有消息都将使用新的对称加密算法和密钥进行加密并发送。这包括服务器发送的证书、服务器密钥交换、服务器 Hello Done 等消息,以及客户端发送的 Finished 消息。在此之后,TLS 握手过程完成。

4. 服务端生成会话票据 Session Ticket,以实现会话恢复支持

服务端 -》 客户端【New Session Ticket, Change Cipher Spec, Encrypted Handshake Message】

  1. New Session Ticket

New Session Ticket是在TLS连接过程中,服务器端向客户端发送其生成的加密Session Ticket。Session Ticket主要用于实现会话复用,可以让客户端在下一次连接时重用之前已建立的TLS会话,从而可以减少加密过程中的握手时间和CPU资源负载。Session Ticket中包含了客户端和服务器端的随机数、过期时间以及加密套件相关的信息。

  1. Change Cipher Spec

在 TLS 握手过程中,Change Cipher Spec是在计算对称加密密钥和MAC密钥后,用于通知对端即将使用这些密钥加密和解密数据的消息。Change Cipher Spec消息是在握手完成之前,客户端和服务器端都需要发送给对端,以确保对称加密算法和密钥已就绪,并在之后的数据传输过程中正确使用。

  1. Encrypted Handshake Message

Encrypted Handshake Message是在Change Cipher Spec消息之后发送。在这步操作中,客户端和服务器端会将所有握手过程中的消息都进行加密,并通过TLS Record Protocol传输到对端。这个步骤的目的是确保握手过程中的所有信息都被加密保护。Encrypted Handshake Message中通常包含了前面握手过程中约定好的协议版本、加密套件、公钥、证书等等信息。

至此整个握手流程结束,后续发送的数据将使用 ”对称加密算法“ 加密发送。

5. 网站接收浏览器发来的数据之后要做以下的操作:

客户端发送数据到服务端,加密的信息是应用层协议 HTTP

对于所有 ACK 消息仍使用 TCP 协议,并不用 TLS。

总结

当客户端和服务器通过 TLS 连接进行通信时,它们必须通过握手过程进行身份验证,建立秘密密钥,并协商加密算法和其他参数。TLS 握手过程包括以下步骤:

  1. 客户端向服务器发送一个 ClientHello 消息,其中包含支持的加密算法、SSL/TLS 协议版本、随机数和可选的会话ID。
  2. 当服务器收到 ClientHello 消息时,它会向客户端回复一个 ServerHello 消息,其中包括选择的 加密算法SSL/TLS 协议版本服务器随机数 和 可选的 会话ID
  3. 服务器还将发送一个数字证书,该证书由一个信任的认证机构(CA)签署,证明服务器的身份。证书中包含基于公钥密码学的加密算法,服务器使用该算法向客户端提供公钥。
  4. 客户端使用服务器提供的公钥验证证书的签名,并利用公钥加密随机生成的另一个密钥,称为“客户端密码”。
  5. 客户端生成一个 PreMasterSecret,该密钥是由协商的 SSL/TLS 版本和随机生成的 客户端随机数服务器随机数 组成。客户端使用服务器公钥加密该密钥并将其发送到服务器。
  6. 服务器使用其私钥解密客户端消息中的 PreMasterSecret,并使用它计算 MasterSecret,该密钥是用于加密和解密通信数据的对称密钥。服务器向客户端发送 ChangeCipherSpec 消息,该消息指示从该点开始使用 MasterSecret 来加密通信数据。
  7. 客户端向服务器发送一个 ChangeCipherSpec 消息,然后生成一个与服务器 Cipher Suite 相匹配的 MasterSecret。客户端向服务器发送一个 Finished 消息,该消息是使用协商的 MasterSecret 的散列值计算的。
  8. 服务器收到 ChangeCipherSpecFinished 消息后,创建自己的 MasterSecret,并使用客户端发送的 Finished 消息的散列值来验证通信正在按预期工作。
  9. 客户端和服务器之间的加密会话就此建立,他们可以安全地交换任意数量的数据,直到有一方决定将连接关闭为止。
  10. 如果双方想要长时间保持连接状态,服务器将向客户端发送一个 NewSession Ticket 以实现会话恢复支持。

总体而言,TLS 握手过程确保安全地建立通信连接,保护敏感数据免受中间人攻击和其他安全威胁。

参考:

https://blog.csdn.net/qq_45516476/article/details/106753782

实现 Linux 终端录屏转gif动画

在一些开源其中,有些文档使用git动画来介绍的话效果会好很多,所以这里把在Linux终端下如何生成git动画效果整理出来,供大家参考。

安装录屏软件 asciinema

Mac

 brew install asciinema

Ubuntu

 sudo apt-add-repository ppa:zanchey/asciinema

Debian

 sudo apt-get install asciinema

Pip安装

 sudo pip3 install asciinema

目前此软件不支持 Windows。更多安装教程参考:https://zhuanlan.zhihu.com/p/28423868

用法介绍

 ❯ asciinema -h
 usage: asciinema [-h] [--version] {rec,play,cat,upload,auth} ...
 ​
 Record and share your terminal sessions, the right way.
 ​
 positional arguments:
  {rec,play,cat,upload,auth}
    rec                 Record terminal session
    play               Replay terminal session
    cat                 Print full output of terminal session
    upload             Upload locally saved terminal session to asciinema.org
    auth               Manage recordings on asciinema.org account
 ​
 optional arguments:
  -h, --help           show this help message and exit
  --version             show program's version number and exit
 ​
 example usage:
  Record terminal and upload it to asciinema.org:
    asciinema rec
  Record terminal to local file:
    asciinema rec demo.cast
  Record terminal and upload it to asciinema.org, specifying title:
    asciinema rec -t "My git tutorial"
  Record terminal to local file, limiting idle time to max 2.5 sec:
    asciinema rec -i 2.5 demo.cast
  Replay terminal recording from local file:
    asciinema play demo.cast
  Replay terminal recording hosted on asciinema.org:
    asciinema play https://asciinema.org/a/difqlgx86ym6emrmd8u62yqu8
  Print full output of recorded session:
    asciinema cat demo.cast
 ​
 For help on a specific command run:
  asciinema <command> -h
 ​

录屏

录屏命令

 asciinema rec

此时生成的文件将保存到临时目录里,一般为 /tmp/目录

也可以指定文件名

 asciinema rec demo.cast

当看到以下信息表示录屏工作开始,以后的操作将会被记录下来

 asciinema: recording asciicast to /tmp/tmpg4auzrud-ascii.cast
 asciinema: press <ctrl-d> or type "exit" when you're done

此时你可以进行正常的操作。

当操作完成后,按 ctrol-dexit退出录屏,看到提示信息

 asciinema: recording finished
 asciinema: press <enter> to upload to asciinema.org, <ctrl-c> to save locally
 asciinema: asciicast saved to /tmp/tmp1tj9jqnx-ascii.cast

如果按enter键会将结束自动上传到 asciinema.org网站,按下Ctrl+C 表示进行本地存储, 这里存储位置为 /tmp/tmp1tj9jqnx-ascii.cast

这种方法会在用户本地生成一个 .cast的文件,后面我们对其进行回放。

回放

我们先预览下上面生成的动画效果

 asciinema play demo.cast

查看会话

有时候我们需要查看用户的所有终端历史会话内容,此时可执行命令

 asciinema cat demo.cast

如果在屏幕过程中存在一些特殊命令,如 vi,则会话内容将显示成为乱码,还有可能提示错误。

上传

我们也可以将本地生成的文件上传到公网

asciinema upload demo.cast

提示

asciinema upload demo.cast
View the recording at:

    https://asciinema.org/a/KG2utenPw4pXk12TcEprPDaRh

This installation of asciinema recorder hasn't been linked to any asciinema.org
account. All unclaimed recordings (from unknown installations like this one)
are automatically archived 7 days after upload.

If you want to preserve all recordings made on this machine, connect this
installation with asciinema.org account by opening the following link:

    https://asciinema.org/connect/4fc6bdf3-ecc4-445a-a045-540aa101dee1

我们可以直接在浏览器里访问上面的URL来访问生成的效果。

转成Gif

有时候我们需要将上面的录屏内容转成gif格式在网络上传播,这时我们还需要利用一些工具将其转为gif动画才可以。这里我们使用一个docker镜像 asciinema/asciicast2gif 来操作

下载镜像

下载 Docker 镜像到本地

 docker pull asciinema/asciicast2gif

转换命令

docker run --rm -v $PWD:/data asciinema/asciicast2gif -s 2 -t solarized-dark demo.cast demo.gif

为了方便,我们用命令别名操作,将以下代码保存到 .bashrc 文件中,最后再执行 source ~/.bashrc 应用配置(如果用的zsh的话,则需要保存到 .zshrc文件)

 alias asciicast2gif='docker run --rm -v $PWD:/data asciinema/asciicast2gif'

以后就可以直接使用命令

 asciicast2gif demo.cast demo.gif

这里将录屏生成的json文件demo.json转成 demo.gif 文件,这时我们可以看下gif的生成效果。

另外在生成动画的时候,也可以指定一些参数,如倍速、缩放比例、高度和宽度,如

 asciicast2gif -t solarized-dark -s 2 -S 1 -w 400 -h 500 demo.cast demo.gif

参数

-t 表示颜色方案,必须为 asciinema, tango, solarized-dark, solarized-light, monokai (default: asciinema) 其中的一个,默认方案是 asciinema

-s 表示动画速度,默认为1

-S 图像比例/像素密度(默认值:2)

-w 将端子剪裁到指定的列数(宽度)

-h 将终端剪裁到指定的行数(高度)

常见问题

有时候在转gif的出现失败的情况,如果指定了一些参数的话,可以试着将参数移除试看看。我在用的时候经常出现在指定宽度和高度参数的时候会转换失败,将这两个参数省略则没有问题,怀疑是需要宽高不合理造成的。

参考资料

Linux 中的 Tun/Tap 介绍

TUN/TAP 设备

在计算机中TUN与TAP是操作系统内核中的虚拟网络设备。不同于硬件设备这些虚拟的网络设备全部用软件实现,但提供了与硬件设备完全相同的功能。

我们先了解一下物理设备的工作原理

所有主机物理网卡收到的数据包时,会先将其交给内核的 Network Stack 处理,然后通过 Socket API 通知给用户态的用户程序。

Linux 中 Tun/Tap 驱动程序为应用程序提供了两种交互方式:

  • 虚拟网络接口和字符设备/dev/net/tun。写入字符设备/dev/net/tun的数据会发送到虚拟网络接口中;
  • 发送到虚拟网络接口中的数据也会出现在该字符设备上;

我们再看下 tun 设备的工作原理

用户态应用往字符设备 /dev/tunX 写数据时,写入的数据都会出现在TUN虚拟设备上,当内核发送一个包给 TUN 虚拟设备时,通过读这个字符设备 /dev/tunX 同样可以拿到包的内容。

用户态应用程序写数据到 tun/tap 设备后进入内核态,内核态通过TCP协议复制到用户态,最后数据再次复制到内核态并通过物理网卡转发出去,期间共经历了三次用户态与内核态的复制操作,相比传统的一次复制操作来说,开销还是比较大的,因此性能会有一定的下降,这正是它的缺点。

TAP 设备与 TUN 设备工作方式完全相同,区别在于:

  • TUN 设备的 /dev/tunX 文件收发的是 IP 层数据包,只能工作在 IP 层,无法与物理网卡做 bridge,但是可以通过三层交换(如 ip_forward)与物理网卡连通。
  • TAP 设备的 /dev/tapX 文件收发的是 MAC 层数据包,拥有 MAC 层功能,可以与物理网卡做 bridge,支持 MAC 层广播

应用场景

tun/tap 的最主要应用场景就是vpn。
基实现原理就是用到隧道技术,将无法直接发送的包通先封成允许通过的合法数据包,然后经过隧道的方式传递给对方,对方收到数据包再解包成原始数据包,再继续传递下去,直到接收端收到,然后响应并原路返回。
以下图为例

  1. 应用进程(用户态)发起一个请求时,数据包并不是直接通过eth0网卡流出去,而是将请求数据包写入一个 TUN 字符设备,此时字符设备的数据会被发到虚拟网卡上(进入内核态)。根据TUN设置的特点,凡是写到这个设备的数据都可以在设备的另一端被应用程序读出的原理,应用程序客户端VPN(Port:28001)不断的从TUN 设备里将数据包读出来,然后再经过物理的网卡 eth0(IP1) 网卡流出,这一步就是一个普通的应用程序的客户端发起一个请求的过程。
  2. 流出的数据包通过 eth0 (IP2)被服务端VPN(Port:38001)接收到,然后再将收到的数据包以同样的方式写入 TUN 设备,此时进入内核态,经过 TCP/IP 协议栈,则再次将数据包经过物理网卡eth0(IP2)出去,经过交换同机或路由器直到最终到达目的主机(目的主机非本机)。
  3. 然后目的主机将响应按原来的线路返回给发起请求应用程序。

总结

  • 数据包在整个流程中,需要进行一些封包解包的操作, 这个操作由设备驱动完成
  • 如果数据包目的地不是VPN(Port:38001)当前所在主机的话,则需要向数据流向其它机器,此时务必修改IPTABLES的来源地址进行,即需要做SNAT,否则响应数据包将无法原路返回给客户端。

使用方法

我们先介绍一下在 Linux 中是如何对 TUN/TAP 虚拟设备进行操作创建管理和删除的,但对于如何充分利用这些设备必须通过编写程序代码来实现,在后面会给出网友整理出来的演示代码。

命令行输入 ip help 查看 ip 命令是否支持 tun/tap 工具,支持的话就会显示 tuntap 选项:

# ip help
Usage: ip [ OPTIONS ] OBJECT { COMMAND | help }
       ip [ -force ] -batch filename
where  OBJECT := { link | addr | addrlabel | route | rule | neigh | ntable |
                   tunnel | tuntap | maddr | mroute | mrule | monitor | xfrm |
                   netns | l2tp | tcp_metrics | token }

不支持就请升级或下载最新的 iproute2 工具包,也可以使用类似的 tunctl 工具。

先查看一下 ip tuntap 的基本用法

# ip tuntap help
Usage: ip tuntap { add | del | show | list | lst | help } [ dev PHYS_DEV ]
    [ mode { tun | tap } ] [ user USER ] [ group GROUP ]
    [ one_queue ] [ pi ] [ vnet_hdr ] [ multi_queue ] [ name NAME ]

Where:    USER  := { STRING | NUMBER }
    GROUP := { STRING | NUMBER }
  1. 创建 tap/tun 设备:
# ip tuntap add dev tap0 mod tap # 创建 tap 
# ip tuntap add dev tun0 mod tun # 创建 tun

# ifconfig -a

新添加的虚拟网卡默认是 DOWN 状态.
对于 tun 类型的虚拟网卡,它的MAC地址全是 0,这个是正常的

  1. 激活虚拟网卡
# ip link set tun0 up
# ip link set tap0 up

对它的操作与普通网卡的命令是一样的

  1. 分配IP
# ip addr add 10.0.0.1/24 dev tun0

此时 PING 10.0.0.1 是可以通的。

# ping 10.0.0.1
PING 10.0.0.1 (10.0.0.1) 56(84) bytes of data.
64 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.031 ms
64 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.036 ms
64 bytes from 10.0.0.1: icmp_seq=3 ttl=64 time=0.031 ms
64 bytes from 10.0.0.1: icmp_seq=4 ttl=64 time=0.037 ms
  1. 删除 tap/tun 设备:
# ip tuntap del dev tap0 mod tap # 删除 tap
# ip tuntap del dev tun0 mod tun # 删除 tun

代码演示

上面我们手动创建了虚拟网卡,但没有办法测试网卡的使用效果,下面是一段实现虚拟网卡读取的演示代码,代码摘自:https://segmentfault.com/a/1190000009249039

#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <linux/if_tun.h>
#include<stdlib.h>
#include<stdio.h>

int tun_alloc(int flags)
{

    struct ifreq ifr;
    int fd, err;
    char *clonedev = "/dev/net/tun";

    if ((fd = open(clonedev, O_RDWR)) < 0) {
        return fd;
    }

    memset(&ifr, 0, sizeof(ifr));
    ifr.ifr_flags = flags;

    if ((err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0) {
        close(fd);
        return err;
    }

    printf("Open tun/tap device: %s for reading...n", ifr.ifr_name);

    return fd;
}

int main()
{

    int tun_fd, nread;
    char buffer[1500];

    /* Flags: IFF_TUN   - TUN device (no Ethernet headers)
     *        IFF_TAP   - TAP device
     *        IFF_NO_PI - Do not provide packet information
     */
    tun_fd = tun_alloc(IFF_TUN | IFF_NO_PI);

    if (tun_fd < 0) {
        perror("Allocating interface");
        exit(1);
    }

    while (1) {
        nread = read(tun_fd, buffer, sizeof(buffer));
        if (nread < 0) {
            perror("Reading from interface");
            close(tun_fd);
            exit(1);
        }

        printf("Read %d bytes from tun/tap devicen", nread);
    }
    return 0;
}

演示

#--------------------------第一个shell窗口----------------------
#将上面的程序保存成tun.c,然后编译
dev@debian:~$ gcc tun.c -o tun

#启动tun程序,程序会创建一个新的tun设备,
#程序会阻塞在这里,等着数据包过来
dev@debian:~$ sudo ./tun
Open tun/tap device tun1 for reading...
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device

#--------------------------第二个shell窗口----------------------
#启动抓包程序,抓经过tun1的包
# tcpdump -i tun1
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tun1, link-type RAW (Raw IP), capture size 262144 bytes
19:57:13.473101 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 1, length 64
19:57:14.480362 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 2, length 64
19:57:15.488246 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 3, length 64
19:57:16.496241 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 4, length 64

#--------------------------第三个shell窗口----------------------
#./tun启动之后,通过ip link命令就会发现系统多了一个tun设备,
#在我的测试环境中,多出来的设备名称叫tun1,在你的环境中可能叫tun0
#新的设备没有ip,我们先给tun1配上IP地址
dev@debian:~$ sudo ip addr add 192.168.3.11/24 dev tun1

#默认情况下,tun1没有起来,用下面的命令将tun1启动起来
dev@debian:~$ sudo ip link set tun1 up

#尝试ping一下192.168.3.0/24网段的IP,
#根据默认路由,该数据包会走tun1设备,
#由于我们的程序中收到数据包后,啥都没干,相当于把数据包丢弃了,
#所以这里的ping根本收不到返回包,
#但在前两个窗口中可以看到这里发出去的四个icmp echo请求包,
#说明数据包正确的发送到了应用程序里面,只是应用程序没有处理该包
dev@debian:~$ ping -c 4 192.168.3.12
PING 192.168.3.12 (192.168.3.12) 56(84) bytes of data.

--- 192.168.3.12 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3023ms

参考资料

用 Goalng 开发 OPA 策略

Open Policy Agent 简称OPA是一个开源的通用策略引擎,可在整个堆栈中实现统一的、上下文感知的策略实施。OPA 已经成为了云原生计算基金会 (CNCF) 领域的毕业项目,已经在 Kubernetes / Istio 等多个知名项目里使用 。

OPA的核心思想就是策略即代码。

它使用Rego语言开发,Rego 的灵感来自 Datalog,它是一种易于理解、已有数十年的历史的查询语言。Rego 扩展了 Datalog 以支持 JSON 等文档模型。对于它的详细介绍请参考官方文档 https://www.openpolicyagent.org/docs/latest/policy-language/#what-is-rego,这里不再介绍,本方主要介绍如何使用Golang 来开发一个opa策略。

概述

OPA 将 策略决策策略执行 分离,当您的软件需要做出策略决策时,它会查询 OPA 并提供结构化数据(例如 JSON)作为输入。 OPA 接受任意结构化数据作为输入。

对于它的输入一般称为 input, 可以为任意类型,输出也一样可以为任意类型,即可以输出布尔值 truefalse,也可以输出一个 JSON 字符串对象。

Continue reading

一文看懂Golang 定时器源码

计时器分 Timer 和 Ticker 两种,它们底层基本是一样的,两差的区别请参考 https://blog.haohtml.com/archives/19859, 这里我们的介绍对象是 Timer 。

golang timer

计时器结构体

https://github.com/golang/go/blob/go1.17.6/src/time/sleep.go#L84-L98

 // NewTimer creates a new Timer that will send
 // the current time on its channel after at least duration d.
 func NewTimer(d Duration) *Timer {
     c := make(chan Time, 1)
     t := &Timer{
         C: c,
         r: runtimeTimer{
             when: when(d),
             f:    sendTime,
             arg:  c,
         },
     }
     startTimer(&t.r)
     return t
 }

通过调用 NewTimer() 函数创建一个 Timer,首先创建一个长度为1的有缓冲channel,再创建一个Timer的结构体,并将 channel 置于 Timer 结构体内。

注意这里的 runtimeTimer.f 字段是一个函数 sendTime ,其实现如下

func sendTime(c interface{}, seq uintptr) {
	// Non-blocking send of time on c.
	// Used in NewTimer, it cannot block anyway (buffer).
	// Used in NewTicker, dropping sends on the floor is
	// the desired behavior when the reader gets behind,
	// because the sends are periodic.
	select {
	case c.(chan Time) <- Now():
	default:
	}
}

当 sendTime 函数主要用在 newTimer() 时,它以无阻塞的方式将当前时间 Now() 发送到 c 通道里。如果用在 newTicker() 时,如果读取落后,会将发送丢弃,它是周期性的。

我们给出 Timer 的结构体声明。

 type Timer struct {
  C <-chan Time
  r runtimeTimer
 }

一共两个字段,为了理解方面我们称 runtimeTimer 为 timer 值。

我们再看一下其中的 runtimeTimer 结构体的声明

 // Interface to timers implemented in package runtime.
 // Must be in sync with ../runtime/time.go:/^type timer
 type runtimeTimer struct {
  pp       uintptr
  when     int64
  period   int64
  f        func(interface{}, uintptr) // NOTE: must not be closure
  arg      interface{}
  seq      uintptr
  nextwhen int64
  status   uint32
 }

对于 runnerTimer结构体要与在 runtime/time.go 文件中的 timer 结构体保持同步。

结构体字段说明

  • pp 指针类型,这里指 GPM 中的 P。如果这个计时器 timer 在一个heap 上,它在哪个 P 的堆上
  • when 表示唤醒执行的时间,表示什么时间开始执行
  • period 周期,一定是大于 0; when+period 表示下次唤醒执行的时间
  • f 执行函数,不允许为匿名函数,最好为非阻塞函数
  • arg 上面f函数的参数
  • seq 同 arg,其在 runOneTimer 函数中的调用方式为 f(arg, seq)
  • nextwhen 下次运行的时间,其值只有在 timerModifiedXX status 状态下才设置
  • status 状态,其定义的的可用值有10种,定义在 runtime/time.go,我们下面对这些状态进行了介绍。

每次开启一个goroutine 执行 f(arg, now),基中when表示执行的时间,而 when+period 表示下次执行的时间。(这时有点疑问,对调用的函数参数,f的第二个参数是 now, 但后面介绍的时候第二个参数却是 seq)

通过查看 https://github.com/golang/go/blob/go1.17.6/src/runtime/time.go#L41-L116 可知以下几点:

Continue reading

Golang常见编译参数

在执行 go build 命令的时候,经常需要添加一些参数,或许是为了调试,也或许是为了生成最终部署二进制文件。

在编译特定包时需要传递参数,格式应遵守“包名=参数列表”,如

go build -gcflags -gcflags='log=-N -l' main.go

-gcflags

go build 可以用 -gcflagsgo编译器传入参数,也就是传给 go tool compile 的参数,因此可以用 go tool compile –help 查看所有可用的参数。

其中 -m 可以检查代码的编译优化情况,包括逃逸情况和函数是否内联。

-ldflags

go build用 -ldflags 给go链接器传入参数,实际是给go tool link的参数,可以用go tool link –help查看可用的参数。

常用-X来指定版本号等编译时才决定的参数值。例如代码中定义var buildVer string,然后在编译时用go build -ldflags “-X main.buildVer=1.0” … 来赋值。注意-X只能给string类型变量赋值。