分享 Unicorn 进程如何保证平滑重启?

early · 2018年06月12日 · 最后由 ForrestDouble 回复于 2018年06月23日 · 5476 次阅读
本帖已被管理员设置为精华贴

周末花时间通读了Working with Unix Processes 这本书的中文版,再回头看之前没怎么看懂的unicorn-unix-magic-tricks文章,收获颇丰。本文结合一点源码,来梳理一下 Unicorn 进程是如何进行平滑更替的。

根据 Unicorn 的信号机制,当需要 hot-reload 时,可以给 Unicorn 的 Master 进程一个USR2信号:

$ kill -s USR2 current-unicorn-master-pid 
#等待适当的时间后让老Master自尽
$ kill -s QUIT old-unicorn-master-pid

这个过程中,Unicorn 进程会进行平滑重启。用户对这一过程零感知,中途不会有服务中断,这在部署新代码的时候是极具价值的。

那么这个过程是如何进行的呢?常见的思维是认为之前监听的端口会被短暂释放,然后再启动新的进程重新监听端口。有多台机器同时提供服务时,这样是可行的,但是在单机情况下 (假设只有一组 Unicorn) 就会有服务中断。Unicorn 通过使用 unix 的高端 tricks 避免了这种情况。

启动流程

为了搞清楚这个问题,我们先来简单看一下 Unicorn 的启动流程,代码

#有删减
app = Unicorn.builder(ARGV[0] || 'config.ru', op)
Unicorn::Launcher.daemonize!(options) if rackup_opts[:daemonize]
Unicorn::HttpServer.new(app, options).start.join

第一行,生成了一个lambda,调用这个 lambda 可以在 Unicorn 中加载 Rails 项目代码,这是实现preloading的关键,这个点先暂时放一下,后面会提到。

第二行会检测参数中是否指定以守护进程的形式提供服务,通过两次 fork让进程独立于终端进程,变成独立的 daemon。

第三行先执行了 HttpServer 的实例方法start,然后执行了 join 方法:

def start # 有删减
  inherit_listeners! # 会读环境变量UNICORN_FD中的socket数据
  # trap 定义要捕获的信号,这些信号可以通过kill传递进来
  @queue_sigs.each { |sig| trap(sig) { @sig_queue << sig; awaken_master } } 
  trap(:CHLD) { awaken_master }
  build_app! if preload_app # 触发preloading
  bind_new_listeners! # 监听指定的端口
  spawn_missing_workers # fork出子进程,子进程开始各自处理请求
  self
end

def initialize # 有删减
  @queue_sigs = [
    :WINCH, :QUIT, :INT, :TERM, :USR1, :USR2, :HUP, :TTIN, :TTOU ]
end

关注一下上面的这一行代码:

@queue_sigs.each { |sig| trap(sig) { @sig_queue << sig; awaken_master } } 

信号的捕获类似中断的触发,不会影响当前进程的执行。捕获到信号后,将信号推到一个 queue 中,然后通过awaken_master方法用 pipe 通知睡眠的 Master,有新的信号进来了,然后 Master 会检查信号,并执行相关的代码。执行的过程在随后执行的 join 方法中,下面看看join方法:

def join  # 方法很长,有删减
  proc_name 'master' # 定义主进程的名字
  begin
    reap_all_workers
    case @sig_queue.shift #在queue中取信号,上面trap捕获的信号
    when nil # 刚启动时,queue为空
      master_sleep(sleep_time) # Master在此长眠,等待pipe信号
    when :QUIT # graceful shutdown
      break
    when :TERM, :INT # immediate shutdown
      stop(false)
      break
    when :USR1 # rotate logs
      Unicorn::Util.reopen_logs
      soft_kill_each_worker(:USR1)
    when :USR2 # exec binary, stay alive in case something went wrong
      reexec
    when :WINCH
    #...
    end
  rescue => e
    Unicorn.log_error(@logger, "master loop error", e)
  end while true # 死循环
end

join 方法会进入一个死循环,Master 会在master_sleep方法中睡眠,也就是不停地读 pipe 信号,当读到上面awaken_master写入的 pipe 信号时,会从master_sleep方法中退出,继续执行 join 方法中的死循环,死循环中会@sig_queue取信号,然后根据相应的信号,执行代码。

触发平滑更替

当取到的信号是USR2时,就涉及到本文的主要内容了,从上面看,Master 会执行reexec方法:

def reexec # 有删减
  if @reexec_pid > 0
    #检查是否正在执行本过程
  end

  if pid
    old_pid = "#{pid}.oldbin" #修改pid的名字
    begin
      self.pid = old_pid  # clear the path for a new pid file
    rescue ArgumentError
     #...   
    rescue => e
    #...
    end
  end
  # 核心逻辑,下面的fork启动一个新的进程,进程会执行随后的代码块
  @reexec_pid = fork do
    listener_fds = listener_sockets
    # 保存当前监听的socket到环境变量,前面启动时会读这个变量,这是复用老master的socket的关键
    ENV['UNICORN_FD'] = listener_fds.keys.join(',')  
    Dir.chdir(START_CTX[:cwd]) 
    cmd = [ START_CTX[0] ].concat(START_CTX[:argv]) 
    close_sockets_on_exec(listener_fds) # 
    cmd << listener_fds
    logger.info "executing #{cmd.inspect} (in #{Dir.pwd})"
    before_exec.call(self)
    exec(*cmd) # 系统调用
  end # 代码块完毕
  proc_name 'master (old)' # Master正式变成老Master,然后又去睡觉了
end

上面这个方法中有非常令人困惑的代码,下面我们来详细解释。

方法中的代码分为两部分,一部分是 fork 后面的代码块,另一部分是除了这个代码块之外的其他代码。fork 调用会创建一个新的子进程,这个新进程只会执行代码块的代码,代码块也只会被子进程执行。执行的过程和 Master 进程互不相关,互不干扰,各自在自己的进程中执行

这个代码块就是本文最核心的关注点,这个代码块是新创建的子进程执行的。我们来详细看看这个代码块中的代码。

listener_fds = listener_sockets
ENV['UNICORN_FD'] = listener_fds.keys.join(',')

在 COW 机制下,执行 fork 调用,新的子进程能共享当前 Master 的所有数据,它读的所有数据都是当前 Master 进程的数据。上面这两行代码就是将当前 Master 进程监听的 socket(文件描述符,是数字) 打包成一个字符串存放到一个环境变量中。在上面介绍的start方法中,通过inherit_listeners!,会去读这个环境变量,然后创建 Socket 对象,实现 socket 复用。

Dir.chdir(START_CTX[:cwd])  #第一行
cmd = [ START_CTX[0] ].concat(START_CTX[:argv]) # 第二行
# START_CTX 定义, https://github.com/defunkt/unicorn/blob/v5.4.0/lib/unicorn/http_server.rb#L50
# Unicorn::HttpServer::START_CTX[0] = "/home/bofh/2.3.0/bin/unicorn"
START_CTX = {
  :argv => ARGV.map(&:dup),
  0 => $0.dup,
}
# We favor ENV['PWD'] since it is (usually) symlink aware for Capistrano
# and like systems
START_CTX[:cwd] = begin
  a = File.stat(pwd = ENV['PWD'])
  b = File.stat(Dir.pwd)
  a.ino == b.ino && a.dev == b.dev ? pwd : Dir.pwd
rescue
  Dir.pwd
end

第一行实现类似cd 到启动unicorn的目录的作用。

第二行打包了 Master 进程启动时传递的参数。

close_sockets_on_exec(listener_fds)

这行会将无用的 socket 的close_on_exec标记改为 true,这样在随后的 exec 调用后,这些 socket 数据会被清理。其他的 socket 的这个标记会被设为 false

接下来就是exec系统调用:

exec(*cmd) # 

exec 会创建一个新的进程,结果是:

  • 这个进程会复用当前这个子进程的 pid(一个数字),fork 的话会创建一个新的
  • 新进程会覆盖这个子进程的所有数据,也就是它马上就死掉,被新进程替换
  • 由于相关 socket 的close_on_exec为 false,所以子进程死后其监听的 socket 会被系统保留, 被 bind 的端口也就不会被释放,这是平滑的关键。

exec 执行的路径,所带的参数都是和原来 Master 进程启动时一模一样! 也就是说,它会重走一遍上面我们梳理的那条启动流程,它天然就是一个新的 Master 进程。然后在进入睡眠之前,它会创建出自己的子进程,子进程会一起监听老 Master 监听的 socket,两套 Unicorn 进程,同时提供服务。

这个时候,我们回头看看前面启动流程中被跳过的地方,关注一下Master如何创建子进程preloading的本质

Master 如何创建子进程

Master 通过spawn_missing_workers方法创建子进程。

def spawn_missing_workers
  if @worker_data
    worker = Unicorn::Worker.new(*@worker_data)
    after_fork_internal
    worker_loop(worker)
    exit
  end

  worker_nr = -1
  until (worker_nr += 1) == @worker_processes
    @workers.value?(worker_nr) and next
    worker = Unicorn::Worker.new(worker_nr)
    before_fork.call(self, worker)
    # fork 子进程
    pid = @worker_exec ? worker_spawn(worker) : fork

    unless pid
      after_fork_internal
      worker_loop(worker)
      exit
    end
    @workers[pid] = worker
    worker.atfork_parent
  end
  rescue => e
    @logger.error(e) rescue nil
    exit!
end

上面 until 所在的代码块会被执行@worker_processes次,也就是会创建出这么多个子进程。这个代码块的代码依然令人困惑,值得我们花时间去详细探究。

当 fork 被调用的时候,会创建一个子进程,Master 和子进程都会各自执行 fork 后面的代码。也就是这部分代码:

unless pid
  after_fork_internal
  worker_loop(worker)
  exit
end
@workers[pid] = worker
worker.atfork_parent

在子进程中,pid 的返回为空,子进程会执行 unless 中的代码。而在 Master 中 pid 就是被创建的子进程的 pid,所以 Master 不会执行 unless 中的代码。执行结果是:

  • Master 没有执行 unless 代码块,会继续去跑上面的 util 代码块,每次都会新创建子进程
  • 每个子进程都会各自执行 unless 代码块,在 worker_loop 方法中进入死循环,处理请求
  • Master 创建完子进程后,就去睡觉了

preloading 的本质

在上面的 start 方法中,有一行:

build_app! if preload_app # 触发preloading
def build_app!
  if app.respond_to?(:arity) && app.arity == 0
    if defined?(Gem) && Gem.respond_to?(:refresh)
      logger.info "Refreshing Gem list"
      Gem.refresh
    end
    self.app = app.call
  end
end

build_app! 方法会执行 app.call,这个 app 就是前面启动流程中的:

app = Unicorn.builder(ARGV[0] || 'config.ru', op)

它会返回一个 lambda,调用这个 lambda 通过Rack::Builder.new会去加载 Rails 项目代码。preloading 的实质就是在 Master 进程fork 子进程之前加载 Rails 代码,这样在 fork 之后,子进程就能共享这部分数据,不用自己再花上好几秒时间去加载 (在 COW 机制有效时才有意义,ruby2.0 后就支持了),实现了极速创建子进程。

如果在 fork 之前没有调用 build_app!,那么新的子进程就需要各自去单独加载 Rails 代码,这样就很浪费时间了。

最后一步

回到主题,这时候给老 Master 一个QUIT信号,它会在 trap 中被捕获,当收到信号时,老 master 就通知原来的子进程处理完当前请求后就退出,一次平滑过渡就此完成。

由于 fork 调用,新的子进程会共享当前 Master 的所有数据

这句有点歧义

IChou 回复

求详解?

『共享』有共同持有的意思,会互相影响

fork 应该是完整的 copy,在 fork 的子进程里面的操作不会在 master 中反应出来,如果需要通讯还需要借助其他手段

IChou 回复

在 COW 背景下,不就是共享同一块数据的么?如果各自有修改数据的行为,才会触发 copy,来避免相互影响。

我更新了一下,加上了 COW 的背景。

huacnlee 将本帖设为了精华贴。 06月12日 15:13

所以说有点歧义嘛,之前的说法容易让人理解为:『fork 可以共享所有数据(包括修改也可以)』😜

@huacnlee 我这边表情全挂了,浏览器没报错,返回也全是 200,但图全是裂的

IChou 回复

你是对的,感谢大神提醒。

我这边表情包也显示不出来。挺好奇原因的

稍后修复

unicorn 无缝重启 这个,前几月公司也弄过

new 一个 worker, kill 一个老的 worker

当老的 worker 都没有时 杀掉 master

11 楼 已删除
12 楼 已删除
early 图解 Unicorn 工作原理 提及了此话题。 12月06日 22:00
需要 登录 后方可回复, 如果你还没有账号请 注册新账号