使用open-falcon cAdvisor实现对k8s(kubernetes)集群的监控

1. 前言

当我们的k8s要面临落地时,监控和日志肯定时不可缺少的。它主要为了帮助系统运维人员事前及时预警发现故障,事后通过翔实的数据追查定位问题。

2. 可选方案

  • Heapster(数据采集自cAdvisor)+Influxdb(存储)+Grafana(展示)
    这套方案缺点是没有报警功能

  • Prometheus+Grafana
    参考:http://blog.csdn.net/zqg5258423/article/details/53119009

  • open-falcon+cAdvisor
    本文主要介绍这种方式,官方文档:https://book.open-falcon.org/zh/intro/index.html
    实际案例:https://zhuanlan.zhihu.com/p/27697789

3. openfalcon架构图

未分类

可以看到,它的核心组件是agent,它需要运行在每台node上获取数据,所以对于k8s集群的监控,我们会主要对agent做二次开发,嵌入cAdvisor代码,实现对容器的监控。

4. 涉及的组件及简要说明

  • agent 监控信息上报 需要二次开发
  • transfer 监控信息转发
  • graph 监控信息存储
  • query 监控信息查询
  • hbs 心跳服务器
  • judge 报警事件判断
  • alarm 报警事件处理 需要二次开发
  • sender 报警事件发送 需要二次开发
  • nodata 假数据填充
  • Redis 报警事件队列

5. 监控流程

Agent监控各个主机和容器的信息(嵌入了cadvisor代码),并将数据通过transfer转发存入graph,控制台通过http方式访问query api查询graph获取数据。

6. 报警流程

不再单独部署portal模块,报警策略信息由控制台报警模块提供并写入MySQL的portal数据库,供hbs读取,hbs加载mysql portal库策略数据并关联到endpoint后提供给Judge组件使用,transfer转发到judge的每一条数据会触发相关策略进行判断将结果放入redis,alarm从redis中获取报警event后保存至控制台数据库,以提供控制台
前端展示报警信息

7. 监控系统本身的健康检查

对于k8s集群主机的基础指标监控,容器的基本指标,我们用falcon来监控, 而k8s核心组件和falcon核心组件的可用性监控,用一个独立的工具scripts来监控,它会部署在多个数据中心的多台主机上进行探测。

对监控系统的监控不能再依赖于监控系统, 使用独立工具的好处在于没有依赖,简单稳定,可用性高。而多中心多主机的部署也是为了保证工具的可用性。

iptables转发端口配置方法

默认的 NAT 网络模式里的虚拟机可以访问外网,但不能从外界访问虚拟机,但是可以通过 KVM 服务器配置 iptables 端口转发来访问虚拟机。

端口转发就是基于 nat 表的 PREROUTING 和 POSTROUTING 链, 所有的数据报文都要先经过 nat 的 PREROUTING 链进行处理, 再根据路由规则选择是进入 filter 的 INPUT 链还是 filter 的 FORWARD 链, 不管进入哪个链, 之后都会进去 nat 表的 POSTROUTING 链, 最后数据报文再转发出去。

开启 KVM 服务器的 ip_forward 内核转发功能

# 打开 ip_forward 转发:
echo "1"> /proc/sys/net/ipv4/ip_forward

这种方法是暂时的,系统重启后会还原。要永久生效可修改 /etc/stsctl.conf 文件。

# 编辑 /etc/stsctl.conf 文件
vim /etc/stsctl.conf
# 添加 net.ipv4.ip_forward = 1
net.ipv4.ip_forward = 1
# 保存退出后执行 sysctl –p 使其生效
sysctl –p

设置 PREROUTING 路由规则

iptables -t nat -A PREROUTING -d 1.1.1.1/32 -p tcp -m tcp --dport 9556 -j DNAT --to-destination 192.168.122.8:22

这里是把访问 KVM 服务器公网 IP 1.1.1.1(假设的 IP) 的 9556 端口数据通过 DNAT 的方式将数据报文中的目的 ip 信息改为后端的 192.168.122.8:22

设置 filter 表的 FORWARD 规则

iptables -I FORWARD -d 192.168.122.8/32 -j ACCEPT

如果当前 FORWARD 链的默认规则为 REJECT 则需要添加, 如果是 ACCEPT 则不需要执行上面的操作。

设置 POSTROUTING 路由规则

iptables -t nat -A POSTROUTING -d 192.168.122.8/32 -p tcp -m tcp --dport 22 -j SNAT --to-source 192.168.122.1

这里是把访问 192.168.122.8 端口 22 的返回数据再通过 SNAT 的方式将数据报文的源地址改为 192.168.122.1 (即 KVM 服务器的内网地址) 发送出去。

保存和查看 NAT 规则

/etc/rc.d/init.d/iptables save

# 保存 iptables 配置
service iptables save
# 重启 iptables
service iptables restart
# 查看 NAT 规则
iptables -t nat -nvL

其它

KVM 服务器启动时也会创建一些 NAT 规则,在上面 “查看 NAT 规则” 可以看到,正是那些规则才使我们创建的 NAT 网络模式的虚拟机默认即可访问外网。但它没并有写入 iptables 规则保存文件,所以我们在添加、修改 NAT 规则时重启 iptables 后最好也相应重启 KVM 服务。(如果我们使用 service iptables save 保存 iptables 配置时 KVM 服务在运行状态,那么就会把它的规则一起保存到 iptables 规则保存文件 /etc/sysconfig/iptables 里。)

iptables 是在内核层面实现端口转发功能的,相比其它应用层转发工具更高效、更灵活,但也相对更加复杂,请谨慎使用。

如何配置及排除GRUB故障

本文将会向你介绍 GRUB 的知识,并会说明你为什么需要一个引导加载程序,以及它是如何给系统增加功能的。

Linux 引导过程 是从你按下你的电脑电源键开始,直到你拥有一个全功能的系统为止,整个过程遵循着这样的主要步骤:

  • 一个叫做 POST(上电自检)的过程会对你的电脑硬件组件做全面的检查。

  • 当 POST 完成后,它会把控制权转交给引导加载程序,接下来引导加载程序会将 Linux 内核(以及 initramfs)加载到内存中并执行。

  • 内核首先检查并访问硬件,然后运行初始化进程(主要以它的通用名 init 而为人熟知),接下来初始化进程会启动一些服务,最后完成系统启动过程。

在该系列的第七讲(“SysVinit、Upstart 和 Systemd”)中,我们介绍了现代 Linux 发行版使用的一些服务管理系统和工具。在继续学习之前,你可能想要回顾一下那一讲的知识。

GRUB 引导装载程序介绍

在现代系统中,你会发现有两种主要的 GRUB 版本(一种是有时被称为 GRUB Legacy 的 v1 版本,另一种则是 v2 版本),虽说多数最新版本的发行版系统都默认使用了 v2 版本。如今,只有 红帽企业版 Linux 6 及其衍生系统仍在使用 v1 版本。

因此,在本指南中,我们将着重关注 v2 版本的功能。

不管 GRUB 的版本是什么,一个引导加载程序都允许用户:

  • 通过指定使用不同的内核来修改系统的行为;

  • 从多个操作系统中选择一个启动;

  • 添加或编辑配置区块来改变启动选项等。

如今,GNU 项目负责维护 GRUB,并在它们的网站上提供了丰富的文档。当你在阅读这篇指南时,我们强烈建议你看下 GNU 官方文档。

当系统引导时,你会在主控制台看到如下的 GRUB 画面。最开始,你可以根据提示在多个内核版本中选择一个内核(默认情况下,系统将会使用最新的内核启动),并且可以进入 GRUB 命令行模式(使用 c 键),或者编辑启动项(按下 e 键)。

未分类

GRUB 启动画面

你会考虑使用一个旧版内核启动的原因之一是之前工作正常的某个硬件设备在一次升级后出现了“怪毛病(acting up)”(例如,你可以参考 AskUbuntu 论坛中的这条链接)。

在启动时会从 /boot/grub/grub.cfg 或 /boot/grub2/grub.cfg 文件中读取GRUB v2 的配置文件,而 GRUB v1 使用的配置文件则来自 /boot/grub/grub.conf 或 /boot/grub/menu.lst。这些文件不应该直接手动编辑,而应通过 /etc/default/grub的内容和 /etc/grub.d 目录中的文件来更新。

在 CentOS 7 上,当系统最初完成安装后,会生成如下的配置文件:

GRUB_TIMEOUT=5
GRUB_DISTRIBUTOR="$(sed 's, release .*$,,g' /etc/system-release)"
GRUB_DEFAULT=saved
GRUB_DISABLE_SUBMENU=true
GRUB_TERMINAL_OUTPUT="console"
GRUB_CMDLINE_LINUX="vconsole.keymap=la-latin1 rd.lvm.lv=centos_centos7-2/swap crashkernel=auto  vconsole.font=latarcyrheb-sun16 rd.lvm.lv=centos_centos7-2/root rhgb quiet"
GRUB_DISABLE_RECOVERY="true"

除了在线文档外,你也可以使用下面的命令查阅 GNU GRUB 手册:

# info grub

如果你对 /etc/default/grub 文件中的可用选项特别感兴趣的话,你可以直接查阅配置一节的帮助文档:

# info -f grub -n 'Simple configuration'

使用上述命令,你会发现 GRUB_TIMEOUT 用于设置启动画面出现和系统自动开始启动(除非被用户中断)之间的时间。当该变量值为 -1 时,除非用户主动做出选择,否则不会开始启动。

当同一台机器上安装了多个操作系统或内核后,GRUB_DEFAULT 就需要用一个整数来指定 GRUB 启动画面默认选择启动的操作系统或内核条目。我们既可以通过上述启动画面查看启动条目列表,也可以使用下面的命令:

在 CentOS 和 openSUSE 系统上

# awk -F/' '$1=="menuentry " {print $2}' /boot/grub2/grub.cfg

在 Ubuntu 系统上

# awk -F/' '$1=="menuentry " {print $2}' /boot/grub/grub.cfg

如下图所示的例子中,如果我们想要使用版本为 3.10.0-123.el7.x86_64 的内核(第四个条目),我们需要将 GRUB_DEFAULT 设置为 3(条目从零开始编号),如下所示:

GRUB_DEFAULT=3

未分类

使用旧版内核启动系统

最后一个需要特别关注的 GRUB 配置变量是 GRUB_CMDLINE_LINUX,它是用来给内核传递选项的。我们可以在 内核变量文件和 man 7 bootparam 中找到能够通过 GRUB 传递给内核的选项的详细文档。

我的 CentOS 7 服务器上当前的选项是:

GRUB_CMDLINE_LINUX="vconsole.keymap=la-latin1 rd.lvm.lv=centos_centos7-2/swap crashkernel=auto  vconsole.font=latarcyrheb-sun16 rd.lvm.lv=centos_centos7-2/root rhgb quiet"

为什么你希望修改默认的内核参数或者传递额外的选项呢?简单来说,在很多情况下,你需要告诉内核某些由内核自身无法判断的硬件参数,或者是覆盖一些内核检测的值。

不久之前,就在我身上发生过这样的事情,当时我在自己已用了 10 年的老笔记本上尝试了衍生自 Slackware 的 Vector Linux。完成安装后,内核并没有检测出我的显卡的正确配置,所以我不得不通过 GRUB 传递修改过的内核选项来让它工作。

另外一个例子是当你需要将系统切换到单用户模式以执行维护工作时。为此,你可以直接在 GRUB_CMDLINE_LINUX 变量中直接追加 single 并重启即可:

GRUB_CMDLINE_LINUX="vconsole.keymap=la-latin1 rd.lvm.lv=centos_centos7-2/swap crashkernel=auto  vconsole.font=latarcyrheb-sun16 rd.lvm.lv=centos_centos7-2/root rhgb quiet single"

编辑完 /etc/default/grub 之后,你需要运行 update-grub (在 Ubuntu 上)或者 grub2-mkconfig -o /boot/grub2/grub.cfg (在 CentOS 和 openSUSE 上)命令来更新 grub.cfg 文件(否则,改动会在系统启动时丢失)。

这条命令会处理早先提到的那些启动配置文件来更新 grub.cfg 文件。这种方法可以确保改动持久化,而在启动时刻通过 GRUB 传递的选项仅在当前会话期间有效。

修复 Linux GRUB 问题

如果你安装了第二个操作系统,或者由于人为失误而导致你的 GRUB 配置文件损坏了,依然有一些方法可以让你恢复并能够再次启动系统。

在启动画面中按下 c 键进入 GRUB 命令行模式(记住,你也可以按下 e 键编辑默认启动选项),并可以在 GRUB 提示中输入 help 命令获得可用命令:

未分类

修复 Linux 的 Grub 配置问题

我们将会着重关注 ls 命令,它会列出已安装的设备和文件系统,并且我们将会看看它查找到的东西。在下面的图片中,我们可以看到有 4 块硬盘(hd0 到 hd3)。

貌似只有 hd0 已经分区了(msdos1 和 msdos2 可以证明,这里的 1 和 2 是分区号,msdos 则是分区方案)。

现在我们来看看能否在第一个分区 hd0(msdos1)上找到 GRUB。这种方法允许我们启动 Linux,并且使用高级工具修复配置文件,或者如果有必要的话,干脆重新安装 GRUB:

# ls (hd0,msdos1)/

从高亮区域可以发现,grub2 目录就在这个分区:

未分类

查找 Grub 配置

一旦我们确信了 GRUB 位于 (hd0, msdos1),那就让我们告诉 GRUB 该去哪儿查找它的配置文件并指示它去尝试启动它的菜单:

set prefix=(hd0,msdos1)/grub2
set root=(hd0,msdos1)
insmod normal
normal

未分类

查找并启动 Grub 菜单

然后,在 GRUB 菜单中,选择一个条目并按下回车键以使用它启动。一旦系统成功启动后,你就可以运行 grub2-install /dev/sdX 命令修复问题了(将 sdX 改成你想要安装 GRUB 的设备)。然后启动信息将会更新,并且所有相关文件都会得到恢复。

# grub2-install /dev/sdX

其它更加复杂的情景及其修复建议都记录在 Ubuntu GRUB2 故障排除指南 中。该指南中阐述的概念对于其它发行版也是有效的。

总结

本文向你介绍了 GRUB,并指导你可以在何处找到线上和线下的文档,同时说明了如何面对由于引导加载相关的问题而导致系统无法正常启动的情况。

幸运的是,GRUB 是文档支持非常丰富的工具之一,你可以使用我们在文中分享的资源非常轻松地获取已安装的文档或在线文档。

你有什么问题或建议吗?请不要犹豫,使用下面的评论框告诉我们吧。我们期待着来自你的回复!

使用docker部署hexo博客

hexo博客

在文章 用Hexo搭建个人博客(https://blog.xiayyu.me/2017/06/15/hexo-blog/) 中有关于hexo的详细介绍.本文主要介绍如何利用docker方便快捷的搭建静态网页服务器,用来部署我的hexo博客.

hexo服务器

直接上docker-compose.yml

version: '3'
services:
  # 静态网页服务器容器
  blog:
    container_name: blog
    image: nginx:stable-alpine
    restart: unless-stopped
    volumes:
      - blog-sftp:/usr/share/nginx/html:ro
    environment:
      # 以下三个环境变量为jrcs/letsencrypt-nginx-proxy-companion所需
      VIRTUAL_HOST: ${WEB_DOMAIN_NAME}, www.${WEB_DOMAIN_NAME}, blog.${WEB_DOMAIN_NAME}
      LETSENCRYPT_HOST: ${WEB_DOMAIN_NAME}, www.${WEB_DOMAIN_NAME}, blog.${WEB_DOMAIN_NAME}
      LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
  # sftp服务器容器用来上传静态网页内容
  sftp:
    container_name: blog-sftp
    image: atmoz/sftp:alpine
    restart: unless-stopped
    volumes:
      - blog-sftp:/home/blog/upload
    ports:
      - "2222:22"
    # cmd: 用户:密码:uid:gid:目录,必须写到目录,否则出现文件权限问题.
    command: blog:${SFTP_PW}:1001:100:upload
# 将网页服务器添加到同一docker网络中
networks:
  default:
    external:
      name: nginx-proxy
# 设置sftp容器和blog容器的共享volume
volumes:
  blog-sftp:

hexo默认支持的部署方式中sftp实现起来方便,特别是对于windows友好.为安全起见我没有直接使用vps的sftp服务,而是通过docker添加了一个sftp服务,并且通过docker volume实现容器间的文件共享.启动上述docker容器之后,只需要在hexo的配置文件_config.yml中填好相应的部署信息就ok了

deploy:
  type: sftp
  host: [hostname]
  user: blog
  pass: [passwd]
  port: 2222
  remotePath: /upload

使用Docker快速搭建Nginx PHP-FPM环境

背景

在上一周笔者对docker了解,仅限于这样认知:它能替代虚拟机,并且比虚拟机更省资源。

在老师和同事的感染下,感觉不学习docker好像就不能在IT圈混一样,于是,开始涌入Docker的大潮中。但万事开头难,听了同事的推荐,看了宁皓网的基础的视频之后感觉仅是对基本的命令进行了了解。但是真拿出来用还是不够用的,于是开始搜罗更

重资料,学习搭建LNMP环境。
终于功夫不负有心人,在今天终于是实验成功了,特此写下这篇笔记,为后来人提供一个示例。

实战

1、下载nginx官方镜像和php-fpm镜像

docker pull nginx
docker pull bitnami/php-fpm

未分类

笔者未进行更改docker源,依然是官方源。
当然,你可以使用中国源。

2、使用php-fpm镜像开启php-fpm应用容器

docker run -d --name myFpm -p  -v /var/www/html:/usr/share/nginx/html bitnami/php-fpm
  • -d : 该参数为后台运行之意
  • -v : 指定宿主机与容器的映射关系。/var/www/html为宿主机的项目目录(自定义的),/usr/share/nginx/html为nginx服务器项目默认的路径。

3、使用nginx镜像开启nginx应用容器

docker run -d --name myNginx -p 8080:80 -v /var/www/html:/usr/share/nginx/html nginx
  • -p : 该参数设置端口对应的关系。所有访问宿主机8080端口的URL会转发到nginx容器的80端口。

4、查看对应的IP信息

  • 首先查看是否启动成功
docker ps -a

未分类

可以看到,上述在STATUS一栏中显示UP,其含义为正在运行。

  • 查看IP信息
docker inspect myFpm | grep "IPAddress"

未分类

5、修改nginx的相关配置

在容器中是没有vim命令的,所以不能在容器中直接修改配置文件。所以我们必须通过变通的方式去解决这个问题,否则只能在每个容器中安装vim。

  • 首先登录到对应的容器中,查看配置信息路径,这在之后修改时会用到。
docker exec -it myNginx /bin/bash

未分类

  • -i : –interactive,交互模式。
  • -t : –tty,开启一个伪终端。
  • /bin/bash : 必须写,否则会报错。这是开始伪终端时,进入bash界面,也就是命令行界面。

  • 查看对应的配置文件位置

/etc/nginx/conf.d/default.conf

未分类

  • 退出命令行,不要使用exit,因为exit会让容器停止。这里使用ctrl + p + q来退出容器。

  • 使用专用的复制命令将配置文件复制到宿主机,然后在宿主机进行编辑(这就是变通的方法)

docker cp myNginx:/etc/nginx/conf.d/default.conf ./default.conf

这里用到了上一步查询到的配置文件路径信息

  • 在宿主机修改配置文件的php部分,内容如下:
location ~ .php$ {
   fastcgi_pass   172.17.0.2:9000;
   fastcgi_index  index.php;
   fastcgi_param  SCRIPT_FILENAME  /usr/share/nginx/html$fastcgi_script_name;
   fastcgi_param  SCRIPT_NAME      $fastcgi_script_name;
   include        fastcgi_params;
}
  • 再次使用复制命令将其复制到容器中,然后再次进入容器中,将nginx配置文件重新载入
docker cp ./default.conf myNginx:/etc/myNginx:/etc/nginx/conf.d/default.conf
  • 进入到nginx容器中重新载入配置文件
docker exec -it myNginx /bin/bash
service nginx reload

成功了

我看了一下,用到的模块还都有。

未分类

未分类

未分类

未分类

docker容器网络下UDP协议的一个问题

  • 最近在工作中遇到一个 docker 容器下 UDP 协议网络不通的问题,困扰了很久,也比较有意思,所以想写下来和大家分享。

  • 我们有个应用是 UDP 协议的,部署上去发现无法工作,但是换成 TCP 协议是可以的(应用同时支持 UDP、TCP 协议,切换成 TCP 模式发现一切正常)。虽然换成 TCP 能解决问题,但是我们还是想知道到底 UDP 协议在网络模式下为什么会出现这个问题,以防止后面其他 UDP 应用会有异常。

  • 这个问题抽象出来是这样的:如果有 UDP 服务运行在主机上(或者运行在网络模型为 Host 的容器里),并且监听在 0.0.0.0 地址(也就是所有的 ip 地址),从运行在 docker bridge 网络的容器运行客户端访问服务,两者通信有问题。

  • 注意以上的的限制条件,通过测试,我们发现下来几种情况都是正常的:

    • 使用 TCP 协议没有这个问题,这个已经说过了
    • 如果 UDP 服务器监听在 eth0 IP 地址上也不会出现这个问题
    • 并不是所有的应用都有这个问题,我们的 DNS(dnsmasq + kubeDNS) 也是同样的部署方式,但是功能都正常
  • 这个问题在 docker 上也有 issue 记录:https://github.com/moby/moby/issues/15127,但是目前并没有合理的解决方案。

这篇文章就分析一下出现这个问题的原因,希望给同样遇到这个问题的读者提供些帮助。

问题重现

这个问题很容易重现,我的实验是在 ubuntu16.04 下用 netcat 命令完成的,其他系统应该类似。在主机上通过 nc 监听 56789 端口,然后在容器里使用 nc 发数据。第一个报文是能发送出去的,但是以后的报文虽然在网络上能看到,但是对方无法接收。

在主机上运行 nc UDP 服务器(-u 表示 UDP 协议,-l 表示监听的端口)

$ nc -ul 56789

然后启动一个容器,运行客户端:

$ docker run -it apline sh
/ # nc -u 172.16.13.13 56789

nc 的通信是双方的,不管对方输入什么字符,回车后对方就能立即收到。但是在这个模式下,客户端第一次输入对方能够收到,后续的报文对方都收不到。

在这个实验中,容器使用的是 docker 的默认网络,容器的 ip 是 172.17.0.3,通过 veth pair(图中没有显示)连接到虚拟网桥 docker0(ip 地址为 172.17.0.1),主机本身的网络为 eth0,其 ip 地址为 172.16.13.13。

 172.17.0.3
+----------+
|   eth0   |
+----+-----+
     |
     |
     |
     |
+----+-----+          +----------+
| docker0  |          |  eth0    |
+----------+          +----------+
172.17.0.1            172.16.13.13

tcpdump 抓包

遇到这种疑难杂症,第一个想到的抓包,我们需要在 docker0 上抓包,因为这是报文必经过的地方。通过过滤容器的 ip 地址,很容器找到感兴趣的报文:

$ tcpdump -i docker0 -nn host 172.17.0.3

为了模拟多数应用一问一答的通信方式,我们一共发送三个报文,并用 tcpdump 抓取 docker0 接口上的报文:

  • 客户端先向服务器端发送 hello 字符串
  • 服务器端回复 world
  • 客户端继续发送 hi 消息

抓包的结果如下,可以发现第一个报文发送出去没有任何问题(因为 UDP 是没有 ACK 报文的,所以客户端无法知道对方有没有收到,这里说的没有问题是值没有对应的 ICMP 报文),但是第二个报文从服务端发送的报文,对方会返回一个 ICMP 告诉端口 38908 不可达;第三个报文从客户端发送的报文也是如此。以后的报文情况类似,双方再也无法进行通信了。

11:20:43.973286 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 6
11:20:50.102018 IP 172.17.0.1.56789 > 172.17.0.3.38908: UDP, length 6
11:20:50.102129 IP 172.17.0.3 > 172.17.0.1: ICMP 172.17.0.3 udp port 38908 unreachable, length 42
11:20:54.503198 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 3
11:20:54.503242 IP 172.16.13.13 > 172.17.0.3: ICMP 172.16.13.13 udp port 56789 unreachable, length 39

而此时主机上 UDP nc 服务器并没有退出,使用 lsof -i :56789 可能看到它仍然在监听着该端口。

问题原因

从网络报文的分析中可以看到服务端返回的报文源地址不是我们预想的 eth0 地址,而是 docker0 的地址,而客户端直接认为该报文是非法的,返回了 ICMP 的报文给对方。

那么问题的原因也可以分为两个部分:

  • 为什么应答报文源地址是错误的?
  • 既然 UDP 是无状态的,内核怎么判断源地址不正确呢?

主机多网络接口 UDP 源地址选择问题

第一个问题的关键词是:UDP 和多网络接口。因为如果主机上只有一个网络接口,发出去的报文源地址一定不会有错;而我们也测试过 TCP 协议是能够处理这个问题的。

通过搜索,发现这确实是个已知的问题。在 UNP() 这本书中,已经描述过这个问题,下面是对应的内容:

未分类

这个问题可以归结为一句话:UDP 在多网卡的情况下,可能会发生服务器端源地址不对的情况,这是内核选路的结果。 为什么 UDP 和 TCP 有不同的选路逻辑呢?因为 UDP 是无状态的协议,内核不会保存连接双方的信息,因此每次发送的报文都认为是独立的,socket 层每次发送报文默认情况不会指明要使用的源地址,只是说明对方地址。因此,内核会为要发出去的报文选择一个 ip,这通常都是报文路由要经过的设备 ip 地址。

有了这个原因,还要解释一下问题:为什么 dnsmasq 服务没有这个问题呢?因此我使用 strace 工具抓取了 dnsmasq 和出问题应用的网络 socket 系统调用,来查看它们两个到底有什么区别。

dnsmasq 在启动阶段监听了 UDP 和 TCP 的 54 端口(因为是在本地机器上测试的,为了防止和本地 DNS 监听的 DNS端口冲突,我选择了 54 而不是标准的 53 端口):

socket(PF_INET, SOCK_DGRAM, IPPROTO_IP) = 4
setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(4, {sa_family=AF_INET, sin_port=htons(54), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
setsockopt(4, SOL_IP, IP_PKTINFO, [1], 4) = 0

socket(PF_INET, SOCK_STREAM, IPPROTO_IP) = 5
setsockopt(5, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(5, {sa_family=AF_INET, sin_port=htons(54), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(5, 5)                            = 0

比起 TCP,UDP 部分少了 listen,但是多个 setsockopt(4, SOL_IP, IP_PKTINFO, [1], 4) 这句。到底这两点和我们的问题是否有关,先暂时放着,继续看传输报文的部分。

dnsmasq 收包和发包的系统调用,直接使用 recvmsg 和 sendmsg 系统调用:

recvmsg(4, {msg_name(16)={sa_family=AF_INET, sin_port=htons(52072), sin_addr=inet_addr("10.111.59.4")}, msg_iov(1)=[{"315n1 11fterminal19-05u50163"..., 4096}], msg_controllen=32, {cmsg_len=28, cmsg_level=SOL_IP, cmsg_type=, ...}, msg_flags=0}, 0) = 67

sendmsg(4, {msg_name(16)={sa_family=AF_INET, sin_port=htons(52072), sin_addr=inet_addr("10.111.59.4")}, msg_iov(1)=[{"315n201200111fterminal19-05u50163"..., 83}], msg_controllen=28, {cmsg_len=28, cmsg_level=SOL_IP, cmsg_type=, ...}, msg_flags=0}, 0) = 83

而出问题的应用 strace 结果如下:

[pid   477] socket(PF_INET6, SOCK_DGRAM, IPPROTO_IP) = 124
[pid   477] setsockopt(124, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
[pid   477] setsockopt(124, SOL_IPV6, IPV6_MULTICAST_HOPS, [1], 4) = 0
[pid   477] bind(124, {sa_family=AF_INET6, sin6_port=htons(6088), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0

[pid   477] getsockname(124, {sa_family=AF_INET6, sin6_port=htons(6088), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0
[pid   477] getsockname(124, {sa_family=AF_INET6, sin6_port=htons(6088), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0

[pid   477] recvfrom(124, "j20124502012422413215242321n243160f0n24142222524224"..., 2048, 0, {sa_family=AF_INET6, sin6_port=htons(38790), inet_pton(AF_INET6, "::ffff:172.17.0.3", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 168

[pid   477] sendto(124, "k20222102022r2403215241321v2435333TDH2442202024032"..., 533, 0, {sa_family=AF_INET6, sin6_port=htons(38790), inet_pton(AF_INET6, "::ffff:172.17.0.3", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 533

其对应的逻辑是这样的:使用 ipv6 绑定在 0.0.0.0 和 6088 端口,调用 getsockname 获取当前 socket 绑定的端口信息,数据传输过程使用的是 recvfrom 和 sendto。

对比下来,两者的不同有几点:

  • 后者使用的是 ipv6,而前者是 ipv4
  • 后者使用 recvfrom 和 sendto 传输数据,而前者是 sendmsg 和 recvmsg
  • 前者有调用 setsockopt 设置 IP_PKTINFO 的值,而后者没有

因为是在传输数据的时候出错的,因此第一个疑点是 sendmsg 和 sendto 的某些区别导致选择源地址有不同,通过 man sendto 可以知道 sendmsg 包含了更多的控制信息在 msghdr。一个合理的猜测是 msghdr 中包含了内核选择源地址的信息!

通过查找,发现 IP_PKTINFO 这个选项就是让内核在 socket 中保存 IP 报文的信息,当然也包括了报文的源地址和目的地址。IP_PKTINFO 和 msghdr 的关系可以在这个 stackoverflow 中找到:

https://stackoverflow.com/questions/3062205/setting-the-source-ip-for-a-udp-socket。

而 man 7 ip 文档中也说明了 IP_PKTINFO 是怎么控制源地址选择的:

IP_PKTINFO (since Linux 2.2)
              Pass  an  IP_PKTINFO  ancillary message that contains a pktinfo structure that supplies some information about the incoming packet.  This only works for datagram ori‐
              ented sockets.  The argument is a flag that tells the socket whether the IP_PKTINFO message should be passed or not.  The message itself can only be sent/retrieved as
              control message with a packet using recvmsg(2) or sendmsg(2).

                  struct in_pktinfo {
                      unsigned int   ipi_ifindex;  /* Interface index */
                      struct in_addr ipi_spec_dst; /* Local address */
                      struct in_addr ipi_addr;     /* Header Destination
                                                      address */
                  };

              ipi_ifindex  is the unique index of the interface the packet was received on.  ipi_spec_dst is the local address of the packet and ipi_addr is the destination address
              in the packet header.  If IP_PKTINFO is passed to sendmsg(2) and ipi_spec_dst is not zero, then it is used as the local source address for the  routing  table  lookup
              and  for  setting up IP source route options.  When ipi_ifindex is not zero, the primary local address of the interface specified by the index overwrites ipi_spec_dst
              for the routing table lookup.

如果 ipi_spec_dst 和 ipi_ifindex 不为空,它们都能作为源地址选择的依据,而不是让内核通过路由决定。

也就是说,通过设置 IP_PKTINFO socket 选项为 1,然后使用 recvmsg 和 sendmsg 传输数据就能保证源地址选择符合我们的期望。这也是 dnsmasq 使用的方案,而出问题的应用是因为使用了默认的 recvfrom 和 sendto。

关于 UDP 连接的疑惑

另外一个疑惑是:为什么内核会把源地址和之前不同的报文丢弃?认为它是非法的?因为我们前面已经说过,UDP 协议是无连接的,默认情况下 socket 也不会保存双方连接的信息。即使服务端发送报文的源地址有误,只要对方能正常接收并处理,也不会导致网络不通。

因为 conntrack,内核的 netfilter 模块会保存连接的状态,并作为防火墙设置的依据。它保存的 UDP 连接,只是简单记录了主机上本地 ip 和端口,和对端 ip 和端口,并不会保存更多的内容。

可以参考 intables info 网站的文章:http://www.iptables.info/en/connection-state.html#UDPCONNECTIONS。

在找到根源之前,我们曾经尝试过用 SNAT 来修改服务端应答报文的源地址,期望能够修复该问题。但是却发现这种方法行不通,为什么呢?

因为 SNAT 是在 netfilter 最后做的,在之前 netfilter 的 conntrack 因为不认识该 connection,直接丢弃了,所以即使添加了 SNAT 也是无法工作的。

那能不能把 conntrack 功能去掉呢?比如解决方案:

iptables -I OUTPUT -t raw -p udp --sport 5060 -j CT --notrack
iptables -I PREROUTING -t raw -p udp --dport 5060 -j CT --notrack

答案也是否定的,因为 NAT 需要 conntrack 来做翻译工作,如果去掉 conntrack 等于 SNAT 完全没用。

解决方案

知道了问题的原因,解决方案也就很容易找到。

使用 TCP 协议

如果服务端和客户端使用 TCP 协议进行通信,它们之间的网络是正常的。

$ nc -l 56789

监听在特定端口

使用 nc 启动一个 udp 服务器,监听在 eth0 上:

➜  ~ nc -ul 172.16.13.13 56789

nc 可以跟两个参数,分别代表 ip 和 端口,表示服务端监听在某个特定 ip 上。如果接收到的报文目的地址不是 172.16.13.13,也会被内核直接丢弃。

这种情况下,服务端和客户端也能正常通信。

改动应用程序实现

修改应用程序的逻辑,在 UDP socket 上设置 IP_PKTIFO,并通过 recvmsg 和 sendmsg 函数传输数据。

导出并导入docker镜像(适用于保存传输镜像)

假如由于网络原因,需要在一台无网络的电脑上运行镜像,那么docker是支持的。

最关键的是,学会使用docker的 save 命令。

可以参考:https://docs.docker.com/engine/reference/commandline/save/

你需要做的主要有3步骤:

  • 先从一个有网络的电脑下载docker镜像
sudo docker pull ubuntu
  • 保存镜像到本地文件
sudo docker save -o ubuntu_image.docker ubuntu
  • 把镜像拷贝到无网络的电脑,然后通过docker加载镜像即可。
sudo docker load ubuntu_image.docke

Docker镜像的存储机制

近几年 Docker 风靡技术圈,不少从业人员都或多或少使用过,也了解如何通过 Dockerfile 构建镜像,从远程镜像仓库拉取自己所需镜像,推送构建好的镜像至远程仓库,根据镜像运行容器等。这个过程十分简单,只需执行 docker build、docker pull、docker push、docker run 等操作即可。但大家是否想过镜像在本地到底是如何存储的?容器又是如何根据镜像启动的?推送镜像至远程镜像仓库时,服务器又是如何存储的呢?下面我们就来简单聊一聊。

Docker 镜像本地存储机制及容器启动原理

  • Docker 镜像不是一个单一的文件,而是有多层构成。我们可通过 docker images 获取本地的镜像列表及对应的元信息, 接着可通过docker history 查看某个镜像各层内容及对应大小,每层对应着 Dockerfile 中的一条指令。Docker 镜像默认存储在 /var/lib/docker/中,可通过 DOCKER_OPTS 或者 docker daemon 运行时指定 –graph= 或 -g 指定。

  • Docker 使用存储驱动来管理镜像每层内容及可读写的容器层,存储驱动有 devicemapper、aufs、overlay、overlay2、btrfs、zfs 等,不同的存储驱动实现方式有差异,镜像组织形式可能也稍有不同,但都采用栈式存储,并采用 Copy-on-Write(CoW) 策略。且存储驱动采用热插拔架构,可动态调整。那么,存储驱动那么多,该如何选择合适的呢?大致可从以下几方面考虑:

    • 若内核支持多种存储驱动,且没有显式配置,Docker 会根据它内部设置的优先级来选择。优先级为 aufs > btrfs/zfs > overlay2 > overlay > devicemapper。若使用 devicemapper 的话,在生产环境,一定要选择 direct-lvm, loopback-lvm 性能非常差。

    • 选择会受限于 Docker 版本、操作系统、系统版本等。例如,aufs 只能用于 Ubuntu 或 Debian 系统,btrfs 只能用于 SLES (SUSE Linux Enterprise Server, 仅 Docker EE 支持)。

    • 有些存储驱动依赖于后端的文件系统。例如,btrfs 只能运行于后端文件系统 btrfs 上。

    • 不同的存储驱动在不同的应用场景下性能不同。例如,aufs、overlay、overlay2 操作在文件级别,内存使用相对更高效,但大文件读写时,容器层会变得很大;devicemapper、btrfs、zfs 操作在块级别,适合工作在写负载高的场景;容器层数多,且写小文件频繁时,overlay 效率比 overlay2 更高;btrfs、zfs 更耗内存。

  • Docker 容器其实是在镜像的最上层加了一层读写层,通常也称为容器层。在运行中的容器里做的所有改动,如写新文件、修改已有文件、删除文件等操作其实都写到了容器层。容器层删除了,最上层的读写层跟着也删除了,改动自然也丢失了。若要持久化这些改动,须通过 docker commit [repository[:tag]] 将当前容器保存成为一个新镜像。若想将数据持久化,或是多个容器间共享数据,需将数据存储在 Docker volume 中,并将 volume 挂载到相应容器中。

存储驱动决定了镜像及容器在文件系统中的存储方式及组织形式,下面分别对常见的 aufs、overlay 作一简单介绍。

AUFS

AUFS 简介

AUFS 是 Debian (Stretch 之前的版本,Stretch默认采用 overlay2) 或 Ubuntu 系统上 Docker 的默认存储驱动,也是 Docker 所有存储驱动中最为成熟的。具有启动快,内存、存储使用高效等特点。如果使用的 Linux 内核版本为 4.0 或更高,且使用的是 Docker CE,可考虑使用overlay2 (比 AUFS 性能更佳)。

配置 AUFS 存储驱动

  • 验证内核是否支持 AUFS
$ grep aufs /proc/filesystems
nodev aufs
  • 若内核支持,可在 docker 启动时通过指定参数 –storage-driver=aufs 选择 AUFS

AUFS 存储驱动工作原理

采用 AUFS 存储驱动时,有关镜像和容器的所有层信息都存储在 /var/lib/docker/aufs/目录下,下面有三个子目录:

  • /diff:每个目录中存储着每层镜像包含的真实内容

  • /layers:存储有关镜像层组织的元信息,文件内容存储着该镜像的组建镜像列表

  • /mnt:挂载点信息存储,当创建容器后,mnt 目录下会多出容器对应的层及该容器的 init 层。目录名称与容器 Id 不一致。实际的读写层存储

在/var/lib/docker/aufs/diff,直到容器删除,此读写层才会被清除掉。

采用 AUFS 后容器如何读写文件?

读文件

容器进行读文件操作有以下三种场景:

  • 容器层不存在: 要读取的文件在容器层中不存在,存储驱动会从镜像层逐层向下找,多个镜像层中若存在同名文件,上层的有效。

  • 文件只存在容器层:读取容器层文件

  • 容器层与镜像层同时存在:读取容器层文件

修改文件或目录

容器中进行文件的修改同样存在三种场景:

  • 第一次写文件:若待修改的文件在某个镜像层中,aufs 会先执行 copy_up 操作将文件从只读的镜像层拷贝到可读写的容器层,然后进行修改。在文件非常大的情况下效率比较低下。

  • 删除文件:删除文件时,若文件在镜像层,其实是在容器层创建一个特殊的 whiteout 文件,容器层访问不到,并没有实际删掉。

  • 目录重命名:目前 AUFS 还不支持目录重命名。

OverlayFS

OverlayFS 简介

OverlayFS 是一种类似 AUFS 的现代联合文件系统,但实现更简单,性能更优。OverlayFS 严格说来是 Linux 内核的一种文件系统,对应的 Docker 存储驱动为 overlay 或者 overlay2,overlay2 需 Linux 内核 4.0 及以上,overlay 需内核 3.18 及以上。且目前仅 Docker 社区版支持。条件许可的话,尽量使用 overlay2,与 overlay 相比,它的 inode 利用率更高。

容器如何使用 overlay/overlay2 读写文件

读文件

读文件存在以下三种场景:

  • 文件不存在容器层:若容器要读的文件不在容器层,会继续从底层的镜像层找

  • 文件仅在容器层:若容器要读的文件在容器层,直接读取,不用在底层的镜像层查找

  • 文件同时在容器层和镜像层:若容器要读的文件在容器层和镜像层中都存在,则从容器层读取

修改文件或目录

写文件存在以下三种场景:

  • 首次写文件:若要写的文件位于镜像层中,则执行 copy_up 将文件从镜像层拷贝至容器层,然后进行修改,并在容器层保存一份新的。若文件较大,效率较低。OverlayFS 工作在文件级别而不是块级别,这意味着即使对文件稍作修改且文件很大,也须将整个文件拷贝至容器层进行修改。但需注意的是,copy_up 操作仅发生在首次,后续对同一文件进行修改,操作容器层文件即可

  • 删除文件或目录:容器中删除文件或目录时,其实是在容器中创建了一个 whiteout 文件,并没有真的删除文件,只是使其对用户不可见

  • 目录重命名:仅当源路径与目标路径都在容器层时,调用 rename(2) 函数才成功,否则返回 EXDEV

远程镜像仓库如何存储镜像?

不少人可能经常使用 docker,那么有没有思考过镜像推送至远程镜像仓库,是如何保存的呢?Docker 客户端是如何与远程镜像仓库交互的呢?

  • 我们平时本地安装的 docker 其实包含两部分:docker client 与 docker engine,docker client 与 docker engine 间通过 API 进行通信。Docker engine 提供的 API 大致有认证、容器、镜像、网络、卷、swarm 等,具体调用形式请参考:Docker Engine API(https://docs.docker.com/engine/api/v1.27/#)。

  • Docker engine 与 registry (即:远程镜像仓库)的通信也有一套完整的 API,大致包含 pull、push 镜像所涉及的认证、授权、镜像存储等相关流程,具体请参考:Registry API(https://github.com/docker/distribution/blob/master/docs/spec/api.md)。目前常用 registry 版本为 v2,registry v2 拥有断点续传、并发拉取镜像多层等特点。能并发拉取多层是因为镜像的元信息与镜像层数据分开存储,当 pull 一个镜像时,先进行认证获取到 token 并授权通过,然后获取镜像的 manifest 文件,进行 signature 校验。校验完成后,依据 manifest 里的层信息并发拉取各层。其中 manifest 包含的信息有:仓库名称、tag、镜像层 digest 等, 更多,请参考:manifest 格式文档(https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-1.md)。

  • 各层拉下来后,也会先在本地进行校验,校验算法采用 sha256。Push 过程则先将镜像各层并发推至 registry,推送完成后,再将镜像的 manifest 推至 registry。Registry 其实并不负责具体的存储工作,具体存储介质根据使用方来定,registry 只是提供一套标准的存储驱动接口,具体存储驱动实现由使用方实现。

  • 目前官方 registry 默认提供的存储驱动包括:微软 azure、Google gcs、Amazon s3、Openstack swift、阿里云 oss、本地存储等。若需要使用自己的对象存储服务,则需要自行实现 registry 存储驱动。网易云目前将镜像存储在自己的对象存储服务 nos 上,故专门针对 nos 实现了一套存储驱动,另外认证服务也对接了网易云认证服务,并结合自身业务实现了一套认证、授权逻辑,并有效地限制了仓库配额。

  • Registry 干的事情其实很简单,大致可分为:① 读配置 ;② 注册 handler ;③ 监听。本质上 registry 是个 HTTP 服务,启动后,监听在配置文件设定的某端口上。当 http 请求过来后,便会触发之前注册过的 handler。Handler 包含 manifest、tag、blob、blob-upload、blob-upload-chunk、catalog 等六类,具体请可参考 registry 源码:/registry/handlers/app.go:92。配置文件包含监听端口、auth 地址、存储驱动信息、回调通知等。

删除Docker镜像中为none的镜像

Dockerfile 代码更新频繁,自然docker build构建同名镜像也频繁的很,产生了众多名为none的无用镜像。

分别执行以下三行可清除:

docker ps -a | grep "Exited" | awk '{print $1 }'|xargs docker stop
docker ps -a | grep "Exited" | awk '{print $1 }'|xargs docker rm
docker images|grep none|awk '{print $3 }'|xargs docker rmi

之前有写过一些 Docker 技巧和命令(在文章的尾部),这里再重复2个。

停止所有容器,删除所有容器:

docker kill $(docker ps -q) ; docker rm $(docker ps -a -q)

停止所有容器,删除所有容器,删除所有镜像:

docker kill $(docker ps -q) ; docker rm $(docker ps -a -q) ; docker rmi $(docker images -q -a)

利用docker部署ceph集群

背景

Ceph官方现在提供两类镜像来创建集群,一种是常规的,每一种Ceph组件是单独的一个镜像,如ceph/daemon、ceph/radosgw、ceph/mon、ceph/osd等;另外一种是最新的方式,所有的Ceph组件都集成在一个镜像ceph/daemon中,如果要创建对应的Ceph组件容器,只需要指定类型即可。这里,我们使用第一种方式来创建Ceph集群。
  
另外,这里所有的容器,都是在同一个Docker host上创建的。

重新开始

在搭建过程中,如果遇到了任何问题,想删除之前的环境,并重新开始搭建时,可以运行如下命令:

#  rm -rf /etc/ceph /var/lib/ceph/  /opt/osd/; mkdir -p /etc/ceph /var/lib/ceph/osd /opt/osd/;chown -R 64045:64045 /var/lib/ceph/osd/;chown -R 64045:64045 /opt/osd/; docker rm -f $(docker ps -a | grep "ceph" | awk '{print $1}');

  
这里,分别删除了ceph配置、osd目录。并重新创建目录并更改所属用户(这一步非常重要),再删除所有有ceph关键字的容器。

搭建集群

创建monitor节点

这里,假设monitor节点的ip为192.168.1.111。

# docker run -itd --name mymon --network my-bridge --ip 192.168.1.111 -e MON_NAME=mymon -e MON_IP=192.168.1.111 -v /etc/ceph:/etc/ceph ceph/mon

在monitor节点上标识osd

# docker exec mymon ceph osd create
0
# docker exec mymon ceph osd create
1
# docker exec mymon ceph osd create
2

创建osd节点

# docker run -itd --name osd0 --network my-bridge -e CLUSTER=ceph -e WEIGHT=1.0 -e MON_NAME=mymon -e MON_IP=192.168.1.111 -v /etc/ceph:/etc/ceph -v /opt/osd/0:/var/lib/ceph/osd/ceph-0 ceph/osd 
# docker run -itd --name osd1 --network my-bridge -e CLUSTER=ceph -e WEIGHT=1.0 -e MON_NAME=mymon -e MON_IP=192.168.1.111 -v /etc/ceph:/etc/ceph -v /opt/osd/1:/var/lib/ceph/osd/ceph-1 ceph/osd 
# docker run -itd --name osd2 --network my-bridge -e CLUSTER=ceph -e WEIGHT=1.0 -e MON_NAME=mymon -e MON_IP=192.168.1.111 -v /etc/ceph:/etc/ceph -v /opt/osd/2:/var/lib/ceph/osd/ceph-2 ceph/osd 

再创建两个monitor节点组成集群

# docker run -itd --name mymon_1 --network my-bridge --ip 192.168.1.112 -e MON_NAME=mymon_1 -e MON_IP=192.168.1.112 -v /etc/ceph:/etc/ceph ceph/mon
# docker run -itd --name mymon_2 --network my-bridge --ip 192.168.1.113 -e MON_NAME=mymon_2 -e MON_IP=192.168.1.113 -v /etc/ceph:/etc/ceph ceph/mon

创建Ceph网关节点

#  docker run -itd --name myrgw --network my-bridge --ip 192.168.1.100 -p 9080:80 -e RGW_NAME=myrgw -v /etc/ceph:/etc/ceph ceph/radosgw

验证

执行ceph命令来查看ceph集群状态。

# docker exec mymon ceph -s

缺点

这种搭建方法,所有容器共享了/etc/ceph中的配置,因此,仅限于在一个docker host中搭建集群。在最新的Ceph镜像ceph/daemon中,提供了一种共享kv存储etcd的实现方式,可以实现多host情况下的Ceph集群搭建,适用于swarm、k8s或者Marathon的环境下搭建Ceph,有兴趣的朋友可以试一试。