从源代码出发,Jenkins 任务排队时间过长问题的解决过程
最近开发了一个部署相关的工具,使用 Jenkins 来构建应用。Jenkins 的任务从模板中创建而来。每次部署时,通过 Jenkins API 来触发构建任务。在线上运行时发现,通过 API 触发的 Jenkins 任务总是会时不时在队列中等待较长的时间。某些情况下的等待时间甚至长达几分钟。直接在 Jenkins 界面上触发的任务却几乎不需要排队,直接马上就可以执行。过长的等待时间影响了构建的效率,这是一个急需解决的问题。这个问题奇怪的地方在于,手动从界面上触发的任务几乎不需要排队,而 API 触发的任务的排队时间则完全随机,毫无规律可言。
当任务在队列中时,Jenkins 会在界面上显示该任务在队列中等待的原因。对于 API 创建的任务,它们的等待原因是“Finished waiting”。从这个原因的字面含义确实看不出来什么。当出现这样的问题时,最直接的办法是从源代码中寻找答案。
从 GitHub 上把 Jenkins 的源代码下载到本地。寻找问题的起点是搜索字符串“Finished waiting”。这个字符串定义在资源包(resource bundle)中,对应的键是 Queue.FinishedWaiting
。再搜索使用了这个键的代码,定位到了类 hudson.model.Queue
。从名字可以看出来,这个类表示的是 Jenkins 内部的工作队列。在这个类中定义了可能出现在队列中的不同类型的条目。类 WaitingItem
表示的是处于等待状态的条目。这个类的 getCauseOfBlockage()
方法刚好用到了Queue.FinishedWaiting
这个键,表示当前条目被阻塞的原因。这个 WaitingItem
类是主要的调查目标。
WaitingItem
类中有 enter()
和 leave()
两个方法,分别表示该条目进入队列和离开队列。看起来那些等待时间过长的任务,在进入了队列之后,经过很长一段时间,它们的 leave()
方法才被调用。
继续追踪这两个方法的调用,会发现 enter()
方法在 scheduleInternal()
中被调用,用来调度新的任务。而 leave()
方法则在 maintain()
中被调用,用来在合适的时机从队列中移除任务并执行。所以看起来问题是出在 maintain()
方法中。
maintain()
方法负责维护队列,并把任务在不同的状态中移动。当有可能影响到任务调度的情况发生时,Jenkins 会在内部调用这个方法。在 scheduleInternal()
方法中,把新的任务添加到队列之后,它会调用 scheduleMaintenance()
方法提交一个 Runnable
任务来调用 maintain()
方法。
经过上面的分析,任务等待时间过长的原因可能是,maintain()
方法被调用时,并没有发现处于等待的任务。所以这个任务需要等到下一次 maintain()
方法调用时才会被执行。这中间可能有与时序相关的问题。
问题找到了之后,下一步考虑的是怎么解决问题。如果要从根本上解决问题,应该从 Jenkins 入手,尝试在 Jenkins 中找到问题发生的根源,并进行修复。这种方案要求对 Jenkins 的代码库有足够程度的了解,在本地构建开发环境,并尝试稳定地复现问题。找到问题并解决之后,还需要添加 Pull Request 到 Jenkins 的代码库,并等待新版本的发布。整个过程耗时漫长。作为 Jenkins 的使用者,我自己并没有太大的意愿去花费精力在 Jenkins 自身的问题上。这种与时序有关的问题,很难复现和调试。我需要的是一个能够有效解决问题的 workaround。
前面提到了,产生问题的原因是 maintain()
方法在执行时可能没有发现等待中的任务。那么解决的办法可以很直接,那就是每次提交任务之后,等待几秒钟,再调用一次 maintain()
方法。这样就可以确保 maintain()
方法能够发现等待中的任务。maintain()
方法是由 scheduleMaintenance()
方法调用的,而 scheduleMaintenance()
是 Queue
类的一个公开方法。我只需要能够调用这个 scheduleMaintenance()
方法就可以了。
Jenkins 有一个叫做脚本控制台的功能,可以在运行的 Jenkins 实例上执行 Groovy 脚本。Groovy 脚本可以直接对运行的 Jenkins 实例进行修改。
每个 Jenkins 运行的实例中,Queue
对象只有一个。只需要找到这个 Queue
对象,并调用其中的 scheduleMaintenance()
方法,问题就解决了。脚本控制台已经内置提供了很多 Jenkins 的对象。实际上需要执行的代码很简单。
Jenkins.instance.queue.scheduleMaintenance()
通过在脚本控制台进行测试,发现只要执行了上述的脚本,原本在队列中的任务,马上就可以被调度执行。这也证明了问题确实解决了。
最后一个问题是如何把对 scheduleMaintenance()
的调用自动化,也就是在每次通过 API 触发了任务,等待几秒钟之后,自动调用 scheduleMaintenance()
方法。Jenkins 的脚本控制台并没有提供公开的 API。我采取的做法是用 HTTP 客户端模拟脚本控制台界面上的操作。脚本控制台的界面在运行脚本时,实际上执行了一个表单提交动作。这个表单被提交到了网址 <jenkins_url>/computer/(built-in)/script
,内容类型是 application/x-www-form-urlencoded
。请求中只需要包含一个参数 script
,表示需要执行的脚本内容。Jenkins 使用的是 BASIC 认证,需要把访问的用户名和密码包含在请求中。使用 HTTP 客户端模拟上述请求并不难。
下面给出了使用 Spring RestTemplate 执行 Groovy 脚本的代码示例。
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
var authentication = "Basic " + Base64.getEncoder()
.encodeToString(String.format("%s:%s", username, password).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", authentication);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("script", "Jenkins.instance.queue.scheduleMaintenance()");
var entity = new HttpEntity<>(map, headers);
try {
restTemplate.exchange(
String.format("%s/computer/(built-in)/script", jenkinsUrl),
HttpMethod.POST, entity,
String.class);
} catch (Exception e) {
// 处理异常
}
至此,Jenkins 任务排队时间过长的问题得到了解决。虽然并没有从根本上解决问题,但已经是一个在有限的时间内可以完成的不错的解法。