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

early · 2018年06月12日 · 最后由 ForrestDouble 回复于 2018年06月23日 · 3080 次阅读
本帖已被设为精华帖!

周末花时间通读了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就通知原来的子进程处理完当前请求后就退出,一次平滑过渡就此完成。

共收到 10 条回复

由于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
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册