所谓的ELK栈,由Elasticsearch、Logstash和Kibana等工具组成,是处理分布式系统日志的好方法。
我不会在这篇文章中详细介绍ELK堆栈,但我只想说,它相当强大,设置相对简单,即使是免费版本也能很好地扩展和工作。ELK栈提供了收集、结构化、持久化和分析不同来源的日志的机制。然而,由于有大量的服务一直在发送日志,所以偶尔清理一下,删除一些旧的日志文件是很重要的,这样我们就不会耗尽磁盘空间。
在ELK堆栈中没有这方面的 "内置 "功能(至少我没有找到),但是,根据你如何用logstash 设置你的日志收集,它可以归结为从elasticsearch 删除索引,这可以用cURL 来完成。
因此,假设我们有两个不同的日志来源--来自我们的gateway 服务器和来自我们的application 服务器。在logstash ,我们可以使用许多可用的inputs ,如filebeat,并通过这样的配置将日志发送到elasticsearch 。
output {
elasticsearch {
hosts => [ "elasticsearch:9200" ]
index => "logstash-application-%{+YYYY.MM.dd}"
}
}
这意味着,传入的日志将以index 后指定的格式发送到elasticsearch 。这是一个好主意(至少在我们的规模下--对于更大的系统可能有所不同),简单的为每一天创建一个索引,并将日志保存在那里。当然,这也可以每小时、每月或以任何其他任意方式进行。但重要的是,为了能够根据日志的时间长短来删除它们,要在其中加入某种时间戳。
在下面两节中,我们将构思一个小脚本,它将使我们能够很容易地从不同的来源删除旧的日志,并指定我们要回溯到多远。
让我们开始吧。
概念
那么,让我们把我们的脚本称为logdeleter 。基本上,我们想指定一些时间概念--我们想保留的日志的最大年龄(比如说1个月)。有几种方法可以做到这一点,但我认为2个参数--一个是值,一个是单位,在这里就足够了。
--v- 时间值--u- 时间的单位
另外,我们希望能够指定不同种类的日志,所以在我们的例子中,我们将添加一个前缀参数,它将只删除索引以给定前缀开始的日志。
--p- 要删除的索引的前缀(日期部分之前的所有内容)。
在一个更复杂的版本中,这也可以是一个RegEx。出于测试的目的,当我写删除脚本或一般的破坏性操作时,我喜欢添加一个dry-run选项,它可以向用户显示如果用给定的参数运行脚本会发生什么。
--dr- dryRun模式--实际上并不删除索引,但显示将被删除的索引
好了--有了这些参数,我们应该可以做以下事情,甚至更多。
// 1 month
./logdeleter --v 1 --u m --p logstash-application- --p logstash-gateway-
// 1 week
./logdeleter --v 1 --u w --p logstash-application- --p logstash-gateway-
// 20 days
./logdeleter --v 20 --u d --p logstash-application- --p logstash-gateway-
// 1 year
./logdeleter --v 1 --u y --p logstash-application- --p logstash-gateway-
// 1 year dry-run
./logdeleter --dr --v 1 --u y --p logstash-application- --p logstash-gateway-
这应该为我们提供了足够的灵活性来满足大多数的使用需求。
让我们开始用Go编写代码吧。
编码
好的,首先要做的是--依赖性。我们需要的东西不多,我们可以很容易地使用标准库来编写,但是Go弹性搜索客户端在过去对我来说很有效,而且我总是希望有好的日志,所以logrus 也在其中。
这个简单脚本中最 "复杂 "的部分是参数解析。
type arrayFlags []string
func (i *arrayFlags) String() string {
return strings.Join(*i, ",")
}
func (i *arrayFlags) Set(value string) error {
*i = append(*i, value)
return nil
}
var dryRun bool
var value int
var unit string
var prefixes arrayFlags
func init() {
flag.IntVar(&value, "v", 1, "Delete logs older than this value together with the unit, e.g. 1")
flag.StringVar(&unit, "u", "m", "Delete logs older than this unit together with the value, e.g. m for month")
flag.Var(&prefixes, "p", "Prefixes (part before the date) of the indices, which should be deleted, e.g. logstash-application-")
flag.BoolVar(&dryRun, "dr", false, "Run the script without actually deleting anything")
}
func main() {
flag.Parse()
if value <= 0 {
log.Fatal("You need to specify a valid time after which logs are deleted, e.g. --v=1 --u=w for 1 week\n")
}
if unit != "m" && unit != "w" && unit != "d" && unit != "y" {
log.Fatal("You need to specify a valid unit for the time after which logs are deleted, e.g. --v=1 --u=w for 1 week. Valid units are d, w, m, y\n")
}
if len(prefixes) == 0 {
log.Fatal("You need to specify prefixes for which logs should be deleted, e.g. --p=logstash-application --p=logstash-gateway\n")
}
...
}
好的。因此,这主要是对flag 包的基本使用--我们在init 函数中定义我们的参数,然后调用flag.Parse() ,以便将它们设置为我们上面定义的球状物。
然而,在这个例子中,prefix / --p 参数实际上要复杂一些,因为它应该可以为这个标志添加多个值。这就是arrayFlags 类型的由来,这是我们创建的一个自定义标志类型,它可以处理为一个参数读取和设置多个值。
在解析了标志后,我们添加了一些检查,如果给定的参数不工作,就通知用户。例如,一个无效的时间单位或值,或者根本没有前缀。
下一个目标是获得与elasticsearch 的连接,并查询所有现有的索引名,这样我们就可以找到我们想删除的索引。
ESHost := os.Getenv("ES_HOST")
if ESHost == "" {
ESHost = "http://127.0.0.1:9200"
}
ctx := context.Background()
client, err := elastic.NewClient(elastic.SetURL(ESHost), elastic.SetSniff(false))
if err != nil {
log.Fatalf("Could not connect to ElasticSearch: %v\n", err)
}
log.Infof("LogDeleter started, deleting logs older than %d%s with prefixes %s", value, unit, prefixes)
names, err := client.IndexNames()
if err != nil {
log.Fatalf("Could not fetch indices from ELasticSearch: %v\n", err)
}
这里没有什么特别的事情发生--我们只是使用elasticsearch 客户端的API,就是这样。
现在是脚本的 "中心部分"--我们遍历所有的索引,通过我们给定的prefixes ,解析过滤后的索引日期,并检查给定的日期是否在用time 参数计算的截止日期之后。此外,我们还检查我们是否在干运行,如果是的话,我们实际上不会删除任何东西,但会打印被删除的指数。
for _, index := range names {
if hasCorrectPrefix(index, prefixes) {
indexDate := trimPrefix(index, prefixes)
date, err := time.Parse("2006.01.02", indexDate)
if err != nil {
log.Errorf("Index %s's date could not be parsed", index)
}
if shouldBeDeleted(date, value, unit) {
if !dryRun {
_, err := client.DeleteIndex(index).Do(ctx)
if err != nil {
log.Errorf("Could not delete index %s, %v\n", index, err)
} else {
log.Infof("Deleted Index: %s\n", index)
}
} else {
log.Infof("DryRun - would have deleted Index: %s\n", index)
}
}
}
}
在上面的片段中,有几个辅助函数。第一个是hasCorrectPrefix ,它简单地检查给定的索引是否以任何给定的prefixes 为前缀。
func hasCorrectPrefix(index string, prefixes []string) bool {
result := false
for _, prefix := range prefixes {
if strings.HasPrefix(index, prefix) {
return true
}
}
return result
}
然后,删除该prefix ,以便使用trimPrefix ,获得索引名称的date 部分。
func trimPrefix(index string, prefixes []string) string {
for _, prefix := range prefixes {
if strings.HasPrefix(index, prefix) {
return strings.TrimPrefix(index, prefix)
}
}
return index
}
这两个函数本来可以在一个循环中完成,但由于性能真的不是这里最重要的目标,为了可读性,我把它分开了。
在解析完日期后,我们使用shouldBeDeleted 辅助函数,以便找出当前索引是否应该被删除。
func shouldBeDeleted(date time.Time, value int, unit string) bool {
if calculateTargetDate(date, value, unit).After(time.Now()) {
return false
}
return true
}
func calculateTargetDate(date time.Time, value int, unit string) time.Time {
if unit == "d" {
return date.AddDate(0, 0, value)
}
if unit == "w" {
return date.AddDate(0, 0, value*7)
}
if unit == "m" {
return date.AddDate(0, value, 0)
}
if unit == "y" {
return date.AddDate(value, 0, 0)
}
return date
}
所以,首先索引的日期被用来计算target date - 这是一个日期,给定的时间参数,这个日志仍然是好的,不必被删除。为了做到这一点,我们解析单位参数,并根据它在索引日期上加上天/周/年。然后,如果索引日期是After ,我们就知道这个索引仍然是好的,我们什么都不做,否则,我们就删除它。
实际的删除是非常简单的,我们只需调用
_, err := client.DeleteIndex(index).Do(ctx)
并处理错误。
就这样。 :)
完整的代码可以在这里找到
结论
ELK栈在建立分析和存储微服务系统日志的管道方面为我们提供了非常好的服务。编写这个简短的脚本的动机主要是为了在Go中获得一些乐趣,同时也是因为拥有可维护的操作工具而不是bash脚本是件好事。
由于交叉编译和创建静态二进制文件的可能性,Go对于像这样的小型操作程序来说是一种很好的语言,可以毫无问题地分发。