JVM常用命令学习(一)jps的使用

Java环境说明

注意:不同版本的JDK可能略有差异

1
$ java -version java version "1.7.0_79" Java(TM) SE Runtime Environment (build 1.7.0_79-b15) Java HotSpot(TM) 64-Bit Server VM (build 24.79-b02, mixed mode)

jps命令

jps(Java Virtual Machine Process Status Tool)。jps只是用来显示Java进程信息,用来查看基于HotSpot的JVM里面中,所有具有访问权限的Java进程的具体状态, 包括进程ID,进程启动的路径及启动参数等等。

如果使用jps查看远程服务器的Java进程信息,需要在远程服务器上开启jstatd服务。

1
$ jps -help usage: jps [-help] jps [-q] [-mlvV] [<hostid>] Definitions: <hostid>: <hostname>[:<port>]

通过help提示可以看出基本的命令格式

  • jps [option] : 查看Java进程信息
  • jps [option] [:] : 查看一个远程server的Java进程信息,port是远程rmi的端口,如果没有指定则默认为1099。
1
2
3
4
5
6
7
[option]参数:
- q : 只输出进程的pid
- m : 输出传递给main方法的参数,如果是内嵌的JVM则输出为null。
- l : 输出应用程序主类的完整包名,或者是应用程序JAR文件的完整路径。
- v : 输出传给JVM的参数
- V : 输出通过标记的文件传递给JVM的参数(.hotspotrc文件,或者是通过参数-XX:Flags=<filename>指定的文件)。

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ jps 22549 QuorumPeerMain 18187 Jps 14983 Jstatd
# 只输出进程的pid
$ jps -q 22549 14983 18259
# 输出应用程序主类的完整包名,或者是应用程序JAR文件的完整路径。
$ jps -l 22549 org.apache.zookeeper.server.quorum.QuorumPeerMain 18113 sun.tools.jps.Jps 14983 sun.tools.jstatd.Jstatd
# 输出传递给main方法的参数,如果是内嵌的JVM则输出为null。
$ jps -m 22549 QuorumPeerMain /usr/local/zookeeper/bin/../conf/zoo.cfg 18344 Jps -m 14983 Jstatd
# 输出传给JVM的参数
$ jps -v 22549 QuorumPeerMain -Dzookeeper.log.dir=. -Dzookeeper.root.logger=INFO,CONSOLE -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.local.only=false 17992 Jps -Dapplication.home=/data/jdk1.7.0_79 -Xms8m 14983 Jstatd -Dapplication.home=/data/jdk1.7.0_79 -Xms8m -Djava.security.policy=jstatd.all.policy
# 在hadoop1的机器的${JAVA_HOME}/bin目录下创建jstatd.all.policy安全策略文件
grant codebase "file:${java.home}/../lib/tools.jar" {
permission java.security.AllPermission;
};
# 在hadoop1的机器启动jstatd服务,使用内部RMI Registry默认端口号1099
$ ./jstatd -J-Djava.security.policy=jstatd.all.policy
# 在其他机器查看hadoop1的Java进程信息
$ jps -l hadoop1
$ jps -l hadoop1:1099
14983 sun.tools.jstatd.Jstatd
22549 org.apache.zookeeper.server.quorum.QuorumPeerMain

参考文章:

Kibana学习(六)Kibana设置登录认证

前阵子MongoDB和Elasticsearch被黑客利用漏洞入侵并删除数据的事情闹得沸沸扬扬,我们公司使用Elasticsearch也是受害者之一,幸好我们使用Elasticsearch只是应用日志数据,不涉及到业务数据,所以被删除的日志数据对我们影响不是很大。这里我总结一下我们遇到的问题,和处理措施。

Elasticsearch被黑客入侵的事情发生在2017年1月份,当时Elasticsearch技术群有人遇到了索引数据无故丢失的现象,然后出现非法的.warning索引,正好当天我也发现我们Elasticsearch的索引也少了几天的日志索引,打开.warning索引之后发现类似如下信息。

Warning_Index

索引的描述信息说明你的数据已经被backup到黑客的服务器上,如果想要恢复需要给他们0.5比特币。

看来这回是真的中招了,开始分析黑客能够入侵的原因。首先想到的就是先把ES的http端口9200禁止对外开放,以前是为了维护ES索引方便,所以把9200端口对外网开放了,虽然做了外网IP的访问控制,但是仍然被入侵进来,说明对外开放ES的9200端口并不安全。然后开始检查服务器的防火墙,对外网开放的IP过滤情况。这里我检查了服务器本身的防火墙并没有问题,然后也咨询过运维的同学ES服务器做了外网IP访问的限制。

但是为什么还会出现这种情况呢?我又仔细的检查了一遍全部的服务器配置,这里我们的服务器使用的是阿里云的服务器,访问的流程是:client客户端 -> SLB服务器 -> Kibana/ES服务器,通过和运维的同学一起排查,终于找到了问题的原因,原因是运维同学的马虎,只对ES服务器进行了外网IP的过滤,没有对SLB服务器进行外网IP的访问限制,而且我们的ES服务器也使用的默认9200端口,所以直接访问SLB的9200即可直接操作我们的ES的索引数据。

问题的原因:

  1. ES的http服务端口9200对外网开放
  2. 使用了ES的9200端口,极容易被黑客攻击
  3. SLB服务器并没有对外网IP进行访问限制
  4. 访问Kibana也没有用户名和密码限制,任何人都可以通过公网IP直接看到我们的数据

处理措施:

  1. 关闭ES的http服务端口9200对外网开放
  2. 在阿里云的SLB代理层做端口的映射,SLB使用19200端口映射到ES服务的9200端口,15601端口映射到Kibana服务的5601端口
  3. SLB服务器和ES服务器都设置对外网IP访问限制
  4. 在访问Kibana和ES之前加一层Nginx的反向代理服务器,并且设置http-base认证来实现Kibana和ES的登录

问题的原因就分析到这里,下面我们说明一下如何在Nginx中设置的用户登录认证。

主要就是利用Nginx接管所有Kibana请求,通过Nginx配置将Kibana的访问加上权限控制,简单常见的方式可以使用如下两种方式:

方式一:使用Nginx的用户认证模块(ngx_http_auth_basic_module模块,Nginxg一般已经默认安装),用户访问时会直接弹出登录提示,要求输入用户名及密码后登录。
此方案需要依赖htpasswd命令生成密码文件。

方式二:利用Nginx的IP访问控制,限定允许和禁止访问的IP,非允许的IP访问后网页会显示403 Forbidden信息。

这里我们在本地可以尝试方式二,因为我们的线上环境使用了阿里云的SLB代理,SLB已经提供了类似的功能,所以就不在自己的Nginx中进行IP访问设置了。

1
2
3
4
5
6
7
8
# 需要先安装htpasswd
$ apt-get -y install apache2-utils
# 生成passwd.db文件,接下来需要输入访问Nginx的密码
$ htpasswd -c -d /usr/local/nginx-1.8.0/passwd.db elk_user
New password:
Re-type new password:
Adding password for user elk_user

nginx.conf配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# 如果是大规模集群环境,此处配置多台Kibana服务器即可(访问Nginx的15601端口后,会轮询访问下面的多台Kibana服务器)
upstream kibana_server {
server 192.168.99.128:5601;
}
server {
listen 15601;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
# 权限的描述
auth_basic "Protect Kibana";
# 指定我们刚才生成好的passwd.db文件
auth_basic_user_file /usr/local/nginx-1.8.0/passwd.db;
root html;
index index.html index.htm;
# 代理的访问地址是Kibana的地址
# proxy_pass http://localhost:5601;
# 如果有多台服务器建议使用upstream配置方式进行访问的负载均衡
proxy_pass http://kibana_server$request_uri;
# 只允许公司的外网IP,局域网IP可以访问
# allow 123.**.**.***;
# allow 192.168.99.0/255;
# deny all;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
# 如果是大规模集群环境,此处配置多台ES服务器即可(访问Nginx的19200端口后,会轮询访问下面的多台ES服务器)
upstream es_server {
server 192.168.99.128:9200;
server 192.168.99.212:9200;
server 192.168.99.213:9200;
}
server {
listen 19200;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
# 权限的描述
auth_basic "Protect Elasticsearch";
# 指定我们刚才生成好的passwd.db文件
auth_basic_user_file /usr/local/nginx-1.8.0/passwd.db;
root html;
index index.html index.htm;
# 代理的访问地址是Elasticsearch的地址
# proxy_pass http://localhost:9200;
# 如果有多台服务器建议使用upstream配置方式进行访问的负载均衡
proxy_pass http://es_server$request_uri;
# 只允许公司的外网IP,局域网IP可以访问
# allow 123.**.**.***;
# allow 192.168.99.0/255;
# deny all;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}

配置好Nginx的配置文件之后,需要重新加载

1
2
# 重新加载Nginx配置文件
$ ./nginx -s reload

通过上面的配置访问http://localhost:15601和http://localhost:19200就会弹出用户名密码的验证框,如果输入正确就会跳转到对应http://localhost:5601和http://localhost:9200的地址,也就是Kibana和ES的访问地址。

ES Protected

也可以通过curl命令来进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# elk_user是用户名和密码
# 192.168.99.128:19200是访问Nginx的IP地址,和Nginx监听的端口号
# 如果upstream配置多台ES服务器进行轮询访问,那么下面的name可能每访问一次都会不同
$ curl -i elk_user:elk_user@192.168.99.128:19200
HTTP/1.1 200 OK
Server: nginx/1.4.6 (Ubuntu)
Date: Sun, 12 Feb 2017 08:59:37 GMT
Content-Type: application/json; charset=UTF-8
Content-Length: 308
Connection: keep-alive
{
"name" : "node-1",
"cluster_name" : "ben-es",
"version" : {
"number" : "2.3.5",
"build_hash" : "90f439ff60a3c0f497f91663701e64ccd01edbb4",
"build_timestamp" : "2016-07-27T10:36:52Z",
"build_snapshot" : false,
"lucene_version" : "5.5.0"
},
"tagline" : "You Know, for Search"
}

更多关于ES的安全防护措施请阅读参考文章

参考文章:

Logstash学习(七)Logstash的webhdfs插件

最近公司有需要查询历史日志的需求,但是之前在ES设置只保存了最近7天的日志,为了满足需求我将ES服务器的磁盘存储升级到了500G,同时延长了日志的周期至30天,但是这只是临时的解决方案,因为查询历史日志的需求仅仅是最近一个月还不够,所以为了长远考虑需要做日志的持久化存储。

方案选型

目前公司的架构比较简单,现在需要同时写入到HDFS中。

1
file -> logstash -> kafka -> logstash -> elasticsearch

这里考虑继续使用Logstash写入到HDFS中,当然也可以选择其他方案从Kafka读取数据写入HDFS的(例如:Flume,KafkaConnector等等),使用Logstash会有下面两种方案。

  • 方案一

    • 优点:Logstash读取一次日志,然后双写到ES和HDFS中
    • 缺点:日志经过Logstash处理之后,无法将原始日志写入到HDFS中,只能将处理后的日志写入到HDFS中。而且Logstash挂掉之后,会影响ES和HDFS的数据的实时性。
  • 方案二

    • 优点:Logstash单独写入ES和HDFS,这样其中一个Logstash挂掉之后,不会影响另一个的数据实时性。而且Logstash单独写入HDFS可以直接保留了原始日志。
    • 缺点:需要两套Logstash集群来读取Kafka中的数据,系统开销增加。

这里我们的需求是要在HDFS中保存原始的日志,所以选择的方案二。

实例

其实Logstash官方并没有提供写入HDFS的插件,但是这里官网推荐社区开发的插件logstash-output-webhdfs

logstash-output-webhdfs插件地址:

安装logstash-output-webhdfs插件

1
2
3
4
5
$ cd $LS_HOME
$ ./bin/logstash-plugin install logstash-output-webhdfs
Validating logstash-output-webhdfs
Installing logstash-output-webhdfs
Installation successful

Logstash配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
input {
file {
path => ["/home/yunyu/Downloads/rpserver.INFO"]
type => "go"
start_position => "beginning"
ignore_older => 0
}
}
output {
stdout {
codec => rubydebug
}
webhdfs {
workers => 2
# hdfs的namenode地址
host => "hadoop1"
# Hadoop的webhdfs使用的端口
port => 50070
# hadoop运行的用户,以这个用户的权限去写入hdfs
user => "yunyu"
# 按年月日建目录,按type和小时建log文件
path => "/logstash/%{+YYYY}/%{+MM}/%{+dd}/%{type}-%{+HH}.log"
flush_size => 1000
# 压缩格式,可以不压缩
# compression => "snappy"
idle_flush_time => 10
retry_interval => 1
}
}

查看HDFS文件

1
2
3
4
# hadoop启动步骤略过,直接查看HDFS文件目录
$ hdfs dfs -ls /logstash/2017/02/08/
Found 1 items
-rwxr-xr-x 2 yunyu supergroup 282 2017-02-07 19:53 /logstash/2017/02/08/go-03.log

可以导出或者cat输出HDFS中的日志文件查看内容,就先写到这里了。

遇到问题和解决方法

刚开始在使用Logstash写入HDFS的并没有遇到什么问题,然后我把收集Go,Node,PHP的日志3个Logstash进程全部配置完成并启动,一切都很正常。然后开始在线上进行部署,但是第二天发现Go的Logstash进程已经挂掉了,查看Logstash的日志发现,我发现Logstash的日志开始出现如下的错误信息。仔细观察了一下错误信息,发现

SSH登录到服务器发现Logstash报错,连接不上Hadoop的50075端口,于是查找了一下Hadoop的50075端口是什么服务使用的,发现50075是Hadoop的DataNode的http服务端口号,jps查看了一下果然没有DataNode进程,说明DataNode进程已经挂了。

1
2
50070 : NameNode的http服务的端口
50075 : DataNode的http服务的端口

然后查看DataNode的日志文件,发现如下的错误信息,DataNode的进程挂掉是因为内存不够用了,无法创建新的线程了。

1
2
3
4
5
6
7
8
9
10
11
2017-02-10 13:38:03,040 WARN org.apache.hadoop.hdfs.server.datanode.DataNode: Unexpected exception in block pool Block pool BP-316305454-10.253.43.185-1486538923200 (Datanode Uuid 3aa12dfa-9686-462c-b871-fa995534ad11) service to yy-logs-hdfs01/10.253.43.185:9000
java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:714)
at org.apache.hadoop.hdfs.server.datanode.DataNode.recoverBlocks(DataNode.java:2529)
at org.apache.hadoop.hdfs.server.datanode.BPOfferService.processCommandFromActive(BPOfferService.java:699)
at org.apache.hadoop.hdfs.server.datanode.BPOfferService.processCommandFromActor(BPOfferService.java:611)
at org.apache.hadoop.hdfs.server.datanode.BPServiceActor.processCommand(BPServiceActor.java:857)
at org.apache.hadoop.hdfs.server.datanode.BPServiceActor.offerService(BPServiceActor.java:672)
at org.apache.hadoop.hdfs.server.datanode.BPServiceActor.run(BPServiceActor.java:823)
at java.lang.Thread.run(Thread.java:745)

既然找到是内存不足的原因,就只能从这方面进行优化了,这里我尝试修改Hadoop的启动HeapSize大小来控制内存的使用,Hadoop的HeapSize默认是1000m。可以在环境变量配置如下信息来控制Hadoop各个进程的HeapSize大小。

1
2
3
4
5
export HADOOP_NAMENODE_OPTS="-Xms512m -Xmx512m"
export HADOOP_SECONDARYNAMENODE_OPTS="-Xms256m -Xmx256m"
export HADOOP_DATANODE_OPTS="-Xms512m -Xmx512m"
export HADOOP_CLIENT_OPTS="-Xms256m -Xmx256m"
export YARN_OPTS="-Xms256m -Xmx256m"

控制Hadoop的启动HeapSize大小之后,我开始重启HDFS服务和YARN服务,服务启动都正常。但是当我尝试启动Logstash开始写入数据到HDFS时,Logstash报错如下。错误信息大概的意思是HDFS的文件/logstash/2017/02/10/node_proxy-05.log被锁住了,当前Logstash进程无法写入到这个文件。

1
Failed to APPEND_FILE /logstash/2017/02/10/go-03.log for DFSClient_NONMAPREDUCE_1910367742_31 on 10.253.43.185 because this file lease is currently owned by DFSClient_NONMAPREDUCE_-73615217_30 on 10.253.43.185\\n\\tat org.apache.hadoop.hdfs.server.namenode.FSNamesystem.recoverLeaseInternal

我有开始Google百度相关错误信息,发现HDFS为了防止文件并发写,有一个Lease(租约)的概念。实际上HDFS(及大多数分布式文件系统)不支持文件并发写,Lease是HDFS用于保证唯一写的手段。Lease可以看做是一把带时间限制的写锁,仅持有写锁的客户端可以写文件。更多Lease相关的知识请看参考文章。

既然已经知道是HDFS的Lease锁住了文件,那就去Hadoop官网找相应释放Lease的方法即可。最后我在Hadoop的官网找到了相关的命令如下:

1
2
3
4
5
6
7
8
9
10
11
recoverLease
Usage: hdfs debug recoverLease [-path <path>] [-retries <num-retries>]
COMMAND_OPTION Description
[-path path] HDFS path for which to recover the lease.
[-retries num-retries] Number of times the client will retry calling recoverLease. The default number of retries is 1.
Recover the lease on the specified path. The path must reside on an HDFS filesystem. The default number of retries is 1.
# 执行recoverLease来释放文件的锁
$ hdfs debug recoverLease -path /logstash/2017/02/10/go-03.log

释放完HDFS的Lease之后,我继续尝试重启Logstash,发现Logstash可以开始写入数据到HDFS中。但是还是偶尔会报下面的错误信息。仔细看错误信息发现和上面的错误类似,仍然是Lease的问题。但是这里的错误只是说Lease正在被另一个进程释放中,需要等会再试。这样就说明可能是我们Logstash写入HDFS的频率过快,导致HDFS来不及释放Lease。

1
{:timestamp=>"2017-02-10T17:19:47.073000+0800", :message=>"webhdfs write caused an exception: {\"RemoteException\":{\"exception\":\"RecoveryInProgressException\",\"javaClassName\":\"org.apache.hadoop.hdfs.protocol.RecoveryInProgressException\",\"message\":\"Failed to APPEND_FILE /logstash/2017/02/10/go-03.log for DFSClient_NONMAPREDUCE_464764675_30 on 10.253.43.185 because lease recovery is in progress. Try again later.\\n\\tat org.apache.hadoop.hdfs.server.namenode.FSNamesystem.recoverLeaseInternal(FSNamesystem.java:2920)\\n\\tat org.apache.hadoop.hdfs.server.namenode.FSNamesystem.appendFileInternal(FSNamesystem.java:2685)\\n\\tat org.apache.hadoop.hdfs.server.namenode.FSNamesystem.appendFileInt(FSNamesystem.java:2985)\\n\\tat org.apache.hadoop.hdfs.server.namenode.FSNamesystem.appendFile(FSNamesystem.java:2952)\\n\\tat org.apache.hadoop.hdfs.server.namenode.NameNodeRpcServer.append(NameNodeRpcServer.java:653)\\n\\tat org.apache.hadoop.hdfs.protocolPB.ClientNamenodeProtocolServerSideTranslatorPB.append(ClientNamenodeProtocolServerSideTranslatorPB.java:421)\\n\\tat org.apache.hadoop.hdfs.protocol.proto.ClientNamenodeProtocolProtos$ClientNamenodeProtocol$2.callBlockingMethod(ClientNamenodeProtocolProtos.java)\\n\\tat org.apache.hadoop.ipc.ProtobufRpcEngine$Server$ProtoBufRpcInvoker.call(ProtobufRpcEngine.java:616)\\n\\tat org.apache.hadoop.ipc.RPC$Server.call(RPC.java:969)\\n\\tat org.apache.hadoop.ipc.Server$Handler$1.run(Server.java:2049)\\n\\tat org.apache.hadoop.ipc.Server$Handler$1.run(Server.java:2045)\\n\\tat java.security.AccessController.doPrivileged(Native Method)\\n\\tat javax.security.auth.Subject.doAs(Subject.java:422)\\n\\tat org.apache.hadoop.security.UserGroupInformation.doAs(UserGroupInformation.java:1657)\\n\\tat org.apache.hadoop.ipc.Server$Handler.run(Server.java:2043)\\n\"}}. Maybe you should increase retry_interval or reduce number of workers. Retrying...", :level=>:warn}

然后我尝试优化Logstash的如下配置

  • flush_size : 如果event计数超出flush_size设置的值,即使未达到store_interval_in_secs,也会将数据发送到webhdfs
  • idle_flush_time : 以x秒为间隔将数据发送到webhdfs
  • retry_interval : 两次重试之间等待多长时间

  • 提高flush_size的值,来减少访问webhdfs的频率,同时提高HDFS的写入量

  • 降低idle_flush_time的值,因为提高了flush_size,所以可以适当的减少数据发送到webhdfs的时间间隔
  • 提高retry_interval的值,来减少高频重试带来的额外负载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
input {
kafka {
# 指定Zookeeper集群地址
zk_connect => "zk1:2181,zk2:2181,zk3:2181"
# 指定当前消费者的group_id,group_id不能和其他logstash消费者相同,>否则同时启动多个Logstash消费者offset会被覆盖
group_id => "logstash_hdfs_go"
# 指定消费的Topic
topic_id => "log_go"
# 指定消费的内容类型(默认是json)
codec => "json"
}
}
output {
webhdfs {
workers => 1
host => "hadoop1"
port => 50070
user => "hadoop"
path => "/logstash/%{+YYYY}/%{+MM}/%{+dd}/%{type}-%{+HH}.log"
# flush_size => 500
flush_size => 5000
# idle_flush_time => 10
idle_flush_time => 5
# retry_interval => 3
retry_interval => 3
}
}

使用新的配置之后,重启Logstash已经看不到上面因为Lease未释放导致重试的异常信息了。折腾了一个下午也是不容易啊,最后写个博客记录一下处理过程,辛苦了。。

参考文章:

Docker实战(二十六)Docker的run命令使用

上一篇介绍了Dockerfile的配置详情用法,这里具体在讲解一下Docker的run命令使用,之前在构建自己的Docker镜像时,很多参数都没有深入研究,只是保证Docker容器的正常使用,这里run命令可以动态设置一些启动Docker容器的参数。

Docker run命令格式

最基本的docker run命令的格式如下:

1
$ sudo docker run [OPTIONS] IMAGE[:TAG] [COMMAND] [ARG...]

之前第一篇Docker文章介绍过Docker的基础命令,其中也说明了run命令的一些参数,这里我们在回顾一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 指定配置启动
$ sudo docker run -d -p 10.211.55.4:9999:22 birdben/ubuntu:v1 '/usr/sbin/sshd' -D
# 参数:
# -d:表示以“守护模式”执行,日志不会出现在输出终端上(输出结果可以用docker logs 查看)。使用 -d 参数启动后会返回一个唯一的 id,也可以通过 docker ps 命令来查看容器信息。
# -i:表示以“交互模式”运行容器,-i 则让容器的标准输入保持打开
# -t:表示容器启动后会进入其命令行,-t 选项让Docker分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上
# -v:表示需要将本地哪个目录挂载到容器中,格式:-v <宿主机目录>:<容器目录>,-v 标记来创建一个数据卷并挂载到容器里。在一次 run 中多次使用可以挂载多个数据卷。
# -p:表示宿主机与容器的端口映射,此时将容器内部的 22 端口映射为宿主机的 9999 端口,这样就向外界暴露了 9999 端口,可通过 Docker 网桥来访问容器内部的 22 端口了。
# 注意:
# 这里使用的是宿主机的 IP 地址:10.211.55.4,与对外暴露的端口号 9999,它映射容器内部的端口号 22。ssh外部需要访问:ssh root@10.211.55.4 -p 9999
# 容器是否会长久运行,是和docker run指定的命令有关,和 -d 参数无关。
# run启动不一定要使用“镜像 ID”,也可以使用“仓库名:标签名”

注意:

这里在说明一下自己的之前构建的镜像的问题,这里不推荐在Docker中安装SSH,通过SSH来远程登录到Docker容器,应该通过在宿主机运行docker exec的方式进入Docker容器来进行操作,这样安全性更好,这是自己之前构建Docker镜像存在的一些问题。

容器识别

Name(–name)

可以通过三种方式为容器命名:

  • 使用UUID长命名(”f78375b1c487e03c9438c729345e54db9d20cfa2ac1fc3494b6eb60872e74778”)
  • 使用UUID短命令(”f78375b1c487”)
  • 使用Name(“evil_ptolemy”)

这个UUID标示是由Docker deamon生成的。如果你在执行docker run时没有指定–name,那么deamon会自动生成一个随机字符串UUID。但是对于一个容器来说有个name会非常方便,当你需要连接其它容器时或者类似需要区分其它容器时,使用容器名称可以简化操作。无论容器运行在前台或者后台,这个名字都是有效的。

1
$ docker run -itd -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name docker_shadowsocks birdben/shadowsocks:v1

推荐运行Docker容器的时候指定一个容器名字,后续对于Docker容器的操作比较容易,否则每次使用Docker容器的ID操作都需要使用docker ps来查看比较麻烦。

PID equivalent

如果在使用Docker时有自动化的需求,你可以将containerID输出到指定的文件中(PIDfile),类似于某些应用程序将自身ID输出到文件中,方便后续脚本操作。

1
$ docker run -itd -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --cidfile="/var/run/docker_shadowsocks.pid" --name docker_shadowsocks birdben/shadowsocks:v1

Image[:tag]

当一个镜像的名称不足以分辨这个镜像所代表的含义时,你可以通过tag将版本信息添加到run命令中,以执行特定版本的镜像。这个也是我之前比较常用的方式。

1
$ docker run -itd -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor birdben/shadowsocks:v1

Network Settings

默认情况下,所有的容器都开启了网络接口,同时可以接受任何外部的数据请求。这个是设置Docker网络配置的参数,如果你有多个Docker容器之前需要网络通信,那么这个参数是比较常用的。

1
2
3
4
5
6
7
8
--dns=[] : Set custom dns servers for the container
--net="bridge" : Set the Network mode for the container
'bridge': creates a new network stack for the container on the docker bridge
'none': no networking for this container
'container:<name|id>': reuses another container network stack
'host': use the host network stack inside the container
--add-host="" : Add a line to /etc/hosts (host:IP)
--mac-address="" : Sets the container's Ethernet device's MAC address

你可以通过docker run –net none来关闭网络接口,此时将关闭所有网络数据的输入输出,你只能通过STDIN、STDOUT或者files来完成I/O操作。默认情况下,容器使用主机的DNS设置,你也可以通过–dns来覆盖容器内的DNS设置。同时Docker为容器默认生成一个MAC地址,你可以通过–mac-address 12:34:56:78:9a:bc来设置你自己的MAC地址。

Docker支持的网络模式有:

  • none : 关闭容器内的网络连接
  • bridge : 通过veth接口来连接容器,默认配置。
  • host : 允许容器使用host的网络堆栈信息。 注意:这种方式将允许容器访问host中类似D-BUS之类的系统服务,所以认为是不安全的。
  • container : 使用另外一个容器的网络堆栈信息。

管理/etc/hosts

/etc/hosts文件中会包含容器的hostname信息,我们也可以使用–add-host这个参数来动态添加/etc/hosts中的数据。使用–add-host参数我们就可以动态配置hosts,而不需要在构建镜像的时候将/etc/hosts文件写好并且覆盖到Docker镜像中,这个也是我之前所犯下的错误。

1
$ /docker run -it --add-host mysql:192.168.2.108 birdben/shadowsocks:v1 cat /etc/hosts 127.0.0.1 localhost 192.168.2.109 hadoop1 192.168.2.110 hadoop2 192.168.2.111 hadoop3 192.168.2.108 mysql

Clean up (–rm)

默认情况下,每个容器在退出时,它的文件系统也会保存下来,这样一方面调试会方便些,因为你可以通过查看日志等方式来确定最终状态。另外一方面,你也可以保存容器所产生的数据。但是当你仅仅需要短暂的运行一个容器,并且这些数据不需要保存,你可能就希望Docker能在容器结束时自动清理其所产生的数据。这个时候你就需要–rm这个参数了,–rm参数设置为true会在停止Docker容器的时候自动删除该容器的,在构建调试Docker容器的时候比较方便,省去手动反复删除Docker容器的操作,自己深有体会推荐构建调试的时候使用。

1
2
3
4
5
6
7
# 。
# 注意:--rm 和 -d不能共用!
$ docker run -itd --rm=true -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1
Conflicting options: --rm and -d
# 正确用法,挂载到宿主机的日志文件是不会被删除的
$ docker run -it --rm=true -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1

覆盖Dockerfile配置文件默认值

Docker run命令可以指定参数来覆盖Dockerfile中的配置,这些参数中,有四个是无法被覆盖的:FROM、MAINTAINER、RUN和ADD(这里可能是原作者写漏了,COPY也应该是无法被覆盖的),其余参数都可以通过docker run进行覆盖。我们将介绍如何对这些参数进行覆盖。

1
2
3
4
5
6
7
CMD (Default Command or Options)
ENTRYPOINT (Default Command to Execute at Runtime)
EXPOSE (Incoming Ports)
ENV (Environment Variables)
VOLUME (Shared Filesystems)
USER
WORKDIR

CMD

1
$ docker run [OPTIONS] IMAGE[:TAG] [COMMAND] [ARG...]

这个命令中的COMMAND部分是可选的。因为这个IMAGE在build时,开发人员可能已经设定了默认执行的命令。作为操作人员,你可以使用上面命令中新的command来覆盖旧的command。

如果镜像中设定了ENTRYPOINT,那么命令中的CMD也可以作为参数追加到ENTRYPOINT中。

1
2
3
4
# 这里使用/bin/bash命令覆盖原来启动supervisor的命令,这里是直接进入Docker容器了,并且检查supervisor进程并没有启动
$ docker run -it -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1 /bin/bash
root@77d46c117967:/# ps -ef | grep supervisor
root 16 1 0 03:54 ? 00:00:00 grep --color=auto supervisor

ENTRYPOINT

1
--entrypoint="": Overwrite the default entrypoint set by the image

这个ENTRYPOINT和COMMAND类似,它指定了当容器执行时,需要启动哪些进程。相对COMMAND而言,ENTRYPOINT是很难进行覆盖的,这个ENTRYPOINT可以让容器设定默认启动行为,所以当容器启动时,你可以执行任何一个二进制可执行程序。你也可以通过COMMAND为ENTRYPOINT传递参数。但当你需要在容器中执行其它进程时,你就可以指定其它ENTRYPOINT了。

下面就是一个例子,容器可以在启动时自动执行Shell,然后启动其它进程。

1
2
3
4
$ sudo docker run -i -t --entrypoint /bin/bash example/redis
#or two examples of how to pass more parameters to that ENTRYPOINT:
$ sudo docker run -i -t --entrypoint /bin/bash example/redis -c ls -l
$ sudo docker run -i -t --entrypoint /usr/bin/redis-cli example/redis --help
1
2
3
4
5
# 常见问题:"docker-entrypoint.sh": executable file not found in $PATH.
# 多数原因是因为docker-entrypoint.sh脚本文件没有执行权限,需要在Dockerfile修改脚本文件的执行权限,或者在宿主机修改挂载的docker-entrypoint.sh脚本文件的执行权限
RUN chmod +x docker-entrypoint.sh

EXPOSE

1
2
3
4
5
6
7
8
--expose=[]: Expose a port or a range of ports from the container
without publishing it to your host
-P=false : Publish all exposed ports to the host interfaces
-p=[] : Publish a container᾿s port to the host (format:
ip:hostPort:containerPort | ip::containerPort |
hostPort:containerPort | containerPort)
(use 'docker port' to see the actual mapping)
--link="" : Add link to another container (name:alias)

–expose可以让容器接受外部传入的数据。容器内监听的端口不需要和外部主机的端口相同。比如说在容器内部,一个HTTP服务监听在80端口,对应外部主机的端口就可能是49880.

如果使用-p或者-P,那么容器会开放部分端口到主机,只要对方可以连接到主机,就可以连接到容器内部。当使用-P时,Docker会在主机中随机从49153和65535之间查找一个未被占用的端口绑定到容器。你可以使用docker port来查找这个随机绑定端口。

当你使用–link方式时,作为客户端的容器可以通过私有网络形式访问到这个容器。同时Docker会在客户端的容器中设定一些环境变量来记录绑定的IP和PORT。

1
2
# 这里设置宿主机和Docker容器的端口都是443
$ docker run -itd -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1

ENV

1
$ docker run -e MYVAR1 --env MYVAR2=foo --env-file ./env.list ubuntu bash

这将在容器中设置简单(非数组)环境变量。 为了说明这里显示所有三个标志。 其中-e,-env取一个环境变量和值,或者如果没有提供”=”,那么通过export设置该变量的当前值(即,来自主机的$MYVAR1被设置为容器中的$MYVAR1) 。当没有提供”=”且该变量没有在客户端环境中定义时,那个变量将从容器的环境变量列表中删除。所有三个标志,-e,–env和–env文件可以重复。

不管这三个标志的顺序如何,首先处理–env文件,然后处理-e,-env标志。 这样,-e或–env将根据需要覆盖变量。

1
2
3
4
$ cat ./env.list
TEST_FOO=BAR
$ docker run --env TEST_FOO="This is a test" --env-file ./env.list busybox env | grep TEST_FOO
TEST_FOO=This is a test

–env-file标志采用文件名作为参数,并且期望每一行都处于VAR = VAL格式,模拟传递给–env的参数。 注释行只需要前缀为#

使用–env-file传递的文件示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
$ cat ./env.list
TEST_FOO=BAR
# this is a comment
TEST_APP_DEST_HOST=10.10.0.127
TEST_APP_DEST_PORT=8888
_TEST_BAR=FOO
TEST_APP_42=magic
helloWorld=true
123qwe=bar
org.spring.config=something
# pass through this variable from the caller
TEST_PASSTHROUGH
$ TEST_PASSTHROUGH=howdy docker run --env-file ./env.list busybox env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=5198e0745561
TEST_FOO=BAR
TEST_APP_DEST_HOST=10.10.0.127
TEST_APP_DEST_PORT=8888
_TEST_BAR=FOO
TEST_APP_42=magic
helloWorld=true
TEST_PASSTHROUGH=howdy
HOME=/root
123qwe=bar
org.spring.config=something
$ docker run --env-file ./env.list busybox env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=5198e0745561
TEST_FOO=BAR
TEST_APP_DEST_HOST=10.10.0.127
TEST_APP_DEST_PORT=8888
_TEST_BAR=FOO
TEST_APP_42=magic
helloWorld=true
TEST_PASSTHROUGH=
HOME=/root
123qwe=bar
org.spring.config=something

VOLUME

通过”-v”参数来覆盖挂载路径,这个是比较常用的,因为大多数日志或者数据文件都会挂载到宿主机目录的。

1
2
3
-v=[]: Create a bind mount with: [host-dir]:[container-dir]:[rw|ro].
If "container-dir" is missing, then docker creates a new volume.
--volumes-from="": Mount all volumes from the given container(s)
1
2
3
# 宿主机目录:/Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks
# Docker容器目录:/var/log/shadowsocks
$ docker run -itd -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1

USER

容器中默认的用户是root,但是开发人员创建新的用户之后,这些新用户也是可以使用的。开发人员可以通过Dockerfile的USER设定默认的用户,并通过”-u”来覆盖这些参数。

1
2
3
# 这里我通过-u参数指定的shadowsocks用户来运行,但是我并没有创建过shadowsocks用户,所以下面报错了
$ docker run -it --rm=true -u="shadowsocks" -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1
docker: Error response from daemon: linux spec user: unable to find user shadowsocks: no matching entries in passwd file.

WORKDIR

容器中默认的工作目录是根目录(/)。开发人员可以通过Dockerfile的WORKDIR来设定默认工作目录,操作人员可以通过”-w”来覆盖默认的工作目录。

1
2
3
4
5
6
# -w指定默认工作目录是"/usr/local"
$ docker run -itd -w="/usr/local" -p 443:443 -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/shadowsocks:/var/log/shadowsocks -v /Users/yunyu/workspace_git/birdDocker/shadowsocks/logs/supervisor:/var/log/supervisor --name shadowsocks_docker birdben/shadowsocks:v1
358618f9aa4d1c388b3dc9c7e5c6236bb9b6b150728e474710e83f0409dd6007
$ docker exec -it 358618f9aa4d /bin/bash
root@358618f9aa4d:/usr/local#

这里我只记录了自己使用Docker常用的配置,更多详细配置用法请看参考文章,谢谢!

参考文章:

Docker实战(二十五)Dockerfile文件配置

虽然自己构建了很多自己的Docker镜像,但是随着自己对于Docker的慢慢熟悉,发现自己之前构建的Docker镜像的用法不是很推荐使用,还有就是很多Docker的细节用法不是很熟悉,本篇具体记录了Docker配置文件的一些用法,以及比较容易混淆的配置。

Docker配置文件详解

FROM

指定基础镜像,用于继承其他镜像使用的

1
FROM ubuntu:14.04

MAINTAINER

镜像创建者的基本信息

1
MAINTAINER birdben (191654006@163.com)

RUN

RUN用来执行命令行命令的,只是在构建镜像build的时候执行

RUN的两种格式:

  • shell 格式 : RUN <命令>,就像直接在命令行中输入的命令一样
  • exec 格式 : RUN [“可执行文件”, “参数1”, “参数2”],更像是函数调用中的格式
1
2
3
4
RUN sudo apt-get update \
&& sudo apt-get install -y vim wget curl openssh-server sudo
RUN ["executable", "param1", "param2"]

CMD

CMD指令的主要功能是在build完成后,为了给docker run启动到容器时提供默认命令或参数,构建镜像build时不执行。如果用户启动容器run时指定了运行的命令,则会覆盖掉CMD指定的命令。

注意:CMD只有最后一个有效

CMD的格式:

  • CMD [“executable”, “param1”, “param2”]
1
2
3
4
5
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# 这里的配置相当于在终端执行下面的命令:
$ /usr/bin/supervisord -c /etc/supervisor/supervisord.conf

ENTRYPOINT

ENTRYPOINT配置容器启动后执行的命令,构建镜像build时不执行,并且不可被 docker run 提供的参数覆盖。

注意:每个 Dockerfile 中只能有一个 ENTRYPOINT,当指定多个时,只有最后一个起效。

ENTRYPOINT的格式:

  • ENTRYPOINT [“executable”, “param1”, “param2”]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ENTRYPOINT ["/bin/echo"]
CMD ["this is a echo test"]
# 那么docker build出来的镜像以后的容器功能就像一个/bin/echo程序,如果docker run期间如果没有参数的传递,会默认CMD指定的参数"this is a echo test",这里就会输出"this is a test"这串字符。如果docker run使用传递参数了,例如:build出来的镜像名称叫birdDocker,那么我可以像下面这样传递参数
$ docker run -it birdDocker "this is a run test"
# 这里birdDocker镜像对应的容器表现出来的功能就像一个echo程序一样。这里docker run的参数"this is a run test"会添加到ENTRYPOINT后面,就成了这样/bin/echo "this is a run test"。
# 也就是说CMD的参数只是docker run没有传参的时,使用的默认参数。
# 同理,下面的docker-entrypoint.sh脚本文件也可以通过上面的docker run的方式来接收参数。
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["zkServer.sh", "start-foreground"]

RUN, CMD, ENTRYPOINT区别

RUN是在build成镜像时就运行的,先于CMD和ENTRYPOINT的。Build完成了,RUN也运行完成后,再运行CMD或者ENTRYPOINT。CMD会在每次启动容器的时候运行,而RUN只在创建镜像时执行一次,固化在image中。

ENTRYPOINT和CMD的不同点在于执行docker run时参数传递方式,CMD指定的命令可以被docker run传递的命令覆盖,如果docker run没有指定参数,则会使用CMD的默认参数执行,例如,如果用CMD指定:

1
2
...
CMD ["echo"]

然后运行

1
docker run CONTAINER_NAME echo foo

那么CMD里指定的echo会被新指定的echo覆盖,所以最终相当于运行echo foo,所以最终打印出的结果就是:

1
foo

而ENTRYPOINT会把容器名后面的所有内容都当成参数传递给其指定的命令(不会对命令覆盖),比如:

1
2
...
ENTRYPOINT ["echo"]

然后运行

1
docker run CONTAINER_NAME echo foo

则CONTAINER_NAME后面的echo foo都作为参数传递给ENTRYPOING里指定的echo命令了,所以相当于执行了

1
echo "echo foo"

最终打印出的结果就是:

1
echo foo

另外,在Dockerfile中,ENTRYPOINT指定的参数比运行docker run时指定的参数更靠前,比如:

1
2
...
ENTRYPOINT ["echo", "foo"]

执行

1
docker run CONTAINER_NAME bar

相当于执行了:

1
echo foo bar

打印出的结果就是:

1
foo bar

Dockerfile中只能指定一个ENTRYPOINT,如果指定了很多,只有最后一个有效。

执行docker run命令时,也可以添加-entrypoint参数,会把指定的参数继续传递给ENTRYPOINT,例如:

1
2
...
ENTRYPOINT ["echo","foo"]

然后执行:

1
docker run CONTAINER_NAME --entrypoint bar

那么,就相当于执行了echo foo bar,最终结果就是

1
foo bar

EXPOSE

设置Docker容器对外暴露的本地端口,需要在启动容器run时使用-p指定Docker容器外的端口和Docker容器内的端口

1
2
3
4
EXPOSE $ZOO_PORT 2888 3888
# 启动Docker容器时,需要-p指定端口映射
$ docker run -p 9999:22 -p 2181:2181 -t -i "birdben/zookeeper:v1"

ENV

定义Docker容器内的环境变量

ENV的格式:

  • ENV # 只能设置一个变量
  • ENV = … # 允许一次设置多个变量
1
2
ENV ZOOKEEPER_HOME /software/zookeeper-3.4.8
ENV PATH ${ZOOKEEPER_HOME}/bin:$PATH

VOLUME

将本地主机目录挂载到目标容器中,将其他容器挂载的挂载点挂载到目标容器中

1
2
3
4
5
6
7
8
9
10
VOLUME ["/home/golang/birdTracker"]
# VOLUME 选项是将本地的目录挂载到容器中 此处要注意:当你运行-v <hostdir>:<Containerdir> 时要确保目录内容相同否则会出现数据丢失
# /Users/yunyu/workspace_git/birdTracker:/home/golang/birdTracker
# /Users/yunyu/workspace_git/birdTracker是宿主机中的目录
# /home/golang/birdTracker是Docker容器中的目录
# 这里挂载的路径是birdTracker项目的目录
# 这里可以在启动Docker容器时,通过-v参数来指定映射的目录
$ docker run -it -v /Users/yunyu/workspace_git/birdTracker:/home/golang/birdTracker birdben/golang:v1

ADD

将复制指定的 到容器中的

1
2
3
4
5
# 源可以是URL
ADD <src>... <dest>
# 复制当前目录下文件到容器/temp目录,如果是压缩文件则自动解压缩复制
ADD local_tar_file /temp

COPY

将复制本地主机的 (为 Dockerfile 所在目录的相对路径)到容器中的

1
2
3
4
5
# 源不可以是URL
COPY <src>... <dest>
# 复制当前目录下文件到容器/temp目录
COPY local_files /temp

ADD与COPY的区别

ADD与COPY是完全不同的命令。COPY是这两个中最简单的,它只是从主机复制一份文件或者目录到镜像里。ADD同样可以这么做,但是它还有更神奇的功能,像解压TAR文件或从远程URLs获取文件。为了降低Dockerfile的复杂度以及防止意外的操作,最好用COPY来复制文件。Best Practices for Writing Dockerfiles建议尽量使用COPY,并使用RUN与COPY的组合来代替ADD,这是因为虽然COPY只支持本地文件拷贝到container,但它的处理比ADD更加透明,建议只在复制tar文件时使用ADD,如ADD trusty-core-amd64.tar.gz /。

1
2
3
4
FROM busybox:1.24
ADD example.tar.gz /add #解压缩文件到add目录
COPY example.tar.gz /copy #直接复制文件

USER

指定运行容器时的用户名或UID,后续的RUN、CMD、ENTRYPOINT也会使用指定用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 当服务不需要管理员权限时,可以通过该命令指定运行用户。并且可以在之前创建所需要的用户,要临时获取管理员权限可以使用 gosu,而不推荐 sudo。
ENV SSS_USER=shadowsocks
ENV SSS_SUPERVISOR_LOG_DIR=/var/log/supervisor
ENV SSS_SHADOWSOCKS_LOG_DIR=/var/log/shadowsocks
RUN set -x \
&& useradd $SSS_USER \
&& mkdir -p $SSS_SUPERVISOR_LOG_DIR \
&& mkdir -p $SSS_SHADOWSOCKS_LOG_DIR \
&& chown $SSS_USER:$SSS_USER $SSS_SUPERVISOR_LOG_DIR \
&& chown $SSS_USER:$SSS_USER $SSS_SHADOWSOCKS_LOG_DIR \
&& chmod 777 /var/run
USER $SSS_USER

WORKDIR

进入容器的默认路径,相当于cd,后续的RUN、CMD、ENTRYPOINT也会使用指定路径。

1
2
3
4
5
6
7
8
# 可以使用多个 WORKDIR 指令,后续命令如果参数是相对路径,则会基于之前命令指定的路径。例如
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
# 则最终路径为 /a/b/c

ONBUILD

基础镜像Dockerfile,基础镜像更新,各个项目不用同步 Dockerfile 的变化,重新构建后就继承了基础镜像的更新。

1
2
3
4
FROM node:slim
RUN "mkdir /app"
WORKDIR /app
CMD [ "npm", "start" ]

这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为 my-node 的话,各个项目内的自己的 Dockerfile 就变为:

1
2
3
4
FROM my-node
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/

基础镜像变化后,各个项目都用这个 Dockerfile 重新构建镜像,会继承基础镜像的更新。

如果这个 Dockerfile 里面有些东西需要调整呢?比如 npm install 都需要加一些参数,那怎么办?这一行 RUN 是不可能放入基础镜像的,因为涉及到了当前项目的 ./package.json,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的 Dockerfile 的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。

ONBUILD 可以解决这个问题。让我们用 ONBUILD 重新写一下基础镜像的 Dockerfile:

1
2
3
4
5
6
7
FROM node:slim
RUN "mkdir /app"
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

这次我们回到原始的 Dockerfile,但是这次将项目相关的指令加上 ONBUILD,这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的 Dockerfile 就变成了简单地:

1
FROM my-node

是的,只有这么一行。当在各个项目目录中,用这个只有一行的 Dockerfile 构建镜像时,之前基础镜像的那三行 ONBUILD 就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行 npm install,生成应用镜像。

参考文章:

Docker实战(二十四)Docker安装Golang环境

最近在使用Golang开发,使用Docker创建一个Golang环境的镜像方便以后开发使用。这里是外置挂在了我自己的birdTracker项目,使用Docker的Golang环境启动运行birdTracker项目。

Dockerfile文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
############################################
# version : birdben/golang:v1
# desc : 当前版本安装的golang
############################################
# 设置继承自ubuntu官方镜像
FROM ubuntu:14.04
# 下面是一些创建者的基本信息
MAINTAINER birdben (191654006@163.com)
# 设置环境变量,所有操作都是非交互式的
ENV DEBIAN_FRONTEND noninteractive
ENV GO_USER=golang
ENV GO_LOG_DIR=/var/log/golang
# 这里的GOPATH路径是挂载的birdTracker项目的目录
ENV GOPATH=/home/golang/birdTracker
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
#ENV GOLANG_VERSION 1.7.4
#ENV GOLANG_DOWNLOAD_URL https://golang.org/dl/go$GOLANG_VERSION.linux-amd64.tar.gz
#ENV GOLANG_DOWNLOAD_SHA256 47fda42e46b4c3ec93fa5d4d4cc6a748aa3f9411a2a2b7e08e3a6d80d753ec8b
# 替换 sources.list 的配置文件,并复制配置文件到对应目录下面。
# 这里使用的AWS国内的源,也可以替换成其他的源(例如:阿里云的源)
COPY sources.list /etc/apt/sources.list
# 安装基础工具
RUN sudo apt-get clean
RUN sudo rm -rf /var/lib/apt/lists/*
RUN sudo apt-get update
RUN sudo apt-get install -y vim wget curl git
# 使用apt方式安装golang
RUN sudo apt-get -y install golang
# 下载并安装golang
#RUN curl -fsSL "$GOLANG_DOWNLOAD_URL" -o golang.tar.gz \
# && echo "$GOLANG_DOWNLOAD_SHA256 golang.tar.gz" | sha256sum -c - \
# && tar -C /usr/local -xzf golang.tar.gz \
# && rm golang.tar.gz
# 创建用户和创建目录
RUN set -x && useradd $GO_USER && mkdir -p $GO_LOG_DIR $GOPATH && chown $GO_USER:$GO_USER $GO_LOG_DIR $GOPATH
WORKDIR $GOPATH
# VOLUME 选项是将本地的目录挂载到容器中 此处要注意:当你运行-v <hostdir>:<Containerdir> 时要确保目录内容相同否则会出现数据丢失
# 对应关系如下
# /Users/yunyu/workspace_git/birdTracker:/home/golang/birdTracker
# 这里挂载的路径是birdTracker项目的目录
VOLUME ["/home/golang/birdTracker"]
# 执行go_docker.sh脚本,该脚本在birdTracker项目根目录下,用于打包编译启动golang项目的
# 注意挂载的go_docker.sh必须有可执行权限,需要执行chmod +x /Users/yunyu/workspace_git/birdTracker/go_docker.sh
CMD ["/home/golang/birdTracker/go_docker.sh"]

Dockerfile源文件链接:

go_docker.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
MAIN_PATH=`pwd`/src/main/
LOG_PATH=`pwd`/logs
cd $MAIN_PATH
# 下载第三方引用包
go get github.com/golang/glog
go install
echo "日志目录:"$LOG_PATH
if [ ! -d "$LOG_PATH" ]; then
mkdir -p "$LOG_PATH"
fi
cd $GOPATH
./bin/main -v 10 -log_dir=$LOG_PATH -stderrthreshold=INFO

构建Docker镜像

1
docker build -t "birdben/golang:v1" .

运行Docker容器

1
2
CURRENT_UID=`whoami`
docker run -it -v /Users/yunyu/workspace_git/birdTracker:/home/golang/birdTracker --name golang_${CURRENT_UID} birdben/golang:v1

ELK学习(三)ELK处理URL参数

上一篇我们对Nginx的access.log进行了初步的解析和提取字段处理,如果想进一步对客户端的IP来源进行分析和地理定位,我们需要借助第三方库GeoIP来进行地理定位。

提取特殊字段

提取URL参数

如果想要让URL参数也解析并且成为索引字段,比如一些通用参数,如uid, country, language, etc. 那么可以使用KV插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
filter {
grok {
...
}
...
# 再单独将取得的URL、request字段取出来进行key-value值匹配
# 需要kv插件。提供字段分隔符"&?",值键分隔符"=",则会自动将字段和值采集出来。
kv {
source => "request" # 默认是message,我们这里只需要解析上面grok抽取出来的request字段
field_split => "&?"
value_split => "="
include_keys => [ "network", "country", "language", "deviceId" ]
}
 
# 把所有字段进行urldecode(显示中文)
urldecode {
all_fields => true
}
}

好了,现在还有一个问题,如果请求中有中文,那么日志中的中文是被urlencode之后存储的。我们具体分析的时候,比如有个接口是/api/search?keyword=我们,需要统计的是keyword被查询的热门顺序,那么就需要解码了。logstash牛逼的也有urldecode命令,urldecode可以设置对某个字段,也可以设置对所有字段进行解码。

1
2
3
urldecode {
all_fields => true
}
过滤掉安全扫描

对于安全扫描,只需要过滤 http_user_agent 中含有 inf-ssl-duty-scan 的请求就可以了

1
2
3
4
5
6
7
8
9
10
11
12
# 过滤安全扫描
if [http_user_agent] =~ "inf-ssl-duty-scan" {
drop { }
}
# 将client_ip为"-"的转换成"0.0.0.0",或者直接删除client_ip字段,两种方式的效果都一样,如果IP地址是"-"就会删除client_ip字段,否则geoip转换会报错
if [client_ip] == "-" {
mutate {
replace => { "client_ip" => "0.0.0.0" }
# remove_field => [ "client_ip" ]
}
}

查询Nginx请求日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 按照服务器响应时间区间查询请求
{"query":{"filtered":{"filter":{"bool":{"must":[{"range":{"upstream_response_time":{"gte":0.001,"lte":0.002}}}]}}}}}
# 按照整体响应时间区间查询请求
{"query":{"filtered":{"filter":{"bool":{"must":[{"range":{"request_time":{"gte":0.001,"lte":0.002}}}]}}}}}
# 查询某一个指定的请求
{"query":{"filtered":{"filter":{"bool":{"must":[{"term":{"fastcgi_script_name.raw":"/token/sign"}}]}}}}}
# 查询某一个指定的请求,服务器相应时间大于5s的
{"query":{"filtered":{"filter":{"bool":{"must":[{"range":{"upstream_response_time":{"gte":5}}},{"term":{"fastcgi_script_name.raw":"/token/sign"}}]}}}}}
# 查询某一IP端的请求
{"query":{"filtered":{"filter":{"bool":{"must":[{"range":{"client_ip":{"gte":"0.0.0.0"}}}]}}}}}
# 查询某一个IP的请求
{"query":{"filtered":{"filter":{"bool":{"must":[{"term":{"client_ip":"112.17.244.47"}}]}}}}}

参考文章:

ELK学习(二)ELK利用GeoIP进行地理定位

上一篇我们对Nginx的access.log进行了初步的解析和提取字段处理,如果想进一步对客户端的IP来源进行分析和地理定位,我们需要借助第三方库GeoIP来进行地理定位。

GeoIP的使用

这里要说明一下GeoIP是一个免费的IP定位数据库,现在目前有两个版本GeoIP和GeoIP2

GeoIP官网地址:

这里GeoIP和GeoIP2我都尝试过了,使用GeoIP2是因为GeoIP不支持IPv6,开始access.log日志的IP使用的IPv6,但是ES 2.x版本也只支持IPv4类型,所以为了简单把access.log日志的IP改为了IPv4。下面说一下GeoIP和GeoIP2的安装和使用

安装GeoIP数据库

GeoIP(免费版叫GeoLite)数据库下载地址

1
2
3
4
wget http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz
gunzip GeoLiteCity.dat.gz
# 将数据库文件移动到Logstash的安装目录下
mv GeoLiteCity.dat /usr/local/logstash/geoip/
Logstash配置文件中指定GeoIP数据库文件

Logstash默认是安装了logstash-filter-geoip插件的,所以可以直接使用下面的配置。我们继续上一篇的Logstash配置文件修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
if [type] == "nginx_access" {
grok {
match => {
"message" => "%{NGINX_ACCESS_LOGS}"
}
}
if "_grokparsefailure" in [tags] {
drop { }
}
# 将client_ip为"-"的转换成"0.0.0.0",或者直接删除client_ip字段,两种方式的效果都一样,如果IP地址是"-"就会删除client_ip字段,否则geoip转换会报错
if [client_ip] == "-" {
mutate {
replace => { "client_ip" => "0.0.0.0" }
# remove_field => [ "client_ip" ]
}
}
# 这里是geoip指定字段的用法,不适用于geoip2
geoip {
source => "client_ip"
target => "geoip"
# 这里指定好解压后GeoIP数据库文件的位置
database => "/usr/local/logstash/geoip/GeoLiteCity.dat"
# 直接使用location字段即可,不需要add_field和remove_field,除非不想使用location这个名字把字段换成别的名字
# 添加自己的坐标字段名称my_location
# add_field => [ "[geoip][my_location]", "%{[geoip][longitude]}" ]
# add_field => [ "[geoip][my_location]", "%{[geoip][latitude]}" ]
# 指定保留geoip的字段,注意geoip和geoip2字段的区别
# fields => ["country_name", "country_code2","region_name", "city_name", "real_region_name", "latitude", "longitude"]
# remove_field => [ "[geoip][longitude]", "[geoip][latitude]" ]
}
mutate {
# 使用自己的坐标字段名称my_location
# convert => [ "[geoip][my_location]", "float" ]
convert => [ "[geoip][location]", "float" ]
# 将request_time和upstream_response_time转换成float,否则ES查询出来的是string类型
convert => [ "[request_time]", "float" ]
convert => [ "[upstream_response_time]", "float" ]
}
}
GeoIP的格式
1
2
3
4
5
6
7
8
9
10
11
12
geoip.city_name:城市名称
geoip.continent_code:洲际编码
geoip.country_code2:国家编码,国际域名缩写
geoip.country_code3:国家编码,国际域名缩写
geoip.country_name:国家名称
geoip.ip:IP地址
* geoip.region_name:中国区域编码
* geoip.real_region_name:中国区域名称
geoip.timezone:时区
geoip.location:经纬度坐标
geoip.latitude:纬度坐标
geoip.longitude:经度坐标
安装GeoIP2数据库

GeoIP2(免费版叫GeoLite2)数据库下载地址

1
2
3
4
wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz
gunzip GeoLite2-City.mmdb.gz
# 将数据库文件移动到Logstash的安装目录下
mv GeoLite2-City.mmdb /usr/local/logstash/geoip/
Logstash配置文件中指定GeoIP2数据库文件
1
2
$ cd ${LS_HOME}/bin
$ logstash-plugin install logstash-filter-geoip2

这里需要单独安装geoip2插件,因为geoip2插件不是Logstash的官方插件。不使用geoip是因为对IPv6支持的不好,但是ES 2.x版本也只支持IPv4,不支持IPv6类型存储,所以后来只是将access.log的IP地址改为IPv4了。这里只是介绍下如何使用GeoIP2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
if [type] == "nginx_access" {
grok {
match => {
"message" => "%{NGINX_ACCESS_LOGS}"
}
}
if "_grokparsefailure" in [tags] {
drop { }
}
# 将client_ip为"-"的转换成"0.0.0.0",或者直接删除client_ip字段,两种方式的效果都一样,如果IP地址是"-"就会删除client_ip字段,否则geoip转换会报错
if [client_ip] == "-" {
mutate {
replace => { "client_ip" => "0.0.0.0" }
# remove_field => [ "client_ip" ]
}
}
geoip2 {
source => "client_ip"
target => "geoip"
# 这里指定好解压后GeoIP数据库文件的位置
database => "/usr/local/logstash/geoip/GeoLite2-City.mmdb"
# 这里是geoip2指定字段的用法
# fields => ["city_name", "continent_code", "country_code2", "country_code3", "country_name", "dma_code", "ip", "postal_code", "region_name", "region_code", "timezone", "location"]
}
mutate {
convert => [ "[geoip][location]", "float" ]
# 将request_time和upstream_response_time转换成float,否则ES查询出来的是string类型
convert => [ "[request_time]", "float" ]
convert => [ "[upstream_response_time]", "float" ]
}
}

注意这里的field不能直接用geoip插件的写法,因为有一些字段在geoip2中已经被去掉了,例如:real_region_name,否则会报如下错误

1
Unknown error while looking up GeoIP data {:exception=>#<Exception: [real_region_name] is not a supported field option.>, :field=>"client_ip", :event=>#<LogStash::Event:0x4b6443d9 @metadata={"path"=>"/home/yunyu/Downloads/access.log"}, @accessors=#<LogStash::Util::Accessors:0x213c01d

下面是GeoIP2的格式,可以对比一下GeoIP的格式,不同的地方用*号标识出来了。

GeoIP2的格式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
geoip.city_name:城市名称
geoip.continent_code:洲际编码
geoip.country_code2:国家编码,国际域名缩写
geoip.country_code3:国家编码,国际域名缩写
geoip.country_name:国家名称
* geoip.dma_code:指定市场区域,Designated Market Area(DMA)
geoip.ip:IP地址
* geoip.postal_code:邮政编码
* geoip.region_code:中国区域编码
* geoip.region_name:中国区域名称
geoip.timezone:时区
geoip.location:经纬度坐标
geoip.latitude:纬度坐标
geoip.longitude:经度坐标

ES的索引Template模板

引用GeoIP之后,对应的ES的索引Template模板也有相应的变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
{
"template": "nginx_access_logs_index_*",
"order":0,
"settings": {
"index.number_of_replicas": "1",
"index.number_of_shards": "5",
"index.refresh_interval": "10s"
},
"mappings": {
"_default_": {
"_all": {
"enabled": false
},
"dynamic_templates": [
{
"my_string": {
"match_mapping_type": "string",
"mapping": {
"type": "string",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}
]
},
"nginx_access": {
"properties": {
"ident": {
"type": "string",
"index": "not_analyzed"
},
"auth": {
"type": "string",
"index": "not_analyzed"
},
"client_ip": {
"type": "ip"
},
"client_timestamp": {
"type": "string",
"index": "not_analyzed"
},
"http_method": {
"type": "string",
"index": "not_analyzed"
},
"http_version": {
"type": "string",
"index": "not_analyzed"
},
"url": {
"type": "string",
"analyzer": "ik",
"search_analyzer": "ik_smart",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
},
"refer_url": {
"type": "string",
"analyzer": "ik",
"search_analyzer": "ik_smart",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
},
"status_code": {
"type": "string",
"index": "not_analyzed"
},
"http_bytes": {
"type": "string",
"index": "not_analyzed"
},
"ua": {
"type": "string",
"analyzer": "ik",
"search_analyzer": "ik_smart",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
},
"device_id": {
"type": "string",
"index": "not_analyzed"
},
"sdk_version": {
"type": "string",
"index": "not_analyzed"
},
"geoip": {
"dynamic": true,
"type": "object",
"properties": {
"location": {
"type": "geo_point"
}
}
},
"host": {
"type": "string",
"index": "not_analyzed"
},
"path": {
"type": "string",
"index": "not_analyzed"
},
"type": {
"type": "string",
"index": "not_analyzed"
},
"@timestamp": {
"format": "strict_date_optional_time||epoch_millis",
"type": "date"
},
"@version": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}

这里添加了一个geoip字段,并且使用dynamic,这样允许Logstash的geoip插件将解析后的详细字段也保存到ES索引中。这里geoip插件解析出来会带有一个location字段,这个字段就是经纬度的坐标点,所以这里设置geoip.location字段的类型是geo_point。

从Logstash的1.3.0版本开始,如果GeoIP查询返回纬度和经度,则会创建一个[geoip] [location]字段。 该字段存储为GeoJSON格式。 此外,弹性搜索输出提供的默认Elasticsearch模板将[geoip] [location]字段映射到Elasticsearch geo_point。

以下是elastic.co官方文档对于geo_point类型的解释

用于索引字段

geo_point映射将索引具有lat,lon格式的单个字段。lat_lon选项可以设置为也将.lat和.lon作为数字字段索引,geohash可以设置为true以索引.geohash值。

一个好的做法是启用索引lat_lon,因为geo距离和边界框过滤器可以使用内存检查或使用索引的lat lon值执行,并且它实际上取决于哪个数据集执行得更好。请注意,索引lat lon只有当字段有一个地理点值,而不是多个值时才有意义。

Geohashes

地理散列是一种纬度/经度编码的形式,它将地球分成网格。此网格中的每个单元格都由geohash字符串表示。每个单元又可以被进一步细分成由较长串表示的较小单元。因此,geohash越长,单元格越小(从而更精确)。

因为geohash只是字符串,它们可以像任何其他字符串一样存储在倒排索引中,这使得查询非常有效。

如果启用geohash选项,geohash“子字段”将被索引为,例如pin.geohash。geohash的长度由geohash_precision参数控制,geohash_precision参数可以设置为绝对长度(例如12,默认值)或距离(例如1km)。

更有用的是,将geohash_prefix选项设置为true不仅可以索引geohash值,还可以索引所有包围的单元格。例如,u30的geohash将被索引为[u,u3,u30]。此选项可以由Geohash单元格过滤器用于非常有效地查找特定单元格中的地理位置。

geo_point类型支持4种方式,下面是官网给出的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# mapping类型是geo_point
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"location": {
"type": "geo_point"
}
}
}
}
}
# 方式1
PUT my_index/my_type/1
{
"text": "Geo-point as an object",
"location": {
"lat": 41.12,
"lon": -71.34
}
}
# 方式2
PUT my_index/my_type/2
{
"text": "Geo-point as a string",
"location": "41.12,-71.34"
}
# 方式3
PUT my_index/my_type/3
{
"text": "Geo-point as a geohash",
"location": "drm3btev3e86"
}
# 方式4
PUT my_index/my_type/4
{
"text": "Geo-point as an array",
"location": [ -71.34, 41.12 ]
}
# 查询方式
GET my_index/_search
{
"query": {
"geo_bounding_box": {
"location": {
"top_left": {
"lat": 42,
"lon": -72
},
"bottom_right": {
"lat": 40,
"lon": -74
}
}
}
}
}

以下是翻译自elastic.co官方文档

说明:

  • 方式1:Geo-point表示为一个object,具有lat和lon两个key
  • 方式2:Geo-point表示为一个string,格式为”lat,lon”
  • 方式3:Geo-point表示为geohash
  • 方式4:Geo-point表示为一个array,格式为: [lon, lat]
  • 查询方式:地理边界框查询,它查找落在框内的所有geo-points

注意:

string方式的geo-points顺序是lat,lon,而数组方式的geo-points顺序是反过来的lon,lat。最初,lat,lon用于数组和字符串,但是数组格式早期改变以符合GeoJSON使用的格式。

1
2
3
Please note that string geo-points are ordered as lat,lon, while array geo-points are ordered as the reverse: lon,lat.
Originally, lat,lon was used for both array and string, but the array format was changed early on to conform to the format used by GeoJSON.

Kibana配置TileMap

创建TileMap视图

在Visualization中选择创建TileMap

配置TileMap

TileMap配置后的效果图

然后在索引中选择geoip.location字段,如下图左边所示。注意:我这里替换成了高德地图后的效果,具体可以参考Kibana系列的文章。

ChinaMap

配置错误处理

如果配置的过程中报错”No Compatible Fields: The “[nginx_access_logsindex*]” index pattern does not contain any of the following field types: geo_point”

错误原因:这个错误是因为我们的geoip的location字段类型不是geo_point,之前尝试Logstash写入数据到ES的时候没有在nginx_access_logsindex*索引模板中明确的指定geoip.location的类型,所以默认使用的dynamic_templates的配置,动态创建出来的geoip.location字段是string类型的,所以需要在ES的模板中指定好geoip.location字段的类型是geo_point。

Geohash错误

参考文章:

ELK学习(一)ELK收集Nginx日志

最近公司的系统遇到了并发性能问题,需要分析系统各个接口的请求和响应时间,所以提议收集Nginx日志来进行分析。

Nginx日志格式

默认的Nginx配置文件/etc/nginx/nginx.conf

1
2
3
4
5
6
7
##
# Logging Settings
##
log_format main '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" [$http_cookie] "$http_x_forwarded_for" $request_time $upstream_response_time $fastcgi_script_name';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;

日志实例

下面是几个具有典型代表的access.log日志

1
2
3
4
10.XXX.XXX.XX - - [23/Jan/2017:18:54:19 +0800] "HEAD / HTTP/1.0" 204 0 "-" "-" [-] "-" 0.019 0.001 /
10.XXX.XXX.XX - - [23/Jan/2017:18:54:19 +0800] "HEAD / HTTP/1.0" 301 0 "-" "-" [-] "-" 0.018 - /
10.XXX.XXX.XX - - [23/Jan/2017:18:54:18 +0800] "GET /api/user/detail?ID=1234567890 HTTP/1.0" 200 1778 "-" "XYZ/2.1.1 (iPhone; iOS 10.2; Scale/2.00)" [-] "XXX.XXX.XX.XXX" 0.011 0.010 /api/user/detail
10.XXX.XXX.XX - - [23/Jan/2017:18:54:16 +0800] "POST /api/user/relation HTTP/1.0" 200 125 "-" "ABC/4.2.1 (iPhone; iOS 10.2; Scale/2.00)" [-] "XXX.XXX.XX.XXX" 0.073 0.071 /api/user/relation

看到上面的日志实例,可能已经看出来不同的日志差别还是挺大的,有些日志缺少client_ip, upstream_response_time, ua等等,这些日志是阿里云健康检查的日志,所以后面我们会想办法处理这些日志的。

Nginx日志参数

1
2
3
4
5
6
7
8
9
10
11
12
13
$remote_addr : remote_addr代表客户端的IP,但它的值不是由客户端提供的,而是服务端根据客户端的IP指定的,当你的浏览器访问某个网站时,假设中间没有任何代理,那么网站的Web服务器(Nginx,Apache等)就会把remote_addr设为你的机器IP,如果你用了某个代理,那么你的浏览器会先访问这个代理,然后再由这个代理转发到网站,这样web服务器就会把remote_addr设为这台代理机器的IP。这里使用了阿里云的代理,所以是阿里云的内部IP地址。
$remote_user : 已经经过Auth Basic Module验证的用户名
$time_local : 通用日志格式下的本地时间
$request : 客户端请求的动作(通常为GET或POST),请求的URL(带参数),HTTP协议版本
$status : 请求状态
$body_bytes_sent : 发送给客户端的字节数,不包括响应头的大小; 该变量与Apache模块mod_log_config里的“%B”参数兼容。
$http_referer : 从哪个页面链接访问过来的
$http_user_agent : 记录客户端浏览器相关信息
$http_cookie : cookie的key和value
$http_x_forwarded_for : 正如上面所述,当你使用了代理时,Web服务器就不知道你的真实IP了,为了避免这个情况,代理服务器通常会增加一个叫做x_forwarded_for的头信息,把连接它的客户端IP(即你的上网机器IP)加到这个头信息里,这样就能保证网站的Web服务器能获取到真实IP。一般情况下有可能是多个IP地址,第一个就是客户端的真实IP地址。
$request_time : 指的就是从接受用户请求的第一个字节到发送完响应数据的时间,即包括接收请求数据时间、程序响应时间、输出响应数据时间。单位为秒,精度毫秒。
$upstream_response_time : 是指从Nginx向后端(php-cgi)建立连接开始到接受完数据然后关闭连接为止的时间。
$fastcgi_script_name : 请求的URL(不带参数)

注意:

从上面的描述可以看出,$request_time肯定比$upstream_response_time值大,特别是使用POST方式传递参数时,因为Nginx会把request body缓存住,接受完毕后才会把数据一起发给后端。所以如果用户网络较差,或者传递数据较大时,$request_time会比$upstream_response_time大很多。

Grok表达式

Grok表达式的调试工具推荐使用

这里定义了一个名为NGINX_ACCESS_LOGS的Grok表达式

1
2
3
NGUSERNAME [a-zA-Z\.\@\-\+_%]+
NGUSER %{NGUSERNAME}
NGINX_ACCESS_LOGS %{IPORHOST:server_ip} %{NGUSER:ident} %{NGUSER:auth} \[%{HTTPDATE:client_timestamp}\] "%{WORD:http_method} %{DATA:url} HTTP/%{NUMBER:http_version}" %{NUMBER:status_code} (?:%{NUMBER:http_bytes}|-) "(?:%{DATA:refer_url}|-)" "%{DATA:ua}" \[(?:%{GREEDYDATA:cookie}|-)\] "(?:%{IPORHOST:client_ip}|-)" (?:%{NUMBER:request_time}|-) (?:%{NUMBER:upstream_response_time}|-) %{GREEDYDATA:fastcgi_script_name}

Grok表达式的匹配结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
{
"NGINX_ACCESS_LOGS": [
[
"10.XXX.XXX.XX - - [23/Jan/2017:18:54:16 +0800] "POST /api/user/relation HTTP/1.0" 200 125 "-" "ABC/4.2.1 (iPhone; iOS 10.2; Scale/2.00)" [-] "XXX.XXX.XX.XXX" 0.073 0.071 /api/user/relation"
]
],
"server_ip": [
[
"10.XXX.XXX.XX"
]
],
"HOSTNAME": [
[
"10.XXX.XXX.XX",
"XXX.XXX.XX.XXX"
]
],
"IP": [
[
null,
null
]
],
"IPV6": [
[
null,
null
]
],
"IPV4": [
[
null,
null
]
],
"ident": [
[
"-"
]
],
"NGUSERNAME": [
[
"-",
"-"
]
],
"auth": [
[
"-"
]
],
"client_timestamp": [
[
"23/Jan/2017:18:54:16 +0800"
]
],
"MONTHDAY": [
[
"23"
]
],
"MONTH": [
[
"Jan"
]
],
"YEAR": [
[
"2017"
]
],
"TIME": [
[
"18:54:16"
]
],
"HOUR": [
[
"18"
]
],
"MINUTE": [
[
"54"
]
],
"SECOND": [
[
"16"
]
],
"INT": [
[
"+0800"
]
],
"http_method": [
[
"POST"
]
],
"url": [
[
"/api/user/relation"
]
],
"http_version": [
[
"1.0"
]
],
"BASE10NUM": [
[
"1.0",
"200",
"125",
"0.073",
"0.071"
]
],
"status_code": [
[
"200"
]
],
"http_bytes": [
[
"125"
]
],
"refer_url": [
[
"-"
]
],
"ua": [
[
"ABC/4.2.1 (iPhone; iOS 10.2; Scale/2.00)"
]
],
"cookie": [
[
"-"
]
],
"client_ip": [
[
"XXX.XXX.XX.XXX"
]
],
"request_time": [
[
"0.073"
]
],
"upstream_response_time": [
[
"0.071"
]
],
"fastcgi_script_name": [
[
"/api/user/relation"
]
]
}

这里使用了Logstash默认定义的一些Grok表达式,下面列举了一些常用的Grok表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- IPORHOST : 匹配IP(IPv4或IPv6地址)
- HTTPDATE : 匹配时间(默认格式:01/Jan/2017:00:00:01 +0800)
- GREEDYDATA : 匹配字符串(.*)
- DATA : 匹配字符串(.*?)
- NUMBER : 匹配数字
- QS : 带引号的字符串
- WORD : 字符串,包括数字和大小写字母
- NOTSPACE : 不带任何空格的字符串
- SPACE : 空格字符串
- URIPROTO : URI协议
比如:http、ftp等
- URIHOST : URI主机
比如:www.baidu.com、10.10.1.11:8080等
- URIPATH : URI路径
比如://www.baidu.com/test/、/aaa.html等
- URIPARAM : URI里的GET参数
比如:?a=1&b=2&c=3
- URIPATHPARAM : URI路径+GET参数
比如://www.baidu.com/test/aaa.html?a=1&b=2&c=3
- URI : 完整的URI
比如:http://www.baidu.com/test/aaa.html?a=1&b=2&c=3

具体内置的Grok表达式可以参考:

这里有几个地方让我有些迷惑:

  • DATA和GREEDYDATA有什么区别
  • client_ip是”-“的情况怎么处理
  • upstream_response_time是”-“的情况怎么处理
DATA和GREEDYDATA的区别

实际上是下面正则表达式的区别

  • .* : 贪婪模式
  • .*? : 勉强模式
  • .*+ : 侵占模式

具体区别请参考:

如何处理client_ip是”-“的情况

在Logstash中匹配client_ip为”-“的情况,然后将client_ip字段去掉,这样geoip解析的时候就不会报错了,后续会提供详细的配置文件

如何处理upstream_response_time是”-“的情况

upstream_response_time是”-“的情况比较好解决,只要稍微修改下Grok表达式即可,修改后如果upstream_response_time是”-“,Grok表达式提取出来的upstream_response_time的值就是null

1
2
修改前:%{NUMBER:upstream_response_time}
修改后:(?:%{NUMBER:upstream_response_time}|-)

以阿里云的健康检查请求日志为例:

1
10.XXX.XXX.XX - - [23/Jan/2017:18:54:19 +0800] "HEAD / HTTP/1.0" 301 0 "-" "-" [-] "-" 0.018 - /

匹配结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
{
"NGINX_ACCESS_LOGS": [
[
"10.XXX.XXX.XX - - [23/Jan/2017:18:54:19 +0800] "HEAD / HTTP/1.0" 301 0 "-" "-" [-] "-" 0.018 - /"
]
],
"server_ip": [
[
"10.XXX.XXX.XX"
]
],
"HOSTNAME": [
[
"10.XXX.XXX.XX",
null
]
],
"IP": [
[
null,
null
]
],
"IPV6": [
[
null,
null
]
],
"IPV4": [
[
null,
null
]
],
"ident": [
[
"-"
]
],
"NGUSERNAME": [
[
"-",
"-"
]
],
"auth": [
[
"-"
]
],
"client_timestamp": [
[
"23/Jan/2017:18:54:19 +0800"
]
],
"MONTHDAY": [
[
"23"
]
],
"MONTH": [
[
"Jan"
]
],
"YEAR": [
[
"2017"
]
],
"TIME": [
[
"18:54:19"
]
],
"HOUR": [
[
"18"
]
],
"MINUTE": [
[
"54"
]
],
"SECOND": [
[
"19"
]
],
"INT": [
[
"+0800"
]
],
"http_method": [
[
"HEAD"
]
],
"url": [
[
"/"
]
],
"http_version": [
[
"1.0"
]
],
"BASE10NUM": [
[
"1.0",
"301",
"0",
"0.018",
null
]
],
"status_code": [
[
"301"
]
],
"http_bytes": [
[
"0"
]
],
"refer_url": [
[
"-"
]
],
"ua": [
[
"-"
]
],
"cookie": [
[
"-"
]
],
"client_ip": [
[
null
]
],
"request_time": [
[
"0.018"
]
],
"upstream_response_time": [
[
null
]
],
"fastcgi_script_name": [
[
"/"
]
]
}

Logstash配置文件

这里我们尽量把处理方式简化,在单一的服务器上安装好Nginx和ELK环境,对Nginx的access.log日志进行收集,解析,最终写入到ES中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
input {
file {
path => ["/home/yunyu/Downloads/access.log"]
type => "nginx_access"
codec => "plain"
start_position => "beginning"
ignore_older => 0
}
}
filter {
if [type] == "nginx_access" {
grok {
match => {
"message" => "%{NGINX_ACCESS_LOGS}"
}
}
if "_grokparsefailure" in [tags] {
drop { }
}
date {
match => ["client_timestamp", "dd/MMM/yyyy:HH:mm:ss Z"]
target => "@timestamp"
}
}
}
output {
stdout {
codec => rubydebug
}
if [type] == "nginx_access" {
elasticsearch {
codec => "json"
hosts => ["hadoop1:9200", "hadoop2:9200", "hadoop3:9200"]
index => "nginx_access_logs_index_%{+YYYY.MM.dd}"
document_type => "%{type}"
template => "/usr/local/elasticsearch/template/nginx_access_logs_template.json"
template_name => "nginx_access_logs_template"
template_overwrite => true
workers => 1
flush_size => 20000
idle_flush_time => 10
}
}
}

ES的索引Template模板

上面Logstash写入日志数据到ES的时候,使用了nginx_access_logs_template.json模板,下面是ES模板的具体配置。这里我们只是把Logstash提取出来的值按照ES的Template定义好的类型写入到ES索引中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
{
"template": "nginx_access_logs_index_*",
"order":0,
"settings": {
"index.number_of_replicas": "1",
"index.number_of_shards": "5",
"index.refresh_interval": "10s"
},
"mappings": {
"_default_": {
"_all": {
"enabled": false
},
"dynamic_templates": [
{
"my_string": {
"match_mapping_type": "string",
"mapping": {
"type": "string",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}
]
},
"nginx_access": {
"properties": {
"ident": {
"type": "string",
"index": "not_analyzed"
},
"auth": {
"type": "string",
"index": "not_analyzed"
},
"client_ip": {
"type": "ip"
},
"client_timestamp": {
"type": "string",
"index": "not_analyzed"
},
"http_method": {
"type": "string",
"index": "not_analyzed"
},
"http_version": {
"type": "string",
"index": "not_analyzed"
},
"url": {
"type": "string",
"analyzer": "ik",
"search_analyzer": "ik_smart",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
},
"refer_url": {
"type": "string",
"analyzer": "ik",
"search_analyzer": "ik_smart",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
},
"status_code": {
"type": "string",
"index": "not_analyzed"
},
"http_bytes": {
"type": "string",
"index": "not_analyzed"
},
"ua": {
"type": "string",
"analyzer": "ik",
"search_analyzer": "ik_smart",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
},
"device_id": {
"type": "string",
"index": "not_analyzed"
},
"sdk_version": {
"type": "string",
"index": "not_analyzed"
},
"host": {
"type": "string",
"index": "not_analyzed"
},
"path": {
"type": "string",
"index": "not_analyzed"
},
"type": {
"type": "string",
"index": "not_analyzed"
},
"@timestamp": {
"format": "strict_date_optional_time||epoch_millis",
"type": "date"
},
"@version": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}

参考文章:

Kibana学习(五)替换高德地图

修改地图配置

找到src/ui/public/vislib/visualizations/_map.js文件,修改请求的地图API

替换成高德地图
1
2
3
4
5
6
7
8
9
10
11
12
var mapTiles = {
//url: tilemap.url,
//options: _.assign({}, tilemapOptions, { attribution })
// 替换成高德地图,参数:lang指定显示语言;style指定地图的风格,一般使用7
url: 'http://webst0{s}.is.autonavi.com/appmaptile?lang=zh_cn&style=7&x={x}&y={y}&z={z}',
options: {
attribution: 'Tiles by <a href="http://www.mapquest.com/">MapQuest</a> &mdash; ' +
'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, ' +
'<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>',
subdomains: '1234'
}
};
修改地图的默认展示
1
2
3
4
5
6
7
//修改为5,默认展示地图,当值为2是默认展示空白
//var defaultMapZoom = 2;
var defaultMapZoom = 5;
//修改默认展示中国地图的位置
//var defaultMapCenter = [15, 5];
var defaultMapCenter = [32.97180377635759, 108.28125];

进行编译

K4有一套机制是要通过webpack来进行编译的,从而达到大数据多而不崩,畅而不卡的效果所架构的,及时修改源代码也要编译后才能用,所以要删除${KB_HOME}/optimize/bundles目录,然后重启Kibana重新进行编译。在这一步Kibana启动时间会比平时慢,往往会卡停一段时间,因为此时正在进行编译创造bundles文件夹编译完之后发现之前删除掉的bundles又出现了。

Kibana启动日志
1
2
3
{"type":"log","@timestamp":"2017-01-23T07:39:18+00:00","tags":["info","optimize"],"pid":12483,"message":"Optimizing and caching bundles for sense, kibana and statusPage. This may take a few minutes"}
{"type":"log","@timestamp":"2017-01-23T07:39:53+00:00","tags":["info","optimize"],"pid":12653,"message":"Optimizing and caching bundles for sense, kibana and statusPage. This may take a few minutes"}
{"type":"log","@timestamp":"2017-01-23T07:40:35+00:00","tags":["info","optimize"],"pid":12653,"message":"Optimization of bundles for sense, kibana and statusPage complete in 42.81 seconds"}

问题总结

  • Kibana地图空白,没有地图形状

这个是默认显示的是空白,只要点放大的那个 + 号,就可以看到地图形状了。或者可以按照上面配置修改defaultMapZoom的值为5,就可以避免默认展示空白。

Kibana地图空白

  • Kibana默认显示中国地图

如上面配置修改defaultMapCenter默认显示的位置即可

  • 重启Kibana半天没有反应

修改后第一次启动的时候会比较慢,因为Kibana使用webpack进行编译的,从而提供高效稳定的性能。修改源码后需要编译后才能运行,上面的删除optimize/bundles就是删除编译的结果,让kibana重新编译

参考文章: