Fork me on GitHub

分类 工具技巧 下的文章

使用 Arthas 排查线上问题

Arthas 是阿里开源的一款 Java 应用诊断工具,可以在线排查问题,动态跟踪 Java 代码,以及实时监控 JVM 状态。这个工具的大名我早有耳闻,之前一直听别人推荐,却没有使用过。最近在线上遇到了一个问题,由于开发人员在异常处理时没有将线程堆栈打印出来,只是简单地抛出了一个系统错误,导致无法确定异常的具体来源;因为是线上环境,如果要修改代码重新发布,流程会非常漫长,所以只能通过分析代码来定位,正当我看着繁复的代码一筹莫展的时候,突然想到了 Arthas 这个神器,于是尝试着使用 Arthas 来排查这个问题,没想到轻松几步就定位到了原因,上手非常简单,着实让我很吃惊。正所谓 “工欲善其事,必先利其器”,这话果真不假,于是事后花了点时间对 Arthas 的各种用法学习了一番,此为总结。

快速入门

如果你处于联网环境,可以直接使用下面的命令下载并运行 Arthas:

$ wget https://arthas.aliyun.com/arthas-boot.jar
$ java -jar arthas-boot.jar

程序会显示出系统中所有正在运行的 Java 进程,Arthas 为每个进程分配了一个序号:

[INFO] JAVA_HOME: C:\Program Files\Java\jdk1.8.0_351\jre
[INFO] arthas-boot version: 3.7.1
[INFO] Found existing java process, please choose one and input the serial number of the process, eg : 1. Then hit ENTER.
* [1]: 9400 .\target\demo-0.0.1-SNAPSHOT.jar
  [2]: 13964 org.eclipse.equinox.launcher_1.6.500.v20230717-2134.jar
  [3]: 6796 org.springframework.ide.vscode.boot.app.BootLanguageServerBootApp

从这个列表中找到出问题的那个 Java 进程,并输入相应的序号,比如这里我输入 1,然后按下回车,Arthas 就会自动下载完整的包,并 Attach 到目标进程,输出如下:

[INFO] Start download arthas from remote server: https://arthas.aliyun.com/download/3.7.1?mirror=aliyun
[INFO] Download arthas success.
[INFO] arthas home: C:\Users\aneasystone\.arthas\lib\3.7.1\arthas
[INFO] Try to attach process 9400
[INFO] Attach process 9400 success.
[INFO] arthas-client connect 127.0.0.1 3658
  ,---.  ,------. ,--------.,--.  ,--.  ,---.   ,---.  
 /  O  \ |  .--. ''--.  .--'|  '--'  | /  O  \ '   .-' 
|  .-.  ||  '--'.'   |  |   |  .--.  ||  .-.  |`.  `-. 
|  | |  ||  |\  \    |  |   |  |  |  ||  | |  |.-'    |
`--' `--'`--' '--'   `--'   `--'  `--'`--' `--'`-----' 

wiki       https://arthas.aliyun.com/doc
tutorials  https://arthas.aliyun.com/doc/arthas-tutorials.html
version    3.7.1
main_class
pid        9400                                                                 
time       2023-09-06 07:16:31

[arthas@9400]$

下载的 Arthas 包位于 ~/.arthas 目录,如果你没有联网,需要提前下载完整的包。

Arthas 偶尔会出现 Attach 不上目标进程的情况,可以查看 ~/logs/arthas 目录下的日志进行排查。

查看所有命令

使用 help 可以查看 Arthas 支持的 所有子命令

[arthas@9400]$ help
 NAME         DESCRIPTION
 help         Display Arthas Help
 auth         Authenticates the current session
 keymap       Display all the available keymap for the specified connection.
 sc           Search all the classes loaded by JVM
 sm           Search the method of classes loaded by JVM
 classloader  Show classloader info
 jad          Decompile class
 getstatic    Show the static field of a class
 monitor      Monitor method execution statistics, e.g. total/success/failure count, average rt, fail rate, etc.
 stack        Display the stack trace for the specified class and method
 thread       Display thread info, thread stack
 trace        Trace the execution time of specified method invocation.
 watch        Display the input/output parameter, return object, and thrown exception of specified method invocation
 tt           Time Tunnel
 jvm          Display the target JVM information
 memory       Display jvm memory info.
 perfcounter  Display the perf counter information.
 ognl         Execute ognl expression.
 mc           Memory compiler, compiles java files into bytecode and class files in memory.
 redefine     Redefine classes. @see Instrumentation#redefineClasses(ClassDefinition...)
 retransform  Retransform classes. @see Instrumentation#retransformClasses(Class...)
 dashboard    Overview of target jvm's thread, memory, gc, vm, tomcat info.
 dump         Dump class byte array from JVM
 heapdump     Heap dump
 options      View and change various Arthas options
 cls          Clear the screen
 reset        Reset all the enhanced classes
 version      Display Arthas version
 session      Display current session information
 sysprop      Display and change the system properties.
 sysenv       Display the system env.
 vmoption     Display, and update the vm diagnostic options.
 logger       Print logger info, and update the logger level
 history      Display command history
 cat          Concatenate and print files
 base64       Encode and decode using Base64 representation
 echo         write arguments to the standard output
 pwd          Return working directory name
 mbean        Display the mbean information
 grep         grep command for pipes.
 tee          tee command for pipes.
 profiler     Async Profiler. https://github.com/jvm-profiling-tools/async-profiler
 vmtool       jvm tool
 stop         Stop/Shutdown Arthas server and exit the console.

这些命令根据功能大抵可以分为以下几类:

  • 与 JVM 相关的命令
  • 与类加载、类、方法相关的命令
  • 统计和观测命令
  • 类 Linux 命令
  • 其他命令

与 JVM 相关的命令

这些命令主要与 JVM 相关,用于查看或修改 JVM 的相关属性,查看 JVM 线程、内存、CPU、GC 等信息:

  • jvm - 查看当前 JVM 的信息;
  • sysenv - 查看 JVM 的环境变量;
  • sysprop - 查看 JVM 的系统属性;
  • vmoption - 查看或修改 JVM 诊断相关的参数;
  • memory - 查看 JVM 的内存信息;
  • heapdump - 将 Java 进程的堆快照导出到某个文件中,方便我们对堆内存进行分析;
  • thread - 查看所有线程的信息,包括线程名称、线程组、优先级、线程状态、CPU 使用率、堆栈信息等;
  • dashboard - 查看当前系统的实时数据面板,包括了线程、内存、GC 和 Runtime 等信息;可以把它看成是 threadmemoryjvmsysenvsysprop 几个命令的综合体;
  • perfcounter - 查看当前 JVM 的 Perf Counter 信息;
  • logger - 查看应用日志信息,支持动态更新日志级别;
  • mbean - 查看或实时监控 Mbean 的信息;
  • vmtool - 利用 JVMTI 接口,实现查询内存对象,强制 GC 等功能;

与类加载、类、方法相关的命令

这些命令主要与类加载、类或方法相关,比如在 JVM 中搜索类或类的方法,查看类的静态属性,编译或反编译,对类进行热更新等:

  • classloader - 查看 JVM 中所有的 Classloader 信息;
  • dump - 将指定类导出成 .class 字节码文件;
  • jad - 将指定类反编译成 Java 源码;
  • mc - 内存编译器,将 Java 源码编译成 .class 字节码文件;
  • redefine / retransform - 这两个命令都可以对已加载的类进行热更新,但是 redefinejad / watch / trace / monitor / tt 等命令会冲突,而且 redefine 后的原来的类不能恢复,所以推荐使用 retransform 命令,关于 JDK 中 Redefine 和 Retransform 机制的区别可以参考 这里
  • sc - Search Class,搜索 JVM 中的类;
  • sm - Search Method,搜索 JVM 中的类的方法;
  • getstatic - 查看类的静态属性;
  • ognl - 执行 ognl 表达式;ognl 非常灵活,可以实现很多功能,比如上面的查看或修改系统属性,查看类的静态属性都可以通过 ognl 实现;

统计和观测

这些命令可以对类方法的执行情况进行统计和监控,是排查线上问题的利器:

  • monitor - 对给定的类方法进行监控,统计其调用次数,调用耗时以及成功率等;
  • stack - 查看一个方法的执行调用堆栈;
  • trace - 对给定的类方法进行监控,输出该方法的调用耗时,和 monitor 的区别在于,它还能跟踪一级方法的调用链路和耗时,帮助快速定位性能问题;
  • watch - 观测指定方法的执行数据,包括方法的入参、返回值、抛出的异常等;
  • tt - 和 watch 命令一样,tt 也可以观测指定方法的执行数据,但 tt 是将每次的执行情况都记录下来,然后再针对每次调用进行排查和分析,所以叫做 Time Tunnel;
  • reset - 上面这些与统计观测相关的命令都是通过 字节码增强技术 来实现的,会在指定类的方法中插入一些切面代码,因此在生产环境诊断结束后,记得执行 reset 命令重置增强过的类(或执行 stop 命令);
  • profiler - 使用 async-profiler 对应用采样,并将采样结果生成火焰图;
  • jfr - 动态开启关闭 JFR 记录,生成的 jfr 文件可以通过 JDK Mission Control 进行分析;

Arthas 命令与 JDK 工具的对比

细数 JDK 自带的那些调试和诊断工具 这篇笔记中我总结了很多 JDK 自带的诊断工具,其实有很多 Arthas 命令和那些 JDK 工具的功能是类似的,只是 Arthas 在输出格式上做了优化,让输出的内容更加美观和易读,而且在功能上做了增强。

Arthas 命令JDK 工具对比
syspropjinfo -sysprops都可以查看 JVM 的系统属性,但是 syspropjinfo 强的是,它还能修改系统属性
vmoptionjinfo -flag都可以查看 JVM 参数,但是 vmoption 只显示诊断相关的参数,比如 HeapDumpOnOutOfMemoryErrorPrintGC
memoryjmap -heap都可以查看 JVM 的内存信息,但是 memory 以表格形式显示,方便用户阅读
heapdumpjmap -dump都可以导出进程的堆内存,只是它在使用上更加简洁
threadjstack都可以列出 JVM 的所有线程,但是 thread 以表格形式显示,方便用户阅读,而且增加了 CPU 使用率的功能,可以方便我们快速找出当前最忙的线程
perfcounterjcmd PerfCounter.print都可以查看 JVM 进程的性能统计信息
classloaderjmap -clstats都可以查看 JVM 的 Classloader 统计信息,但是 classloader 命令还支持以树的形式查看,另外它还支持查看每个 Classloader 实际的 URL,通过 Classloader 查找资源等
jfrjcmd JFR.start都可以开启或关闭 JFR 记录,并生成的 jfr 文件

类 Linux 命令

除了上面那些用于问题诊断的命令,Arthas 还提供了一些类 Linux 命令,方便我们在 Arthas 终端中使用,比如:

  • base64 - 执行 base64 编码和解码;
  • cat - 打印文件内容;
  • cls - 清空当前屏幕区域;
  • echo - 打印参数;
  • grep - 使用字符串或正则表达式搜索文本,并输出匹配的行;
  • history - 输出历史命令;
  • pwd - 输出当前的工作目录;
  • tee - 从 stdin 读取数据,并同时输出到 stdout 和文件;
  • wc - 暂时只支持 wc -l,统计输出的行数;

此外,Arthas 还支持在后台运行任务,仿照 Linux 中的相关命令,我们可以使用 & 在后台运行任务,使用 jobs 列出所有后台任务,使用 Ctrl + Z 暂停任务,使用 bgfg 将暂停的任务转到后台或前台继续运行,使用 kill 终止任务。具体内容可以参考 Arthas 后台异步任务

其他命令

还有一些与 Arthas 本身相关的命令,比如查看 Arthas 的版本号、配置、会话等信息:

  • version - 查看 Arthas 版本号;
  • options - 查看或修改 Arthas 全局配置;
  • keymap - 查看当前所有绑定的快捷键,可以通过 ~/.arthas/conf/inputrc 文件自定义快捷键;
  • session - 查看当前会话信息;
  • auth - 验证当前会话;
  • quit - 退出当前 Arthas 客户端,其他 Arthas 客户端不受影响;
  • stop - 关闭 Arthas 服务端,所有 Arthas 客户端全部退出;这个命令会重置掉所有的增强类(除了 redefine 的类);

线上问题排查

了解了 Arthas 的命令之后,接下来总结一些使用 Arthas 对常见问题的排查思路。

使用 watch 监听方法出入参和异常

相信不少人见过类似下面这样的代码,在遇到异常情况时直接返回系统错误,而没有将异常信息和堆栈打印出来:

@PostMapping("/add")
public String add(@RequestBody DemoAdd demoAdd) {
  try {
    Integer result = demoService.add(demoAdd);
    return String.valueOf(result);
  } catch (Exception e) {
    return "系统错误!";
  }
}

有时候只打印了异常信息 e.getMessage(),但是一看日志全是 NullPointerException,一旦出现异常,根本不知道是哪行代码出了问题。这时,Arthas 的 watch 命令就可以派上用场了:

$ watch com.example.demo.service.DemoService add -x 2
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 1) cost in 143 ms, listenerId: 1

我们对 demoService.add() 方法进行监听,当遇到正常请求时:

$ curl -X POST -H "Content-Type: application/json" -d '{"x":1,"y":2}' http://localhost:8080/add
3

watch 的输出如下:

method=com.example.demo.service.DemoService.add location=AtExit
ts=2023-09-11 08:00:46; [cost=1.4054ms] result=@ArrayList[
    @Object[][
        @DemoAdd[DemoAdd(x=1, y=2)],
    ],
    @DemoService[
    ],
    @Integer[3],
]

location=AtExit 表示这个方法正常结束,result 表示方法在结束时的变量值,默认只监听方法的入参、方法所在的实例对象、以及方法的返回值。

当遇到异常请求时:

$ curl -X POST -H "Content-Type: application/json" -d '{"x":1}' http://localhost:8080/add
系统错误!

watch 的输出如下:

method=com.example.demo.service.DemoService.add location=AtExceptionExit
ts=2023-09-11 08:05:20; [cost=0.1402ms] result=@ArrayList[
    @Object[][
        @DemoAdd[DemoAdd(x=1, y=null)],
    ],
    @DemoService[
    ],
    null,
]

可以看到 location=AtExceptionExit 表示这个方法抛出了异常,同样地,result 默认只监听方法的入参、方法所在的实例对象、以及方法的返回值。那么能不能拿到具体的异常信息呢?当然可以,通过自定义观察表达式可以轻松实现。

默认情况下,watch 命令使用的观察表达式为 {params, target, returnObj},所以输出结果里并没有异常信息,我们将观察表达式改为 {params, target, returnObj, throwExp} 重新监听:

$ watch com.example.demo.service.DemoService add "{params, target, returnObj, throwExp}" -x 2

此时就可以输出具体的异常信息了:

method=com.example.demo.service.DemoService.add location=AtExceptionExit
ts=2023-09-11 08:11:19; [cost=0.0961ms] result=@ArrayList[
    @Object[][
        @DemoAdd[DemoAdd(x=1, y=null)],
    ],
    @DemoService[
    ],
    null,
    java.lang.NullPointerException
        at com.example.demo.service.DemoService.add(DemoService.java:11)
        at com.example.demo.controller.DemoController.add(DemoController.java:20)
    ,
]

观察表达式其实是一个 ognl 表达式,可以观察的维度也比较多,参考 表达式核心变量

从上面的例子可以看到,使用 watch 命令有一个很不方便的地方,我们需要提前写好观察表达式,当忘记写表达式或表达式写得不对时,就有可能没有监听到我们的调用,或者虽然监听到调用却没有得到我们想要的内容,这样我们就得反复调试。所以 Arthas 又推出了一个 tt 命令,名为 时空隧道(Time Tunnel)

使用 tt 命令时大多数情况下不用太关注观察表达式,直接监听类方法即可:

$ tt -t com.example.demo.service.DemoService add

tt 会自动地将所有调用都保存下来,直到用户按下 Ctrl+C 结束监听;注意如果方法的调用非常频繁,记得用 -n 参数限制记录的次数,防止记录太多导致内存爆炸:

$ tt -t com.example.demo.service.DemoService add -n 10

当监听结束后,使用 -l 参数查看记录列表:

$ tt -l
 INDEX  TIMESTAMP            COST(ms)  IS-RET  IS-EXP  OBJECT       CLASS                    METHOD                   
------------------------------------------------------------------------------------------------------------
 1000   2023-09-15 07:51:10  0.8111     true   false  0x62726348   DemoService              add
 1001   2023-09-15 07:51:16  0.1017     false  true   0x62726348   DemoService              add

其中 INDEX 列非常重要,我们可以使用 -i 参数指定某条记录查看它的详情:

$ tt -i 1000
 INDEX          1000
 GMT-CREATE     2023-09-15 07:51:10
 COST(ms)       0.8111
 OBJECT         0x62726348
 CLASS          com.example.demo.service.DemoService
 METHOD         add
 IS-RETURN      true
 IS-EXCEPTION   false
 PARAMETERS[0]  @DemoAdd[
                    x=@Integer[1],
                    y=@Integer[2],
                ]
 RETURN-OBJ     @Integer[3]
Affect(row-cnt:1) cost in 0 ms.

从输出中可以看到方法的入参和返回值,如果方法有异常,异常信息也不会丢了:

$ tt -i 1001
 INDEX            1001                                                                                      
 GMT-CREATE       2023-09-15 07:51:16
 COST(ms)         0.1017
 OBJECT           0x62726348
 CLASS            com.example.demo.service.DemoService                                                      
 METHOD           add
 IS-RETURN        false
 IS-EXCEPTION     true
 PARAMETERS[0]    @DemoAdd[
                      x=@Integer[1],                                                                        
                      y=null,
                  ]
                        at com.example.demo.service.DemoService.add(DemoService.java:21)
                        at com.example.demo.controller.DemoController.add(DemoController.java:21)
                        ...
Affect(row-cnt:1) cost in 13 ms.

tt 命令记录了所有的方法调用,方便我们回溯,所以被称为时空隧道,而且,由于它保存了当时调用的所有现场信息,所以我们还可以主动地对一条历史记录进行重做,这在复现某些不常见的 BUG 时非常有用:

$ tt -i 1000 -p
 RE-INDEX       1000
 GMT-REPLAY     2023-09-15 07:52:31
 OBJECT         0x62726348
 CLASS          com.example.demo.service.DemoService
 METHOD         add
 PARAMETERS[0]  @DemoAdd[
                    x=@Integer[1],
                    y=@Integer[2],
                ]
 IS-RETURN      true
 IS-EXCEPTION   false
 COST(ms)       0.1341
 RETURN-OBJ     @Integer[3]
Time fragment[1000] successfully replayed 1 times.

另外,由于 tt 保存了当前环境的对象引用,所以我们甚至可以通过这个对象引用来调用它的方法:

$ tt -i 1000 -w 'target.properties()' -x 2
@DemoProperties[
    title=@String[demo title],
]
Affect(row-cnt:1) cost in 148 ms.

使用 logger 动态更新日志级别

Spring Boot 生产就绪特性 Actuator 这篇笔记中,我们学习过 Spring Boot Actuator 内置了一个 /loggers 端点,可以查看或修改 logger 的日志等级,比如下面这个 POST 请求将 com.example.demo 的日志等级改为 DEBUG

$ curl -s -X POST -d '{"configuredLevel": "DEBUG"}' \
  -H "Content-Type: application/json" \
  http://localhost:8080/actuator/loggers/com.example.demo

使用这种方法修改日志级别可以不重启目标程序,这在线上问题排查时非常有用,但是有时候我们会遇到一些没有开启 Actuator 功能的 Java 程序,这时就可以使用 Arthas 的 logger 命令,实现类似的效果。

直接输入 logger 命令,查看程序所有的 logger 信息:

$ logger
 name                    ROOT
 class                   ch.qos.logback.classic.Logger
 classLoader             org.springframework.boot.loader.LaunchedURLClassLoader@6433a2
 classLoaderHash         6433a2
 level                   INFO
 effectiveLevel          INFO
 additivity              true
 codeSource              jar:file:/D:/demo/target/demo-0.0.1-SNAPSHOT.jar!/BOOT
                         -INF/lib/logback-classic-1.2.10.jar!/
 appenders               name            CONSOLE
                         class           ch.qos.logback.core.ConsoleAppender
                         classLoader     org.springframework.boot.loader.LaunchedURLClassLoader@6433a2
                         classLoaderHash 6433a2
                         target          System.out

默认情况下只会打印有 appender 的 logger 信息,可以加上 --include-no-appender 参数打印所有的 logger 信息,不过这个输出会很长,通常使用 -n 参数打印指定 logger 的信息:

$ logger -n com.example.demo
 name                    com.example.demo
 class                   ch.qos.logback.classic.Logger
 classLoader             org.springframework.boot.loader.LaunchedURLClassLoader@6433a2
 classLoaderHash         6433a2
 level                   null
 effectiveLevel          INFO
 additivity              true
 codeSource              jar:file:/D:/demo/target/demo-0.0.1-SNAPSHOT.jar!/BOOT 
                         -INF/lib/logback-classic-1.2.10.jar!/

可以看到 com.example.demo 的日志级别是 null,说明并没有设置,我们可以使用 -l 参数来修改它:

$ logger -n com.example.demo -l debug
Update logger level fail. Try to specify the classloader with the -c option. 
Use `sc -d CLASSNAME` to find out the classloader hashcode.

需要注意的是,默认情况下,logger 命令会在 SystemClassloader 下执行,如果应用是传统的 war 应用,或者是 Spring Boot 的 fat jar 应用,那么需要指定 classloader。在上面执行 logger -n 时,输出中的 classLoader 和 classLoaderHash 这两行很重要,我们可以使用 -c <classLoaderHash> 来指定 classloader:

$ logger -n com.example.demo -l debug -c 6433a2
Update logger level success.

也可以直接使用 --classLoaderClass <classLoader> 来指定 classloader:

$ logger -n com.example.demo -l debug --classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader
Update logger level success.

使用 ognl 查看系统属性和应用配置

有时候我们会在线上环境遇到一些莫名奇妙的问题:比如明明数据库地址配置得好好的,但是程序却报数据库连接错误;又或者明明在配置中心对配置进行了修改,但是程序中却似乎始终不生效;这时我们不确定到底是程序本身逻辑的问题,还是程序没有加载到正确的配置,如果能将程序加载的配置信息打印出来,这个问题就很容易排查了。

如果程序使用了 System.getenv() 来获取环境变量,我们可以使用 sysenv 来进行确认:

$ sysenv JAVA_HOME
 KEY                          VALUE 
---------------------------------------------------------------
 JAVA_HOME                    C:\Program Files\Java\jdk1.8.0_351

如果程序使用了 System.getProperties() 来获取系统属性,我们可以使用 sysprop 来进行确认:

$ sysprop file.encoding
 KEY                          VALUE  
-----------------------------------
 file.encoding                GBK

如果发现系统属性的值有问题,可以使用 sysprop 对其动态修改:

$ sysprop file.encoding UTF-8
Successfully changed the system property.
 KEY                          VALUE  
-----------------------------------
 file.encoding                UTF-8

实际上,无论是 sysenv 还是 sysprop,我们都可以使用 ognl 命令实现:

$ ognl '@System@getenv("JAVA_HOME")'
@String[C:\Program Files\Java\jdk1.8.0_351]
$ 
$ ognl '@System@getProperty("file.encoding")'
@String[UTF-8]

OGNL 是 Object Graphic Navigation Language 的缩写,表示对象图导航语言,它是一种表达式语言,用于访问对象属性、调用对象方法等,它被广泛集成在各大框架中,如 Struts2、MyBatis、Thymeleaf、Spring Web Flow 等。

除了环境变量和系统属性,应用程序本身的配置文件也常常需要排查,在 Spring Boot 程序中,应用配置非常灵活,当存在多个配置文件时,往往搞不清配置是否生效了。这时我们也可以通过 ognl 命令来查看配置,不过使用 ognl 有一个限制,它只能访问静态方法,所以我们在代码中要实现一个 SpringUtils.getBean() 静态方法,这个方法通过 ApplicationContext 来获取 Spring Bean:

@Component
public class SpringUtils implements ApplicationContextAware {

    private static ApplicationContext CONTEXT;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        CONTEXT = applicationContext;
    }
    
    public static Object getBean(String beanName) {
        return CONTEXT.getBean(beanName);
    }
}

这样我们就可以通过 ognl 来查看应用程序的配置类了:

$ ognl '@com.example.demo.utils.SpringUtils@getBean("demoProperties")'
@DemoProperties[
    title=@String[demo title],
]

那么如果我们的代码中没有 SpringUtils.getBean() 这样的静态方法怎么办呢?

在上面我们学到 Arthas 里有一个 tt 命令,可以记录方法调用的所有现场信息,并可以使用 ognl 表达式对现场信息进行查看;这也就意味着我们可以调用监听目标对象的方法,如果监听目标对象有类似于 getBean()getApplicationContext() 这样的方法,那么我们就可以间接地获取到 Spring Bean。在 Spring MVC 程序中,RequestMappingHandlerAdapter 就是这样绝佳的一个监听对象,每次处理请求时都会调用它的 invokeHandlerMethod() 方法,我们对这个方法进行监听:

$ tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 1) cost in 43 ms, listenerId: 2
 INDEX  TIMESTAMP            COST(ms)  IS-RET  IS-EXP  OBJECT       CLASS                    METHOD
------------------------------------------------------------------------------------------------------------
 1002   2023-09-15 07:59:27  3.5448     true   false   0x57023e7    RequestMappingHandlerAd  invokeHandlerMethod

因为这个对象有 getApplicationContext() 方法,所以可以通过 tt -w 来调用它,从而获取配置 Bean 的内容:

$ tt -i 1002 -w 'target.getApplicationContext().getBean("demoProperties")'
@DemoProperties[
    title=@String[demo title],
]
Affect(row-cnt:1) cost in 3 ms.

使用 jad/sc/retransform 热更新代码

有时候我们排查出问题原因后,发现只需一个小小的改动就可以修复问题,可能是加一行判空处理,或者是修复一处逻辑错误;又或者问题太复杂一时排查不出结果,需要加几行调试代码来方便问题的定位;如果修改代码,再重新发布到生产环境,耗时会非常长,而且重启服务也可能会影响到当前的用户。在比较紧急的情况下,热更新功能就可以排上用场了,不用重启服务就能在线修改代码逻辑。

热更新代码一般分为下面四个步骤:

第一步,使用 jad 命令将要修改的类反编译成 .java 文件:

$ jad --source-only com.example.demo.service.DemoService > /tmp/DemoService.java

第二步,修改代码:

$ vi /tmp/DemoService.java

比如我们在 add() 方法中加入判空处理:

           public Integer add(DemoAdd demoAdd) {
                if (demoAdd.getX() == null) {
                    demoAdd.setX(0);
                }
                if (demoAdd.getY() == null) {
                    demoAdd.setY(0);
                }
/*20*/         log.debug("x = {}, y = {}", (Object)demoAdd.getX(), (Object)demoAdd.getY());
/*21*/         return demoAdd.getX() + demoAdd.getY();
           }

第三步,使用 mc 命令将修改后的 .java 文件编译成 .class 字节码文件:

$ mc /tmp/DemoService.java -d /tmp
Memory compiler output:
D:\tmp\com\example\demo\service\DemoService.class
Affect(row-cnt:1) cost in 1312 ms.

mc 命令有时会失败,这时我们可以在本地开发环境修改代码,并编译成 .class 文件,再上传到服务器上。

最后一步,使用 redefineretransform 对类进行热更新:

$ retransform /tmp/com/example/demo/service/DemoService.class
retransform success, size: 1, classes:
com.example.demo.service.DemoService

redefineretransform 都可以热更新,但是 redefinejad / watch / trace / monitor / tt 等命令冲突,所以推荐使用 retransform 命令。热更新成功后,使用异常请求再请求一次,现在不会报系统错误了:

$ curl -X POST -H "Content-Type: application/json" -d '{"x":1}' http://localhost:8080/add
1

如果要还原所做的修改,那么只需要删除这个类对应的 retransform entry,然后再重新触发 retransform 即可:

$ retransform --deleteAll
$ retransform --classPattern com.example.demo.service.DemoService

不过要注意的是,Arthas 的热更新也并非无所不能,它也有一些限制,比如不能修改、添加、删除类的字段和方法,只能在原来的方法上修改逻辑。

另外,在生产环境热更新代码并不是很好的行为,而且还非常危险,一定要严格地控制,上线规范也同样重要。

其他使用场景

Arthas 的使用非常灵活,有时候甚至还会有一些意想不到的功能,除了上面这些使用场景,Arthas 的 Issues 中还收集了一些 用户案例,其中有几个案例对我印象很深,非常有启发性,可供参考。

参考

更多

深入 Arthas 实现原理

扫描二维码,在手机上阅读!

使用 Google Colab 体验 AI 绘画

AIGC 的全称为 AI Generated Content,是指利用人工智能技术来生成内容,被认为是继 PGC(Professionally Generated Content,专业生成内容)和 UGC(User Generated Content,用户生成内容)之后的一种新型内容创作方式。目前,这种创作方式一般可分为两大派别:一个是以 OpenAIChatGPTGPT-4、Facebook 的 LLaMA、斯坦福的 Alpaca大语言模型 技术为代表的文本生成派,另一个是以 Stability AIStable DiffusionMidjourney、OpenAI 的 DALL·E 2扩散模型 技术为代表的图片生成派。

在文本生成方面,目前 AI 已经可以和用户聊天,回答各种问题,而且可以基于用户的要求进行文本创作,比如写文案、写邮件、写小说等;在图片生成方面,AI 的绘画水平也突飞猛进,目前 AI 已经可以根据用户的提示词创作出各种不同风格的绘画作品,而且可以对图片进行风格迁移、自动上色、缺损修复等,AI 生成的作品几乎可以媲美专业画师,生成作品的效率越来越高,而生成作品的成本却越来越低,这让 AI 绘画技术得以迅速普及,让普通用户也可以体验专业画师的感觉,我从小就很特别羡慕那些会画画的人,现在就可以借助 AI 技术让我实现一个画家的梦。

AI 绘画的发展历史

2014 年 10 月,Ian J. Goodfellow 等人发表了一篇论文 《Generative Adversarial Networks》,在论文中提出了一种新的深度学习算法 GAN(生成式对抗网络),这个算法包含两个模型:生成模型(Generative Model,简称 G 模型)和 判别模型(Discriminative Model,简称 D 模型),在训练过程中,G 模型的目标是尽量生成以假乱真的图片去欺骗 D 模型,而 D 模型的目标是判断 G 模型生成的图片是不是真实的,这样,G 模型和 D 模型就构成了一个动态的博弈过程,仿佛老顽童周伯通的左右手互搏一样,当 D 模型无法判断输入的图片是 G 模型生成的还是真实的时候,G 模型和 D 模型的训练就达到了平衡,这时我们得到的 G 模型就可以生成以假乱真的图片了。

不过由于 GAN 算法包含了两个模型,稳定性较差,可能出现有趣的 海奥维提卡现象(the helvetica scenario),如果 G 模型发现了一个能够骗过 D 模型的 bug,它就会开始偷懒,一直用这张图片来欺骗 D 模型,导致整个平衡的无效。在 2020 年,Jonathan Ho 等人发表论文 《Denoising Diffusion Probabilistic Models》,提出了一种新的 扩散模型(Diffusion Model),相比 GAN 来说,扩散模型的训练更稳定,而且能够生成更多样的样本,一时间扩散模型在 AI 圈里迅速起飞,2021 年 11 月 OpenAI 推出 DALL·E,2022 年 3 月,David Holz 推出 Midjourney,5 月 Google Brain 推出 Imagen,都是基于扩散模型实现的。

到了 2022 年 8 月,Stability AI 开发出 Stable Diffusion 模型,相比于之前的商业产品,Stable Diffusion 是一个完全开源的模型,无论是代码还是权重参数库都对所有人开放使用,而且 Stable Diffusion 对资源的消耗大幅降低,消费级显卡就可以驱动,大大降低了 AI 绘画的门槛,普通人也可以在他们的电脑上体验 AI 绘画的乐趣。到了 10 月,游戏设计师 Jason Allen 使用 AI 绘画工具 Midjourney 生成的一幅名为《太空歌剧院》的作品在美国科罗拉多州举办的艺术博览会上获得数字艺术类冠军,引起了一波不小的争论,也让 AI 绘画再一次成为热门话题,之后各大公司和团队纷纷入局,各种 AI 绘画工具如雨后春笋般冒了出来。

正因为如此,有人将 2022 年称为 AI 绘画元年。

选择 GPU

虽说 Stable Diffusion 的门槛已经被大大降低了,但还是有一定门槛的,因为运行 Stable Diffusion 要配备一张 GPU 显卡,可以使用 NVIDIA 卡(俗称 N 卡)或 AMD 卡(俗称 A 卡),不过主流的推理框架都使用了 NVIDIA 的 CUDA 工具包,所以一般都推荐使用 N 卡。GPU 显卡价格不菲,可以参考驱动之家的 桌面显卡性能天梯图 进行选购,除非你是资深的游戏玩家或者深度学习的爱好者,大多数家用电脑上都不具备这个条件。

也可以使用各大公有云厂商推出的 GPU 云服务器,比如 阿里云腾讯云华为云百度智能云 等,但是价格也都不便宜,比较适合中小企业,对于那些刚对深度学习感兴趣,希望尝试一些深度学习项目的小白个人用户来说,就不划算了。

好在网上有很多 白嫖 GPU 的攻略,国外的有 Google Colab 和 Kaggle,它们都提供了 V100、P100、T4 等主流显卡,可以免费使用 12 个小时,超时之后会自动清理;国内的有阿里的天池,相比来说磁盘和使用时间稍短一点,不过对于新人入门来说也足够了;另外还有百度的 AI Studio 和 趋动云 等产品,它们可以通过打卡做任务等形式赚取 GPU 算力,在 GPU 不够用时不妨一试。下面是网上找的一些使用教程,供参考:

Google Colab 入门

综合对比下来,Google Colab 的使用体验最好,Google Colab 又叫作 Colaboratory,简称 Colab,中文意思是 合作实验室,正如其名,它可以帮助用户在浏览器中编写和执行 Python 代码,无需任何配置就可以进行一些数据科学或机器学习的实验,借助 Jupyter 交互式笔记本,实验过程和结果也可以轻松分享给其他用户。很多开源项目都提供了 Colab 脚本,可以直接运行体验,这一节将以 Colab 为例,介绍它的基本使用方法。

首先在浏览器输入 colab.research.google.com 访问 Colab 首页:

colab-home.png

首页上对 Colab 有个简单的介绍,还提供了一些数据科学和机器学习的入门例子和学习资源。我们通过左上角的 文件 -> 新建笔记本 菜单项创建一个新的笔记本:

new-notebook.png

然后点击 修改 -> 笔记本设置 将硬件加速器调整为 GPU:

notebook-setting.png

然后点击右上角的 连接 按钮,Google 会动态地为我们分配计算资源,稍等片刻,我们就相当于拥有了一台 12.7 G 内存,78.2 G 磁盘,且带 GPU 显卡的主机了:

resource-info.png

Colab 的基本使用

在这个笔记本中,我们可以编写 Markdown 文档,也可以编写和执行 Python 代码:

execute-python-code.png

甚至可以在命令前加个 ! 来执行 Shell 命令:

execute-shell.png

这个环境里内置了很多常用的数据科学或机器学习的 Python 库,比如 numpy、pandas、matplotlib、scikit-learn、tensorflow 等:

pip-list.png

另外,由于这是一台 GPU 主机,我们还可以使用 nvidia-smi 来查看显卡信息:

nvidia-smi.png

可以看到,我们免费得到了一张 Tesla T4 的显卡,显存大约 15G 左右。

测试 GPU 速度

接下来,我们测试下这个 GPU 的速度。首先通过 TensorFlow 的 tf.test.gpu_device_name() 获取 GPU 设备的名称:

gpu-device-name.png

然后编写两个方法:

gpu-vs-cpu.png

这两个方法所做的事情是一样的,只不过一个使用 CPU 来运行,另一个使用 GPU 来运行。在这个方法中,先使用 tf.random.normal((100, 100, 100, 3)) 随机生成一个 100*100*100*3 的四维张量,然后使用 tf.keras.layers.Conv2D(32, 7)(random_image_cpu) 对这个张量计算卷积,卷积过滤器数量 filters 为 32,卷积窗口 kernel_size7*7,最后使用 tf.math.reduce_sum(net_cpu) 对卷积结果求和。

接下来第一次执行,并使用 timeit 来计时:

gpu-vs-cpu-first-run.png

可以看到,在 GPU 上的执行速度比 CPU 上的要慢一点,这是因为 TensorFlow 第一次运行时默认会使用 cuDNN 的 autotune 机制对计算进行预热。

我们再执行第二次:

gpu-vs-cpu-second-run.png

这时,在 GPU 上的执行速度明显快多了,相比于 CPU 来说,速度有着 50 多倍的提升。

这里是 这一节的完整代码

在 Google Colab 里运行 Stable Diffusion

2023 年 4 月 21 日,Google Colab 官方发了一份声明,由于 Stable Diffusion 太火了,消耗了 Google Colab 大量的 GPU 资源,导致预算不够,现在已经被封了,只有付费用户才能运行,免费用户运行会有警告:

colab-warning.png

对 Google Colab 有一定了解后,我们就可以免费使用它的 GPU 来做很多有趣的事情了,比如我想要运行 Stable Diffusion 来体验 AI 绘画。

camenduru/stable-diffusion-webui-colab 这个项目整理了大量 Stable Diffusion 的 Colab 脚本,基于 AUTOMATIC1111/stable-diffusion-webui 实现了可视化 Web 页面,集成了 Hugging FaceCivitai 上热门的模型和插件,我们随便选择一个,点击左侧的 stable 打开 Colab 页面执行即可:

sd-webui-colab.png

运行成功后,在控制台中可以看到打印了几个随机生成的外网链接:

running.png

随便选择一个链接打开,进入 Stable Diffusion WebUI 页面:

webui.png

接下来,开始你的 AI 绘画之旅吧!

参考

更多

文本生成派

图片生成派

扫描二维码,在手机上阅读!

使用 RSSHub 为任意网址生成订阅源

最近在学习 APISIX 时,发现它的 官方博客 有不少的干货内容,于是想着能在我的阅读器里订阅这个博客的更新,不过找了半天都没有找到这个博客的订阅入口,后来在博客的页面代码里找到了 rss.xml 和 atom.xml 两个订阅链接,不过打开一看全都是 404 Page Not Found

其实遇到这种情况,也有不少的解决方法,有很多网站提供了 RSS 生成的功能,比如 RSS.appFetchRSSfeed43 等都提供了免费的 RSS 源转换功能,不过这些工具要么使用起来不太好用,要么访问速度巨慢,要么就是有各种各样的限制。于是便想实现一个自己的 RSS 生成服务,正好前几天看到了一个叫做 RSSHub 的项目,这是一个开源、简单易用、易于扩展的 RSS 生成器,口号是 万物皆可 RSS,可以给任何奇奇怪怪的内容生成 RSS 订阅源,而且看社区也挺活跃,于是就利用周末时间折腾一下,使用 RSSHub 搭建了一个自己的 RSS 生成服务。

快速开始

RSSHub 和那些在线的 RSS 生成服务不一样,它是通过编写扩展的方式来添加新的 RSS 订阅源。不过在编写自己的扩展之前,可以先到官网上搜索一下,看看有没有其他人已经写过了,官网上目前已经适配了数百家网站的上千项内容。由于我要订阅的 APSIX 博客比较小众,目前还没有人写过,所以就只能自己动手了。

RSSHub 是基于 Node.js 实现的,所以先确保机器上已经安装了 Node.js 运行环境:

$ node -v
v16.14.2

以及包管理器 Npm 或 Yarn,我这里使用的是 Npm:

$ npm -v
8.5.0

然后,下载 RSSHub 的源码:

$ git clone https://github.com/DIYgod/RSSHub.git

进入 RSSHub 的根目录,运行以下命令安装依赖:

$ cd RSSHub
$ npm install

依赖安装成功后,运行以下命令在本地启动 RSSHub:

$ npm run dev

启动成功后,在浏览器中打开 http://localhost:1200 就可以看到 RSSHub 的首页了:

rsshub-homepage.png

新建路由

此时 RSSHub 内置的上千个订阅源,都可以在本地访问,比如通过 /bilibili/ranking/0/3/1 这个地址可以订阅 B 站三天内的排行榜。这个订阅源的格式一般分为三个部分:

/命名空间/路由/参数

新建命名空间

命名空间应该和 RSS 源网站的二级域名相同,所以 B 站的命名空间为 bilibili,而我们要新建的 APISIX 博客地址为 apisix.apache.org/zh/blog,所以命名空间应该为 apache

每个命名空间对应 lib/v2 目录下的一个子文件夹,所以我们在这个目录下创建一个 apache 子文件夹:

$ mkdir lib/v2/apache

注册路由

第二步,我们需要在命名空间子文件夹下按照 RSSHub 的 路由规范 来组织文件,一个典型的文件夹结构如下:

├───lib/v2
│   ├───furstar
│       ├─── templates
│           ├─── description.art
│       ├─── router.js
│       ├─── maintainer.js
│       ├─── radar.js
│       └─── someOtherJs.js

其中,每个文件的作用如下:

  • router.js - 注册路由
  • maintainer.js - 提供路由维护者信息
  • radar.js - 为每个路由提供对应 RSSHub Radar 规则
  • someOtherJs.js - 一些其他的代码文件,一般用于实现路由规则
  • templates - 该目录下是以 .art 结尾的模版文件,它使用 art-template 进行排版,用于渲染自定义 HTML 内容

编写 router.js 文件

其中最重要的一个文件是 router.js,它用于注册路由信息,我们创建该文件,内容如下:

module.exports = (router) => {
    router.get('/apisix/blog', require('./apisix/blog'));
};

RSSHub 使用 @koa/router 来定义路由,在上面的代码中,我们通过 router.get() 定义了一个 HTTP GET 的路由,第一个参数是路由路径,它需要符合 path-to-regexp 语法,第二个参数指定由哪个文件来实现路由规则。

在路由路径中,我们还可以使用参数,比如上面 bilibili 的路由如下:

router.get('/ranking/:rid?/:day?/:arc_type?/:disableEmbed?', require('./ranking'));

其中 :rid:days:arc_type:disableEmbed 都是路由的参数,每个参数后面的 ? 表示这是一个可选参数。路由参数可以从 ctx.params 对象中获取。

编写 maintainer.js 文件

maintainer.js 文件用于提供路由维护者信息,当用户遇到 RSS 路由的问题时,他们可以联系此文件中列出的维护者:

module.exports = {
    '/apisix/blog': ['aneasystone'],
};

编写路由规则

接下来我们就可以实现路由规则了。首先我们需要访问指定网址来获取数据,RSSHub 提供了两种方式来获取数据:

  • 对于一些简单的 API 接口或网页,可以直接使用 got 发送 HTTP 请求获取数据;
  • 对于某些反爬策略很严的网页,可能需要使用 puppeteer 模拟浏览器打开网页来获取数据。

这其实就是爬虫技术,我们获取的数据通常是 JSON 或 HTML 格式,如果是 HTML 格式,RSSHub 提供了 cheerio 方便我们进一步处理。

上面在注册路由时我们指定了路由规则文件为 ./apisix/blog,所以接下来,创建 ./apisix/blog.js 文件。路由规则实际上就是生成 ctx.state.data 对象,这个对象包含三个字段:

  • title - 源标题
  • link - 源链接
  • item - 源文章

我们先编写一个最简单的路由规则,文件内容如下:

module.exports = async (ctx) => {
    ctx.state.data = {
        title: `Blog | Apache APISIX`,
        link: `https://apisix.apache.org/zh/blog/`,
        item: [{}],
    };
};

这时虽然源文章列表还是空的,但是我们已经可以通过 http://localhost:1200/apache/apisix/blog 地址来访问我们创建的 RSS 源了:

apache-apisix-blog-rss.png

只不过源文章中的 titledescription 都是 undefined

接下来要做的事情就是如何获取源文章了,很显然,我们需要访问 APISIX 的博客,并从页面 HTML 中解析出源文章。首先使用 got 发送 HTTP 请求获取页面 HTML:

const url = 'https://apisix.apache.org/zh/blog/';
const { data: res } = await got(url);

得到 HTML 之后,使用 cheerio 对其进行解析:

const $ = cheerio.load(res);

cheerio 有点类似于 jQuery,可以通过 CSS 选择器对 HTML 进行解析和提取,我们可以很方便地在页面中提取出源文章列表:

const articles = $('section.sec_gjjg').eq(1).find('article');
const results = [];
articles.each((i, elem) => {
    const a = $(elem).find('header > a');
    const time = $(elem).find('footer').find('time').text();
    const author = $(elem).find('footer').find('img').attr('src');
    results.push({
        title: a.find('h2').text(),
        description: a.find('p').text(),
        link: a.attr('href'),
        pubDate: timezone(parseDate(time, 'YYYY年M月D日'), +8),
        author,
    });
});
return results;

每个源文章包含以下几个字段:

  • title - 文章标题
  • link - 文章链接
  • description - 文章正文
  • pubDate - 文章发布日期
  • author - 文章作者(可选)
  • category - 文章分类(可选)

至此,我们的路由规则就创建好了,可以在浏览器中对我们的路由进行验证和调试。我们这里的路由规则比较简单,稍微复杂一点的例子可以参考 RSSHub 官方文档 制作自己的 RSSHub 路由使用缓存日期处理。另外,lib/v2 目录下有很多其他人编写的路由规则,也是很好的参考资料。

其他工作

实现自己的订阅源之后,还可以编写 radar.js 文件,为每个路由提供对应 RSSHub Radar 规则。RSSHub Radar 是 RSSHub 的一款浏览器插件,方便用户查找某个网站是否存在 RSS 订阅源。最后为你的路由 添加相应的文档,一个订阅源就开发完成了。

不过如果只是自己部署使用,这些工作也可以跳过。

部署

最后,将 RSSHub 部署到自己的服务器上,官方提供了几种 部署方式,比较推荐的是 Docker 或 Docker Compose 部署。

我这里使用 Docker 来简化部署流程。由于我希望将 Redis 作为我的 RSSHub 缓存,这样可以保证每次重启 RSSHub 之后缓存不会失效。首先启动一个 Redis 实例:

$ docker run -d -p 6379:6379 redis:alpine

然后启动 RSSHub 即可:

$ docker run --name rsshub \
    -d -p 1200:1200 \
    -e NODE_ENV=production \
    -e CACHE_TYPE=redis \
    -e REDIS_URL=redis://172.18.0.1:6379/ \
    -v /root/rsshub/v2:/app/lib/v2 \
    diygod/rsshub

注意我们将 lib/v2 目录挂载进容器,这样才能让我们的订阅源生效。我制作了三个 RSS 订阅源,有需要的小伙伴可以自取:

参考

扫描二维码,在手机上阅读!

实战 APISIX 服务发现

APISIX 使用小记 中,我们通过 APISIX 官方提供的入门示例学习了 APISIX 的基本概念,并使用 Admin API 和 Dashboard 两种方法创建路由。在创建路由时,我们必须明确地知道服务的 IP 和端口,这给运维人员带来了一定的负担,因为服务的重启或扩缩容都可能会导致服务的 IP 和端口发生变化,当服务数量非常多的时候,维护成本将急剧升高。

APISIX 集成了多种服务发现机制来解决这个问题,通过服务注册中心,APISIX 可以动态地获取服务的实例信息,这样我们就不用在路由中写死固定的 IP 和端口了。

如下图所示,一个标准的服务发现流程大致包含了三大部分:

discovery-cn.png

  1. 服务启动时将自身的一些信息,比如服务名、IP、端口等信息上报到注册中心;各个服务与注册中心使用一定机制(例如心跳)通信,如果注册中心与服务长时间无法通信,就会注销该实例;当服务下线时,会删除注册中心的实例信息;
  2. 网关会准实时地从注册中心获取服务实例信息;
  3. 当用户通过网关请求服务时,网关从注册中心获取的实例列表中选择一个进行代理;

目前市面上流行着很多注册中心,比如 Eureka、Nacos、Consul 等,APISIX 内置了下面这些服务发现机制:

基于 Eureka 的服务发现

Eureka 是 Netflix 开源的一款注册中心服务,它也被称为 Spring Cloud Netflix,是 Spring Cloud 全家桶中的核心成员。本节将演示如何让 APISIX 通过 Eureka 来实现服务发现,动态地获取下游服务信息。

启动 Eureka Server

我们可以直接运行官方的示例代码 spring-cloud-samples/eureka 来启动一个 Eureka Server:

$ git clone https://github.com/spring-cloud-samples/eureka.git
$ cd eureka && ./gradlew bootRun

或者也可以直接使用官方制作好的镜像:

$ docker run -d -p 8761:8761 springcloud/eureka

启动之后访问 http://localhost:8761/ 看看 Eureka Server 是否已正常运行。

启动 Eureka Client

如果一切顺利,我们再准备一个简单的 Spring Boot 客户端程序,引入 spring-cloud-starter-netflix-eureka-client 依赖,再通过 @EnableEurekaClient 注解将服务信息注册到 Eureka Server:

@EnableEurekaClient
@SpringBootApplication
@RestController
public class EurekaApplication {

    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class, args);
    }

    @RequestMapping("/")
    public String home() {
        return String.format("Hello, I'm eureka client.");
    }
}

在配置文件中设置服务名称和服务端口:

spring.application.name=eureka-client
server.port=8081

默认注册的 Eureka Server 地址是 http://localhost:8761/eureka/,可以通过下面的参数修改:

eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/

默认情况下,Eureka Client 是将该服务所在的主机名注册到 Eureka Server,这在某些情况下可能会导致其他服务调不通该服务,我们可以通过下面的参数,让 Eureka Client 注册 IP 地址:

eureka.instance.prefer-ip-address=true
eureka.instance.ip-address=192.168.1.40

启动后,在 Eureka 页面的实例中可以看到我们注册的服务:

eureka-instances.png

APISIX 集成 Eureka 服务发现

接下来,我们要让 APISIX 通过 Eureka Server 找到我们的服务。首先,在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  eureka:
    host:
      - "http://192.168.1.40:8761"
    prefix: /eureka/

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/11 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/eureka",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/eureka", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "eureka",
        "service_name": "EUREKA-CLIENT"
    }
}'

之前创建路由时,我们在 upstream 中通过 nodes 参数表示上游服务器节点,这里我们不再需要写死服务器节点信息,而是通过 "discovery_type": "eureka""service_name": "EUREKA-CLIENT" 来让 APISIX 使用 eureka 服务发现机制,上游的服务名称为 EUREKA-CLIENT

值得注意的是,虽然上面的 Eureka Client 的 spring.application.name 是小写,但是注册到 Eureka Server 的服务名称是大写,所以这里的 service_name 参数必须是大写。此外,这里我们还使用了 proxy-rewrite 插件,它相当于 Nginx 中的路径重写功能,当多个上游服务的接口地址相同时,通过路径重写可以将它们区分开来。

访问 APISIX 的 /eureka 地址验证一下:

$ curl http://127.0.0.1:9080/eureka
Hello, I'm eureka client.

我们成功通过 APISIX 访问到了我们的服务。

关于 APISIX 集成 Eureka 的更多信息,可以参考官方文档 集成服务发现注册中心 和官方博客 API 网关 Apache APISIX 集成 Eureka 作为服务发现

基于 Nacos 的服务发现

Nacos 是阿里开源的一款集服务发现、配置管理和服务管理于一体的管理平台,APISIX 同样支持 Nacos 的服务发现机制。

启动 Nacos Server

首先,我们需要准备一个 Nacos Server,Nacos 官网提供了多种部署方式,可以 通过源码或安装包安装通过 Docker 安装通过 Kubernetes 安装,我们这里直接使用 docker 命令启动一个本地模式的 Nacos Server:

$ docker run -e MODE=standalone -p 8848:8848 -p 9848:9848 -d nacos/nacos-server:v2.2.0

不知道为什么,有时候启动会报这样的错误:com.alibaba.nacos.api.exception.runtime.NacosRuntimeException: errCode: 500, errMsg: load derby-schema.sql error,多启动几次又可以了。

启动成功后,访问 http://localhost:8848/nacos/ 进入 Nacos 管理页面,默认用户名和密码为 nacos/nacos

nacos-home.png

启动 Nacos Client

接下来,我们再准备一个简单的 Spring Boot 客户端程序,引入 nacos-discovery-spring-boot-starter 依赖,并通过它提供的 NameService 将服务信息注册到 Nacos Server:

@SpringBootApplication
@RestController
public class NacosApplication implements CommandLineRunner {

    @Value("${spring.application.name}")
    private String applicationName;

    @Value("${server.port}")
    private Integer serverPort;
    
    @NacosInjected
    private NamingService namingService;
    
    public static void main(String[] args) {
        SpringApplication.run(NacosApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        namingService.registerInstance(applicationName, "192.168.1.40", serverPort);
    }

    @RequestMapping("/")
    public String home() {
        return String.format("Hello, I'm nacos client.");
    }
}

在配置文件中设置服务名称和服务端口:

spring.application.name=nacos-client
server.port=8082

以及 Nacos Server 的地址:

nacos.discovery.server-addr=127.0.0.1:8848

启动后,在 Nacos 的服务管理页面中就可以看到我们注册的服务了:

nacos-service-management.png

APISIX 集成 Nacos 服务发现

接下来,我们要让 APISIX 通过 Nacos Server 找到我们的服务。首先,在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  nacos:
    host:
      - "http://192.168.1.40:8848"
    prefix: "/nacos/v1/"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/22 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/nacos",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/nacos", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "nacos",
        "service_name": "nacos-client"
    }
}'

和上面 Eureka 服务发现的例子一样,我们也使用 proxy-rewrite 插件实现了路径重写功能,访问 APISIX 的 /nacos 地址验证一下:

$ curl http://127.0.0.1:9080/nacos
Hello, I'm nacos client.

我们成功通过 APISIX 访问到了我们的服务。

关于 APISIX 集成 Nacos 的更多信息,可以参考官方文档 基于 Nacos 的服务发现 和官方博客 Nacos 在 API 网关中的服务发现实践

基于 Consul 的服务发现

Consul 是由 HashiCorp 开源的一套分布式系统的解决方案,同时也可以作为一套服务网格解决方案,提供了丰富的控制平面功能,包括:服务发现、健康检查、键值存储、安全服务通信、多数据中心等。

启动 Consul Server

Consul 使用 Go 语言编写,安装和部署都非常简单,官方提供了 Consul 的多种安装方式,包括 二进制安装Kubernetes 安装HCP 安装。这里我们使用最简单的二进制安装方式,这种方式只需要执行一个可执行文件即可,首先,我们从 Install Consul 页面找到对应操作系统的安装包并下载:

$ curl -LO https://releases.hashicorp.com/consul/1.15.1/consul_1.15.1_linux_amd64.zip
$ unzip consul_1.15.1_linux_amd64.zip

下载并解压之后,Consul 就算安装成功了,使用 consul version 命令进行验证:

$ ./consul version
Consul v1.15.1
Revision 7c04b6a0
Build Date 2023-03-07T20:35:33Z
Protocol 2 spoken by default, understands 2 to 3 (agent will automatically use protocol >2 when speaking to compatible agents)

Consul 安装完成后,就可以启动 Consul Agent 了,Consul Agent 有 -server-client 两种模式,-client 一般用于服务网格等场景,这里我们通过 -server 模式启动:

$ ./consul agent -server -ui -bootstrap-expect=1 -node=agent-one -bind=127.0.0.1 -client=0.0.0.0 -data-dir=./data_dir
==> Starting Consul agent...
              Version: '1.15.1'
           Build Date: '2023-03-07 20:35:33 +0000 UTC'
              Node ID: '8c1ccd5a-69b3-4c95-34c1-f915c19a3d08'
            Node name: 'agent-one'
           Datacenter: 'dc1' (Segment: '<all>')
               Server: true (Bootstrap: true)
          Client Addr: [0.0.0.0] (HTTP: 8500, HTTPS: -1, gRPC: -1, gRPC-TLS: 8503, DNS: 8600)
         Cluster Addr: 127.0.0.1 (LAN: 8301, WAN: 8302)
    Gossip Encryption: false
     Auto-Encrypt-TLS: false
            HTTPS TLS: Verify Incoming: false, Verify Outgoing: false, Min Version: TLSv1_2
             gRPC TLS: Verify Incoming: false, Min Version: TLSv1_2
     Internal RPC TLS: Verify Incoming: false, Verify Outgoing: false (Verify Hostname: false), Min Version: TLSv1_2

==> Log data will now stream in as it occurs:

其中 -ui 表示开启内置的 Web UI 管理界面,-bootstrap-expect=1 表示服务器希望以 bootstrap 模式启动,-node=agent-one 用于指定节点名称,-bind=127.0.0.1 这个地址用于 Consul 集群内通信,-client=0.0.0.0 这个地址用于 Consul 和客户端之间的通信,包括 HTTP 和 DNS 两种通信方式,-data-dir 参数用于设置数据目录。关于 consul agent 更多的命令行参数,可以参考 Agents OverviewAgents Command-line Reference

简单起见,我们也可以使用 -dev 参数以开发模式启动 Consul Agent:

$ ./consul agent -dev

如果 Consul Agent 启动成功,访问 http://localhost:8500/ 进入 Consul 的管理页面,在服务列表可以看到 consul 这个服务:

consul-services.png

在节点列表可以看到 agent-one 这个节点:

consul-nodes.png

启动 Consul Client

让我们继续编写 Consul Client 程序,引入 spring-cloud-starter-consul-discovery 依赖,并通过 @EnableDiscoveryClient 注解将服务信息注册到 Consul Server:

@EnableDiscoveryClient
@SpringBootApplication
@RestController
public class ConsulApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(ConsulApplication.class, args);
    }

    @RequestMapping("/")
    public String home() {
        return String.format("Hello, I'm consul client.");
    }
}

可以看到和 Eureka Client 的代码几乎是完全一样的,不过有一点要注意,我们还需要在 pom.xml 文件中引入 spring-boot-starter-actuator 依赖,开启 Actuator 端点,因为 Consul 默认是通过 /actuator/health 接口来对程序做健康检查的。

在配置文件中设置服务名称和服务端口:

spring.application.name=consul-client
server.port=8083

以及 Consul 相关的配置:

spring.cloud.consul.host=127.0.0.1
spring.cloud.consul.port=8500
spring.cloud.consul.discovery.service-name=${spring.application.name}
spring.cloud.consul.discovery.prefer-ip-address=true
spring.cloud.consul.discovery.ip-address=192.168.1.40

启动后,在 Consul 的服务管理页面中就可以看到我们注册的服务了:

consul-service-detail.png

APISIX 集成 Consul 服务发现

接下来,我们要让 APISIX 通过 Consul Server 找到我们的服务。首先,在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  consul:
    servers:
      - "http://192.168.1.40:8500"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/33 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/consul",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/consul", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul",
        "service_name": "consul-client"
    }
}'

访问 APISIX 的 /consul 地址验证一下:

$ curl http://127.0.0.1:9080/consul
Hello, I'm consul client.

关于 APISIX 集成 Consul 的更多信息,可以参考官方文档 基于 Consul 的服务发现

基于 Consul KV 的服务发现

Consul 还提供了分布式键值数据库的功能,这个功能和 Etcd、ZooKeeper 类似,主要用于存储配置参数和元数据。基于 Consul KV 我们也可以实现服务发现的功能。

首先准备 consul-kv-client 客户端程序,它的地址为 192.168.1.40:8084,我们通过 Consul KV 的 HTTP API 手工注册服务:

$ curl -X PUT http://127.0.0.1:8500/v1/kv/upstreams/consul-kv-client/192.168.1.40:8084 -d ' {"weight": 1, "max_fails": 2, "fail_timeout": 1}'

其中,/v1/kv/ 后的路径按照 {Prefix}/{Service Name}/{IP}:{Port} 的格式构成。可以在 Consul 的 Key/Value 管理页面看到我们注册的服务:

consul-key-value.png

然后在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  consul_kv:
    servers:
      - "http://192.168.1.40:8500"
    prefix: "upstreams"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/44 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/consul_kv",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/consul_kv", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul_kv",
        "service_name": "http://192.168.1.40:8500/v1/kv/upstreams/consul-kv-client/"
    }
}'

注意这里的 service_name 参数需要设置为 KV 的 URL 路径,访问 APISIX 的 /consul_kv 地址验证一下:

$ curl http://127.0.0.1:9080/consul_kv
Hello, I'm consul_kv client.

另一点需要注意的是,这种方式注册的服务没有健康检查机制,服务退出后需要手工删除对应的 KV:

$ curl -X DELETE http://127.0.0.1:8500/v1/kv/upstreams/consul-kv-client/192.168.1.40:8084

关于 APISIX 集成 Consul KV 的更多信息,可以参考官方文档 基于 Consul KV 的服务发现 和官方博客 Apache APISIX 集成 Consul KV,服务发现能力再升级

基于 DNS 的服务发现

Consul 不仅支持 HTTP API,而且还支持 DNS API,它内置了一个小型的 DNS 服务器,默认端口为 8600,我们以上面的 consul-client 为例,介绍如何在 APISIX 中集成 DNS 的服务发现。

注册到 Consul 中的服务默认会在 Consul DNS 中添加一条 <服务名>.service.consul 这样的域名记录,使用 dig 命令可以查询该域名的信息:

$ dig @192.168.1.40 -p 8600 consul-client.service.consul

; <<>> DiG 9.11.3-1ubuntu1.17-Ubuntu <<>> @192.168.1.40 -p 8600 consul-client.service.consul
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 32989
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;consul-client.service.consul.  IN      A

;; ANSWER SECTION:
consul-client.service.consul. 0 IN      A       192.168.1.40

;; Query time: 4 msec
;; SERVER: 192.168.1.40#8600(192.168.1.40)
;; WHEN: Tue Mar 21 07:17:40 CST 2023
;; MSG SIZE  rcvd: 73

上面的查询结果中只包含 A 记录,A 记录中只有 IP 地址,没有服务端口,如果用 A 记录来做服务发现,服务的端口必须得固定;好在 Consul 还支持 SRV 记录,SRV 记录中包含了服务的 IP 和端口信息:

$ dig @192.168.1.40 -p 8600 consul-client.service.consul SRV

; <<>> DiG 9.11.3-1ubuntu1.17-Ubuntu <<>> @192.168.1.40 -p 8600 consul-client.service.consul SRV
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41141
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 3
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;consul-client.service.consul.  IN      SRV

;; ANSWER SECTION:
consul-client.service.consul. 0 IN      SRV     1 1 8083 c0a80128.addr.dc1.consul.

;; ADDITIONAL SECTION:
c0a80128.addr.dc1.consul. 0     IN      A       192.168.1.40
agent-one.node.dc1.consul. 0    IN      TXT     "consul-network-segment="

;; Query time: 3 msec
;; SERVER: 192.168.1.40#8600(192.168.1.40)
;; WHEN: Tue Mar 21 07:18:22 CST 2023
;; MSG SIZE  rcvd: 168

我们在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  dns:
    servers:
      - "192.168.1.40:8600"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/55 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/dns",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/dns", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "dns",
        "service_name": "consul-client.service.consul"
    }
}'

访问 APISIX 的 /dns 地址验证一下:

$ curl http://127.0.0.1:9080/dns
Hello, I'm consul client.

关于 Consul DNS 的更多信息,可以参考官方文档 Discover services with DNS,除了 Consul DNS,我们也可以使用其他的 DNS 服务器来做服务发现,比如 CoreDNS 就是 Kubernetes 环境下的服务发现默认实现。

关于 APISIX 集成 DNS 的更多信息,可以参考官方文档 基于 DNS 的服务发现 和官方博客 API 网关 Apache APISIX 携手 CoreDNS 打开服务发现新大门

基于 APISIX-Seed 架构的控制面服务发现

上面所介绍的所有服务发现机制都是在 APISIX 上进行的,我们需要修改 APISIX 的配置,并重启 APISIX 才能生效,这种直接在网关上实现的服务发现也被称为 数据面服务发现,APISIX 还支持另一种服务发现机制,称为 控制面服务发现

控制面服务发现不直接对 APISIX 进行修改,而是将服务发现结果保存到 Etcd 中,APISIX 实时监听 Etcd 的数据变化,从而实现服务发现:

control-plane-service-discovery.png

APISIX 通过 APISIX-Seed 项目实现了控制面服务发现,这样做有下面几个好处:

  • 简化了 APISIX 的网络拓扑,APISIX 只需要关注 Etcd 的数据变化即可,不再和每个注册中心保持网络连接;
  • APISIX 不用额外存储注册中心的服务数据,减小内存占用;
  • APISIX 的配置变得简单,更容易管理;

虽然如此,目前 APISIX-Seed 还只是一个实验性的项目,从 GitHub 上的活跃度来看,官方似乎对它的投入并不是很高,目前只支持 ZooKeeperNacos 两种服务发现,而且官方也没有提供 APISIX-Seed 安装包的下载,需要我们自己通过源码来构建:

$ git clone https://github.com/api7/apisix-seed.git
$ make build

构建完成后,可以得到一个 apisix-seed 可执行文件,然后我们以上面的 Nacos 为例,介绍如何通过 APISIX-Seed 来实现控制面服务发现。

首先,我们将 APISIX 的配置文件中所有服务发现相关的配置都删掉,并重启 APISIX,接着打开 conf/conf.yaml 配置文件,文件中已经提前配置好了 Etcd、ZooKeeper、Nacos 等相关的配置,我们对其做一点裁剪,只保留下面这些信息:

etcd:
  host:
    - "http://127.0.0.1:2379"
  prefix: /apisix
  timeout: 30
    
log:
  level: warn
  path: apisix-seed.log
  maxage: 168h
  maxsize: 104857600
  rotation_time: 1h

discovery:
  nacos:
    host:
      - "http://127.0.0.1:8848"
    prefix: /nacos

然后启动 apisix-seed

$ ./apisix-seed
panic: no discoverer with key: dns

goroutine 15 [running]:
github.com/api7/apisix-seed/internal/discoverer.GetDiscoverer(...)
        D:/code/apisix-seed/internal/discoverer/discovererhub.go:42
        D:/code/apisix-seed/internal/core/components/watcher.go:84 +0x1d4
created by github.com/api7/apisix-seed/internal/core/components.(*Watcher).Init
        D:/code/apisix-seed/internal/core/components/watcher.go:48 +0x2b6
panic: no discoverer with key: consul

goroutine 13 [running]:
github.com/api7/apisix-seed/internal/discoverer.GetDiscoverer(...)
        D:/code/apisix-seed/internal/discoverer/discovererhub.go:42
github.com/api7/apisix-seed/internal/core/components.(*Watcher).handleQuery(0x0?, 0xc000091200, 0x0?)
        D:/code/apisix-seed/internal/core/components/watcher.go:84 +0x1d4
created by github.com/api7/apisix-seed/internal/core/components.(*Watcher).Init
        D:/code/apisix-seed/internal/core/components/watcher.go:48 +0x2b6

不过由于上面我们在路由中添加了 dns、consul 这些服务发现类型,这些 APISIX-Seed 是不支持的,所以启动会报错,我们需要将这些路由删掉:

$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/11 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/33 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/44 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/55 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'

只保留一条 /nacos 的路由,然后重启 apisix-seed 即可:

$ ./apisix-seed
2023-03-22T07:49:53.849+0800    INFO    naming_client/push_receiver.go:80       udp server start, port: 55038

访问 APISIX 的 /nacos 地址验证一下:

$ curl http://127.0.0.1:9080/nacos
Hello, I'm nacos client.

关于 APISIX-Seed 的更多信息,可以参考官方文档 基于 APISIX-Seed 架构的控制面服务发现APISIX-Seed 项目文档

基于 Kubernetes 的服务发现

我们的服务还可能部署在 Kubernetes 集群中,这时,不用依赖外部的服务注册中心也可以实现服务发现,因为 Kubernetes 提供了强大而丰富的监听资源的接口,我们可以通过监听 Kubernetes 集群中 Services 或 Endpoints 等资源的实时变化来实现服务发现,APISIX 就是这样做的。

我们以 Kubernetes 使用小记 中的 kubernetes-bootcamp 为例,体验一下 APISIX 基于 Kubernetes 的服务发现。

首先在 Kubernetes 集群中创建对应的 Deployment 和 Service:

$ kubectl create deployment kubernetes-bootcamp --image=jocatalin/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created

$ kubectl expose deployment/kubernetes-bootcamp --type="NodePort" --port 8080
service/kubernetes-bootcamp exposed

通过 kubectl get svc 获取 NodePort 端口,并验证服务能正常访问:

$ kubectl get svc
NAME                  TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes            ClusterIP   10.96.0.1       <none>        443/TCP          115d
kubernetes-bootcamp   NodePort    10.101.31.128   <none>        8080:32277/TCP   59s

$ curl http://192.168.1.40:32277
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-857b45f5bb-jtzs4 | v=1

接下来,为了让 APISIX 能查询和监听 Kubernetes 的 Endpoints 资源变动,我们需要创建一个 ServiceAccount

kind: ServiceAccount
apiVersion: v1
metadata:
 name: apisix-test
 namespace: default

以及一个具有集群级查询和监听 Endpoints 资源权限的 ClusterRole

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: apisix-test
rules:
- apiGroups: [ "" ]
  resources: [ endpoints ]
  verbs: [ get,list,watch ]

再将这个 ServiceAccountClusterRole 关联起来:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: apisix-test
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: apisix-test
subjects:
  - kind: ServiceAccount
    name: apisix-test
    namespace: default

然后我们需要获取这个 ServiceAccount 的 token 值,如果 Kubernetes 是 v1.24 之前的版本,可以通过下面的方法获取 token 值:

$ kubectl get secrets | grep apisix-test
$ kubectl get secret apisix-test-token-c64cv -o jsonpath={.data.token} | base64 -d

Kubernetes 从 v1.24 版本开始,不能再通过 kubectl get secret 获取 token 了,需要使用 TokenRequest API 来获取,首先开启代理:

$ kubectl proxy --port=8001
Starting to serve on 127.0.0.1:8001

然后调用 TokenRequest API 生成一个 token:

$ curl 'http://127.0.0.1:8001/api/v1/namespaces/default/serviceaccounts/apisix-test/token' \
  -H "Content-Type:application/json" -X POST -d '{}'
{
  "kind": "TokenRequest",
  "apiVersion": "authentication.k8s.io/v1",
  "metadata": {
    "name": "apisix-test",
    "namespace": "default",
    "creationTimestamp": "2023-03-22T23:57:20Z",
    "managedFields": [
      {
        "manager": "curl",
        "operation": "Update",
        "apiVersion": "authentication.k8s.io/v1",
        "time": "2023-03-22T23:57:20Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {
          "f:spec": {
            "f:expirationSeconds": {}
          }
        },
        "subresource": "token"
      }
    ]
  },
  "spec": {
    "audiences": [
      "https://kubernetes.default.svc.cluster.local"
    ],
    "expirationSeconds": 3600,
    "boundObjectRef": null
  },
  "status": {
    "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtLdHRyVzFmNTRHWGFVUjVRS3hrLVJMSElNaXM4aENLMnpfSGk1SUJhbVkifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNjc5NTMzMDQwLCJpYXQiOjE2Nzk1Mjk0NDAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImFwaXNpeC10ZXN0IiwidWlkIjoiMzVjZWJkYTEtNGZjNC00N2JlLWIxN2QtZDA4NWJlNzU5ODRlIn19LCJuYmYiOjE2Nzk1Mjk0NDAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmFwaXNpeC10ZXN0In0.YexM_VoumpdwZNbSkwh6IbEu59PCtZrG1lkTnCqG24G-TC0U1sGxgbXf6AnUQ5ybh-CHWbJ7oewhkg_J4j7FiSAnV_yCcEygLkaCveGIQbWldB3phDlcJ52f8YDpHFtN2vdyVTm79ECwEInDsqKhn4n9tPY4pgTodI6D9j-lcK0ywUdbdlL5VHiOw9jlnS7b60fKWBwCPyW2uohX5X43gnUr3E1Wekgpo47vx8lahTZQqnORahTdl7bsPsu_apf7LMw40FLpspVO6wih-30Ke8CNBxjpORtX2n3oteE1fi2vxYHoyJSeh1Pro_Oykauch0InFUNyEVI4kJQ720glOw",
    "expirationTimestamp": "2023-03-23T00:57:20Z"
  }
}

默认的 token 有效期只有一个小时,可以通过参数改为一年:

$ curl 'http://127.0.0.1:8001/api/v1/namespaces/default/serviceaccounts/apisix-test/token' \
  -H "Content-Type:application/json" -X POST \
  -d '{"kind":"TokenRequest","apiVersion":"authentication.k8s.io/v1","metadata":{"name":"apisix-test","namespace":"default"},"spec":{"audiences":["https://kubernetes.default.svc.cluster.local"],"expirationSeconds":31536000}}'

我们在 APISIX 的配置文件 config.yaml 中添加如下内容( 将上面生成的 token 填写到 token 字段 ):

discovery:
  kubernetes:
    service:
      schema: https
      host: 127.0.0.1
      port: "6443"
    client:
      token: ...

这里有一个比较坑的地方,port 必须是字符串,否则会导致 APISIX 启动报错:

invalid discovery kubernetes configuration: object matches none of the required

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/66 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/kubernetes",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/kubernetes", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "kubernetes",
        "service_name": "kubernetes-bootcamp"
    }
}'

访问 APISIX 的 /kubernetes 地址验证一下:

$ curl http://127.0.0.1:9080/kubernetes

不过,如果你的 APISIX 运行在 Kubernetes 集群之外,大概率是访问不通的,因为 APISIX 监听的 Endpoints 地址是 Kubernetes 集群内的 Pod 地址:

$ kubectl describe endpoints/kubernetes-bootcamp
Name:         kubernetes-bootcamp
Namespace:    default
Labels:       app=kubernetes-bootcamp
Annotations:  endpoints.kubernetes.io/last-change-trigger-time: 2023-03-25T00:31:43Z
Subsets:
  Addresses:          10.1.5.12
  NotReadyAddresses:  <none>
  Ports:
    Name     Port  Protocol
    ----     ----  --------
    <unset>  8080  TCP

Events:  <none>

所以想使用基于 Kubernetes 的服务发现,最佳做法是将 APISIX 部署在 Kubernetes 中,或者使用 APISIX Ingress,关于 APISIX 集成 Kubernetes 的更多信息,可以参考官方文档 基于 Kubernetes 的服务发现 和官方博客 借助 APISIX Ingress,实现与注册中心的无缝集成

参考

更多

实现自定义服务发现

https://apisix.apache.org/zh/docs/apisix/discovery/

扫描二维码,在手机上阅读!

APISIX 使用小记

Apache APISIX 是基于 Nginx/OpenResty + Lua 方案打造的一款 动态实时高性能云原生 API 网关,提供了负载均衡、动态上游、灰度发布、服务熔断、身份认证、可观测性等丰富的流量管理功能。APISIX 由国内初创公司 支流科技 于 2019 年 6 月开源,并于 7 月纳入 CNCF 全景图,10 月进入 Apache 孵化器,次年 7 月 毕业,成为国内唯一一个由初创公司贡献的项目,也是中国最快毕业的 Apache 顶级项目。

入门示例初体验

学习一门技术最好的方法就是使用它。这一节,我们将通过官方的入门示例,对 APISIX 的概念和用法有个基本了解。

首先,我们下载 apisix-docker 仓库:

git clone https://github.com/apache/apisix-docker.git

这个仓库主要是用来指导用户如何使用 Docker 部署 APISIX 的,其中有一个 example 目录,是官方提供的入门示例,我们可以直接使用 docker-compose 运行它:

$ cd apisix-docker/example
$ docker-compose up -d
[+] Running 8/8
 - Network example_apisix                Created                         0.9s
 - Container example-web2-1              Started                         5.1s
 - Container example-web1-1              Started                         4.0s
 - Container example-prometheus-1        Started                         4.4s
 - Container example-grafana-1           Started                         5.8s
 - Container example-apisix-dashboard-1  Started                         6.0s
 - Container example-etcd-1              Started                         5.1s
 - Container example-apisix-1            Started                         7.5s

可以看到创建了一个名为 example_apisix 的网络,并在这个网络里启动了 7 个容器:

  • etcd - APISIX 使用 etcd 作为配置中心,它通过监听 etcd 的变化来实时更新路由
  • apisix - APISIX 网关
  • apisix-dashboard - APISIX 管理控制台,可以在这里对 APISIX 的 Route、Upstream、Service、Consumer、Plugin、SSL 等进行管理
  • prometheus - 这个例子使用了 APISIX 的 prometheus 插件,用于暴露 APISIX 的指标,Prometheus 服务用于采集这些指标
  • grafana - Grafana 面板以图形化的方式展示 Prometheus 指标
  • web1 - 测试服务
  • web2 - 测试服务

部署之后可以使用 APISIX 的 Admin API 检查其是否启动成功:

$ curl http://127.0.0.1:9180/apisix/admin/routes \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
{"list":[],"total":0}

目前我们还没有创建任何路由,所以 /apisix/admin/routes 接口返回的结果为空。我们可以使用 Admin API 和 Dashboard 两种方式来创建路由。

使用 Admin API 创建路由

路由( Route ) 是 APISIX 中最基础和最核心的资源对象,APISIX 通过路由定义规则来匹配客户端请求,根据匹配结果加载并执行相应的插件,最后将请求转发到指定的上游服务。一条路由主要包含三部分信息:

  • 匹配规则:比如 methodsurihost 等,也可以根据需要自定义匹配规则,当请求满足匹配规则时,才会执行后续的插件,并转发到指定的上游服务;
  • 插件配置:这是可选的,但也是 APISIX 最强大的功能之一,APISIX 提供了非常丰富的插件来实现各种不同的访问策略,比如认证授权、安全、限流限速、可观测性等;
  • 上游信息:路由会根据配置的负载均衡信息,将请求按照规则转发到相应的上游。

所有的 Admin API 都采用了 Restful 风格,路由资源的请求地址为 /apisix/admin/routes/{id},我们可以通过不同的 HTTP 方法来查询、新增、编辑或删除路由资源(官方示例):

  • GET /apisix/admin/routes - 获取资源列表;
  • GET /apisix/admin/routes/{id} - 获取资源;
  • PUT /apisix/admin/routes/{id} - 根据 id 创建资源;
  • POST /apisix/admin/routes - 创建资源,id 将会自动生成;
  • DELETE /apisix/admin/routes/{id} - 删除指定资源;
  • PATCH /apisix/admin/routes/{id} - 标准 PATCH,修改指定 Route 的部分属性,其他不涉及的属性会原样保留;
  • PATCH /apisix/admin/routes/{id}/{path} - SubPath PATCH,通过 {path} 指定 Route 要更新的属性,全量更新该属性的数据,其他不涉及的属性会原样保留。

下面的例子将入门示例中的 web1 服务添加到路由中:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "web1:80": 1
        }
    }
}'

其中 X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 是 Admin API 的访问 Token,可以在 APISIX 的配置文件 apisix_conf/config.yaml 中找到:

deployment:
  admin:
    allow_admin:
      - 0.0.0.0/0
    admin_key:
      - name: "admin"
        key: edd1c9f034335f136f87ad84b625c8f1
        role: admin
      - name: "viewer"
        key: 4054f7cf07e344346cd3f287985e76a2
        role: viewer

如果路由创建成功,将返回下面的 201 Created 信息:

HTTP/1.1 201 Created
Connection: close
Transfer-Encoding: chunked
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: *
Access-Control-Max-Age: 3600
Content-Type: application/json
Date: Tue, 31 Jan 2023 00:19:03 GMT
Server: APISIX/3.1.0
X-Api-Version: v3

{"key":"\/apisix\/routes\/1","value":{"create_time":1675124057,"uri":"\/web1","status":1,"upstream":{"pass_host":"pass","scheme":"http","nodes":{"web1:80":1},"hash_on":"vars","type":"roundrobin"},"priority":0,"update_time":1675124057,"id":"1"}}

这个路由的含义是当请求的方法是 GET 且请求的路径是 /web1 时,APISIX 就将请求转发到上游服务 web1:80。我们可以通过这个路径来访问 web1 服务:

$ curl http://127.0.0.1:9080/web1
hello web1

如果上游信息需要在不同的路由中复用,我们可以先创建一个 上游(Upstream)

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/upstreams/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "type": "roundrobin",
    "nodes": {
        "web1:80": 1
    }
}'

然后在创建路由时直接使用 upstream_id 即可:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream_id": "1"
}'

另外,你可以使用下面的命令删除一条路由:

$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'

使用 Dashboard 创建路由

APISIX 提供了一套图形化 Dashboard 用来对网关的路由、插件、上游等进行管理,在入门示例中已经自带部署了 Dashboard,通过浏览器 http://localhost:9000 即可访问:

dashboard-login.png

默认的用户名和密码可以在 dashboard_conf/conf.yaml 文件中进行配置:

authentication:
  secret:
    secret     
  expire_time: 3600
  users:
    - username: admin
      password: admin
    - username: user
      password: user

登录成功后进入路由页面:

dashboard-routes.png

然后点击 “创建” 按钮创建一个路由:

dashboard-create-route.png

看上去这里的路由信息非常复杂,但是实际上我们只需要填写 名称路径HTTP 方法 即可,其他的维持默认值,当我们对 APISIX 的路由理解更深刻的时候可以再回过头来看看这些参数。

点击 “下一步” 设置上游信息:

dashboard-create-route-2.png

同样的,我们只关心目标节点的 主机名端口 两个参数即可。

然后再点击 “下一步” 进入插件配置,这里暂时先跳过,直接 “下一步” 完成路由的创建。路由创建完成后,访问 /web2 来验证路由是否生效:

$ curl http://127.0.0.1:9080/web2
hello web2

使用 APISIX 插件

通过上面的示例,我们了解了 APISIX 的基本用法,学习了如何通过 Admin API 或 Dashboard 来创建路由,实现了网关最基础的路由转发功能。APISIX 不仅具有高性能且低延迟的特性,而且它强大的插件机制为其高扩展性提供了无限可能。我们可以在 APISIX 插件中心 查看所有官方已经支持的插件,也可以 使用 lua 语言开发自己的插件,如果你对 lua 不熟悉,还可以使用其他语言 开发 External Plugin,APISIX 支持通过 Plugin Runner 以 sidecar 的形式来运行你的插件,APISIX 和 sidecar 之间通过 RPC 通信,不过这种方式对性能有一定的影响,如果你比较关注性能问题,那么可以使用你熟悉的语言开发 WebAssembly 程序,APISIX 也支持 运行 wasm 插件

external-plugin.png

这一节我们将通过几个官方插件来实现一些常见的网关需求。

在上面的学习中我们知道,一个路由是由匹配规则、插件配置和上游信息三个部分组成的,但是为了学习的递进性,我们有意地避免了插件配置部分。现在我们可以重新创建一个路由,并为其加上插件信息:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/3 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web3",
    "plugins": {
        "limit-count": {
            "count": 2,
            "time_window": 60,
            "rejected_code": 503,
            "key": "remote_addr"
        },
        "prometheus": {}
    },
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "web1:80": 1
        }
    }
}'

上面的命令创建了一个 /web3 路由,并配置了两个插件:

  • limit-count - 该插件使用 固定窗口算法(Fixed Window algorithm) 对该路由进行限流,每分钟仅允许 2 次请求,超出时返回 503 错误码;
  • prometheus - 该插件将路由请求相关的指标暴露到 Prometheus 端点;

我们连续访问 3 次 /web3 路由:

$ curl http://127.0.0.1:9080/web3
hello web1
$ curl http://127.0.0.1:9080/web3
hello web1
$ curl http://127.0.0.1:9080/web3
<html>
<head><title>503 Service Temporarily Unavailable</title></head>
<body>
<center><h1>503 Service Temporarily Unavailable</h1></center>
<hr><center>openresty</center>
<p><em>Powered by <a href="https://apisix.apache.org/">APISIX</a>.</em></p></body>
</html>

可以看到 limit-count 插件的限流功能生效了,第 3 次请求被拒绝,返回了 503 错误码。另外,可以使用下面的命令查看 Prometheus 指标:

$ curl -i http://127.0.0.1:9091/apisix/prometheus/metrics

这个 Prometheus 指标地址可以在 apisix_conf/config.yaml 文件的 plugin_attr 中配置:

plugin_attr:
  prometheus:
    export_uri: /apisix/prometheus/metrics
    export_addr:
      ip: "0.0.0.0"
      port: 9091

APISIX 的插件可以动态的启用和禁用、自定义错误响应、自定义优先级、根据条件动态执行,具体内容可以参考 官方的 Plugin 文档。此外,如果一个插件需要在多个地方复用,我们也可以创建一个 Plugin Config

$ curl http://127.0.0.1:9180/apisix/admin/plugin_configs/1 \
  -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d '
{
    "desc": "enable limit-count plugin",
    "plugins": {
        "limit-count": {
            "count": 2,
            "time_window": 60,
            "rejected_code": 503
        }
    }
}'

然后在创建路由时,通过 plugin_config_id 关联:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream_id": "1",
    "plugin_config_id": "1"
}'

在 APISIX 的插件中心,我们可以看到 APISIX 将插件分成了下面几个大类:

  • General - 通用功能,比如 gzip 压缩配置、重定向配置等;
  • Transformation - 这类插件会对请求做一些转换操作,比如重写请求响应、gRPC 协议转换等;
  • Authentication - 提供一些常见的认证授权相关的功能,比如 API Key 认证、JWT 认证、Basic 认证、CAS 认证、LDAP 认证等;
  • Security - 安全相关的插件,比如开启 IP 黑白名单、开启 CORS、开启 CSRF 等;
  • Traffic - 这些插件对流量进行管理,比如限流、限速、流量镜像等;
  • Observability - 可观测性插件,支持常见的日志(比如 File-Logger、Http-Logger、Kafka-Logger、Rocketmq-Logger 等)、指标(比如 Prometheus、Datadog 等)和链路跟踪(比如 Skywalking、Zipkin、Opentelemetry 等)系统;
  • Serverless - 对接常见的 Serverless 平台,实现函数计算功能,比如 AWS Lambda、Apache OpenWhisk、CNCF Function 等;
  • Other Protocols - 这些插件用于支持 Dubbo、MQTT 等其他类型的协议;

参考

  1. 快速入门指南 | Apache APISIX® -- Cloud-Native API Gateway
  2. API 网关策略的二三事
  3. 从 Apache APISIX 来看 API 网关的演进
  4. 云原生时代的中外 API 网关之争

更多

APISIX 的部署模式

APISIX 支持多种不同的 部署模式,上面的示例中使用的是最常用的一种部署模式:traditional 模式,在这个模式下 APISIX 的控制平台和数据平面在一起:

deployment-traditional.png

我们也可以将 APISIX 部署两个实例,一个作为数据平面,一个作为控制平面,这被称为 decoupled 模式,这样可以提高 APISIX 的稳定性:

deployment-decoupled.png

上面两种模式都依赖于从 etcd 中监听和获取配置信息,如果我们不想使用 etcd,我们还可以将 APISIX 部署成 standalone 模式,这个模式使用 conf/apisix.yaml 作为配置文件,并且每间隔一段时间自动检测文件内容是否有更新,如果有更新则重新加载配置。不过这个模式只能作为数据平面,无法使用 Admin API 等管理功能(这是因为 Admin API 是基于 etcd 实现的):

deployment:
  role: data_plane
  role_data_plane:
    config_provider: yaml

将 APISIX 扩展为服务网格的边车

集成服务发现注册中心

扫描二维码,在手机上阅读!

使用 GitHub Actions 跟踪 GitHub 趋势项目

GitHub Actions 是 GitHub 于 2018 年 10 月推出的一款 CI/CD 服务。一个标准的 CI/CD 流程通常是一个工作流(workflow),工作流中包含了一个或多个作业(job),而每个作业都是由多个执行步骤(step)组成。

GitHub Actions 的创新之处在于它将 CI/CD 中的每个执行步骤划分成一个个原子的操作(action),这些操作可以是编译代码、调用某个接口、执行代码检查或是部署服务等。很显然这些原子操作是可以在不同的 CI/CD 流程中复用的,于是 GitHub 允许开发者将这些操作编写成脚本存在放 GitHub 仓库里,供其他人使用。GitHub 提供了一些 官方的 actions,比如 actions/setup-python 用于初始化 Python 环境,actions/checkout 用于签出某个代码仓库。由于每个 action 都对应一个 GitHub 仓库,所以也可以像下面这样引用 action 的某个分支、某个标签甚至某个提交记录:

actions/setup-node@master  # 指向一个分支
actions/setup-node@v1.0    # 指向一个标签
actions/setup-node@74bc508 # 指向一个 commit

你可以在 GitHub Marketplace 中搜索你想使用的 action,另外,还有一份关于 GitHub Actions 的 awesome 清单 sdras/awesome-actions,也可以找到不少的 action。

GitHub Actions 入门示例

这一节我们将通过一个最简单的入门示例了解 GitHub Actions 的基本概念。首先我们在 GitHub 上创建一个 demo 项目 aneasystone/github-actions-demo(也可以直接使用已有的项目),然后打开 Actions 选项卡:

get-started-with-github-actions.png

我们可以在这里手工创建工作流(workflow),也可以直接使用 GitHub Actions 提供的入门工作流,GitHub Actions 提供的工作流大体分为四种类型:

  • Continuous integration - 包含了各种编程语言的编译、打包、测试等流程
  • Deployment - 支持将应用部署到各种不同的云平台
  • Security - 对仓库进行代码规范检查或安全扫描
  • Automation - 一些自动化脚本

这些工作流的源码都可以在 actions/starter-workflows 这里找到。

GitHub 会自动分析代码并显示出可能适用于你的项目的工作流。由于是示例项目,这里我们直接使用一个最简单的工作流来进行测试,选择 Simple workflow 这个工作流,会在 .github/workflows 目录下创建一个 blank.yml 文件,文件内容如下:

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      # Runs a single command using the runners shell
      - name: Run a one-line script
        run: echo Hello, world!

      # Runs a set of commands using the runners shell
      - name: Run a multi-line script
        run: |
          echo Add other actions to build,
          echo test, and deploy your project.

这个工作流没有任何用处,只是使用 echo 命令输出一行 Hello, world! 以及其他几行日志而已。

然后点击 Start commit 按钮提交文件即可:

simple-workflow.png

由于这里我们指定了工作流在 push 的时候触发,所以提交完文件之后,这个工作流应该就开始执行了。重新打开 Actions 选项卡:

all-workflows.png

这里显示了项目中所有的工作流列表,我们可以在一个项目中创建多个工作流。可以看到我们已经成功创建了一个名为 CI 的工作流,并在右侧显示了该工作流的运行情况。点击查看详细信息:

all-workflow-jobs.png

这里是工作流包含的所有作业(job)的执行情况,我们这个示例中只使用了一个名为 build 的作业。点击作业,可以查看作业的执行日志:

all-workflow-job-logs.png

详解 workflow 文件

在上一节中,我们通过在 .github/workflows 目录下新建一个 YAML 文件,创建了一个最简单的 GitHub Actions 工作流。这个 YAML 的文件名可以任意,但文件内容必须符合 GitHub Actions 的工作流程语法。下面是一些基本字段的解释。

name

出现在 GitHub 仓库的 Actions 选项卡中的工作流程名称。如果省略该字段,默认为当前 workflow 的文件名。

on

指定此工作流程的触发器。GitHub 支持多种触发事件,您可以配置工作流程在 GitHub 上发生特定活动时运行、在预定的时间运行,或者在 GitHub 外部的事件发生时运行。参见 官方文档 了解触发工作流程的所有事件。

在示例项目中,我们使用了几个最常用的触发事件。比如当 main 分支有 pushpull_request 时触发:

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

或者开启手工触发工作流:

on:
  workflow_dispatch:

这时会在工作流页面出现一个手工执行的按钮:

run-workflow-manually.png

也可以使用定时任务来触发工作流:

on:
  schedule:
    - cron: "0 2 * * *"

jobs

一个工作流可以包含一个或多个作业,这些作业可以顺序执行或并发执行。下面定义了一个 ID 为 build 的作业:

jobs:
  build:
    ...

jobs.<job-id>.runs-on

为作业指定运行器(runner),运行器可以使用 GitHub 托管的(GitHub-hosted runners),也可以是 自托管的(self-hosted runners)。GitHub 托管的运行器包括 Windows Server、Ubuntu、macOS 等操作系统,下面的例子将作业配置为在最新版本的 Ubuntu Linux 运行器上运行:

runs-on: ubuntu-latest

jobs.<job-id>.steps

作业中运行的所有步骤,步骤可以是一个 Shell 脚本,也可以是一个操作(action)。在我们的示例中一共包含了三个步骤,第一步使用了一个官方的操作 actions/checkout@v3

# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3

这个操作将代码仓库签出到运行器上,这样你就可以对代码运行脚本或其他操作,比如编译、测试或构建打包等。

第二步,使用 echo 命令输出一句 Hello, world!

# Runs a single command using the runners shell
- name: Run a one-line script
  run: echo Hello, world!

第三步,继续执行多条 echo 命令:

# Runs a set of commands using the runners shell
- name: Run a multi-line script
  run: |
    echo Add other actions to build,
    echo test, and deploy your project.

跟踪 GitHub 趋势项目

学习了 GitHub Actions 的基本知识后,我们就可以开始使用它了。除了常见的 CI/CD 任务,如 自动构建和测试打包和发布部署 等,还可以使用它来做很多有趣的事情。

GitHub 有一个 Trending 页面,可以在这里发现 GitHub 上每天、每周或每月最热门的项目,不过这个页面没有归档功能,无法追溯历史。如果我们能用爬虫每天自动爬取这个页面上的内容,并将结果保存下来,那么查阅起来就更方便了。要实现这个功能,必须满足三个条件:

  1. 能定时执行:可以使用 on:schedule 定时触发 GitHub Actions 工作流;
  2. 爬虫脚本:在工作流中可以执行任意的脚本,另外还可以通过 actions 安装各种语言的环境,比如使用 actions/setup-python 安装 Python 环境,使用 Python 来写爬虫最适合不过;
  3. 能将结果保存下来:GitHub 仓库天生就是一个数据库,可以用来存储数据,我们可以将爬虫爬下来的数据提交并保存到 GitHub 仓库。

可以看到,使用 GitHub Actions 完全可以实现这个功能,这个想法的灵感来自 bonfy/github-trending 项目,不过我在这个项目的基础上做了一些改进,比如将每天爬取的结果合并在同一个文件里,并且对重复的结果进行去重。

首先我们创建一个仓库 aneasystone/github-trending,然后和之前的示例项目一样,在 .github/workflows 目录下创建一个流水线文件,内容如下:

# This workflow will scrap GitHub trending projects daily.

name: Daily Github Trending

on:
  schedule:
    - cron: "0 2 * * *"

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2
      
    - name: Set up Python 3.8
      uses: actions/setup-python@v2
      with:
        python-version: 3.8
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        
    - name: Run Scraper
      run: |
        python scraper.py
    # Runs a set of commands using the runners shell
    - name: Push to origin master
      run: |
        echo start push
        git config --global user.name "aneasystone"
        git config --global user.email "aneasystone@gmail.com"
        
        git add -A
        git commit -m $(date '+%Y-%m-%d')
        git push

在这里我们使用了 on.schedule.cron: "0 2 * * *" 来定时触发工作流,这个 cron 表达式需符合 POSIX cron 语法,可以在 crontab guru 页面上对 cron 表达式进行调试。不过要注意的是,这里的时间为 UTC 时间,所以 0 2 * * * 对应的是北京时间 10 点整。

注:在实际运行的时候,我发现工作流并不是每天早上 10 点执行,而是到 11 点才执行,起初我以为是定时任务出现了延迟,但是后来我才意识到,现在正好是夏天,大多数北美洲、欧洲以及部分中东地区都在实施 夏令时,所以他们的时间要比我们早一个小时。

工作流的各个步骤是比较清晰的,首先通过 actions/checkout@v2 签出仓库代码,然后使用 actions/setup-python@v2 安装 Python 环境,然后执行 pip install 安装 Python 依赖。环境准备就绪后,执行 python scraper.py,这就是我们的爬虫脚本,它会将 GitHub Trending 页面的内容爬取下来并更新到 README.md 文件中,我们可以根据参数爬取不同编程语言的项目清单:

languages = ['', 'java', 'python', 'javascript', 'go', 'c', 'c++', 'c#', 'html', 'css', 'unknown']
for lang in languages:
    results = scrape_lang(lang)
    write_markdown(lang, results)

数据爬取成功后,我们在工作流的最后通过 git commit & git push 将代码提交到 GitHub 仓库保存下来。你可以在这里 aneasystone/github-trending 查看完整的代码。

参考

更多

其他示例

结合 GitHub Actions 的自动化功能,我们可以做很多有趣的事情。比如官方文档中还提供了 其他几个示例,用于检测仓库中失效的链接。

另外,阮一峰在他的 入门教程 中介绍了一个示例,用于将 React 应用发布到 GitHub Pages。

在本地运行 GitHub Actions

https://github.com/nektos/act

扫描二维码,在手机上阅读!

实战 ELK 搭建日志系统

ELKElasticsearch + Logstash + Kibana 的简称。Elasticsearch 是一个基于 Lucene 的分布式全文搜索引擎,提供 RESTful API 进行数据读写;Logstash 是一个收集,处理和转发事件和日志消息的工具;而 Kibana 是 Elasticsearch 的开源数据可视化插件,为查看存储在 Elasticsearch 提供了友好的 Web 界面,并提供了条形图,线条和散点图,饼图和地图等分析工具。

总的来说,Elasticsearch 负责存储数据,Logstash 负责收集日志,并将日志格式化后写入 Elasticsearch,Kibana 提供可视化访问 Elasticsearch 数据的功能。

安装 Elasticsearch

使用下面的 Docker 命令启动一个单机版 Elasticsearch 实例:

> docker run --name es \
  -p 9200:9200 -p 9300:9300 \
  -e ELASTIC_PASSWORD=123456 \
  -d docker.elastic.co/elasticsearch/elasticsearch:8.3.2

从 Elasticsearch 8.0 开始,默认会开启安全特性,我们通过 ELASTIC_PASSWORD 环境变量设置访问密码,如果不设置,Elasticsearch 会在第一次启动时随机生成密码,查看启动日志可以发现类似下面这样的信息:

-------------------------------------------------------------------------------------------------------------------------------------
-> Elasticsearch security features have been automatically configured!
-> Authentication is enabled and cluster connections are encrypted.

->  Password for the elastic user (reset with `bin/elasticsearch-reset-password -u elastic`):
  dV0dN=eiH7CDtoe1IVS0

->  HTTP CA certificate SHA-256 fingerprint:
  4032719061cbafe64d5df5ef29157572a98aff6dae5cab99afb84220799556ff

->  Configure Kibana to use this cluster:
* Run Kibana and click the configuration link in the terminal when Kibana starts.
* Copy the following enrollment token and paste it into Kibana in your browser (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjMuMiIsImFkciI6WyIxNzIuMTcuMC4zOjkyMDAiXSwiZmdyIjoiNDAzMjcxOTA2MWNiYWZlNjRkNWRmNWVmMjkxNTc1NzJhOThhZmY2ZGFlNWNhYjk5YWZiODQyMjA3OTk1NTZmZiIsImtleSI6InNLUzdCSUlCU1dmOFp0TUg4M0VKOnVTaTRZYURlUk1LMXU3SUtaQ3ZzbmcifQ==

-> Configure other nodes to join this cluster:
* Copy the following enrollment token and start new Elasticsearch nodes with `bin/elasticsearch --enrollment-token <token>` (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjMuMiIsImFkciI6WyIxNzIuMTcuMC4zOjkyMDAiXSwiZmdyIjoiNDAzMjcxOTA2MWNiYWZlNjRkNWRmNWVmMjkxNTc1NzJhOThhZmY2ZGFlNWNhYjk5YWZiODQyMjA3OTk1NTZmZiIsImtleSI6InJxUzdCSUlCU1dmOFp0TUg4bkg3OmlLMU1tMUM3VFBhV2V1OURGWEFsWHcifQ==

  If you're running in Docker, copy the enrollment token and run:
  `docker run -e "ENROLLMENT_TOKEN=<token>" docker.elastic.co/elasticsearch/elasticsearch:8.3.2`
-------------------------------------------------------------------------------------------------------------------------------------

另外 Elasticsearch 使用了 HTTPS 通信,不过这个证书是不可信的,在浏览器里访问会有不安全的警告,使用 curl 访问时注意使用 -k--insecure 忽略证书校验:

$ curl -X GET -s -k -u elastic:123456 https://localhost:9200 | jq
{
  "name": "2460ab74bdf6",
  "cluster_name": "docker-cluster",
  "cluster_uuid": "Yip76XCuQHq9ncLfzt_I1A",
  "version": {
    "number": "8.3.2",
    "build_type": "docker",
    "build_hash": "8b0b1f23fbebecc3c88e4464319dea8989f374fd",
    "build_date": "2022-07-06T15:15:15.901688194Z",
    "build_snapshot": false,
    "lucene_version": "9.2.0",
    "minimum_wire_compatibility_version": "7.17.0",
    "minimum_index_compatibility_version": "7.0.0"
  },
  "tagline": "You Know, for Search"
}

不过更安全的做法是将证书文件拷贝出来:

$ docker cp es:/usr/share/elasticsearch/config/certs/http_ca.crt .

然后使用证书访问 Elasticsearch:

$ curl --cacert http_ca.crt -u elastic https://localhost:9200

安装 Logstash

使用下面的 Docker 命令启动一个最简单的 Logstash 实例:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2 \
  -e 'input { stdin { } } output { stdout {} }'

默认情况下,Logstash 会开启 X-Pack 监控,使用环境变量 XPACK_MONITORING_ENABLED=false 可以禁用它。另外,我们使用了 -e 'input { stdin { } } output { stdout {} }' 参数,表示让 Logstash 从标准输入 stdin 读取输入,并将结果输出到标注输出 stdout

2022/07/17 05:34:19 Setting 'xpack.monitoring.enabled' from environment.
Using bundled JDK: /usr/share/logstash/jdk
OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
Sending Logstash logs to /usr/share/logstash/logs which is now configured via log4j2.properties
[2022-07-17T05:34:34,231][INFO ][logstash.runner          ] Log4j configuration path used is: /usr/share/logstash/config/log4j2.properties
[2022-07-17T05:34:34,257][INFO ][logstash.runner          ] Starting Logstash {"logstash.version"=>"8.3.2", "jruby.version"=>"jruby 9.2.20.1 (2.5.8) 2021-11-30 2a2962fbd1 OpenJDK 64-Bit Server VM 11.0.15+10 on 11.0.15+10 +indy +jit [linux-x86_64]"}
[2022-07-17T05:34:34,261][INFO ][logstash.runner          ] JVM bootstrap flags: [-Xms1g, -Xmx1g, -XX:+UseConcMarkSweepGC, -XX:CMSInitiatingOccupancyFraction=75, -XX:+UseCMSInitiatingOccupancyOnly, -Djava.awt.headless=true, -Dfile.encoding=UTF-8, -Djruby.compile.invokedynamic=true, -Djruby.jit.threshold=0, -XX:+HeapDumpOnOutOfMemoryError, -Djava.security.egd=file:/dev/urandom, -Dlog4j2.isThreadContextMapInheritable=true, -Dls.cgroup.cpuacct.path.override=/, -Dls.cgroup.cpu.path.override=/, -Djruby.regexp.interruptible=true, -Djdk.io.File.enableADS=true, --add-opens=java.base/java.security=ALL-UNNAMED, --add-opens=java.base/java.io=ALL-UNNAMED, --add-opens=java.base/java.nio.channels=ALL-UNNAMED, --add-opens=java.base/sun.nio.ch=ALL-UNNAMED, --add-opens=java.management/sun.management=ALL-UNNAMED]
[2022-07-17T05:34:34,317][INFO ][logstash.settings        ] Creating directory {:setting=>"path.queue", :path=>"/usr/share/logstash/data/queue"}
[2022-07-17T05:34:34,347][INFO ][logstash.settings        ] Creating directory {:setting=>"path.dead_letter_queue", :path=>"/usr/share/logstash/data/dead_letter_queue"}
[2022-07-17T05:34:34,917][WARN ][logstash.config.source.multilocal] Ignoring the 'pipelines.yml' file because modules or command line options are specified
[2022-07-17T05:34:34,942][INFO ][logstash.agent           ] No persistent UUID file found. Generating new UUID {:uuid=>"b1e18429-eb7f-4669-9271-0d75fed547c1", :path=>"/usr/share/logstash/data/uuid"}
[2022-07-17T05:34:36,221][INFO ][logstash.agent           ] Successfully started Logstash API endpoint {:port=>9600, :ssl_enabled=>false}
[2022-07-17T05:34:36,474][INFO ][org.reflections.Reflections] Reflections took 67 ms to scan 1 urls, producing 124 keys and 408 values
[2022-07-17T05:34:36,882][INFO ][logstash.javapipeline    ] Pipeline `main` is configured with `pipeline.ecs_compatibility: v8` setting. All plugins in this pipeline will default to `ecs_compatibility => v8` unless explicitly configured otherwise.
[2022-07-17T05:34:36,995][INFO ][logstash.javapipeline    ][main] Starting pipeline {:pipeline_id=>"main", "pipeline.workers"=>2, "pipeline.batch.size"=>125, "pipeline.batch.delay"=>50, "pipeline.max_inflight"=>250, "pipeline.sources"=>["config string"], :thread=>"#<Thread:0x44c28a87 run>"}
[2022-07-17T05:34:37,452][INFO ][logstash.javapipeline    ][main] Pipeline Java execution initialization time {"seconds"=>0.45}
[2022-07-17T05:34:37,521][INFO ][logstash.javapipeline    ][main] Pipeline started {"pipeline.id"=>"main"}
The stdin plugin is now waiting for input:
[2022-07-17T05:34:37,603][INFO ][logstash.agent           ] Pipelines running {:count=>1, :running_pipelines=>[:main], :non_running_pipelines=>[]}

等 Logstash 启动完毕,在控制台随便输入文本,然后回车,就可以看到 Logstash 将其转换为输出结果:

hello world
{
       "message" => "hello world",
      "@version" => "1",
    "@timestamp" => 2022-07-17T05:46:57.976318Z,
          "host" => {
        "hostname" => "6573ef0db968"
    },
         "event" => {
        "original" => "hello world"
    }
}

在上面的例子中,我们使用了 -e 参数来指定 Logstash 的 pipeline 配置,这个参数一般是用来调试 Logstash 的,真实场景下我们会将配置写在配置文件中,默认情况下,Logstash 的 pipeline 配置文件位于 /usr/share/logstash/pipeline/logstash.conf,内容如下:

input {
  beats {
    port => 5044
  }
}

output {
  stdout {
    codec => rubydebug
  }
}

其中 input 表示通过 5044 端口接收从 Filebeat 发送过来的数据,output 表示将结果输出到标准输出 stdout,并指定编码方式为 rubydebug,它会以格式化的 JSON 输出结果。接下来,我们要将我们的输出保存到 Elasticsearch,将配置文件修改如下:

input {
  stdin {
  }
}

output {
  stdout {
    codec => json_lines
  }
  elasticsearch {
    hosts => ["https://172.17.0.4:9200"]
    user => "elastic"
    password => "123456"
    ssl => true
    cacert => "/usr/share/logstash/http_ca.crt"
    index => "logstash-%{+YYYY.MM.dd}"
  }
}

然后重新启动 Logstash:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

其中 http_ca.crt 就是上面我们从 es 容器中复制出来的证书文件。这里有一点要注意的是,hosts 必须是容器里的 IP 地址,这是因为这个证书带有 SAN(Subject Alternative Name),只能通过 localhost 或 容器中的地址来访问 Elasticsearch,如果使用其他地址会报错:

[2022-07-17T07:23:48,228][WARN ][logstash.outputs.elasticsearch][main] Attempted to resurrect connection to dead ES instance, but got an error {:url=>"https://elastic:xxxxxx@192.168.1.35:9200/", :exception=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :message=>"Elasticsearch Unreachable: [https://192.168.1.35:9200/][Manticore::UnknownException] Certificate for <192.168.1.35> doesn't match any of the subject alternative names: [localhost, 876647d76274, 172.17.0.4, 127.0.0.1]"}

启动完成后,我们在控制台中随便输入文本,文本内容会自动输出到 Elasticsearch:

hello
{"@timestamp":"2022-07-17T07:40:23.556377Z","message":"hello","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"hello"}}
world
{"@timestamp":"2022-07-17T07:50:00.327637Z","message":"world","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"world"}}
hello world
{"@timestamp":"2022-07-17T07:50:04.285282Z","message":"hello world","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"hello world"}}

我们通过 HTTP 接口查看 Elasticsearch 的索引:

$ curl -k -u elastic:123456 https://localhost:9200/_cat/indices
yellow open logstash-2022.07.17 9Anz9bHjSay-GEnANNluBA 1 1 3 0 18.3kb 18.3kb

可以看到自动为我们创建了一个 logstash-2022.07.17 索引,我们可以通过 HTTP 接口检索:

$ curl -s -k -u elastic:123456 https://localhost:9200/_search?q=hello | jq
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 0.9808291,
    "hits": [
      {
        "_index": "logstash-2022.07.17",
        "_id": "rGMZC4IBQ5UjjhVY6pAZ",
        "_score": 0.9808291,
        "_source": {
          "@timestamp": "2022-07-17T07:40:23.556377Z",
          "message": "hello",
          "@version": "1",
          "host": {
            "hostname": "988f1263897d"
          },
          "event": {
            "original": "hello"
          }
        }
      },
      {
        "_index": "logstash-2022.07.17",
        "_id": "rmMiC4IBQ5UjjhVYxZBt",
        "_score": 0.39019167,
        "_source": {
          "@timestamp": "2022-07-17T07:50:04.285282Z",
          "message": "hello world",
          "@version": "1",
          "host": {
            "hostname": "988f1263897d"
          },
          "event": {
            "original": "hello world"
          }
        }
      }
    ]
  }
}

安装 Kibana

使用下面的 Docker 命令启动 Kibana 服务:

$ docker run --name kibana \
  -p 5601:5601 \
  -d docker.elastic.co/kibana/kibana:8.3.2

等待 Kibana 启动完成:

[2022-07-17T22:57:20.266+00:00][INFO ][plugins-service] Plugin "cloudSecurityPosture" is disabled.
[2022-07-17T22:57:20.403+00:00][INFO ][http.server.Preboot] http server running at http://0.0.0.0:5601
[2022-07-17T22:57:20.446+00:00][INFO ][plugins-system.preboot] Setting up [1] plugins: [interactiveSetup]
[2022-07-17T22:57:20.448+00:00][INFO ][preboot] "interactiveSetup" plugin is holding setup: Validating Elasticsearch connection configuration…
[2022-07-17T22:57:20.484+00:00][INFO ][root] Holding setup until preboot stage is completed.


i Kibana has not been configured.

Go to http://0.0.0.0:5601/?code=395267 to get started.

启动完成后,在浏览器输入 http://localhost:5601/?code=395267 访问 Kibana,第一次访问 Kibana 时需要配置 Elasticsearch 的 Enrollment token

kibana-enrollment-token.png

这个 Enrollment token 的值可以从 Elasticsearch 的启动日志中找到,也可以使用下面的命令生成:

$ docker exec -it es /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana

注意访问 Kibana 的链接中带有一个 code 参数,这个参数为第一次访问 Kibana 时的验证码,如果不带参数访问,Kibana 会要求你填写:

kibana-verification.png

这个验证码可以从 Kibana 的启动日志中找到,也可以使用下面的命令得到:

$ docker exec -it kibana /usr/share/kibana/bin/kibana-verification-code
Your verification code is:  395 267

填写验证码之后,Kibana 会自动完成 Elasticsearch 的配置,然后进入登录页面:

kibana-login.png

我们输入用户名和密码 elastic/123456 即可进入 Kibana 首页:

kibana-home.png

打开 Discover 页面 http://localhost:5601/app/discover#/

kibana-discover.png

第一次访问需要创建一个数据视图(data view),数据视图的名称必须要和索引相匹配,我们这里填入 logstash-*

kibana-create-data-view.png

然后就可以输入关键字对 Elasticsearch 进行检索了:

kibana-search.png

这里的检索语法被称为 KQL(Kibana Query Language),具体内容可 参考官方文档

配置 Logstash 采集日志文件

在前面的例子中,我们配置了 Logstash 从标准输入获取数据,并转发到标准输出或 Elasticsearch 中。一个完整的 Logstash 配置文件包含三个部分:inputfilteroutput,并且每个部分都是插件:

这一节我们将改用 file 作为输入,从文件系统中采集日志文件内容,并转发到 Elasticsearch。首先修改 logstash.conf 中的 input 配置:

input {
  file {
    type => "log"
    path => ["/app/logs/*.log"]
  }
}

然后重新启动 Logstash 容器,并将日志目录挂载到 /app/logs

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash-file.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -v "/home/aneasystone/logs":/app/logs \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

我们在 logs 目录下写入一点日志:

$ cd ~/logs
$ echo 'hello' > hello.log
$ echo 'hello world' >> hello.log

稍等片刻,就可以看到 Logstash 从日志文件中读取数据了:

{"event":{"original":"hello"},"@timestamp":"2022-07-18T23:48:27.709472Z","message":"hello","type":"log","@version":"1","host":{"name":"48bdb8490d22"},"log":{"file":{"path":"/app/logs/hello.log"}}}
{"event":{"original":"hello world"},"@timestamp":"2022-07-18T23:52:07.345525Z","message":"hello world","type":"log","@version":"1","host":{"name":"48bdb8490d22"},"log":{"file":{"path":"/app/logs/hello.log"}}}

使用 Filebeat 采集日志文件

尽管 Logstash 提供了 file 插件用于采集日志文件,但是一般在生产环境我们很少这样去用,因为 Logstash 相对来说还是太重了,它依赖于 JVM,当配置多个 pipeline 以及 filter 时,Logstash 会非常占内存。而采集日志的工作需要在每一台服务器上运行,我们希望采集日志消耗的资源越少越好。

于是 Filebeat 就出现了。它采用 Go 编写,非常轻量,只专注于采集日志,并将日志转发给 Logstash 甚至直接转发给 Elasticsearch。如下图所示:

efk.jpg

那么有人可能会问,既然可以直接从 Filebeat 将日志转发给 Elasticsearch,那么 Logstash 是不是就没用了?其实这取决于你的使用场景,如果你只是想将原始的日志收集到 Elasticsearch 而不做任何处理,确实可以不用 Logstash,但是如果你要对日志进行过滤和转换处理,Logstash 就很有用了。不过 Filebeat 也提供了 processors 功能,可以对日志做一些简单的转换处理。

下面我们就来实践下这种场景。首先修改 Logstash 配置文件中的 input,让 Logstash 可以接收 Filebeat 的请求:

input {
  beats {
    port => 5044
  }
}

然后重新启动 Logstash 容器,注意将 5044 端口暴露出来:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash-beat.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -p 5044:5044 \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

然后新建一个 Filebeat 的配置文件 filebeat.yml,内容如下:

filebeat.inputs:
- type: log
  paths:
    - /app/logs/*.log

output.logstash:
  hosts: ["192.168.1.35:5044"]

然后使用 Docker 启动 Filebeat 容器:

$ docker run --name filebeat \
  -v "/home/aneasystone/filebeat/filebeat.yml":/usr/share/filebeat/filebeat.yml \
  -v "/home/aneasystone/logs":/app/logs \
  -it --rm docker.elastic.co/beats/filebeat:8.3.2

这时只要我们向 logs 目录写入一点日志:

$ echo "This is a filebeat log" >> filebeat.log

Logstash 就可以收到 Filebeat 采集过来的日志了:

{"message":"This is a filebeat log","host":{"name":"8a7849b5c331"},"log":{"offset":0,"file":{"path":"/app/logs/filebeat.log"}},"event":{"original":"This is a filebeat log"},"input":{"type":"log"},"ecs":{"version":"8.0.0"},"@version":"1","agent":{"id":"3bd9b289-599f-4899-945a-94692bdaa690","name":"8a7849b5c331","ephemeral_id":"268a3170-0feb-4a86-ad96-7cce6a9643ec","version":"8.3.2","type":"filebeat"},"tags":["beats_input_codec_plain_applied"],"@timestamp":"2022-07-19T23:41:22.255Z"}

Filebeat 除了可以将日志推送给 Logstash,还支持很多其他的 output 配置,比如 Elasticsearch、Kafka、Redis 或者写入文件等等。下面是将日志推送给 Elasticsearch 的例子,具体参数请参考 Configure the Elasticsearch output

output.elasticsearch:
  hosts: ["https://localhost:9200"]
  username: "username"
  password: "password" 
  ssl:
    enabled: true
    ca_trusted_fingerprint: "xxx"

日志格式转换

在实际的使用场景中,我们往往要对各个地方采集来的日志做一些格式转换,而不是直接将原始的日志写入 Elasticsearch 中,因为结构化的日志更方便检索和统计。比如在采集 Nginx 的 access log 时,原始的日志内容如下:

172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] "GET / HTTP/1.1" 200 615 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" "-"
172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] "GET /favicon.ico HTTP/1.1" 404 555 "http://localhost/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" "-"

我们查看 Nginx 配置文件中的 log_format 可以知道,其实 access log 中的每一行都是由 remote_addrremote_usertime_localrequest 等字段构成的:

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
}

下面我们就通过 Logstash 的 filter 插件Filebeat 的 processors 功能 来将这个日志转换为结构化的字段。

Logstash 的 filter 插件

Logstash 自带大量的 filter 插件用于对日志做各种各样的处理,如果要从任意文本中抽取出结构化的字段,其中最常用的 filter 插件就是 DissectGrok,其中 Dissect 使用固定的分割符来提取字段,没有使用正则表达式,所以处理速度非常快,不过它只对固定格式的日志有效,对于不固定的文本格式就无能为力了。而 Grok 要强大的多,它使用正则表达式,几乎可以处理任意文本。

这里我们将使用 Grok 来处理 Nginx 的 access log。

Grok 通过一堆的模式来匹配你的日志,一个 Grok 模式的语法如下:

%{SYNTAX:SEMANTIC}

其中,SYNTAX 为模式名称,比如 NUMBER 表示数字,IP 表示 IP 地址,等等,Grok 内置了很多可以直接使用的模式,这里有一份完整列表SEMANTIC 为匹配模式的文本创建一个唯一标识。比如我们有下面这样一行日志:

55.3.244.1 GET /index.html 15824 0.043

可以使用下面的 Grok 模式来匹配:

%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}

Logstash 配置类似如下:

filter {
  grok {
    match => { "message" => "%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}" }
  }
}

其中 match => { "message" => "..." } 表示处理的是 message 字段。处理得到的结构化结果如下:

{
  "client": "55.3.244.1",
  "method": "GET",
  "request": "/index.html",
  "bytes": "15824",
  "duration": "0.043"
}

你可以使用 Grok DebuggerGrok Constructor 对 Grok 模式在线进行调试。

自定义 Grok 模式

另外,Logstash 内置的 Grok 模式可能不能满足你的需要,这时,你也可以自定义 Grok 模式。Grok 是基于正则表达式库 Oniguruma 实现的,所以我们可以使用 Oniguruma 的语法 来创建自定义的 Grok 模式。

自定义 Grok 模式的方式有以下三种:

  • 第一种是直接使用正则来定义 Grok 模式
filter {
  grok {
    match => { "message" => "(?<queue_id>[0-9A-F]{10,11})" }
  }
}
  • 第二种是将 Grok 模式定义在模式文件中,比如创建一个名为 patterns 的目录,然后在目录下新建一个文件(文件名任意),文件内容如下:
# commnets
POSTFIX_QUEUEID [0-9A-F]{10,11}

然后,在 Logstash 配置文件中,通过配置参数 patterns_dir 指定刚刚创建的那个模式目录,这样就可以和内置 Grok 模式一样的使用我们自定义的模式了:

filter {
  grok {
    patterns_dir => ["./patterns"]
    match => { "message" => "%{POSTFIX_QUEUEID:queue_id}" }
  }
}
  • 第三种是将 Grok 模式定义在 pattern_definitions 参数中,这种定义方式非常方便,不过这个模式只能在当前 grok 配置块内生效
filter {
  grok {
    pattern_definitions => {
      "POSTFIX_QUEUEID" => "[0-9A-F]{10,11}"
    }
    match => { "message" => "%{POSTFIX_QUEUEID:queue_id}" }
  }
}

处理 Nginx access log

回到上面一开始的问题,我们希望将 Nginx access log 转换为结构化的格式。学习了 Grok 模式的知识之后,我们完全可以自己写出匹配 Nginx access log 的 Grok 模式,不过这样做有点繁琐。实际上,Logstash 已经内置了很多常用的系统日志格式,比如:Apache httpd、Redis、Mongo、Java、Maven 等,参见这里

虽然没有专门的 Nginx 格式,但是 Nginx 默认的 access log 是符合 Apache httpd 日志规范的,只要你在 Nginx 配置文件中没有随便修改 log_format 配置,我们都可以直接使用 Apache httpd 的 Grok 模式来匹配 Nginx 的日志。

Apache httpd 的 Grok 模式 定义如下:

# Log formats
HTTPD_COMMONLOG %{IPORHOST:clientip} %{HTTPDUSER:ident} %{HTTPDUSER:auth} \[%{HTTPDATE:timestamp}\] "(?:%{WORD:verb} %{NOTSPACE:request}(?: HTTP/%{NUMBER:httpversion})?|%{DATA:rawrequest})" (?:-|%{NUMBER:response}) (?:-|%{NUMBER:bytes})
HTTPD_COMBINEDLOG %{HTTPD_COMMONLOG} %{QS:referrer} %{QS:agent}

# Deprecated
COMMONAPACHELOG %{HTTPD_COMMONLOG}
COMBINEDAPACHELOG %{HTTPD_COMBINEDLOG}

其中 HTTPD_COMBINEDLOG 就是 Apache httpd 的日志格式,在老的版本中通常使用 COMBINEDAPACHELOGGrok Debugger 就还是使用这个老的格式。

对比发现,Nginx 的日志比 Apache httpd 的日志就多了一个 $http_x_forwarded_for 字段,所以我们可以通过下面的配置来处理 Nginx access log:

filter {
  grok {
    match => { "message" => "%{HTTPD_COMBINEDLOG} %{QS:x_forwarded_for}" }
  }
}

Filebeat 的 processors 功能

Filebeat 提供了 很多的 processors 可以对日志进行一些简单的处理,比如 drop_event 用于过滤日志,convert 用于转换数据类型,rename 用于重命名字段等。但是 Filebeat 的定位是轻量级日志采集工具,最大的理念在于 轻量,所以只能做些简单的处理,上面所说的 Logstash 的 Grok 就不支持。

如果你的日志是固定格式的,可以使用 dissect 处理器 来进行处理,下面是一个 dissect 配置的例子:

processors:
  - dissect:
      tokenizer: '"%{pid|integer} - %{name} - %{status}"'
      field: "message"
      target_prefix: "service"

其中 tokenizer 用于定义日志的格式,上面的配置表示从日志中提取 pidnamestatus 三个字段,target_prefix 表示将这三个字段放在 service 下面,默认情况是放在 dissect 下面。

这个处理器可以处理下面这种格式的日志:

"321 - App01 - WebServer is starting"
"321 - App01 - WebServer is up and running"
"321 - App01 - WebServer is scaling 2 pods"
"789 - App02 - Database is will be restarted in 5 minutes"
"789 - App02 - Database is up and running"
"789 - App02 - Database is refreshing tables"

处理之后的日志格式如下:

{
  "@timestamp": "2022-07-23T01:01:54.310Z",
  "@metadata": {
    "beat": "filebeat",
    "type": "_doc",
    "version": "8.3.2"
  },
  "log": {
    "offset": 0,
    "file": {
      "path": ""
    }
  },
  "message": "\"789 - App02 - Database is will be restarted in 5 minutes\"",
  "input": {
    "type": "stdin"
  },
  "host": {
    "name": "04bd0b21796b"
  },
  "agent": {
    "name": "04bd0b21796b",
    "type": "filebeat",
    "version": "8.3.2",
    "ephemeral_id": "64187690-291c-4647-b935-93c249e85d29",
    "id": "43a89e87-0d74-4053-8301-0e6e73a00e77"
  },
  "ecs": {
    "version": "8.0.0"
  },
  "service": {
    "name": "App02",
    "status": "Database is will be restarted in 5 minutes",
    "pid": 789
  }
}

使用 script 处理器

虽然 Filebeat 不支持 Grok 处理器,但是它也提供了一种动态扩展的解决方案,那就是 script 处理器。在 script 处理器里,我们需要定义这样一个 process 方法:

function process(event) {
    // Put your codes here
}

Filebeat 在处理日志时,会将每一行日志都转换为一个 event 对象,然后调用这个 process 方法进行处理,event 对象提供了下面这些 API 用于处理日志:

  • Get(string)
  • Put(string, value)
  • Rename(string, string)
  • Delete(string)
  • Cancel()
  • Tag(string)
  • AppendTo(string, string)

具体的使用方法可以参考官方文档。下面我们写一个简单的 script 处理器来处理上面例子中的日志格式:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            message = message.substring(1, message.length-1);
            var s = message.split(' - ');
            event.Put('service.pid', s[0]);
            event.Put('service.name', s[1]);
            event.Put('service.status', s[2]);
        }

处理之后的日志格式和上面使用 dissect 处理器是一样的。

我们知道 Grok 是基于正则表达式来处理日志的,当然也可以在 Javascript 脚本中使用正则表达式:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            var match = message.match(/"(.+) - (.+) - (.+)"/);
            event.Put('service.pid', match[1]);
            event.Put('service.name', match[2]);
            event.Put('service.status', match[3]);
        }

不过要注意的是,Filebeat 使用 dop251/goja 这个库来解析 Javascript 脚本,目前只支持 ECMA 5.1 的语法规范(也就是 ECMAScript 2009),如果使用了最新的 Javascript 语法,可能会导致 Filebeat 异常退出。

比如我们使用正则表达式的命名捕获分组(named capturing groups)来优化上面的代码:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            var match = message.match(/"(?<pid>.+) - (?<name>.+) - (?<status>.+)"/);
            event.Put('service', match.groups);
        }

Filebeat 直接启动就报错了:

Exiting: error initializing processors: SyntaxError: Invalid regular expression (re2): "(?<pid>[^\r\n]+) - (?<name>[^\r\n]+) - (?<status>[^\r\n]+)" (error parsing regexp: invalid or unsupported Perl syntax: `(?<`) at 3:31

这是因为命名捕获分组这个特性,是在 ECMA 9(也就是 ECMAScript 2018)中才引入的,所以在 script 处理器中编写 Javascript 代码时需要特别注意语法的版本问题。

使用 Elasticsearch 的 Ingest pipelines 功能

如果你的 Filebeat 采集的日志是直接推送给 Elasticsearch 的,那么还可以使用 Elasticsearch 的 Ingest pipelines 功能 对日志进行处理。Ingest pipelines 可以让数据在进入 Elasticsearch 索引之前进行转换,它通过一系列的 processors 对接收到的数据进行预处理,这和 Logstash 的 filter 或 Filebeat 的 processors 功能几乎是一样的。

Ingest pipelines 是支持 Grok 处理器的,所以处理 Nginx 日志就非常简单了,可以参考上面 Logstash 的配置,直接使用 Grok 来处理。首先我们需要在 Elasticsearch 里创建一个 Ingest pipeline。你可以参考 这里 通过 Elasticsearch 的接口来创建,也可以在 Kibana 通过可视化界面进行配置,这里我们选择 Kibana 来创建 Ingest pipeline。

首先访问 Kibana 页面,进入 Stack Management -> Ingest Pipelines

kibana-ingest-pipelines.png

点击 Create pipeline 进入创建页面:

kibana-create-pipeline.png

填写 pipeline 的名称和描述,然后点击 Add a processor 添加一个处理器:

kibana-add-a-processor.png

我们在 Processor 下拉列表中选择 Grok,字段 Field 中填写 message,然后在 Patterns 里填写 Grok 表达式:

%{HTTPD_COMMONLOG} %{QS:referrer} %{QS:http_user_agent} %{QS:x_forwarded_for}

注意这里不能填上面配置 Logstash 那个 Grok 表达式,%{HTTPD_COMBINEDLOG} %{QS:x_forwarded_for},因为 HTTPD_COMBINEDLOG 里包含了一个 agent 字段和 Filebeat 采集的日志里的 agent 字段冲突了,所以这里我们将其重新命名为 http_user_agent

最后点击 Add 完成添加。

创建 Ingest pipeline 之后,可以通过 Kibana 提供的 Test pipeline 功能对文档进行测试:

kibana-test-pipeline.png

我们可以填入一个测试文档:

[
  {
    "_source": {
      "message": "172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] \"GET /favicon.ico HTTP/1.1\" 404 555 \"http://localhost/\" \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36\" \"-\""
    }
  }
]

然后点击 Run the pipeline,就可以得到处理后的结果。

至此,对 Elasticsearch 的配置就完成了。接下来我们配置 Filebeat,将日志直接采集到 Elasticsearch:

output.elasticsearch:
  hosts: ["https://172.17.0.3:9200"]
  username: "elastic"
  password: "123456"
  pipeline: "nginx-accesslog-pipeline"
  ssl:
    enabled: true
    ca_trusted_fingerprint: "6E597C77AB87235D381A82D961C46F2AEEC16A64C2042BF30925E097E0292069"

其中 pipeline 就是我们上面创建的 Ingest pipeline,日志推送到 Elasticsearch 之后会经过该 pipeline 处理后再存入索引,可以使用 pipelines 配置多个 pipeline,默认存入的索引为 filebeat-%{[agent.version]}-%{+yyyy.MM.dd},可以通过 index 配置进行修改,不过注意的是,index 修改之后,要记得 同时修改 setup.template 配置

另外,ca_trusted_fingerprint 表示证书的指纹信息,我们可以从 http_ca.crt 文件中得到:

$ openssl x509 -fingerprint -sha256 -in http_ca.crt
SHA256 Fingerprint=6E:59:7C:77:AB:87:23:5D:38:1A:82:D9:61:C4:6F:2A:EE:C1:6A:64:C2:04:2B:F3:09:25:E0:97:E0:29:20:69
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----

Kibana -> Discover 查询采集后的日志,确认我们配置的字段已经生效:

kibana-discover-filebeat.png

另外,为了简化 Ingest pipeline 的配置,Filebeat 还内置了 大量的 Modules。一个 Module 包括了下面这些配置:

  • Filebeat 的 input 配置,已经将常用组件的默认日志路径配置好了
  • Elasticsearch 的 Ingest pipeline 配置,用于对日志进行处理
  • 定义了各个字段的类型和描述
  • 自带一个默认的 Kibana 面板

通过这种方式,常用组件的日志都可以直接通过内置的 Modules 来配置,比如采集 Nginx 的日志可以参考 Nginx module 页面。

参考

  1. Install Elasticsearch with Docker
  2. Running Logstash on Docker
  3. Install Kibana with Docker
  4. ELK6.0部署:Elasticsearch+Logstash+Kibana搭建分布式日志平台
  5. Filebeat vs. Logstash: The Evolution of a Log Shipper
  6. https://blog.csdn.net/mawming/article/details/78344939
  7. https://www.feiyiblog.com/2020/03/06/ELK%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90%E7%B3%BB%E7%BB%9F/
  8. 如何在 Filebeat 端进行日志处理
  9. Elastic:开发者上手指南

更多

在 WSL Ubuntu 中访问 Docker Desktop

在 WSL Ubuntu 中安装 docker 客户端:

$ curl -O https://download.docker.com/linux/static/stable/x86_64/docker-20.10.9.tgz
$ sudo tar xzvf docker-20.10.9.tgz --strip=1 -C /usr/local/bin docker/docker

然后确保 Docker Desktop 开启了 2375 端口:

tcp-2375.png

设置环境变量 DOCKER_HOST

$ export DOCKER_HOST=tcp://localhost:2375

这样就可以在 WSL Ubuntu 中访问 Docker Desktop 了:

$ docker ps

如果想每次打开 WSL Ubuntu 时,环境变量都生效,可以将上面的 export 命令加到 ~/.profile 文件中。

另外有一点要注意的是,使用这种方式在 WSL Ubuntu 下运行 docker -v 挂载文件时,需要使用真实的 Windows 下的路径,而不是 Linux 下的路径。譬如下面的命令:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

要改成这样:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

其中,C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs 是 WSL Ubuntu 在 Windows 下的根路径。

Elastic Beats

除了 Filebeat,Elastic 还提供了其他一些轻量型的数据采集器:

使用 Logback 对接 Logstash

https://github.com/logfellow/logstash-logback-encoder

其他学习资料

  1. Logstash 最佳实践
  2. Elasticsearch Guide
  3. Logstash Guide
  4. Filebeat Guide
  5. Kibana Guide
扫描二维码,在手机上阅读!

在 VirtualBox 上安装 Docker 服务

在 VirtualBox 上安装 CentOS 实验环境 中,我们在 VirtualBox 上安装了 CentOS 实验环境,这一节我们会继续在这个环境上安装 Docker 服务。

1. 使用 XShell 连接虚拟机

在虚拟机里进行几次操作之后,我们发现,由于这个系统是纯命令行界面,无法使用 VirtualBox 的增强功能,比如共享文件夹、共享剪切板等,每次想从虚拟机中复制一段文本出来都非常麻烦。所以,如果能从虚拟机外面用 XShell 登录进行操作,那就完美了。

我们首先登录虚拟机,查看 IP:

[root@localhost ~]# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:c1:96:99 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.6/24 brd 10.0.2.255 scope global noprefixroute dynamic enp0s3
       valid_lft 453sec preferred_lft 453sec
    inet6 fe80::e0ae:69af:54a5:f8d0/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever

然后使用 XShell 连接 10.0.2.6 的 22 端口:

xshell-docker-1.png

可是却发现连接不了:

Connecting to 10.0.2.6:22...
Could not connect to '10.0.2.6' (port 22): Connection failed.

通过复习 在 VirtualBox 上安装 CentOS 实验环境 的内容,我们知道目前我们使用的 VirtualBox 的网络模式是 NAT 网络,在这种网络模式下,宿主机是无法直接访问虚拟机的,而要通过 端口转发(Port Forwarding)

我们打开 VirtualBox “管理” -> “全局设定” 菜单,找到 “网络” 选项卡,在这里能看到我们使用的 NAT 网络:

virtualbox-network-setting.png

双击 NatNetwork 打开 NAT 网络的配置:

virtualbox-network-setting-2.png

会发现下面有一个 端口转发 的按钮,在这里我们可以定义从宿主机到虚拟机的端口映射:

virtualbox-nat-port-forwarding.png

我们新增这样一条规则:

  • 协议: TCP
  • 主机:192.168.1.43:2222
  • 子系统:10.0.2.6:22

这表示 VirtualBox 会监听宿主机 192.168.1.43 的 2222 端口,并将 2222 端口的请求转发到 10.0.2.6 这台虚拟机的 22 端口。

我们使用 XShell 连接 192.168.1.43:2222,这一次成功进入了:

Connecting to 192.168.1.43:2222...
Connection established.
To escape to local shell, press 'Ctrl+Alt+]'.

WARNING! The remote SSH server rejected X11 forwarding request.
Last login: Mon Feb 21 06:49:44 2022
[root@localhost ~]#

2. 通过 yum 安装 Docker

系统默认的仓库里是没有 Docker 服务的:

[root@localhost ~]# ls /etc/yum.repos.d/
CentOS-Base.repo  CentOS-Debuginfo.repo  CentOS-Media.repo    CentOS-Vault.repo
CentOS-CR.repo    CentOS-fasttrack.repo  CentOS-Sources.repo  CentOS-x86_64-kernel.repo

我们需要先在系统中添加 Docker 仓库,可以直接将仓库文件下载下来放到 /etc/yum.repos.d/ 目录,也可以通过 yum-config-manager 命令来添加。

先安装 yum-utils

[root@localhost ~]# yum install -y yum-utils

再通过 yum-config-manager 添加 Docker 仓库:

[root@localhost ~]# yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
已加载插件:fastestmirror
adding repo from: https://download.docker.com/linux/centos/docker-ce.repo
grabbing file https://download.docker.com/linux/centos/docker-ce.repo to /etc/yum.repos.d/docker-ce.repo
repo saved to /etc/yum.repos.d/docker-ce.repo

接下来我们继续安装 Docker 服务:

[root@localhost ~]# yum install docker-ce docker-ce-cli containerd.io

安装过程根据提示输入 y 确认即可,另外,还会提示你校验 GPG 密钥,正常情况下这个密钥的指纹应该是 060a 61c5 1b55 8a7f 742b 77aa c52f eb6b 621e 9f35

从 https://download.docker.com/linux/centos/gpg 检索密钥
导入 GPG key 0x621E9F35:
 用户ID     : "Docker Release (CE rpm) <docker@docker.com>"
 指纹       : 060a 61c5 1b55 8a7f 742b 77aa c52f eb6b 621e 9f35
 来自       : https://download.docker.com/linux/centos/gpg
是否继续?[y/N]:y

如果安装顺利,就可以通过 systemctl start docker 启动 Docker 服务了,然后运行 docker run hello-world 验证 Docker 服务是否正常:

[root@localhost ~]# systemctl start docker
[root@localhost ~]# docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
2db29710123e: Pull complete 
Digest: sha256:97a379f4f88575512824f3b352bc03cd75e239179eea0fecc38e597b2209f49a
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

看到这个提示信息,说明 Docker 服务已经在虚拟机中正常运行了。

3. 通过 docker-install 脚本安装 Docker

官方提供了一个便捷的脚本来一键安装 Docker,可以通过如下命令下载该脚本:

[root@localhost ~]# curl -fsSL https://get.docker.com -o get-docker.sh

其中,-f/--fail 表示连接失败时不显示 HTTP 错误,-s/--silent 表示静默模式,不输出任何内容,-S/--show-error 表示显示错误,-L/--location 表示跟随重定向,-o/--output 表示将输出写入到某个文件中。

下载完成后,执行该脚本会自动安装 Docker:

[root@localhost ~]# sh ./get-docker.sh

如果想知道这个脚本具体做了什么,可以在执行命令之前加上 DRY_RUN=1 选项:

[root@localhost ~]# DRY_RUN=1 sh ./get-docker.sh
# Executing docker install script, commit: 93d2499759296ac1f9c510605fef85052a2c32be
yum install -y -q yum-utils
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum makecache
yum install -y -q docker-ce
yum install -y -q docker-ce-rootless-extras

可以看出和上一节手工安装的步骤基本类似,安装完成后,启动 Docker 服务并运行 hello-world 验证:

[root@localhost ~]# systemctl start docker
[root@localhost ~]# docker run hello-world

4. 离线安装 Docker

上面两种安装方式都需要连接外网,当我们的机器位于离线环境时(air-gapped systems),我们需要提前将 Docker 的安装包下载准备好。

我们从 https://download.docker.com/linux/ 这里找到对应的 Linux 发行版本和系统架构,比如我这里的系统是 CentOS 7.9,系统架构是 x84_64,所以就进入 /linux/centos/7/x86_64/stable/Packages/ 这个目录。但是这个目录里有很多的文件,我们该下载哪个文件呢?

为了确定要下载的文件和版本,我们进入刚刚安装的那个虚拟机中,通过 yum list installed | grep docker 看看自动安装时都安装了哪些包:

[root@localhost ~]# yum list installed | grep docker
containerd.io.x86_64                 1.4.12-3.1.el7                 @docker-ce-stable
docker-ce.x86_64                     3:20.10.12-3.el7               @docker-ce-stable
docker-ce-cli.x86_64                 1:20.10.12-3.el7               @docker-ce-stable
docker-ce-rootless-extras.x86_64     20.10.12-3.el7                 @docker-ce-stable
docker-scan-plugin.x86_64            0.12.0-3.el7                   @docker-ce-stable

我们将这些包都下载下来复制到一台新的虚拟机中,将网络服务关闭:

[root@localhost ~]# service network stop

然后执行 yum install 命令安装这些 RPM 包:

[root@localhost ~]# yum install *.rpm

我们会发现 yum 在安装的时候会自动解析依赖,还是会从外网下载,会出现一堆的报错:

rpm-install-docker.png

我们可以对照着这个列表再去一个个的下载对应的包,全部依赖安装完毕后,再安装 Docker 即可。

当然这样一个个去试并不是什么好方法,我们可以使用 rpm-q 功能,查询 RPM 包的信息,-R 表示查询 RPM 包的依赖:

[root@localhost docker]# rpm -q -R -p docker-ce-20.10.12-3.el7.x86_64.rpm 
警告:docker-ce-20.10.12-3.el7.x86_64.rpm: 头V4 RSA/SHA512 Signature, 密钥 ID 621e9f35: NOKEY
/bin/sh
/bin/sh
/bin/sh
/usr/sbin/groupadd
container-selinux >= 2:2.74
containerd.io >= 1.4.1
docker-ce-cli
docker-ce-rootless-extras
iptables
libc.so.6()(64bit)
libc.so.6(GLIBC_2.2.5)(64bit)
libc.so.6(GLIBC_2.3)(64bit)
libcgroup
libdevmapper.so.1.02()(64bit)
libdevmapper.so.1.02(Base)(64bit)
libdevmapper.so.1.02(DM_1_02_97)(64bit)
libdl.so.2()(64bit)
libdl.so.2(GLIBC_2.2.5)(64bit)
libpthread.so.0()(64bit)
libpthread.so.0(GLIBC_2.2.5)(64bit)
libpthread.so.0(GLIBC_2.3.2)(64bit)
libseccomp >= 2.3
libsystemd.so.0()(64bit)
libsystemd.so.0(LIBSYSTEMD_209)(64bit)
rpmlib(CompressedFileNames) <= 3.0.4-1
rpmlib(FileDigests) <= 4.6.0-1
rpmlib(PayloadFilesHavePrefix) <= 4.0-1
rtld(GNU_HASH)
systemd
tar
xz
rpmlib(PayloadIsXz) <= 5.2-1

但是这样一个个去下载依赖的包也是很繁琐的,有没有什么办法能将依赖的包一次性都下载下来呢?当然有!还记得上面的 yum install 安装命令吧,其实 yum 在安装之前,先是做了两件事情,第一步解析包的依赖,然后将所有依赖的包下载下来,最后才是安装。而 yum --downloadonly 可以让我们只将依赖包下载下来,默认情况下 yum 将依赖的包下载到 /var/cache/yum/x86_64/[centos/fedora-version]/[repository]/packages 目录,其中 [repository] 表示来源仓库的名称,比如 base、docker-ce-stable、extras 等,不过这样还是不够友好,我们希望下载下来的文件放在一起,这时可以使用 --downloaddir 参数来指定下载目录:

[root@localhost ~]# yum --downloadonly --downloaddir=. install docker-ce docker-ce-cli containerd.io
已加载插件:fastestmirror
Loading mirror speeds from cached hostfile
 * base: mirrors.bupt.edu.cn
 * extras: mirrors.dgut.edu.cn
 * updates: mirrors.bupt.edu.cn
正在解决依赖关系
--> 正在检查事务
---> 软件包 containerd.io.x86_64.0.1.4.12-3.1.el7 将被 安装
--> 正在处理依赖关系 container-selinux >= 2:2.74,它被软件包 containerd.io-1.4.12-3.1.el7.x86_64 需要
---> 软件包 docker-ce.x86_64.3.20.10.12-3.el7 将被 安装
--> 正在处理依赖关系 docker-ce-rootless-extras,它被软件包 3:docker-ce-20.10.12-3.el7.x86_64 需要
--> 正在处理依赖关系 libcgroup,它被软件包 3:docker-ce-20.10.12-3.el7.x86_64 需要
---> 软件包 docker-ce-cli.x86_64.1.20.10.12-3.el7 将被 安装
--> 正在处理依赖关系 docker-scan-plugin(x86-64),它被软件包 1:docker-ce-cli-20.10.12-3.el7.x86_64 需要
--> 正在检查事务
---> 软件包 container-selinux.noarch.2.2.119.2-1.911c772.el7_8 将被 安装
--> 正在处理依赖关系 policycoreutils-python,它被软件包 2:container-selinux-2.119.2-1.911c772.el7_8.noarch 需要
---> 软件包 docker-ce-rootless-extras.x86_64.0.20.10.12-3.el7 将被 安装
--> 正在处理依赖关系 fuse-overlayfs >= 0.7,它被软件包 docker-ce-rootless-extras-20.10.12-3.el7.x86_64 需要
--> 正在处理依赖关系 slirp4netns >= 0.4,它被软件包 docker-ce-rootless-extras-20.10.12-3.el7.x86_64 需要
---> 软件包 docker-scan-plugin.x86_64.0.0.12.0-3.el7 将被 安装
---> 软件包 libcgroup.x86_64.0.0.41-21.el7 将被 安装
--> 正在检查事务
---> 软件包 fuse-overlayfs.x86_64.0.0.7.2-6.el7_8 将被 安装
--> 正在处理依赖关系 libfuse3.so.3(FUSE_3.2)(64bit),它被软件包 fuse-overlayfs-0.7.2-6.el7_8.x86_64 需要
--> 正在处理依赖关系 libfuse3.so.3(FUSE_3.0)(64bit),它被软件包 fuse-overlayfs-0.7.2-6.el7_8.x86_64 需要
--> 正在处理依赖关系 libfuse3.so.3()(64bit),它被软件包 fuse-overlayfs-0.7.2-6.el7_8.x86_64 需要
---> 软件包 policycoreutils-python.x86_64.0.2.5-34.el7 将被 安装
--> 正在处理依赖关系 setools-libs >= 3.3.8-4,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libsemanage-python >= 2.5-14,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 audit-libs-python >= 2.1.3-4,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 python-IPy,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libqpol.so.1(VERS_1.4)(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libqpol.so.1(VERS_1.2)(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libapol.so.4(VERS_4.0)(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 checkpolicy,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libqpol.so.1()(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libapol.so.4()(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
---> 软件包 slirp4netns.x86_64.0.0.4.3-4.el7_8 将被 安装
--> 正在检查事务
---> 软件包 audit-libs-python.x86_64.0.2.8.5-4.el7 将被 安装
---> 软件包 checkpolicy.x86_64.0.2.5-8.el7 将被 安装
---> 软件包 fuse3-libs.x86_64.0.3.6.1-4.el7 将被 安装
---> 软件包 libsemanage-python.x86_64.0.2.5-14.el7 将被 安装
---> 软件包 python-IPy.noarch.0.0.75-6.el7 将被 安装
---> 软件包 setools-libs.x86_64.0.3.3.8-4.el7 将被 安装
--> 解决依赖关系完成

依赖关系解决

========================================================================================================================================================
 Package                                    架构                    版本                                        源                                 大小
========================================================================================================================================================
正在安装:
 containerd.io                              x86_64                  1.4.12-3.1.el7                              docker-ce-stable                   28 M
 docker-ce                                  x86_64                  3:20.10.12-3.el7                            docker-ce-stable                   23 M
 docker-ce-cli                              x86_64                  1:20.10.12-3.el7                            docker-ce-stable                   30 M
为依赖而安装:
 audit-libs-python                          x86_64                  2.8.5-4.el7                                 base                               76 k
 checkpolicy                                x86_64                  2.5-8.el7                                   base                              295 k
 container-selinux                          noarch                  2:2.119.2-1.911c772.el7_8                   extras                             40 k
 docker-ce-rootless-extras                  x86_64                  20.10.12-3.el7                              docker-ce-stable                  8.0 M
 docker-scan-plugin                         x86_64                  0.12.0-3.el7                                docker-ce-stable                  3.7 M
 fuse-overlayfs                             x86_64                  0.7.2-6.el7_8                               extras                             54 k
 fuse3-libs                                 x86_64                  3.6.1-4.el7                                 extras                             82 k
 libcgroup                                  x86_64                  0.41-21.el7                                 base                               66 k
 libsemanage-python                         x86_64                  2.5-14.el7                                  base                              113 k
 policycoreutils-python                     x86_64                  2.5-34.el7                                  base                              457 k
 python-IPy                                 noarch                  0.75-6.el7                                  base                               32 k
 setools-libs                               x86_64                  3.3.8-4.el7                                 base                              620 k
 slirp4netns                                x86_64                  0.4.3-4.el7_8                               extras                             81 k

事务概要
========================================================================================================================================================
安装  3 软件包 (+13 依赖软件包)

总下载量:95 M
安装大小:387 M
Background downloading packages, then exiting:
exiting because "Download Only" specified

可以看到 3 个软件包和 13 个依赖包都下载好了,我们将这 16 个包全部复制到离线机器上,运行 yum install *.rpm 即可。

除了 yum --downloadonly 实现离线安装之外,还有很多其他的方式,比如:搭建自己的本地 yum 源就是一种更通用的解决方案,可以根据参考链接尝试一下。

参考

  1. Install Docker Engine on CentOS
  2. curl - How To Use
  3. Centos7通过reposync搭建本地Yum源
扫描二维码,在手机上阅读!

在 VirtualBox 上安装 CentOS 实验环境

1. 下载 VirtualBox

进入 VirtualBox 官方下载页面 下载即可。

virtualbox-website.png

2. 双击 exe 文件按步骤安装

安装完成后点击运行,运行截图如下:

virtualbox-install-done.png

3. 下载 CentOS ISO

进入 CentOS 官方下载页面,选择任意一个 mirror 站点,下载精简版本的 CentOS-7-x86_64-Minimal-2009.iso 文件。

4. 在 VirtualBox 上创建一个虚拟机

点击 “新建” 弹出 “新建虚拟电脑” 对话框,依次填写:名称、文件夹、类型、版本、内存大小(1024MB)。

copy-virtual-machine.png

在 “虚拟硬盘” 下选择 “现在创建虚拟硬盘”,点击 “创建” 按钮弹出 “创建虚拟硬盘” 对话框:

create-vdi.png

然后选择文件位置、文件大小(8GB)、虚拟硬盘文件类型(VDI)、动态分配,并点击 “创建” 按钮完成虚拟机的创建。

5. 在 VirtualBox 上安装 CentOS 操作系统

完成虚拟机的创建后,该虚拟机默认为 “关闭中” 状态,这时的虚拟机还没有安装任何操作系统,是启动不了的。

create-virtual-machine-done.png

我们点击 “设置” 按钮,在 “设置” 对话框中选择 “存储”,右侧的 “存储介质” 中可以看到 “控制器:IDE” 里显示着 “没有盘片”。我们单击选择 “没有盘片” 这一行,右侧会显示对应的 “分配光驱” 属性:

select-iso.png

我们在下拉列表中点击 “选择虚拟盘”,并选择我们刚刚下载的 CentOS-7-x86_64-Minimal-2009.iso 文件,并点击 OK 确认。这个时候我们就可以开机了。双击这个虚拟机启动,首次启动会提示你选择启动盘,直接确定即可。进入我们的安装界面:

boot-centos.png

选择 “Install CentOS 7” 并按回车就开始安装 CentOS 操作系统了。首先选择语言:

select-language.png

然后选择安装位置:

select-install-location.png

直接 “自动配置分区” 即可:

select-install-location-2.png

点击 “开始安装” 进入自动安装界面:

start-install.png

在这个界面你可以 “设置 ROOT 密码” 或者 “创建用户”,然后等待系统安装完成,并 “重启” 虚拟机。

install-done.png

重启后,输入用户名和密码,就进入 CentOS 系统了:

install-done-2.png

6. 配置网络环境

系统刚安装好时,网络并没有配置,此时无法访问外网,也不能访问宿主机,如下:

# ping www.baidu.com
ping: www.baidu.com: Name or service not known

# 无线局域网适配器 WLAN
# ping 192.168.1.43
connect: Network is unreachable

# 以太网适配器 vEthernet (Default Switch)
# ping 172.24.128.1
connect: Network is unreachable

# 以太网适配器 VirtualBox Host-Only Network
# ping 192.168.56.1
connect: Network is unreachable

可以通过 ifcfg 文件来配置网络:

vi /etc/sysconfig/network-scripts/ifcfg-enp0s3

ifcfg 文件内容如下:

TYPE=Ethernet
PROXY_METHOD=none
BROWSER_ONLY=no
BOOTPROTO=dhcp
DEFROUTE=yes
IPV4_FAILURE_FATAL=no
IPV6INIT=yes
IPV6_AUTOCONF=yes
IPV6_DEFROUTE=yes
IPV6_FAILURE_FATAL=no
IPV6_ADDR_GEN_MODE=stable-privacy
NAME=enp0s3
UUID=68f56f36-xxxx-xxxx-xxxx-24ca69e80f4d
DEVICE=enp0s3
ONBOOT=no

对配置文件内容感兴趣的同学可以参考 这里的参数说明,将最后一行的 ONBOOT=no 改成 ONBOOT=yes,然后重启机器:

# reboot

重启后进入系统,就可以正常访问网络了:

# ping 192.168.1.43

至此,一台虚拟机环境搭建完成。

7. 搭建虚拟机集群环境

一台虚拟机环境对我们来说还远远不够,我们需要多创建几个虚拟机,VirtualBox 提供了虚拟机复制的功能,这样可以省去我们再重新安装。在虚拟机右键菜单中选择 “复制”:

menu-copy.png

在弹出的 “复制虚拟电脑” 对话框中填写新的虚拟机名称和其他一些配置:

copy-virtual-machine.png

“副本类型” 选择 “完全复制”:

copy-virtual-machine-2.png

复制完成后就生成了一个新的虚拟机,新的虚拟机和复制的虚拟机环境是完全一样的。我们通过这种方式复制 5 个虚拟机备用。

8. 测试集群网络

我们启动 centos-1 和 centos-2 两个虚拟机,登陆进去检查和主机的连通性,并使用 ip addr 查看本机的网络地址:

two-virtual-machines.png

我们发现两台虚拟机的网络配置完全一样,就连 IP 和 MAC 地址都一模一样,这导致两个虚拟机之间根本无法通信,这是为什么呢?

如果你仔细的话,打开两个虚拟机网络配置的高级选项,可以看到 VirtualBox 为两个虚拟机生成的 MAC 地址是一模一样的:

two-virtual-machine-settings.png

这是因为在复制虚拟机的时候,对 MAC 地址有这么一个下拉选项:

copy-virtual-machine-3.png

选项中有三种情况:

  • 包含所有网卡的 MAC 地址
  • 仅包含 NAT 网卡的 MAC 地址
  • 为所有网卡重新生成 MAC 地址

第一种和第三种都比较好理解,第一种是完全复制所有网卡的 MAC 地址,新生成的虚拟机所有网卡的 MAC 地址和之前的都一样,第三种是为所有网卡都重新生成 MAC 地址,而默认是第二种 仅包含 NAT 网卡的 MAC 地址,这个表示如果虚拟机的网络模式是 NAT,则完全复制,其他类型的网卡会重新生成 MAC 地址。那为什么对 NAT 网络模式会有这样的特殊照顾呢?

这里我们就需要学习下 VirtualBox 的网络模式了,VirtualBox 提供了各种不同的网络模式来满足各种不同的实验要求:

  • 网络地址转换(NAT)
  • 桥接网卡
  • 内部网络
  • 仅主机(Host-Only)网络
  • 通用网络
  • NAT 网络
  • Cloud Network(实验中)

默认的网络模式为 网络地址转换(NAT)

virtualbox-network-modes-2.png

VirtualBox 官方文档 对 NAT 有如下一段说明:

A virtual machine with NAT enabled acts much like a real computer that connects to the Internet through a router. The router, in this case, is the Oracle VM VirtualBox networking engine, which maps traffic from and to the virtual machine transparently. In Oracle VM VirtualBox this router is placed between each virtual machine and the host. This separation maximizes security since by default virtual machines cannot talk to each other.

可以看出,NAT 就像是一个介于宿主机和虚拟机之间的路由器,用于转发虚拟机到外网的流量。每个虚拟机和宿主机之间都有这么一个路由器,也就是说网络模式为 NAT 的时候,每个虚拟机都是独立于其他虚拟机的,之间是互不影响的,也是不能互相通信的,所以复制的时候,MAC 地址一模一样也无所谓了。

根据下面这张图,如果要让虚拟机之间能通信,我们可以选择除 NAT 之外的任何一个网络模式都可以,但是 Host-Only 模式会导致虚拟机访问不了外网,Internal 模式会导致虚拟机访问不了外网和宿主机:

virtualbox-network-modes.png

所以最好的选择是 桥接网卡NAT 网络 这两个模式,这里我们选择 NAT 网络 模式。

8.1 新建 NAT 网络

首先打开 VirtualBox 的 “管理” 菜单,选择 “全局设定”,然后在全局设定对话框中选择 “网络” 选项卡,点击右侧的加号,添加一个新的 NAT 网络,默认名称为 NatNetwork

create-nat-network.png

可以双击这个 NAT 网络查看它的配置:

nat-network-setting.png

然后打开虚拟机的网络配置,将网络模式从 网络地址转换(NAT) 改成 NAT 网络,界面名称选择刚刚创建的那个 NAT 网络 NatNetwork,并且记得在高级选项中重新生成一个 MAC 地址(因为刚刚复制的虚拟机 MAC 地址都是一样的,在同一个网络中会导致冲突):

change-network-mode-to-nat-network.png

对其他的虚拟机重复刚才的操作。

8.2 验证

我们启动 centos-1 和 centos-2 两个虚拟机,登陆进去检查和主机的连通性,并使用 ip addr 查看本机的网络地址:

two-virtual-machines-3.png

我们发现两台虚拟机的 IP 已经变得不一样了,然后通过 ping 检查虚拟机之间的联通性也没问题,至此,虚拟机集群环境搭建完成。

参考

  1. https://www.virtualbox.org
  2. https://centos.org/
  3. Chapter 6. Virtual Networking
  4. 3.6. CONFIGURING IP NETWORKING WITH IP COMMANDS
  5. 3.5. CONFIGURING IP NETWORKING WITH IFCFG FILES
  6. 11.2. INTERFACE CONFIGURATION FILES

更多

1. 使用 ip 配置网络

根据 Red Hat 官方文档 中的说明,由于 net-tools 不支持 InfiniBand无限带宽技术,缩写为 IB),ip 命令被用来替换 ifconfig 命令:

Note that the ip utility replaces the ifconfig utility because the net-tools package (which provides ifconfig) does not support InfiniBand addresses.

ip 命令的使用示例如下:

ip link set enp0s3 down
ip address add 10.0.2.15/24 dev enp0s3
ip link set enp0s3 up

2. 在 ifcfg 文件中配置静态地址

DEVICE="eth0"
BOOTPROTO="static"
NM_CONTROLLED="no"
ONBOOT="yes"
TYPE="Ethernet"
UUID=27137241-842e-4e50-88dd-8d8da1305dc0
DEFROUTE=yes
IPADDR=192.168.254.109
NETMASK=255.255.255.0
GATEWAY=
DNS1=
DNS2=
HWADDR=00:90:27:50:5B:30
ARPCHECK=no
扫描二维码,在手机上阅读!

记一个 Docker 镜像无法运行的坑

最近在工作中遇到了一个 Docker 镜像无法运行的问题,事后总结时发现其中有几个点挺有意思,值得记录下来以备后用,也可以避免其他人踩坑。

一、运行镜像报错

我的开发环境是 Windows,使用 Docker Desktop 作为本地 Docker 环境。最近在一个项目中需要把 war 包打成 Docker 镜像并推送到仓库里去,却发现打好的这个镜像运行不起来,运行时报下面这样的错:

# docker run --rm -it manager
standard_init_linux.go:207: exec user process caused "no such file or directory"

检查 Dockerfile 文件,是非常简单的:

FROM openjdk:8-jre-alpine

ADD entrypoint.sh entrypoint.sh
ADD *.war app.war

EXPOSE 8088

RUN chmod 755 entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

镜像是基于 openjdk 的基础镜像,将 entrypoint.sh*.war 拷贝到镜像中,并将 entrypoint.sh 作为默认的启动脚本文件。初看应该是没问题的,war 包在本地使用 java -jar 也可以跑起来。

二、检查镜像

那么这个镜像是怎么回事?看报错信息,应该是运行镜像时,找不到某个文件或目录,但是我的这个镜像一共就两个文件,一个 entrypoint.sh 一个 war 包,难道这两个文件没有 ADD 到镜像里?

于是试着运行命令 docker run --rm -it manager sh,想着进 shell 查看下文件,发现依然报错,难道镜像里没有 sh 脚本?试着连换了几个命令:docker run --rm -it manager /bin/bashdocker run --rm -it manager ls 发现都是这个错,这怎么可能呢,连 ls 都没有?

无奈 Google 之,才发现原来如果镜像配置了 entrypoint,要使用下面这样的方式来检查镜像:

# docker run --rm -it --entrypoint sh manager

进到容器里来了之后,使用 ls 可以看到文件都在,没毛病:

/ # ls
app.war        entrypoint.sh  lib            opt            run            sys            var
bin            etc            media          proc           sbin           tmp
dev            home           mnt            root           srv            usr

使用 java -jar app.war 可以正常运行,但是奇怪的是,./entrypoint.sh 脚本却运行不了:

/ # ./entrypoint.sh
sh: ./entrypoint.sh: not found

这个脚本文件明明就在这,却报错 not found,这让我一度怀疑人生,遇到灵异事件了。

三、换行惹的祸

不得已只能继续 Google 之,发现网络上跟我一样的人还有很多,脚本无法运行最可能的原因是 Sha-Bang 写的有问题,所谓 Sha-Bang 就是 #!,通常会写在 shell 文件第一行,用于指定命令行解释器。类似于下面这样:

/ # cat entrypoint.sh
#!/bin/sh
blabla

使用 ls /bin/sh 发现 /bin/sh 文件也在,应该没问题啊。苦思冥想之际,突然脑海中闪过一个想法,难道这里有隐藏字符?一般遇到这种灵异事件的时候,都很可能和隐藏字符有关。使用 cat -v 查看文件,果然发现这里的 /bin/sh 后面多了个 ^M

/ # cat -v entrypoint.sh
#!/bin/sh^M
blabla

顿时豁然开朗,这不就是 Windows 下的换行符吗?一切都是换行符惹的祸。

使用 dos2unix 将 entrypoint.sh 文件中的 Windows 格式的换行符转为 UNIX 格式,再一次使用 docker build,这次终于运行成功了。

四、最坑的 Git 配置

到这里本来已经结束了,不过后来又发生了一件小事,让我又发现另一个坑,才找到了这个问题最根本的原因。当我解决了这个问题之后,就去给同事分享,可是听了我的分享之后,同事却一脸懵逼的表示自己从来都没遇到过这个问题,并且在他电脑上给我演示了一遍 docker builddocker run,一切正常,并没有报错,同样的一份代码,为什么在我的电脑上 build 就有问题?看着同事对我露出的迷之微笑,我又一次陷入了困惑。

我让他把 entrypoint.sh 发给我,看了下,他的换行符格式竟然是 UNIX 的,可是我本地代码换行符明明是 Windows 的,使用 git status 也可以看到 nothing to commit, working tree clean,表明本地代码和仓库代码是一样的,为什么换行符却不一样呢?

查看 Git 的配置文件,和他的对比了一下,发现一个很可能的疑点:

[core]
    autocrlf = true

查询官网文档,终于找到了原因,原来 Git 在 pull 代码的时候可能会偷偷的对你的代码做手脚。如果你配置了 autocrlf = true,那么当你签出代码时,Git 会自动的把 LF 转换成 CRLF,然后当你 push 的时候,又自动的将 CRLF 转换成 LF。这看上去很贴心的功能,实际上却有着很大的漏洞,譬如像我这样,直接在本地打镜像,或者需要直接将 shell 文件上传到 Linux 服务器上运行的,都可能会出问题。关键这个配置在 Windows 下默认是打开的,所以建议把这个配置关掉:

$ git config --global core.autocrlf false

坑之总结

  • 坑一:镜像如果配置了 entrypointdocker run 的时候应该加上 --entrypoint 参数来检查镜像
  • 坑二:脚本运行时报 not found,最可能的原因是 Sha-Bang 写的有问题,检查脚本文件中是否存在隐藏字符,譬如 Windows 的换行符,这里不得不吐槽下,这个 not found 提示真的让人迷惑,提示里能不能把隐藏字符也带上?
  • 坑三:建议关闭 Git 的 autocrlf 配置

参考

  1. How to see docker image contents
  2. Standard_init_linux.go:175 exec user process caused no such file
  3. GitHub 第一坑:换行符自动转换
  4. 自定义 Git - 配置 Git
扫描二维码,在手机上阅读!