Blog

ModSecurity

Content #

ModSecurity 是一个开源的、生产级的 WAF 工具包。

ModSecurity 有两个核心组件。第一个是“规则引擎”,它实现了自定义的“SecRule”语言,有自己特定的语法。但“SecRule”主要基于正则表达式,还是不够灵活,所以后来也引入了 Lua,实现了脚本化配置。

ModSecurity 的规则引擎使用 C++11 实现,可以从GitHub上下载源码,然后集成进 Nginx。因为它比较庞大,编译很费时间,所以最好编译成动态模块,在配置文件里用指令“load_module”加载:

load_module modules/ngx_http_modsecurity_module.so;

只有引擎还不够,要让引擎运转起来,还需要完善的防御规则,所以 ModSecurity 的第二个核心组件就是它的“规则集”。

ModSecurity 源码提供一个基本的规则配置文件“modsecurity.conf-recommended”,使用前要把它的后缀改成“conf”。

有了规则集,就可以在 Nginx 配置文件里加载,然后启动规则引擎:

modsecurity on;
modsecurity_rules_file /path/to/modsecurity.conf;

“modsecurity.conf”文件默认只有检测功能,不提供入侵阻断,这是为了防止误杀误报,把“SecRuleEngine”后面改成“On”就可以开启完全的防护:

#SecRuleEngine DetectionOnly
SecRuleEngine  On

基本的规则集之外,ModSecurity 还额外提供一个更完善的规则集,为网站提供全面可靠的保护。这个规则集的全名叫“OWASP ModSecurity 核心规则集”(Open Web Application Security Project ModSecurity Core Rule Set),因为名字太长了,所以有时候会简称为“核心规则集”或者“CRS”。

CRS 也是完全开源、免费的,可以从 GitHub 上下载:

git clone https://github.com/SpiderLabs/owasp-modsecurity-crs.git

其中有一个“crs-setup.conf.example”的文件,它是 CRS 的基本配置,可以用“Include”命令添加到“modsecurity.conf”里,然后再添加“rules”里的各种规则。

Include /path/to/crs-setup.conf
Include /path/to/rules/*.conf

这些配置文件,里面用“SecRule”定义了很多的规则,基本的形式是“SecRule 变量 运算符 动作”。

另外,ModSecurity 还有强大的审计日志(Audit Log)功能,记录任何可疑的数据,供事后离线分析。但在生产环境中会遇到大量的攻击,日志会快速增长,消耗磁盘空间,而且写磁盘也会影响 Nginx 的性能,所以一般建议把它关闭:

SecAuditEngine off  #RelevantOnly
SecAuditLog /var/log/modsec_audit.log

Viewpoints #

From #

36 | WAF:保护我们的网络服务

...

应用层协议协商(ALPN)

Content #

在 TLS 的扩展里,有一个叫“ALPN”(Application Layer Protocol Negotiation)的东西,用来与服务器就 TLS 上跑的应用协议进行“协商”。

客户端在发起“Client Hello”握手的时候,后面会带上一个“ALPN”扩展,里面按照优先顺序列出客户端支持的应用协议。

就像下图这样,最优先的是“h2”,其次是“http/1.1”,以前还有“spdy”,以后还可能会有“h3”。

服务器看到 ALPN 扩展以后就可以从列表里选择一种应用协议,在“Server Hello”里也带上“ALPN”扩展,告诉客户端服务器决定使用的是哪一种。因为我们在 Nginx 配置里使用了 HTTP/2 协议,所以在这里它选择的就是“h2”。

这样在 TLS 握手结束后,客户端和服务器就通过“ALPN”完成了应用层的协议协商,后面就可以使用 HTTP/2 通信了。

Viewpoints #

From #

33 | 我应该迁移到HTTP/2吗?

TCP的队头阻塞

Content #

客户端用 TCP 发送了三个包,但服务器所在的操作系统只收到了后两个包,第一个包丢了。那么内核里的 TCP 协议栈就只能把已经收到的包暂存起来,“停下”等着客户端重传那个丢失的包,这样就又出现了“队头阻塞”。

由于这种“队头阻塞”是 TCP 协议固有的,所以 HTTP/2 即使设计出再多的“花样”也无法解决。

Viewpoints #

From #

32 | 未来之路:HTTP/3展望

相邻单词出现次数统计(flatMap)

Content #

// 读取文件内容
val lineRDD: RDD[String] = _
// 以行为单位提取相邻单词
val wordPairRDD: RDD[String] = lineRDD.flatMap( line => {
  // 将行转换为单词数组
  val words: Array[String] = line.split(" ")
  // 将单个单词数组,转换为相邻单词数组
  for (i <- 0 until words.length - 1) yield words(i) + "-" + words(i+1)
})

先用 split 语句把 line 转化为单词数组,然后再用 for 循环结合 yield 语句,依次把单个的单词,转化为相邻单词词对。

注意,for 循环返回的依然是数组,也即类型为 Array[String]的词对数组。由此可见,映射函数 f 的类型是(String) => (Array[String])也就说从元素到集合。但如果我们去观察转换前后的两个 RDD,也就是 lineRDD 和 wordPairRDD,会发现它们的类型都是 RDD[String],换句话说,它们的元素类型都是 String。

map 与 mapPartitions 这两个算子在转换前后 RDD 的元素类型,与映射函数 f 的类型是一致的。但在 flatMap 这里,却出现了 RDD 元素类型与函数类型不一致的情况。

不难发现,映射函数 f 的计算过程,对应着图中的步骤 1 与步骤 2,每行文本都被转化为包含相邻词对的数组。紧接着,flatMap 去掉每个数组的“外包装”,提取出数组中类型为 String 的词对元素,然后以词对为单位,构建新的数据分区,如图中步骤 3 所示。这就是 flatMap 映射过程的第二步:去掉集合“外包装”,提取集合元素。

...

不用map而用mapPartitions的例子

Content #

把 Word Count 的计数需求,从原来的对单词计数,改为对单词的哈希值计数:

// 把普通RDD转换为Paired RDD
import java.security.MessageDigest
val cleanWordRDD: RDD[String] = _
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map{ word =>
  // 获取MD5对象实例
  val md5 = MessageDigest.getInstance("MD5")
  // 使用MD5计算哈希值
  val hash = md5.digest(word.getBytes).mkString
  // 返回哈希值与数字1的Pair
  (hash, 1)
}

由于 map(f) 是以元素为单元做转换的,那么对于 RDD 中的每一条数据记录,我们都需要实例化一个 MessageDigest 对象来计算这个元素的哈希值。

在工业级生产系统中,一个 RDD 动辄包含上百万甚至是上亿级别的数据记录,如果处理每条记录都需要事先创建 MessageDigest,那么实例化对象的开销就会聚沙成塔,不知不觉地成为影响执行效率的罪魁祸首。

mapPartitions就是以数据分区为粒度,使用映射函数 f 对 RDD 进行数据转换。

import java.security.MessageDigest
val cleanWordRDD: RDD[String] = _
val kvRDD: RDD[(String, Int)] = cleanWordRDD.mapPartitions( partition => {
  // 注意!这里是以数据分区为粒度,获取MD5对象实例
  val md5 = MessageDigest.getInstance("MD5")
  val newPartition = partition.map( word => {
  // 在处理每一条数据记录的时候,可以复用同一个Partition内的MD5对象
    (md5.digest(word.getBytes()).mkString,1)
  })
  newPartition
})

相比前一个版本,我们把实例化 MD5 对象的语句挪到了 map 算子之外。如此一来,以数据分区为单位,实例化对象的操作只需要执行一次,而同一个数据分区中所有的数据记录,都可以共享该 MD5 对象,从而完成单词到哈希值的转换。

...

创建RDD

Content #

在 Spark 中,创建 RDD 的典型方式有两种:

  1. 通过 SparkContext.parallelize 在内部数据之上创建 RDD;
  2. 通过 SparkContext.textFile 等 API 从外部数据创建 RDD。

第一种创建方式,只需要用 parallelize 函数来封装内部数据即可:

import org.apache.spark.rdd.RDD
val words: Array[String] = Array("Spark", "is", "cool")
val rdd: RDD[String] = sc.parallelize(words)

在 Spark 应用内定义体量超大的数据集,是不太合适的,因为数据集完全由 Driver 端创建,且创建完成后,还要在全网范围内跨节点、跨进程地分发到其他 Executors,所以往往会带来性能问题。因此,parallelize API 的典型用法,是在“小数据”之上创建 RDD。

要想在真正的“大数据”之上创建 RDD,我们还得依赖第二种创建方式。

import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)

Viewpoints #

From #

03 | RDD常用算子(一):RDD内部的数据转换

延迟计算(Lazy Evaluation)_Spark

Content #

Spark 在运行时的计算被划分为两个环节。

  1. 基于不同数据形态之间的转换,构建计算流图(DAG,Directed Acyclic Graph);
  2. 通过 Actions 类算子,以回溯的方式去触发执行这个计算流图。

换句话说,开发者调用的各类 Transformations 算子,并不立即执行计算,当且仅当开发者调用 Actions 算子时,之前调用的转换算子才会付诸执行。

在业内,这样的计算模式有个专门的术语,叫作“延迟计算”(Lazy Evaluation)。

在WordCount执行的过程中,只有最后一行代码会花费很长时间,而前面的代码都是瞬间执行完毕的。这正是由于 Spark 的延迟计算。

flatMap、filter、map 这些算子,仅用于构建计算流图,因此,当你在 spark-shell 中敲入这些代码时,spark-shell 会立即返回。只有在你敲入最后那行包含 take 的代码时,Spark 才会触发执行从头到尾的计算流程,所以直观地看上去,最后一行代码是最耗时的。

Spark 程序的整个运行流程如下图所示:

Viewpoints #

From #

02 | RDD与编程模型:延迟计算是怎么回事?

数组与RDD的对比

Content #

  1. 概念本身来说数组是实体,它是一种存储同类元素的数据结构,而 RDD 是一种抽象,它所囊括的是分布式计算环境中的分布式数据集。
  2. 活动范围数组的“活动范围”很窄,仅限于单个计算节点的某个进程内,而 RDD 代表的数据集是跨进程、跨节点的,它的“活动范围”是整个集群。
  3. 数据定位方面在数组中,承载数据的基本单元是元素,而 RDD 中承载数据的基本单元是数据分片。在分布式计算环境中,一份完整的数据集,会按照某种规则切割成多份数据分片。这些数据分片被均匀地分发给集群内不同的计算节点和执行进程,从而实现分布式并行计算。

Viewpoints #

From #

02 | RDD与编程模型:延迟计算是怎么回事?

anacron程序

Content #

如果某个作业在cron时间表中设置的运行时间已到,但这时候Linux系统处于关闭状态,那么该作业就不会运行。当再次启动系统时,cron程序不会再去运行那些错过的作业。为了解决这个问题,许多Linux发行版提供了anacron程序。

如果anacron判断出某个作业错过了设置的运行时间,它会尽快运行该作业。这意味着如果Linux系统关闭了几天,等到再次启动时,原计划在关机期间运行的作业会自动运行。有了anacron,就能确保作业一定能运行,这正是通常使用 anacron代替cron调度作业的原因。

anacron程序只处理位于cron目录的程序,比如/etc/cron.monthly。它通过时间戳来判断作业是否在正确的计划间隔内运行了。每个cron目录都有一个时间戳文件,该文件位于/var/spool/anacron:

$ ls /var/spool/anacron cron.daily cron.monthly cron.weekly $ $ sudo cat /var/spool/anacron/cron.daily [sudo] password for christine: 20200619 $

anacron程序使用自己的时间表(通常位于/etc/anacrontab)来检查作业目录: $ cat /etc/anacrontab

SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin HOME=/root LOGNAME=root

1 5 cron.daily run-parts –report /etc/cron.daily 7 10 cron.weekly run-parts –report /etc/cron.weekly @monthly 15 cron.monthly run-parts –report /etc/cron.monthly $ anacron时间表的基本格式和cron时间表略有不同:

period delay identifier command

period字段定义了作业的运行频率(以天为单位)。anacron程序用该字段检查作业的时间戳文件。delay字段指定了在系统启动后,anacron程序需要等待多少分钟再开始运行错过的脚本。

注意:anacron不会运行位于/etc/cron.hourly目录的脚本。这是因为anacron并不处理执行时间需求少于一天的脚本。

identifier字段是一个独特的非空字符串,比如cron.weekly。它唯一的作用是标识出现在日志消息和错误email中的作业。command字段包含了run-parts程序和一个cron脚本目录名。run-parts程序负责运行指定目录中的所有脚本。

From #

Linux命令行与shell脚本编程大全