coolEx

Today will be better

gentoo安装网易云音乐

很早之前就在gentoo上安装了网易云音乐,但是一直无法正常使用,今天心血来潮重新排查了下问题,终于搞定了。

首先gentoo上安装网易云音乐,只需要添加gentoo-zh overlay,然后直接安装即可。

启动之后遇到两个问题:高分屏和无法播放。

高分屏

高分屏主要是我本地设置了环境变量:export QT_AUTO_SCREEN_SCALE_FACTOR=1这个配置貌似会和启动脚本加载的参数冲突。启动脚本会去加载/usr/lib/netease-cloud-music/netease-cloud-music-hidpi这个文件,里面主要是设置了一个环境变量,增加一个启动参数。因此直接修改这个文件,增加一行:

export QT_AUTO_SCREEN_SCALE_FACTOR=0

去掉自动修改缩放比例。

然后再启动应用显示比例正常。

无法播放

虽然能够正常显示,但是如果点击播放,一直会提示“网络异常”。google了之后说实际上是因为系统无法播放mp3导致的。每个平台mp3播放支持都不太一样,gentoo需要将包media-plugins/gst-plugins-meta上增加mp3这个USE。实际上netease-cloud-music-1.0.0.ebuild已经增加了gst-plugins-meta依赖,但是木有强制增加mp3 USE。在package.use文件中增加之后重新编译,会自动增加播放mp3需要的gst相关插件,然后播放一切正常。

由于netease-cloud-music一直没更新,因此懒得自己去改ebuild文件,而是直接在安装之后进行了改动。

springboot websocket

后端配置

springboot中增加websocket支持,相关文档在http://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html

首先需要在pom中引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

引入依赖之后,开启websocket功能,和其他springboot扩展一样,通过@EnableWebSocket注解即可。

我们只使用标准的websocket,而不是用其上层包装的STOMP协议。因此,首先需要实现一个对应的handler。

@Component("taskHandler")
public class TaskHandler extends TextWebSocketHandler {
}

通常handler可以继承BinaryWebSocketHandler或者TextWebSocketHandler两个类,支持两种数据格式。通常情况下用文本格式的应该会比较多。

handler定义了一系列websocket通信流程中的接口,包括连接建立(afterConnectionEstablished)、消息接收(handleTextMessage)、连接关闭(afterConnectionClosed)等。可以通过试下这些方法来实现业务逻辑。本文实现的逻辑比较简单,是一个类似消息订阅的功能,客户端通过websocket协议创建连接之后,服务端持有连接session,当有消息需要发送时,向客户端进行广播。

为了实现该功能,首先需要重写afterConnectionEstablished方法,当连接建立的时候,将seesion保存起来。这里需要注意一点,为了减少客户端和服务端交互带来的复杂度,服务端直接获取了客户端建立连接时附带的参数。但是由于这个参数来自于http请求,除了hander之外,还需要为这个handler增加一个HandshakeInterceptor

HandshakeInterceptor可以在客户端和服务端进行websocket握手时,获取到中间信息,并将其保存到websocket session的attribute中。

public class HandshakeParameterInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse,
                                   WebSocketHandler webSocketHandler, Map<String, Object> attribute) throws Exception {
        if (serverHttpRequest instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest request = (ServletServerHttpRequest) serverHttpRequest;
            Map<String, String[]> parameterMap = request.getServletRequest().getParameterMap();
            Map<String, String> httpParams = parameterMap.entrySet().stream().filter(entry -> entry.getValue().length > 0)
                    .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue()[0]));
            attribute.putAll(httpParams);
            return true;
        }

        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse,
                               WebSocketHandler webSocketHandler, Exception e) {
        // to nothing
    }
}

HandshakeInterceptor接口有方法,分别表示握手前和握手后,由于我们需要获取http请求的参数,所以选择拦截握手前的参数。这里看上去和servlet中处理类似,通过request获取到请求参数,并将其全部放置到attribute参数中,以保证后续websocket seesion能够读取到。

这里插播下websocket的握手流程,以便更好的理解为什么能够握手拦截器来获取到请求参数。websocket握手开始于客户端发起的http请求,客户端会发送一个标准的HTTP 1.1请求头,唯一不同的是会带有Upgrade头,其值是websocket,Connection头的值设置为Upgrade。表示该请求要求服务端对连接协议进行升级,升级成websocket协议。请求头类似于:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务端接受到请求之后,判断可以升级成websocket协议,会响应HTTP code 101,表示协议切换,例如:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

此时,就完成了协议升级,后续直接发送websocket帧进行websocket交互。完整websocket握手协议,参见RFC 6455前文的拦截器就在这时起了作用,获取到第一个HTTP请求头的参数,然后和websocket session绑定起来。

websocket连接建立之后,服务端就可以通过session对象向客户端发送消息了。

完成了handler和inteceptor之后,就需要通过配置将他们组装起来了。配置的方式需要实现WebSocketConfigurer接口:

@Configuration
public class WsConfiguration implements WebSocketConfigurer {

    // handlers
    @Autowired
    private TaskHandler taskHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(taskHandler, "ws/topic/task")
                .addInterceptors(parameterIntercepor());
    }

    @Bean
    public HandshakeInterceptor parameterIntercepor() {
        return new HandshakeParameterInterceptor();
    }
}

需要实现registerWebSocketHandlers方法,将handler和对应的path关联起来,然后对handler设置interceptor,如果需要设置跨域请求,也可以通过setAllowedOrigins方法设置,确保websocket请求可以从指定域请求。这样整个springboot应用就可以实现websocket协议的响应了。

对于线上应用,还需要注意tengine(nginx)的配置。默认情况下,HTTP的Upgrade头不会代理到后端,需要在nginx.conf文件中增加:

http {
    ...
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
    server {
        location / {
                proxy_pass   http://backends;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection $connection_upgrade;
                proxy_set_header Host $host;
        }
    }
    ...
}

前端配置

后端完成之后,前端也需要实现websocket请求。前端的websocket请求,可以直接使用对应的接口。对于reactjs实现的前端,可以使用react-websocket组件来实现。

该组件可以直接在render函数中添加<Websocket />标签来使用。该标签中最重要的属性就是url,表示websocket的连接地址,onMessage属性构建数据的回调。这样可以很简单的通过websocket来向后端订阅数据。不过该组件只适用于订阅websocket消息,不适用于双工交互。

mesos + marathon集群搭建(2)

本地存储卷

前面示例已经启动了redis镜像,然后为了验证marathon的资源隔离(主要是内存)特性,做了以下尝试:

  1. 将redis容器的内存限制为32M;
  2. 通过脚本循环向redis写入长字符串;
  3. 发现当任务资源监控超过16M的时候,内存占用又会重新清零。

这个过程我们可以知道,将数据写入redis之后,任务监控到redis实例的内存占用在不断增加。然后超过内存限制的一半之后,任务被kill,几秒钟之后marathon重新启动了任务,本地redis客户端重新可用。这里内存超过一半是因为默认redis开启了rdb文件保存(bgsave),redis需要新fork出进程进行rdb文件写入,导致此时虚拟内存翻倍,容器占用的内存超过了cgroup限制,被内核oom kill。我们可以通过内核日志发现一次oom kill的日志。最后marathon虽然重新启动了任务,但是由于docker容器的隔离性,之前写入硬盘的内容(rdb)文件都已经消失,导致内存清零,之前写入的大字符串消失。

如果我们希望redis任务重新启动之后能够保留数据(虽然对于测试场景无效,因为内存不足,如果数据恢复,进程也会立即被内核kill),就需要将容器中的redis工作目录持久化保存。

marathon文档中描述了两种持久化的方式,对于我们来说,最简单的就是使用本地卷进行持久化。本地卷的大致方式是向mesos master申请指定大小的卷,并将该卷和任务容器关联起来(对于docker容器使用volume-from参数关联即可)。

启动redis任务,其中和卷相关的配置为:

"volumes": [
     {
       "containerPath": "/data",
       "hostPath": "redis",
       "mode": "RW"
     },
     {
       "containerPath": "redis",
       "mode": "RW",
       "persistent": {
         "size": 1024
       }
     }
    ]

该配置表示在slave机器上申请名为redis的卷,大小为1024MB,权限为读写。同时设置将主机的redis卷挂载到容器的/data目录中,挂载权限为读写。

提交任务之后,发现使用了本地卷之后的任务一直在unsheduled状态,slave上没有任务分派的数据。查了半天文档也没有好的解释。于是重新按照marathon文档进行了设置,特别关注其中的Prerequisites章节。其中描述了marathon启动参数中必须设置mesos_authentication_principalmesos_role两个参数。但是其中没有太明确描述需要设置什么值。

由于这两个参数主要作为在mesos master执行操作时后的标识,首先先在marathon机器上的/etc/marathon/conf/目录中分别创建mesos_authentication_principal和mesos_role两个文件:

echo 'marathon' > /etc/marathon/conf/mesos_authentication_principal
echo 'marathon' > /etc/marathon/conf/mesos_role

重启marathon服务之后,在任务详情中就可以看见新增的volume标签页。但情况并没有好转,任务长时间处于waiting状态,卷的状态一直为detached。最终在mesos master(leader那台)上找到了一些错误日志:

Dropping RESERVE offer operation from framework … A reserve operation was attempted with no principal, but there is a reserved resource in the request with principal ‘marathon’ set in ReservationInfo

紧接着一条错误:

Dropping CREATE offer operation from framework…Invalid CREATE Operation: Insufficient disk resources

查询了文档之后大概知道,由于principal设置的问题,mesos master没有认可marathon发起的RESERVE指令(既让slave预留一部分资源,这里是硬盘空间),随后由于没有预留足够的硬盘空间,发送的创建指令由于没有剩余空间而失败。

从错误日志基本可以断定是由于principal设置问题导致的,从marathon任务相关文档中,终于了解了mesos master acl相关的配置,,以及这些配置对frameworks的影响。直接抄文档中的acls内容会有问题,因为其中只是指定了run_tasks和register_frameworks两个操作的acl,通过mesos文档,我们可以查阅到mesos master支持的所有acl项。对于我们的需求,需要授权marathon支持reserve_resources操作,最终将acls参数内容更新为:

{
    "run_tasks": [
        {
            "principals": {
                "type": "ANY"
            },
            "users": {
                "type": "ANY"
            }
        }
    ],
    "register_frameworks": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            }
        }
    ],
    "reserve_resources": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            },
            "resources": {
                "type": "ANY"
            }
        }
    ],
    "unreserve_resources": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            },
            "reserver_principals": {
                "type": "ANY"
            }
        }
    ],
    "create_volumes": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            }
        }
    ],
    "destroy_volumes": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            },
            "creator_principals": {
                "type": "ANY"
            }
        }
    ]
}

这里只是为了能够正常分配,没有做任何访问控制。将上述内容保存到文件(/etc/mesos-acls)然后添加到mesos-master的参数:

echo 'file:///etc/mesos-acls' > /etc/mesos-master/acls

重启所有mesos master,之前一直在waiting状态的任务,已经可以正常申请卷,并启动容器。进入分配到的slave服务器,我们可以看见多了一个挂载点:

/dev/vda1 on /var/lib/mesos/slaves/fb9d0623-c0bf-402e-9288-06206b30ea54-S4/frameworks/487f9562-5998-4cfb-bc92-bbc3a441c69c-0000/executors/redis_single.bd21b767-41b8-11e6-9d7a-024259ae7178/runs/8c9304a1-b960-4aa0-a169-f368a4d77ed9/redis type xfs (rw,relatime,attr2,inode64,noquota)

同时通过docker inspect命令查看redis容器的参数,可以发现其中卷挂载有如下配置:

"Mounts": [
    {
        "Source": "/var/lib/mesos/slaves/fb9d0623-c0bf-402e-9288-06206b30ea54-S4/frameworks/487f9562-5998-4cfb-bc92-bbc3a441c69c-0000/executors/redis_single.bd21b767-41b8-11e6-9d7a-024259ae7178/runs/8c9304a1-b960-4aa0-a169-f368a4d77ed9/redis",
        "Destination": "/data",
        "Mode": "rw",
        "RW": true,
        "Propagation": "rprivate"
    },
    {
        "Source": "/var/lib/mesos/slaves/fb9d0623-c0bf-402e-9288-06206b30ea54-S4/frameworks/487f9562-5998-4cfb-bc92-bbc3a441c69c-0000/executors/redis_single.bd21b767-41b8-11e6-9d7a-024259ae7178/runs/8c9304a1-b960-4aa0-a169-f368a4d77ed9",
        "Destination": "/mnt/mesos/sandbox",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    }
]

既创建的挂载点已经被挂到容器的指定路径了。

然后我们进行验证:
首先通过redis客户端连接到redis上,然后设置工作路径为我们挂载的路径:

redis-cli -h 1.1.1.1 CONFIG SET dir /data

然后写入数据:

redis-cli -h 1.1.1.1 set a b

为了确保能够正常持久化,执行:

redis-cli -h 1.1.1.1 bgsave

查看挂载卷路径,可以发现已经有dump.rdb文件产生。此时kill掉redis任务,等待marathon重新启动。再次启动之后,通过redis-cli连接进入redis之后,可以看见之前设置的key仍然存在:

> redis-cli -h 1.1.1.1 keys *
1) "a"

然后获取这个key的值:

> redis-cli -h 1.1.1.1 get a
1) "b"

从这个实验可以看出,marathon的本地存储卷可以确保服务重新启动之后原来的数据仍然存在,对于存储类的应用(如redis、mysql等)非常有用。

mesos + marathon集群搭建(1)

机器准备

机器准备和mesos本身没有关系,主要由于本身机器申请机制有关,这段主要记录下如何通过配置systemd,将agent启动并且正确注册到服务器上。
这里的核心问题是需要开机获取本机IP,然后按照顺序启动docker和agent的镜像。

由于docker0设备的存在,之前agent获取本机IP的方式会出现问题,所有基于docker启动的agent都需要通过环境变量传入本机IP。因此首先需要在镜像启动前,获取本机IP。systemd本身可以通过%H来指代主机名,但是没有提供本机IP的变量。这边通过开机自动运行脚本,按照k=v的方式写入文件,提供给其他unit file作为environment file使用。

#cat ip.service
[Unit]
Description=get host ip and write to /etc/ip.txt

[Service]
ExecStart=/etc/host-ip.sh

[Install]
WantedBy=multi-user.target

对应的脚本文件内容为:

#cat /etc/host-ip.sh
#!/bin/sh
ip=$(/sbin/ifconfig eth0 | awk '/inet/ {print $2}')
echo IP=$ip > /etc/ip.txt;

这样每次服务启动之后,会写入到ip.txt文件,其内容类似:

IP=1.1.1.1

将unit文件放置在/etc/systemd/system目录中,别忘了设置成自动启动:

systemctl enable ip.service

搞定机器IP之后,就是agent的启动了。首先需要确保的是docker服务本身已经配置成自动启动。

systemctl enable docker

然后创建agent对应的unit文件:

#cat agent.service
[Unit]
Description=aenv agent
Requires=docker.service ip.service
After=docker.service ip.service

[Service]
EnvironmentFile=/etc/ip.txt
Restart=on-failure
RestartSec=10
ExecStartPre=-/usr/bin/docker kill agent
ExecStartPre=-/usr/bin/docker rm agent
ExecStart=/usr/bin/docker run --net host --env HOST_IP=$IP --name agent agent:1.0
ExecStop=-/usr/bin/docker stop agent

[Install]
WantedBy=multi-user.target

这里需要注意几个地方:

  • 依赖关系:在unit部分中,必须指定当前配置文件的依赖关系,这里指定依赖docker和ip两个服务,同时指定当前服务在这两个服务启动之后再启动。
  • 环境变量文件:之前的ip服务已经自动将ip地址写入到了ip.txt文件中,unit文件中可以直接将该文件设置为EnvironmentFile。这样可以通过引用文件中的KEY来获取实际的数据。
  • 启动命令:这里指定了几个阶段的命令,分别是预启动命令,如果当前已经有agent容器,直接杀死并删除;启动命令,设置agent容器启动命令,特别注意设置I变量;关闭命令,关闭服务时执行的命令,直接关闭agent容器。

最后别忘了设置开机自动启动:

systemctl enable agent.service

mesos+marathon集群搭建

mesos和marathon的基本安装可以参照mesosphere的文档进行。这次尝试安装使用的操作系统镜像是Centos 7,因此只要按照文中关于Centos 7的步骤即可。由于之前已经完成了zk集群的搭建,这里不再需要安装zk。

为了能够组建高可用集群,mesos master和marathon都使用3台机器搭建。因此需要修改部分配置文件。

对于mesos master,首先修改/etc/mesos中的zk文件,里面修改成已经搭建完成的zk集群,多个地址之前用逗号分割,mesos master之间通过zk来相互感知和进行选举。其他需要修改的文件都在/etc/mesos-master目录中,对于集群来说,首要设置的是quorum文件。按照官方文档说明,对于3台master,quorum需要设置为2,以进行单点失效后的选举。按照文档,这两个文件设置完成之后,就可以启动三台mesos master机器上的mesos-master服务了:

systemctl start mesos-master

但是,此时当三台机器都启动完成之后,访问任意一台的5050端口,会出现两个问题:

  1. 当访问机器不是当前leader时,网页无法正确跳转,直接跳转到了服务器的hostname(目前主机名只能通过机房DNS才能解析);
  2. 跳转到leader机器之后,过几秒钟就会跳转到其他机器,查看日志发现mesos master在不停的失效,不停的选举;

对于问题1,由于mesos master默认获取的主机名在非机房DNS中都无法解析,最方便的做法就是将hostname直接设置成机器IP。既在/etc/mesos-master目录中创建文件hostname,并将其内容设置为机器IP。

对于问题2,查了很多文档,也尝试了很多方式,最终尝试给mesos-master指定ip为当前机器IP后解决。既在/etc/mesos-master目录中创建文件ip,并将其内容设置为机器IP。这里需要特别注意的是,mesos master会将内容缓存在work_dir中,其值通过/etc/mesos-master目录中的work_dir文件内容指定。通过mesosphere rpm包安装之后默认值为/var/lib/mesos。因此修改了ip文件之后,需要清空其中内容,才会生效。

完成上面两步之后,mesos master集群启动成功。之后可以将mesos-master服务设置为开机自动启动:

systemctl enable mesos-master

然后就是安装marathon集群,还是直接使用rpm包进行安装。其中只有一个需要注意的地方(另一个大坑后面会介绍),就是设置机器的hostname为IP,原因前面已经介绍过。marathon的配置在/etc/marathon/conf目录中,对于主机名,创建hostname文件,并在其中设置当前机器IP即可。marathon启动之后,默认监听8080端口。

最后将marathon服务设置为开机自动启动:

systemctl enable marathon

添加slave和执行docker任务

前面已经介绍了mesos master集群和marathon集群的搭建,要真正能够分配资源并运行任务,还需要向master注册slave。slave和master使用了相同的机器镜像,确保rpm包已经正确安装,同时zk都已经完成配置(slave也使用/etc/mesos/zk的配置)。对于slave的所有配置,都在/etc/mesos-slave目录中,配置方式和mesos相同,防止和参数名称相同的文件即可。最简单的配置和master类似,设置hostname即可。既创建/etc/mesos-slave/hostname文件,并写入当前机器IP。其他服务器可用资源可以让slave自动检测并上报。

设置完成之后,就可以启动slave了。可以在master上看见注册上来的slave及其资源信息。同时记得将slave设置为开机自动启动:

systemctl enable mesos-slave

启动之后,尝试在marathon上提交一个docker任务:启动redis:alpine镜像。但是发现任务没有能够正确的在slave上启动。参照marathon文档介绍,要让slave支持docker,需要设置containerizers参数。执行:

echo 'docker,mesos' > /etc/mesos-slave/containerizers
echo '5mins' > /etc/mesos-slave/executor_registration_timeout

后面那个参数主要是由于docker任务启动的时候,需要去远程拉取镜像,一般刚开始容易超时。(虽然这样设置了之后还是会超时,不过由于marathon的重试机制,超时的任务会稍后重新启动,并且docker镜像本地也会缓存,目前感觉影响不大。)

完成这些配置之后,再重启mesos-slave服务,即可在marathon上提交刚才无法成功的redis:alpine镜像了。

另外,marathon还提供了任务的健康检测。按照其文档描述,在mesos master v0.23.1和v0.24.1以后,针对docker任务也已经支持通过命令来进行健康检测(实际查看slave日志后发现,执行的命令最终被转化成了docker exec命令,进入容器之后运行指定的命令)。对于redis任务,我们可以配置redis-cli info命令来进行健康检测,如果命令执行超时则判定为失败。不过marathon的健康检测还不支持对命令返回值的断言。

slides库reveal.js简介

简介

reveal.js是一个功能强大的在线演示文稿库。尝试它的主要原因有:

  • 文稿内容可以使用html标签或者markdown,可以使用任何编辑器
  • 支持浏览器直接打开
  • 支持使用node作为服务提供在线分享
  • 支持通过chrome的打印成pdf文档

编辑

编写文稿比较方便,直接下载release版本的包,或者clone工程目录即可。
其中release版本可以在https://github.com/hakimel/reveal.js/releases
页面中选择最新版本下载即可。

基本的功能使用非常简单,可以直接编辑代码中的index.html文件,按照里面的格式编辑即可。
html文件中的section的标签对应slides的一页,其中按照标准的html标签编写。例如官方示例的第一页:

<section>
    <h1>Reveal.js</h1>
    <h3>The HTML Presentation Framework</h3>
    <p>
        <small>Created by <a href="http://hakim.se">Hakim El Hattab</a> / <a href="http://twitter.com/hakimel">@hakimel</a></small>
    </p>
</section>

当然,对我来说,reveal.js最方便的地方在于可以直接在源码中使用markdown语法。markdown使用详细介绍文档
使用时需要将markdown代码放在script标签中,并将type设置为text/template

<section data-markdown>
    <script type="text/template">
        ## Markdown support

        Write content using inline or external Markdown.
        Instructions and more info available in the [readme](https://github.com/hakimel/reveal.js#markdown).
    </script>
</section>

这样就可以容易的将markdown文档和slides结合起来了。

演示和分享

slides最大的作用当然是演示了,使用reveal.js标准结构的slides演示最方便的方式,就是直接在浏览器中打开index.html。这样可以方便的在没有网络的情况下,
单机进行演示。如果需要在线分享,需要安装nodejs,然后通过node安装grunt,最后通过npm install安装reveal.js依赖。

npm install
grunt serve

默认启动端口为8000。

最后,一般演讲完之后,还需要分享内容。这时可以通过chrome的打印功能,直接打印成pdf文档。
打印时有两个地方需要特别注意:

  1. 必须在访问的url上增加print-pdf参数。例如原url为/index.html,改成/index.html?print-pdf
  2. 打印页面中“布局”需要设置为“横向”,并且选项中勾选“背景图形”。

然后保存为pdf文件即可。

empty:expect的替代

之前为了能够处理命令交互,编译了静态链接的expect,结果遇到了大问题导致在没有tcl的机器上还是无法运行。找了半天,发现empty是一个不错的替代。

优势

首先,empty是一个纯C实现的,不依赖任何其他库的工具。这对于需要在不同机器上分发执行非常关键,之前expect就是因为无法确保机器上都有tcl或者其初始化脚本,导致无法使用。

其次,empty使用非常简单,可以方便的嵌入到shell脚本中,而无须像expect那样使用单独的脚本。这能够大大降低学习成本和后续维护成本。

使用

empty和一般应用安装方式类似,直接make即可。整个源码包中核心只有一个C文件,结构非常简单。下载地址在:http://sourceforge.net/project/showfiles.php?group_id=136798 这里。安装方式可以参考项目首页命令。

编译完成之后,就可以直接使用empty这个可执行程序了。它的help输出不是很友好,比较简单,大致参数有这几个:

  • -f:应该是fork的意思吧,这个参数表示会执行后面的命令,同时这个命令可以通过-i和-o指定命令的输入和输出管道。如果不指定。empty会以当前进程id和子进程id作为标识生成对应的文件。
  • -w:应该是watch的意思吧,该参数可以执行监听的标准输入管道(一般为-f的标准输出管道)和写入的标准输出管道(一般为-f的标准输入管道),然后后续以键值对的方式追加多个监听的key,和输出的value,即发现标准输入包含key时,向标准输出写入value。这个键值对可以有多个。
  • -i:标准输入对应的管道文件。对于-f参数指定的是执行命令的标准输入,后续可以通过这个标准输入管道,向执行命令发送标准输入内容。特别注意,对于-w参数这里表示监听输入的来源,需要设置为-f的标准输出管道。
  • -o:标准输出对应的管道文件。对于-f参数指定的是执行命令的标准输出,后续可以通过读取这个管道文件,监听命令的输出。对于-w参数,这里表示输出写入的管道,需要设置为-f的标准输入管道。
  • -s:应该是send的缩写吧,可以直接指定向标准输出管道(-o)写入字符
  • -t:设置watch的超时时间,如果超时时间内没有输入,则退出empty命令。默认值为0,表示不超时。

因此,大部分场景下,先通过-f启动命令,然后通过-w命令监视输出,并且写入结果。例如代码中的示例,如果要模拟ssh登录,可以这样:

ssh="ssh"                               # (/full/path/to/)ssh
target="localhost"                      # target host
login="luser"                           # username (Change it!)
password="TopSecret"                    # password (Change it!)

fifo_in="/tmp/empty.in"                 # input fifo
fifo_out="/tmp/empty.out"               # output

# -----------------------------------------------------------------------------
cmd="$ssh $login@$target"
tmp="/tmp/empty.tmp"                    # tempfile to store results

echo "Starting empty"
empty -f -L $tmp -i $fifo_in -o $fifo_out $cmd
if [ $? = 0 ]; then
        if [ -w $fifo_in -a -r $fifo_out ]; then
                echo "Sending Password"
                empty -w -v -i $fifo_out -o $fifo_in -t 5 assword: "$passwordn"
                echo "Sending tests"
                empty -s -o $fifo_in "echo "-- EMPTY TEST BEGIN --"n"
                empty -s -o $fifo_in "uname -an"
                empty -s -o $fifo_in "uptimen"
                empty -s -o $fifo_in "who am in"
                empty -s -o $fifo_in "echo "-- EMPTY TEST END --"n"
                echo "Sending exit"
                empty -s -o $fifo_in 'exitn'
                echo "Check results:"
                sleep 1
                cat $tmp
                rm -f $tmp
        else
                echo "Error: Can't find I/O fifos!"
                return 1
        fi
else
        echo "Error: Can't start empty in daemon mode"
        return 1
fi

echo "Done"

这么一大堆脚本,其实就三行和empty使用有关,一个是empty -f -L $tmp -i $fifo_in -o $fifo_out $cmd,该命令执行ssh命令,并且指定输入、输出和日志文件。empty -w -v -i $fifo_out -o $fifo_in -t 5 assword: "$password\n"该命令通过监听ssh命令的输出,如果遇到“assword:”字样,则向ssh进程发送密码。注意,这里的-i和-o参数和之前相反,因为需要监听ssh命令的输出,向ssh命令的输入发送字符。最后就是empty -s -o $fifo_in "echo \"-- EMPTY TEST BEGIN --\"\n"这时ssh已经认证成功了,因此就直接向远程写入通过ssh执行的命令即可。

除了这种预先知道命令每一步的输出之外,还有一些场景是命令可能在有些场景下会进行交互,有些场景下不会。我们遇到的场景就是webx提供的autoconfig命令。该命令默认会在缺少配置项的时候进行交互,提示用户是否使用默认值,并且写入到antx.properties文件。

这时,就不能简单的通过每一步等待特定的字符,然后输出了。这里我们利用了empty的另一个特性,即自动维护管道文件,命令开始执行时,empty会创建对应的标准输入和标准输出两个管道文件,命令结束时删除,这样就不用用户来维护命令的输入输出,也不会产生垃圾文件。因此,我们可以通过循环的方式,监听是否有我们需要的关键字,在每次监听前,增加判断管道文件是否存在,以防止命令已经正常退出。大致的脚本如下:

empty -f -i input -o output -L $HOME/autoconfig.log autoconfig -u $HOME/antx.properties $f
sleep 1
while true;
do
    if [ -w input -a -r output ]; then
        empty -w -i output -o input -t 5 ".*Yes.*" "yesn" ".*[Quit.*" "quitn"
    else
        break;
    fi
done

while循环里面,首先需要判断的是input和output两个文件是否存在。对于input文件,通过-w检查文件是否存在的同时,检查文件是否可写;对于output文件,通过-r检查文件是否存在的同时,检查文件是否可读。如果两个文件都存在,那么运行empty命令,等待输出并且设置超时时间。

这样的实现有两个好处,首先是可以兼容命令正常运行的流程,即无须进行交互。其次是不需要知道命令当前的状态,我们在一个empty命令里面已经兼容了命令所有可能的输出对应的用户输入值。

静态编译expect以及大坑

expect是脚本处理有交互命令的利器,但是这货依赖tcl,并且默认没有安装。为了方便分发到测试服务器上,尝试了下静态编译。结果以失败告终。下面是整个编译的过程。

编译tcl静态库

由于expect依赖tcl,必须先编译一个静态版本的tcl库。tcl的源码可以从阿里云镜像下载:

wget http://mirrors.aliyun.com/gentoo/distfiles/tcl-core8.6.4-src.tar.gz

编译的方式和编译普通应用类似,唯一需要注意的是,我们只需要编译出静态库即可。

tar vxf tcl-core8.6.4-src.tar.gz 
cd tcl8.6.4/unix/
./configure --disable-shared --prefix=$HOME/source/target
make
make install

这里我们直接安装到指定的路径,并且关闭动态链接库的编译。这样在$HOME/source/target里面,就会tcl的静态库。

编译expect

首先还是从镜像下载expect源码。

wget http://mirrors.aliyun.com/gentoo/distfiles/expect5.45.tar.gz

然后运行configure的时候,需要指定tcl的路径,并且关闭动态链接。

tar vxf expect5.45.tar.gz 
./configure --with-tcl=$HOME/source/target --disable-shared
make

这里遇到了很多链接的问题。首先是

expect.c:(.text+0xbc):对‘tclStubsPtr’未定义的引用
exp_main_sub.c:(.text+0x6fb):对‘tclIntStubsPtr’未定义的引用
tclUnixThrd.c:(.text+0x31):对‘pthread_key_delete’未定义的引用

这些符号无法找到,无法进行链接。向上查找这些错误对应的编译命令,发现已经在链接最终的expect命令了。此时的编译命令为:

gcc 
     -pipe -O2 -fomit-frame-pointer -Wall  
     -Wl,--export-dynamic  
    -o expect exp_main_exp.o 
    -L/alidata1/6080/source/expect5.45 -lexpect5.45 
    -L/alidata1/6080/source/target/lib -ltcl8.6 
    -ldl  -lieee -lm 
    -Wl,-rpath,/alidata1/6080/source/target/lib 
    -Wl,-rpath,/alidata1/6080/source/target/lib/expect5.45

查阅了tcl的faq,前面遇到无法链接的符号在tclstub库中,同时还发现少了pthread库的一些符号。加上这些之后,编译命令变成:

gcc 
     -pipe -O2 -fomit-frame-pointer -Wall  
     -Wl,--export-dynamic  
    -o expect exp_main_exp.o 
    -L/alidata1/6080/source/expect5.45 -lexpect5.45 
    -L/alidata1/6080/source/target/lib -ltcl8.6 
    -ldl  -lieee -lm 
    -Wl,-rpath,/alidata1/6080/source/target/lib 
    -Wl,-rpath,/alidata1/6080/source/target/lib/expect5.45 -ltclstub8.6 -lpthread

此时tcl和一些基础库的链接失败消失,但是出现了

pty_termios.c:(.text+0x116):对‘openpty’未定义的引用

这个错误。同样需要去查找这个函数实现的库,最终定位到了libutil。

最终将编译命令修改成:

gcc 
     -pipe -O2 -fomit-frame-pointer -Wall  
     -Wl,--export-dynamic  
    -o expect exp_main_exp.o 
    -L/alidata1/6080/source/expect5.45 -lexpect5.45 
    -L/alidata1/6080/source/target/lib -ltcl8.6 
    -ldl  -lieee -lm 
    -Wl,-rpath,/alidata1/6080/source/target/lib 
    -Wl,-rpath,/alidata1/6080/source/target/lib/expect5.45 -ltclstub8.6 -lpthread -lutil

终于能够正常编译出静态编译的expect命令了。

就在以为完事大吉的时候,发现这个静态编译的expect命令,虽然已经静态链接了tcl库,但是在其他没有tcl的机器上运行的时候,会提示:

Tcl_Init failed: Can't find a usable init.tcl in the following directories

原来为了支持扩展,tcl会要求初始化的时候执行这个初始化脚本。查阅了tcl的文档,要编译一个完全不依赖机器上tcl库的程序比较麻烦。最终,找到了一个简单的命令empty来处理命令的交互。

R表格处理(dplyr)

R表格处理(dplyr)

源文件是一个excel表格,主要的需求是按照日期分组,然后再按照另一列分组,同时求和和计算均值。大致的内容类似:

日期,用户,收入
2015-12-01 8:00:00,u1,2
2015-12-01 8:00:00,u1,4
2015-12-01 10:00:00,u2,5
2015-12-02 9:00:00,u1,10
2015-12-02 10:00:00,u2,4

统计内容是每天用户的数量、收入总数和平均每个用户的收入。例如:

日期,用户数,总收入,平均收入
2015-12-01,2,11,5.5

R中应该有多种方式能够实现这种数据聚合。网上很多都推荐使用dplyr包来进行运算。

首先是安装,和R其他包安装方式相同:

install.packages("dplyr")

然后是引入dplyr包,并加载入源输入(元数据是从excel导出的csv数据)。

library("dplyr")
data <- read.csv('input.csv')

这时的data变量内容为:

> data
                 日期 用户 收入
1  2015-12-01 8:00:00   u1    2
2  2015-12-01 8:00:00   u1    4
3 2015-12-01 10:00:00   u2    5
4  2015-12-02 9:00:00   u1   10
5 2015-12-02 10:00:00   u2    4

由于输出是按照日统计的,所以将第一列中的时间转成日期,以方便后续的聚合。

data$日期 = as.Date(data$日期)

修改之后会变成:

> data
        日期 用户 收入
1 2015-12-01   u1    2
2 2015-12-01   u1    4
3 2015-12-01   u2    5
4 2015-12-02   u1   10
5 2015-12-02   u2    4

然后开始聚合,这里的聚合有两个未读,首先是日期,然后是用户。

d_u_group <- group_by(data, 日期, 用户)

通过summarise函数,可以计算聚合之后的值:

d_u <- summarise(d_u_group, 'c' = n(), sum(收入))

由于这时的聚合是两个纬度的,所以还不是最终的数据:

> d_u
Source: local data frame [4 x 4]
Groups: 日期 [?]

        日期   用户     c sum(收入)
      (date) (fctr) (int)     (int)
1 2015-12-01     u1     2         6
2 2015-12-01     u2     1         5
3 2015-12-02     u1     1        10
4 2015-12-02     u2     1         4

最后再按照日期做聚合:

d_group <- group_by(d_u, 日期)
d <- summarise(d_group, '用户数' = n(), '总收入' = sum(收入), '平均收入' = 总收入/用户数)

最终的数据结果为:

> d
Source: local data frame [2 x 4]

        日期 用户数 总收入 平均收入
      (date)  (int)  (int)    (dbl)
1 2015-12-01      2     11      5.5
2 2015-12-02      2     14      7.0

容器化dns服务

容器化dns服务

之前将一个小应用使用docker compose部署之后,遇到一个问题,修改域名解析的IP。之前在虚拟机上可以直接修改hosts文件,
在docker容器中修改就比较麻烦,修改主机hosts文件也没有效果。

为了解决这个问题,引入了dnsmasq作为dns服务器。由于主机上本身已经有docker环境,因此也打算把dnsmasq放到容器中去运行。
首先是选择容器,在docker hub上下载量最多的是 andyshinn/dnsmasq 镜像。这个镜像的最大优势就是简单,整个镜像只有6M多。
对于无法访问外网的docker主机来说,部署比较方便。当然,这个镜像最大的劣势也是简单,没有提供配置文件,也无法可视化配置,使用时需要对dnsmasq有一定的了解。本文后面会介绍到刚开始使用遇到的不方便的地方。

安装

由于真正部署这个镜像的服务器无法访问外网,因此不能直接拉去docker镜像,暂时在一台可以访问外网的机器上拉取,然后导出,最后在机器上重新加载的方式来进行安装。也就是

  1. docker pull andyshinn/dnsmasq
  2. docker save andyshinn/dnsmasq > dnsmasq.tar
  3. scp dnsmasq.tar admin@xxx:.
  4. docker load < dnsmasq.tar

这样镜像就算是安装完成了。

启动

启动镜像前,参考了该镜像文档中的启动命令:

docker run -p 53:53/tcp -p 53:53/udp --cap-add=NET_ADMIN andyshinn/dnsmasq -S /consul/10.17.0.2

同时,看了下这个镜像的Dockerfile:

FROM alpine:3.2
RUN apk -U add dnsmasq
EXPOSE 53 53/udp
ENTRYPOINT ["dnsmasq", "-k"]

可以发现,这个镜像真是简单至极。默认就是运行了dnsmasq -k,即强制让dnsmasq运行在前端。然后dnsmasq没有任何特殊的配置,都需要启动过的时候手工指定。

而示例中的启动命令,有这几个含义:

  • 将容器的53端口(TCP和UDP)映射到主机上,这是dns的标准端口
  • 增加NET_ADMIN参数,使得容器中的进程可以修改网络配置
  • 给dnsmasq传递参数,针对.consul后缀的域名,使用10.17.0.2这个上游dns服务器进行解析

这样的启动方式,当然是无法满足需求的。dnsmasq命令的参数,可以在这个文档中进行查询。如果仅仅是解析一个域名,可以通过–address参数指定,域名和IP格式和上面类似:

docker run -p 53:53/tcp -p 53:53/udp --cap-add=NET_ADMIN andyshinn/dnsmasq --address /api.test.com/127.0.0.1

这样容器启动之后,就可以解析api.test.com这个域名到127.0.0.1。

应用

启动容器还是比较方便,然后就是将这个dns服务提供给其他docker容器使用。docker在启动参数中支持通过–dns指定dns服务器地址(指定后,会修改启动容器的/etc/resolv.conf文件)。如果在docker-compose中使用,可以在yml文件中增加dns项。

这样,容器启动之后,就可以使用自己搭建的dns服务了。然而,并没有~

NAT模式下dns服务的失效

首先遇到的问题,是容器中的服务仍然无法解析域名。使用docker exec命令进入容器,ping了域名还是提示无法解析。排查dnsmasq的日志没有输出。

由于dnsmasq解析日志不太好排查,查询了手册之后发现,可以通过–log-facility参数指定日志输出,将容器启动参数改成:

docker run -p 53:53/tcp -p 53:53/udp --cap-add=NET_ADMIN andyshinn/dnsmasq --address /api.test.com/127.0.0.1 -q --log-facility=-

这样可以将dnsmasq的日志输出到标准错误,也就能够在当前命令行中查看到。(如果在docker run的时候使用了-d参数放到了后台执行,可以直接通过docker logs来查看输出)

再次到应用容器中ping域名,会发现dnsmasq实际上已经完成了域名解析,但是域名解析的来源IP都是docker0设备的IP。如果将应用容器的dns服务器地址,修改成dnsmasq容器的内网地址,则解析正常。因此判断这个解析错误,可能和两个容器都运行在NAT模式中有关。

将dns容器改成host模式:

docker run --net=host --cap-add=NET_ADMIN andyshinn/dnsmasq --address /api.test.com/127.0.0.1 -q --log-facility=-

在将应用容器的dns服务器地址修改成主机的IP,可以发现能够正常解析域名。

dns解析自动更新

按照上面方式启动dns容器,可以正常工作。但是如果要增加解析记录,需要重新启动这个容器。文档里面有提到dnsmasq可以通过向进程发送SIGHUP信号,重新加载文件:

When it receives a SIGHUP, dnsmasq clears its cache and then re-loads /etc/hosts and /etc/ethers and any file given by –dhcp-hostsfile, –dhcp-hostsdir, –dhcp-optsfile, –dhcp-optsdir, –addn-hosts or –hostsdir. The dhcp lease change script is called for all existing DHCP leases. If –no-poll is set SIGHUP also re-reads /etc/resolv.conf. SIGHUP does NOT re-read the configuration file.

也就是说,如果通过–addn-hosts或者–hostsdir参数指定的文件(夹),可以通过信号来触发dnsmasq重新加载。为了方便区分,最终采用了后者,既–hostsdir指定hosts文件夹。

首先,在主机上创建一个文件夹和一个文件,写入需要解析的域名:

mkdir hosts
echo '127.0.0.1 a.test.com' > hosts/common

然后启动容器的时候,设置挂载卷,将这个目录共享给容器,同时指定这个目录未hostsdir:

docker run --net=host --cap-add=NET_ADMIN -v /home/admin/hosts:/media andyshinn/dnsmasq --addn-hosts=/media/common -q --log-facility=-

这样,如果需要新增解析记录,需要两步:

  1. 在主机hosts目录中,新增一条记录:echo 'b.test.com' >> hosts/common
  2. 向dns容器发送信号,使其重新加载文件:docker kill -s HUP 2beb1d9f3554

这样虽然还需要手工操作,但是无须再重新运行dns容器。

spring boot profile试用

spring boot profile试用

项目中需要在不同环境切换,之前类似的需求,主要在于一些配置的不同,如数据库配置等。这样的场景,通过maven的profile,结合resource filter即可。但是这次需要针对不同的场景,使用不同的实现类,以此来对接外部不同的系统。

本文基本上参考了spring博客中的介绍,通过@profile注解,来区分不同的使用场景。

自定义场景注解

@Profile注解需要接受一个字符串,作为场景名。这样每个地方都需要记住这个字符串。Spring的@Profile注解支持定义在其他注解之上,以创建自定义场景注解。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Profile("cloud")
public @interface Cloud {
}

这样就创建了一个@Cloud注解,该注解可以标识bean使用于cloud这个场景。后续就不再需要使用@Profile("cloud")的方式。这样即可以简化代码,同时可以利用IDE的自动补全:)

配置类场景控制

有了上面定义的注解,可以直接在有@Configure注解的配置类上的增加,以控制该类创建的Bean,只在指定场景下激活。例如:

@Configuration
@Cloud
public class CloudWebFilterConfig {

    private static final String FROM = "aliyun";

    @Bean
    public Filter ssoFilter() {
        return new SSOFilter();
    }

    @Bean
    public FilterRegistrationBean ssoFilterRegistrationBean() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(ssoFilter());
        filterRegistrationBean.setName("ssoFilter");
        filterRegistrationBean.setUrlPatterns(Collections.singletonList("/*"));

        filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);

        return filterRegistrationBean;
    }
}

上面的配置类,创建了两个bean,其中一个是springboot使用的filter,使用我们针对云上单点登录的实现;另一个是这个filter的配置,配置了这个filter的路径和名称,还有顺序。这里的配置和直接在web.xml中配置filter类似。唯一不同的是,FilterRegistrationBean支持设置filter的优先级。

基于service实现的场景控制

除了上述通过配置类的方式控制之外,@Profile和自定义的(@Cloud)注解还可以直接应用的@Service@Component等注解创建的服务实现上。同样,带上场景注解之后,这些服务实现只会在场景激活之后才会实例化。

例如,我们可以定义一个用户查询接口:

public interface UserService {
    List<AegisUser> fuzzyQuery(String query);
}

在不同场景下,我们需要有不同的实现。

@Service
@Cloud
public class UserServiceAImpl implements UserService {
    public List<AegisUser> fuzzyQuery(String query) {
        ...
    }
}
@Service
@Cloud
public class UserServiceBImpl implements UserService {
    public List<AegisUser> fuzzyQuery(String query) {
        ...
    }
}

上面创建了两个UserService的实现,一个标记为内部使用,一个标记为云上使用。

这样,对于上层平台,只需要直接@Autowired注解注入即可,无须再通过@Qualifier注解指定具体的实现了。运行时注入,会通过场景指定具体的实现类。

场景切换配置

spring boot在启动时需要指定当前激活的场景,主要方式有两个:

  1. 命令行参数指定:启动时,直接在命令行参数中增加:spring.profiles.active=cloud参数
  2. 在properties文件中指定,在应用依赖的properties文件中增加spring.profiles.active=cloud等配置,即可切换场景

为了能够在打包的时候就确定启动场景,最终还是采用了后者,并且和maven的resource filter结合。即在properties文件中使用占位符,在maven的profile中通过filter,通过maven profile来编译时替换。

<profiles>
    <profile>
        <id>internal</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <build.profile.id>internal</build.profile.id>
        </properties>
    </profile>
    <profile>
        <id>cloud</id>
        <properties>
            <build.profile.id>cloud</build.profile.id>
        </properties>
    </profile>
</profiles>

<build>
        <filters>
            <filter>profiles/${build.profile.id}/config.properties</filter>
        </filters>

        <resources>
            <resource>
                <filtering>true</filtering>
                <directory>src/main/resources</directory>
            </resource>
        </resources>
        ...
</build>    

这样只要在maven编译时通过-P参数指定maven profile即可。