发布于 2026-01-05 4 阅读
0

生产环境中调试的 10 个技巧 🐛 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

生产环境中调试的 10 个技巧

🐛

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

调试是开发人员最难的事情之一。它迫使你对自己的作品有更深刻的理解。像 pry 和 byebug 这样友好的 Ruby 调试工具在本地或测试环境中查找 bug 时非常有用。但是,如果 bug 出现在生产环境中呢?如果 bug 只在成千上万个并发作业运行时才会出现呢?你该如何调试这些场景?

最近,我和我的团队遇到了 Sidekiq worker 的一个问题,花了四天多的时间才解决。在调试过程中,我们不断尝试在本地环境中复现问题,但始终未能成功。以下是我们在生产环境中安全地排查问题的过程以及我们采用的调试策略。

🐛

我们使用Sidekiq Enterprise来处理所有后台任务。Sidekiq Enterprise 的一个主要功能是滚动重启。这使得我们可以在 Sidekiq 上运行长时间运行的任务,而无需担心在部署完成后重新加载工作进程时需要终止这些任务。重新加载命令会发出一个 USR2 信号,指示工作进程在完成当前任务后关闭。

我们使用这项功能已经将近一年了,直到上周才出现问题。上周我们部署了一个新任务,该任务本身存在一些问题,并引发了大量错误。在任务崩溃并引发这些错误期间,我们开始发现尝试重新加载 Sidekiq 线程时,线程会卡住。部署完成后,我们在终端查看 Sidekiq 进程时,看到了以下情况:

这些线程什么都没做,却仍然没有终止。考虑到我们每天部署多次,我们不能容忍这么多卡住的线程一直存在。

我们立即尝试在本地重现这个问题。在开发过程中,我们使用笔记本电脑上的 Vagrant 虚拟机。这些虚拟机预装了完整的应用程序、工作进程等等。我们甚至可以将 Vagrant 虚拟机切换到生产模式,使其运行方式几乎与生产服务器完全相同。(稍后我会解释为什么是“几乎”😉)

我们首先尝试在开发模式下重现问题,但没有成功。然后我决定将我的 Vagrant 虚拟机切换到生产模式,看看是否可行。结果还是不行。这时,我们唯一的选择就是在生产环境中进行调试。我已经能感觉到网络上那些审视的目光了。

在生产环境中进行调试是理想的选择吗?当然不是。但有时为了找出问题所在,这是不可避免的。关键在于以安全的方式进行调试,确保不会影响用户。在接下来的调试故事中,我将分享我在生产环境中进行调试时使用的一些工具和策略,这些工具和策略已被证明非常有效。

1. 限制调试范围

首先,在生产环境中进行调试时,务必限制测试和调试的范围。
我们公司有多个生产环境,其中一些使用频率较低。我们首先确认能否在较小的生产环境中重现该问题。

如果只有一个生产环境,请考虑将测试范围缩小到单个服务器甚至单个用户。测试范围越小,对用户造成干扰的风险就越低。

有了测试环境后,我们开始深入研究这个问题。由于之前从未遇到过这种情况,我们最初以为是新任务本身出了问题。在对卡住的进程进行一些进程跟踪并搜索相关信息后,我们发现一个潜在的问题是新任务的代码不是线程安全的。Sidekiq 使用多个线程来处理任务,如果线程不安全,就可能导致线程卡住。我们检查了新任务的代码,确保它是线程安全的。然后,我们检查了新任务发出的所有外部请求是否也出现了卡顿。我们确认新任务的代码没有问题,但仍然在出错时看到线程卡住的情况。

由于没有找到确凿的证据,我们决定看看是不是新的工作代码导致的。这就引出了我的第二个建议。

2. 通过排除可能的原因来确定问题所在

在使用 Sidekiq 处理任务时,会涉及到很多环节。为了准确找出问题所在,我们必须开始排除可能存在问题的代码。在开发环境或测试环境中,这可以很简单,只需注释掉代码即可。但在生产环境中,则需要更具创造性,这也引出了我的下一个建议:

3. 不要害怕为了调试而编写和提交代码

为了查看是否是新的作业代码出了问题,我们创建了一个名为 StandardErrorWorker 的虚拟 Sidekiq 作业,该作业实际上什么也不做,只是抛出一个错误。

class StandardErrorWorker
  include Sidekiq::Worker

  def perform
    raise StandardError
  end
end
Enter fullscreen mode Exit fullscreen mode

如果这个简单的任务导致同样的 Sidekiq 线程卡死,那么我们就可以排除问题出在新任务代码上的可能性。

我知道你们有些人现在肯定很震惊。提交临时代码?简直是亵渎神明!但有时候,你必须这么做。我认为为了调试而编写临时代码完全可以接受。关键在于完成后一定要清理干净。你可以通过添加 TODO、使用像todo_or_die gem这样的工具,或者简单地记个笔记或工单来提醒自己完成后删除代码。想想露营吧⛺️

不留痕迹!

新工作部署完成后,我们在使用频率较低的生产环境中启动了测试场景,结果发现线程卡住了!这对我们来说是一个重要的转折点,因为它表明问题不在于新工作本身,而在于其他方面。

在继续之前,我想指出一点。这次对新工作的调查花了我两天时间。我觉得有些人写这类文章的时候,思路跳跃得飞快,好像一切都轻而易举。醒醒吧,现实生活可不是这样!在现实生活中,你至少得花上一整天的时间苦思冥想,才能得出类似的结论。

我们尝试了其他方法好几天,才开始考虑问题可能不在新任务代码上。新任务是在问题出现时发布的,怎么可能跟它的代码无关呢?!即使在设置虚拟工作进程时,我们也都相当肯定它不会有问题。最终,结果让我们大吃一惊,这也引出了我的第四条建议:

4. 测试每一种可能的情况,无论其发生的可能性有多小。

即使你对某种情况深表怀疑,也要去测试!最坏的情况是,它证实了你已知的结论。最好的情况是,它会告诉你你完全错了,并为你指明一条新的道路。就我们而言,属于后者。

好了,现在继续。确定问题不在新工作上之后,我开始仔细检查 Sidekiq。我最初的想法是,故障发生得如此之快,可能是时序问题。也许在关闭过程中遗漏或跳过了某些步骤。是时候启用调试日志了。

在我们的生产代码中,我将此添加到 sidekiq.rb 初始化文件中,以便在我们正在测试的小型生产环境中启用 Sidekiq 调试日志记录。

if ENV['env_name'] == "small_prod"
  Sidekiq::Logging.logger.level = Logger::DEBUG
end
Enter fullscreen mode Exit fullscreen mode

启用日志记录后,我启动了错误处理任务,执行了一些重新加载命令,结果导致一些工作线程卡住了。但我从日志中发现的信息并不确定。有时 Sidekiq 的关闭流程触发正常,但线程仍然卡住。有时看起来关闭流程根本没有启动。这完全是死胡同,没什么帮助。

这时,我向 Sidekiq 的创始人 Mike Perham 寻求帮助。这让我想到另一条建议,无论你是在生产环境测试还是本地测试,这条建议都适用。

5. 永远不要害怕向同事或社区寻求帮助。

我承认,我可能总是拖延太久才寻求帮助,因为我想确保自己没有忽略任何显而易见的问题。他指出了他的维基百科里一个非常有用的“进程卡住故障排除”章节。说实话,我之前在谷歌上搜索的时候竟然完全没找到这个,感觉自己有点像个傻瓜。但我没有纠结于此,而是认真研究了维基百科的建议。

我在服务器上连接了GDB,然后开始深入分析那些卡住的进程。可惜的是,这完全是条死路。导出线程数据后没有任何有价值的信息,我仍然无法了解这些卡住的线程到底在做什么。每当我遇到这种棘手的情况时,我总是会回头查看日志,看看能不能找到什么遗漏的地方,这也是我的下一个建议。

6. 反复阅读日志。

在本地或测试环境中调试时,您可以轻松地设置断点来检查程序运行情况。但由于生产代码无法设置断点,您只能依靠日志来了解发生了什么。我重新查看了日志,立刻发现了一个问题:我们的 Honeybadger 错误。

我们使用Honeybadger服务来报告应用程序错误。我们的应用程序中集成了Honeybadger gem,以便将应用程序错误发送到 Honeybadger 服务。我注意到日志显示,我们不仅成功地将错误发送到了 Honeybadger,还有很多错误发送失败。这些失败的错误似乎受到了限制。我开始怀疑,Honeybadger 代码中对错误的限制是否导致了进程卡住。

是时候进一步定位问题,排除其他可能的问题组件了。测试 Honeybadger 是否是问题所在很简单。我关闭了 StandardErrorWorker 的 Honeybadger 报告功能,再次重现了故障场景,结果这次一切正常!我重启了 Sidekiq 五次,期间成千上万个任务失败并重试,没有一个线程出现卡顿。现在我知道问题出在 Honeybadger 上,但我不知道具体原因,也不知道 Honeybadger 的哪个部分出了问题。

我立刻查看了 Honeybadger 的源代码。在源代码中,我发现了限流机制的存在,于是决定尝试将其关闭,看看能否解决问题。通常遇到这种情况时,我不会试图完全理解问题的根源,而是会先编写一个测试来验证我正在查看的代码是否真的是问题所在。再次证明,问题定位至关重要!

如果我确认我正在查看的代码确实是问题所在,那么我会深入研究它的工作方式。为了关闭限流,我采用了不太正规的方法,对找到的两个 Honeybadger 方法进行了猴子补丁式修改,这也引出了我的下一个建议:

7. 调试时,猴子补丁是你的好帮手。

当你怀疑某个 gem 可能是问题所在时,不要害怕先用 monkey patch 来验证你的猜测。如果确实如此,你就可以花些时间正式向该 gem 提交一个 PR 了。

首先,我修改了handle_response方法,使其不再添加任何限流措施。

module Honeybadger
  class Worker
    def handle_response(msg, response)
      debug { sprintf('worker response code=%s message=%s', response.code, response.message.to_s.dump) }

      case response.code
      when 429, 503
        warn { sprintf('Error report failed: project is sending too many errors. id=%s code=%s throttle=1.25 interval=%s', msg.id, response.code, throttle_interval) }
        # add_throttle(1.25)
        warn { sprintf('monkey patch code working') }
      when 402
        ...
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

接下来,我修改了工作方法,也去掉了那里的限速。

module Honeybadger
  class Worker
    def work(msg)
      send_now(msg)
      # sleep(throttle_interval)
    rescue StandardError => e
      error {
        msg = "Error in worker thread class=%s message=%s\n\t%s"
        sprintf(msg, e.class, e.message.dump, Array(e.backtrace).join("\n\t"))
      }
      sleep(1)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

打完补丁后,我运行了之前同样的测试。我真的以为这次总算搞定了……结果还是不行😩。工作进程仍然卡住。我又回到 Honeybadger 的源代码,继续研究。这时我注意到了队列对象。这引起了我的兴趣,因为队列的作用是将待处理的项目排队,如果项目很多,就会拖慢速度。

我首先注意到队列有一个 max_size 参数,然后想知道它是什么。结果发现这是一个配置项,默认值为 1000。

哇,这似乎会和限速机制一起出现问题。查看配置设置时,我注意到了这个send_data_at_exit设置。我以前从未听说过,所以就查了一下它的含义。它的默认值是“启用”,以下是它的描述:

在允许程序退出之前,必须先完成已排队异常的发送。

搞定了!Honeybadger 不允许程序在发送完所有排队数据之前退出。是时候验证另一个想法了!我send_data_at_exit将生产环境中的配置变量设置为 false。这意味着当我指示线程退出时,它们会忽略 Honeybadger 队列中积压的所有内容并立即退出。我触发了故障场景,重新加载了工作线程,结果没有一个线程卡住!

我现实中的反应👇

我的队友们都知道我已经为此战斗了好几天,当我大喊时,他们都看着我,说的第一句话就是:“妈的,你解决了,对吧?” 那感觉真是太棒了!

回顾整个过程,在调试这类复杂问题时,我还有一些想法和建议想要分享。

8. 不要忘记你的 gems 和插件,它们也可能是造成问题的原因。

那些几乎每次都能正常运行的 gem 很容易被我们忽略。我们之前从未遇到过 Honeybadger 的问题,所以也从未想到它可能是问题所在。人们很容易想当然地认为问题出在自己身上,因为编写 gem 的人都是完美的,对吧?错!gem 和插件也会有 bug!甚至可能根本不是 bug,而是 gem 没有针对你的使用场景进行优化。

修复方案

我知道你们有些人很想知道我们是如何解决这个问题的。我们有两种方法可以解决线程卡死的问题:一是减小队列大小,二是将其设置send_data_at_exit为 false。我们最终选择了第二种方法。我们的 Sidekiq 故障也会记录在 Elasticsearch 日志集群中,所以即使故障没有完全到达 Honeybadger,我们仍然会有记录。此外,当我们遇到大量故障时,它们通常都是同一种类型的故障,因此 Honeybadger 缺少一些故障记录在案,对可见性方面应该不会造成太大影响。

代码修复完毕,应用程序运行一切正常,终于可以坐下来放松一下了,对吧?

代码修复并不意味着我们就完成了。在完成生产环境调试之后,还有两件事需要做。第一件事是:

9. 确保错误可在本地重现

在生产环境中进行测试的主要原因是无法在本地重现问题。为了避免在生产环境中再次调试,请尝试改进或更新本地环境,使其更好地模拟生产环境,并确保遇到的错误可以重现。

我之前提到过,我们在本地使用 Vagrant 虚拟机,并且可以将其切换到生产模式。生产模式的出现源于之前我们遇到的一个问题,当时我们不得不在生产环境中进行调试。自那以后,生产模式在帮助我们解决生产问题方面发挥了至关重要的作用。当时的问题在于 Honeybadger 没有启用。我们已经进行了相应的调整,以便生产模式下的 Vagrant 虚拟机能够更好地模拟我们实际的生产环境。

最后但并非最不重要的:

10. 分享你的故事!

如果你经历了一场漫长的调试之旅,并从中获得了一些有趣的见解,一定要分享出来!你或许能为遇到同样问题的人节省大量时间。

至少,如果你的问题与某个 gem 或插件有关,请联系维护者或在 GitHub 上提交 issue 告知他们。他们肯定会想知道是否存在 bug,而且可能也想知道如何改进文档。在我经历了一番调试之后,我与 Sidekiq 的创始人 Mike 和Honeybadger 的联合创始人Josh交流了他们如何改进文档和 wiki,以帮助其他人避免我犯的错误。他们非常慷慨地采纳了我的一些建议。

下次遇到生产环境中的 bug 时,不妨参考以下建议,帮助你安全高效地进行调试!现在就开始调试吧!

文章来源:https://dev.to/molly/10-tips-for-debugging-in-Production-ko1