周末花时间通读了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 的话会创建一个新的子进程
的所有数据,也就是它马上就死掉,被新进程替换close_on_exec
为 false,所以子进程
死后其监听的 socket 会被系统保留,
被 bind 的端口也就不会被释放,这是平滑的关键。exec 执行的路径,所带的参数都是和原来 Master 进程启动时一模一样! 也就是说,它会重走一遍上面我们梳理的那条启动流程,它天然就是一个新的 Master 进程。然后在进入睡眠之前,它会创建出自己的子进程,子进程会一起监听老 Master 监听的 socket,两套 Unicorn 进程,同时提供服务。
这个时候,我们回头看看前面启动流程中被跳过的地方,关注一下Master如何创建子进程
和 preloading的本质
。
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 中的代码。执行结果是:
在上面的 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 就通知原来的子进程处理完当前请求后就退出,一次平滑过渡就此完成。