swarm-ui源码分析

shipyard是一款swarm集群管理的dashboard,可通过其管理容器,节点,镜像,镜像仓库等。scale容器数量,容器控制台等。

代码目录结构

▾ controller/
  ▸ api/              --->url以及处理的handler
  ▸ commands/         --->程序启动命令处理
  ▸ manager/          --->管理器,提供一些如docker client, 容器伸缩等接口与实现
  ▸ middleware/       --->web中间件,如鉴权
  ▸ mock_test/        --->无视
  ▸ static/           ---> angular js 前端代码
    Dockerfile
    main.go
    readme.md

前端静态文件也是go web服务器管理的。开发时可用node

main函数入口

func main() {
    //App结构体指定了一些如APP名称,版本等信息,还包含了一个Command结构体用来保存
    //和处理命令行启动参数的
    app := cli.NewApp()            
    app.Name = "shipyard"
    //...
    app.Commands = []cli.Command{       //实例化一个Command里面有很多flags
        {
            Name:   "server",
            Usage:  "run shipyard controller",
            //命令执行时的方法,main函数中最重要的一行代码了
            Action: commands.CmdServer,   
            Flags: []cli.Flag{
                cli.StringFlag{
                    Name:  "listen, l",
                    Usage: "listen address",
                    Value: ":8080",
                },
    //...
    //这里的Run最终执行了app.Commands.Action指定的方法
    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }

所以接下来我们看commands.CmdServer的代码

CmdServer流程

这里有个Context结构体,主要存放命令行启动参数,和一些获取命令行参数的方法。

func CmdServer(c *cli.Context) {
    //获取各种各样的命令行参数,Context提供诸如c.Int(key string) c.String(key string)
    //的方法获取
    rethinkdbAddr := c.String("rethinkdb-addr")
    rethinkdbDatabase := c.String("rethinkdb-database")
    //...
    //实例化一个client,用户访问docker(swarm) api
    client, err := utils.GetClient(dockerUrl, tlsCaCert, tlsCert, tlsKey, allowInsecure)
    //... 
    //实例化一个manager,包含client等信息,实现了诸如修改密码、伸缩容器数量等方法
    controllerManager, err := manager.NewManager(rethinkdbAddr, rethinkdbDatabase, rethinkdbAuthKey, client, disableUsageInfo, authenticator)
    //...
    //Api结构体包含一些重要信息,和manager,以及运行的入口
    shipyardApi, err := api.NewApi(apiConfig)
    if err != nil {
        log.Fatal(err)
    }
    //Run()方法运行了一个http服务器处理前端响应
    if err := shipyardApi.Run(); err != nil {
        log.Fatal(err)
    }
}

看一下Api结构体信息:

    Api struct {
        listenAddr         string
        manager            manager.Manager    //上文提到的manager
        authWhitelistCIDRs []string
        enableCors         bool
        serverVersion      string
        allowInsecure      bool
        tlsCACertPath      string
        tlsCertPath        string
        tlsKeyPath         string
        dUrl               string
        fwd                *forward.Forwarder  //反向代理服务器,可将前端的请求直接透明转发给docker api server
    }

Api Run()方法

主要功能是注册url和对应的处理方法以及启动http服务器。代码比较长,选取其中重要片段分析。

func (a *Api) Run() error {
    //..
    //实例化了一个反向代理服务器,包含http代理和websocket代理
    a.fwd, err = forward.New()
    //...
    //两个比较重要的http handler一个是将客户端请求无缝转发给docker api server的
    swarmRedirect := http.HandlerFunc(a.swarmRedirect)
    //这个是接管http链接的,处理websocket时需要使用
    swarmHijack := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        a.swarmHijack(client.TLSConfig, a.dUrl, w, req)
    })
    //...
    //开始注册各种api路由,如用户管理,角色管理这些docker api不涉及的东西
    apiRouter := mux.NewRouter()
    apiRouter.HandleFunc("/api/accounts", a.accounts).Methods("GET")
    apiRouter.HandleFunc("/api/accounts", a.saveAccount).Methods("POST")
    //...
    //这里可以看到,angular的前端静态文件也是由这个webserver处理的
    globalMux.Handle("/", http.FileServer(http.Dir("static")))
    //...
    //web中间件
    apiAuthRouter := negroni.New()
    apiAuthRequired := mAuth.NewAuthRequired(controllerManager, a.authWhitelistCIDRs)
    apiAccessRequired := access.NewAccessRequired(controllerManager)
    //鉴权中间件
    apiAuthRouter.Use(negroni.HandlerFunc(apiAuthRequired.HandlerFuncWithNext))
    //token检查中间件
    apiAuthRouter.Use(negroni.HandlerFunc(apiAccessRequired.HandlerFuncWithNext))
    //操作事件记录中间件,所有web界面的操作都统一记录下来
    apiAuthRouter.Use(negroni.HandlerFunc(apiAuditor.HandlerFuncWithNext))
    apiAuthRouter.UseHandler(apiRouter)
    globalMux.Handle("/api/", apiAuthRouter)
    //...然后是一些修改账号密码,登录等handler

    //这是真正与swarm交互的handler
    swarmRouter := mux.NewRouter()
    //http handler处理表,通过遍历这个map将所有url注册到server中
        m := map[string]map[string]http.HandlerFunc{
        "GET": {
            "/_ping":                          swarmRedirect,
            "/events":                         swarmRedirect,
            "/info":                           swarmRedirect,
            "/version":                        swarmRedirect,
            "/images/json":                    swarmRedirect,
            "/images/viz":                     swarmRedirect,
            "/images/search":                  swarmRedirect,
            "/images/get":                     swarmRedirect,
            "/images/{name:.*}/get":           swarmRedirect,
            "/images/{name:.*}/history":       swarmRedirect,
            "/images/{name:.*}/json":          swarmRedirect,
            "/containers/ps":                  swarmRedirect,
            "/containers/json":                swarmRedirect,
            "/containers/{name:.*}/export":    swarmRedirect,
            "/containers/{name:.*}/changes":   swarmRedirect,
            "/containers/{name:.*}/json":      swarmRedirect,
            "/containers/{name:.*}/top":       swarmRedirect,
            "/containers/{name:.*}/logs":      swarmRedirect,
            "/containers/{name:.*}/stats":     swarmRedirect,
            "/containers/{name:.*}/attach/ws": swarmHijack,
            "/exec/{execid:.*}/json":          swarmRedirect,
        },
        "POST": {
        //...

    for method, routes := range m {
        for route, fct := range routes {
            //主要逻辑,注册上面的map到http server ...
            swarmRouter.Path(localRoute).Methods(localMethod).HandlerFunc(wrap)
        }
    }
    //同样是一些中间件
        swarmAuthRouter := negroni.New()
    swarmAuthRequired := mAuth.NewAuthRequired(controllerManager, a.authWhitelistCIDRs)
    swarmAccessRequired := access.NewAccessRequired(controllerManager)
    swarmAuthRouter.Use(negroni.HandlerFunc(swarmAuthRequired.HandlerFuncWithNext))
    swarmAuthRouter.Use(negroni.HandlerFunc(swarmAccessRequired.HandlerFuncWithNext))
    swarmAuthRouter.Use(negroni.HandlerFunc(apiAuditor.HandlerFuncWithNext))
    swarmAuthRouter.UseHandler(swarmRouter)
}
    //...最后启动http server 
        runErr = s.ListenAndServeTLS(a.tlsCertPath, a.tlsKeyPath)
    } else {
        runErr = s.ListenAndServe()
    }

swarmRedirect逻辑

对容器的操作,镜像的操作等,大部分都是直接通过转发请求来实现的,也就意味着没有做api的转化。这样做好处是方便且不用再定义新的协议,坏处是没有体现出良好的适配性。 api.swarmRedirect逻辑:

func (a *Api) swarmRedirect(w http.ResponseWriter, req *http.Request) {
    var err error
    req.URL, err = url.ParseRequestURI(a.dUrl)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    //看,这里就调用了反向代理服务器的handler
    a.fwd.ServeHTTP(w, req)
}

代理服务器有两种可能 http代理和websocket代理:

func (f *Forwarder) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    if isWebsocketRequest(req) {
        f.websocketForwarder.serveHTTP(w, req, f.handlerContext)
    } else {
        f.httpForwarder.serveHTTP(w, req, f.handlerContext)
    }
}

看一下http代理的服务器的逻辑:

func (f *httpForwarder) serveHTTP(w http.ResponseWriter, req *http.Request, ctx *handlerContext) {
    //...
    //最重要的就是这行代码,用一个叫RoundTrip的东西把请求透明的发给了swarm
    response, err := f.roundTripper.RoundTrip(f.copyRequest(req, req.URL))
    //...
    //这里把获取到的请求回写给angular客户端
    written, err := io.Copy(w, response.Body)
}

来看一下RoundTripper是什么东西:

// RoundTripper is an interface representing the ability to execute a
// single HTTP transaction, obtaining the Response for a given Request.
// 就是已经有了一个http请求,获取响应。go net.http所有handler的参数都有
// http.Request都可直接作这个方法的参数。
type RoundTripper interface {
    //...
    RoundTrip(*Request) (*Response, error)
}

至此主要的逻辑基本就分析完成了。下面来看如果具体需要了解某块的逻辑如何去看,以scale容器数量为例。

容器scale处理器 scale理解为批量而非伸缩

在界面上操作时发现伸缩对应的url为:/api/containers/{id}/scale 然后在api.go文件中找到对应的处理handler是scaleContainer


func (a *Api) scaleContainer(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("content-type", "application/json")

    vars := mux.Vars(r)
    containerId := vars["id"]
    n := r.URL.Query()["n"]   //获取scale数量

    numInstances, err := strconv.Atoi(n[0])
    //最终调用的manager的ScaleContainer方法
    result := a.manager.ScaleContainer(containerId, numInstances)
    //...
}

manager的ScaleContainer方法

func (m DefaultManager) ScaleContainer(id string, numInstances int) ScaleResult {
    //...
    //获取到要伸缩的容器信息
    containerInfo, err := m.Container(id)

    //循环创建协程去创建容器。这里使用了一个go访问docker api的库
    for i := 0; i < numInstances; i++ {
        go func(instance int) {
            config := containerInfo.Config
            // ...
            id, err := m.client.CreateContainer(config, "", nil)
            // ...
            //并且启动容器
            if err := m.client.StartContainer(id, hostConfig); err != nil {
                errChan <- err
                return
            }
        }(i)
    }
}

client库里面就给docker cli发rest请求了:

func (client *DockerClient) CreateContainer(config *ContainerConfig, name string, auth *AuthConfig) (string, error) {
    data, err := json.Marshal(config)

    uri := fmt.Sprintf("/%s/containers/create", APIVersion)

    headers := map[string]string{}

    data, err = client.doRequest("POST", uri, data, headers)
    if err != nil {
        return "", err
    }
    result := &RespContainersCreate{}
    err = json.Unmarshal(data, result)

    return result.Id, nil
}

开发环境构建

运行环境构建

shipyard有若干依赖组件:

  • swarm集群swarm-manager swarm-agent
  • rethinkdb数据库,用于存放用户信息操作事件信息等
  • proxy 默认docker engine只监听socket,这个可以讲TCP转发Unix套接字
  • 服务发现 etcd。
  • shiyard controller 是shipyard的前后端工程

针对开发环境我们只关心shiyard controller. 官方提供一个简单的一键式部署的方式,将所有组建 运行在容器中。

如图:默认启动的是shipyard-controller这个容器,我们停掉这个容器去启动一个开发环境的容器取代之。 这样我们修改编译运行代码就可以看到结果了。

编译代码

  • go get github.com/tools/godep godep是个非常好用的go包管理器。
  • npm install -g bower 前端代码是用bower管理的
  • make build 编译go代码,后端的controller
  • make media 编译前端angularjs代码
  • 切换到controller目录执行./controller server -l :8080 -d tcp://swarm:3375运行controller
  • 访问localhost:8080即可

开发容器启动命令

fanuxdeMacBook-Air:shipyard fanux$ docker run -it --name shipyard-controller-dev \
> --link shipyard-rethinkdb:rethinkdb \
> --link shipyard-swarm-manager:swarm \
> -p 8080:8080 \
> -v /Users/fanux/work/src/github.com/shipyard/shipyard:/shipyard \
> golang:latest \
> /bin/bash

这里做了一下磁盘映射,这样可以在本地写代码在容器中编译和运行,建议使用go语言的镜像作为基础镜像。

前端代码分析

本示例以开发一个小功能来介绍前端的架构和angular的基本使用。

功能介绍

shipyard在使用镜像部署一个容器时需要输入镜像的名称,不支持选择已有镜像,这样非常不方便而且容易出错, 且很多镜像名称很长不便输入。

我们要实现这样的功能: 在点击Image Name输入框时,显示出可选镜像名称。点击可选镜像即可填充镜像名。如图:

angular路由

先看controller/static/app/config.routes.js路由文件。

    function getRoutes($stateProvider, $urlRouterProvider) {    
        $stateProvider
            .state('error', {
                templateUrl: 'app/error/error.html',
                authenticate: false
            });
        //这里指定了默认路由是 ‘dashboard.containers’也就是容器列表的页面
        $urlRouterProvider.otherwise(function ($injector) {
            var $state = $injector.get('$state');
            $state.go('dashboard.containers');
        });
    }

app目录下面按照模块分的很清楚,我们可以看containers目录下的路由。 找到我们关注的deploy的路由。

//....
        .state('dashboard.deploy', {
            url: '^/deploy',
            //指定路由的页面
            templateUrl: 'app/containers/deploy.html',
            //页面的控制器
            controller: 'ContainerDeployController',
            controllerAs: 'vm',
            authenticate: true,
            resolve: {
                containers: ['ContainerService', '$state', '$stateParams', function(ContainerService, $state, $stateParams) {
                    return ContainerService.list().then(null, function(errorData) {
                        $state.go('error');
                    });
                }]
            }
        })
//...

在deploy.html中找到对应的元素。

控制器与双向绑定

<div class="ui corner labeled input">
    <input name="image" class="input" type="text" ng-click="vm.getImages()"
    ng-model="vm.request.Image" placeholder="Image"></input>
           <div class="ui corner label">
           <i class="asterisk icon"></i>
     </div>
</div>

我们找到了对应输入框的代码。

  • ng-click 点击时执行的函数,vm是控制器里的全局对象。
  • ng-model 双向绑定,输入框的值会绑定到vm.request.Image

控制器deploy.controller.js页面上的变量与函数都在控制器中维护。

vm.getImages()方法:

        vm.getImages = getImages;
        function getImages() {
            console.log("get images");
            $http
                .get('/images/json')
                .success(function(data, status, headers, config) {
                    console.log(data[0].RepoTags[0]);
                    vm.images = data;
                    vm.imagesShow = 1;
                })
                .error(function(data, status, headers, config) {
                });
        }

方法很简单,向后端发送一个http get请求,将结果保存在 vm.images数组中。 vm.imagesShow是为了点击输入框时将镜像列表显示出来。

在页面中加入一个镜像菜单:

<div class="menu" ng-show="vm.imagesShow == 1">
     <div class="item ui label green" style="margin:2px;cursor:pointer;" 
      ng-repeat="i in vm.images" ng-click="vm.selectImage(i.RepoTags[0])">
      {{ i.RepoTags[0] }}
      </div>
</div>

指令:

  • ng-show 后面的表达式为真时 dom 显示,否则隐藏。 我们在点击输入框时设置其为1.
  • ng-repeat 便利数组,vm.images为我们http get到的镜像列表保存其中。

vm.selectImage()非常简单,把镜像名称赋给双向绑定的vm.request.Image,然后设置vm.imagesShow = 0影藏掉 镜像列表。至此我们的功能就开发完成了。

服务

注意到上面功能直接在controller中向后端发送http请求的,一般情况下不这么干,应该使用angular的服务,然后 将服务注入到controller中。以获取容器列表为例介绍服务。

    ContainersController.$inject = ['$scope', 'ContainerService', '$state'];
    function ContainersController($scope, ContainerService, $state) {

可以看到容器的controller注入了一个叫ContainerService的服务。打开对应文件:


    angular
        .module('shipyard.containers')
        .factory('ContainerService', ContainerService)

        ContainerService.$inject = ['$http'];
    function ContainerService($http) {
        return {           
            list: function() {
                var promise = $http
                    .get('/containers/json?all=1')
                    .then(function(response) {
                        return response.data;
                    });
                return promise;
            },
            //...

可以看到里面的list方法。这样我们就可以在容器中使用该方法获取后台数据。

            ContainerService.list()
                .then(function(data) {
                    vm.containers = data; 
                    angular.forEach(vm.containers, function (container) {
                        vm.selected[container.Id] = {Id: container.Id, Selected: vm.selectedAll};
                    });
                }, function(data) {
                    vm.error = data;
                });

results matching ""

    No results matching ""