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

early · June 12, 2018 · Last by ForrestDouble replied at June 23, 2018 · 5472 hits
Topic has been selected as the excellent topic by the admin.

周末花时间通读了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 的所有数据

这句有点歧义

Reply to IChou

求详解?

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

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

Reply to IChou

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

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

huacnlee mark as excellent topic. 12 Jun 15:13

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

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

Reply to IChou

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

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

稍后修复

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

new 一个 worker, kill 一个老的 worker

当老的 worker 都没有时 杀掉 master

11 Floor has deleted
12 Floor has deleted
early in 图解 Unicorn 工作原理 mention this topic. 06 Dec 22:00
You need to Sign in before reply, if you don't have an account, please Sign up first.