dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

Rails初心者がハマったCapistranoの環境変数

こんにちは。
delyコマース事業部エンジニアのjohnです。
もともとは開発部でiOSエンジニアとしてクラシルのiOSアプリ開発をやっていましたが、今年のはじめから新規事業のコマース事業部でwebのフロントエンドやRailsアプリケーションとかいろいろと開発をしています。

この記事は「dely Advent Calendar 2019」の16日目の記事です。
昨日はSREの井上さんによる「10分で完成!WEBサイトパフォーマンス計測基盤 ver.2019」という記事でした。
tech.dely.jp

今回は、Capistranoを使ってRailsアプリケーションをデプロイしたときに環境変数でハマった話を書きます。
なかなか、これ系の記事が少なかったので、gemの中を見るところまでしてみました。
1つのサーバーを使いまわしてのデプロイの話です。インフラがコード化(Infrastructure as Code)され、使い捨てサーバー(Disposable Components)でのデプロイでは当てはまらないかもしれません。

1. Capistranoとは?

まず最初に軽くCapistranoの説明をします。

https://capistranorb.com にはこう書かれています。

A remote server automation and deployment tool written in Ruby.

https://github.com/capistrano/capistrano にはこう書かれています。

Capistrano is a framework for building automated deployment scripts. Although Capistrano itself is written in Ruby, it can easily be used to deploy projects of any language or framework, be it Rails, Java, or PHP.

リモートサーバーの自動化のデプロイツールのようです。
デプロイツールなので、Railsアプリケーションだけでなく、他の言語のプロジェクトでも使えるようですね。
また、Rubyで書かれているのでRubyのプロジェクトでは使いやすいですね。

オフィシャルのプラグインとして
Capistrano::Bundler
Capistrano::Rails

サードパーティのプラグインとして
Capistrano::Puma
Capistrano::yarn

などのRailsに関連するプラグインがあって、デプロイのプロセスの間にタスクを走らせることができます。

たとえば、Capistrano::Puma lib/capistrano/puma.rb#L90-L93では、プラグインをインストールすると

def register_hooks
    after 'deploy:check', 'puma:check'
    after 'deploy:finished', 'puma:smart_restart'
end

上記のようにdeployが終わったら、puma:smart_restartするようになっていて、再起動してくれるようになっています。
実際のpuma:smart_restartはこちらに書いてあります。
Capistrano::Puma lib/capistrano/tasks/puma.rake#L59-L78

if test "[ -f #{fetch(:puma_pid)} ]" and test :kill, "-0 $( cat #{fetch(:puma_pid)} )"
  # NOTE pid exist but state file is nonsense, so ignore that case
  execute :pumactl, "-S #{fetch(:puma_state)} -F #{fetch(:puma_conf)} #{command}"
else
  # Puma is not running or state file is not present : Run it
  invoke 'puma:start'
end

起動していなかったらstartするようになっています。

次から、本題に入っていきます。

2. .bash_profileに記載した環境変数が読み込まれない

あらかじめ、sshでログインして、.bash_profileに環境変数を記載していました。
先ほどのpumaの例に示したとおり、デプロイ後に再起動したのに、環境変数が読み込まれていないという現象にぶち当たりました。
sshでサーバーにログインしてrails sするとちゃんと読み込まれているのに、なぜでしょう?🤔

理由は、公式の記事を見つけたので、こちらをみたらわかりました。
capistranorb.com

By default Capistrano always assigns a non-login, non-interactive shell.

Capistranoはnon-login, non-interactiveなshellだからでした。

f:id:JohnnyKei:20191211200326p:plain
BashInitialisationFiles
上記のように.bash_profileや.bashrcは読み込まれないという、shellのお話でした。

対応策としては、capistranoで定義されているdefault_envという変数に渡すということをしています。
default_envは最終的にはcapistrano内部で使っているsshkitに渡されるようです。
capistrano/sshkit lib/sshkit/command.rb#L149-L151

上記の方法をとった理由は、dotenvを本番環境で使いたくなかったのと、他に簡単にできる方法を思いつかなかったからです。もしかしたら別の方法があるかもしれません。
イマイチなのが、すべてのコマンドで渡されてしまっているところと、環境変数が増えてくると辛いことです。
環境変数に関しては、別のtaskの中で処理するのもありかと思っています。
以下はaws systems manager パラメータストアを使った場合のtaskになります。
aws systems manager パラメータストアについての説明は省かせていただきます。

require 'aws-sdk-ssm'

namespace :env do

  def ssm_client
    Aws::SSM::Client.new
  end

  def ssm_path
    "/path/to/ssm"
  end

  def fetch_parameters
    parameters = []
    is_finished = false
    next_token = nil
    until is_finished
      result = ssm_client.get_parameters_by_path(
        {
          path: ssm_path,
          recursive: false,
          with_decryption: true,
          next_token: next_token
        }
      )
      parameters += result.parameters
      next_token = result.next_token
      is_finished = next_token.nil?
    end
    puts "parameter count: #{parameters.size}"
    parameters
  end

  def ssm_env
    dict = {}
    fetch_parameters.each do |params|
      key = params.name.gsub(ssm_path, '')
      dict[key] = params.value
    end
    dict
  end

  task :set_default_env do
    set :default_env, fetch(:default_env).merge(ssm_env)
  end

end

deploy.rbでdeloyのtaskが呼ばれる前に呼ぶにしています。

invoke 'env:set_default_env'

3. pumaをrestartしても環境変数が読み込まれない

2. の対処でようやく環境変数が読み込まれるようにはなったのですが、環境変数を追加・変更してdefault_envに反映しても、デプロイ後の再起動では反映された状態での再起動が起きませんでした。

GitHubのIssueで聞いているものもあります。
github.com
どうやら原因はpumaの再起動の仕組みによるものでした。
puma/restart.md at master · puma/puma · GitHub
puma/signals.md at master · puma/puma · GitHub

pumaはシグナルを使ってプロセス間の通信を行っているようです。
再起動時は"SIGUSR2"を送っています。

bundle exec pumactl restart --state path/to/state_file

# lib/puma/control_cli.rb#L206
when "restart"
  Process.kill "SIGUSR2", @pid

起動中のプロセスがそれを受け取ってリスタートさせるようです。

#lib/puma/launcher.rb#L407
Signal.trap "SIGUSR2" do
  restart
end

起動中のプロセスで以下のメソッドが呼ばれ、再起動されます。
最終的にはKernel.exec(*argv)をして再起動しているようです。

# lib/puma/launcher.rb#L407

log "* Restarting..."
ENV.replace(previous_env)
@runner.before_restart
restart!

# /lib/puma/launcher.rb#L238
def restart!

argv = restart_args
Dir.chdir(@restart_dir)
argv += [@binder.redirects_for_restart]
Kernel.exec(*argv)

# このときのargsは
# ["path/to/ruby", "/path/to/bin/puma", "-C", "config/puma.rb", {:close_others=>true, 10=>10}]
# なので、pumaをもう一度起動するように実行しているようです。

この際、pumactl restart時に渡した変数は、起動中のプロセスへは渡されないので、環境変数は読み込まれないということになるようです。

対応として、pumaを停止して、再度起動するということをしています。

bundle exec cap production puma:stop
bundle exec cap production puma:start

一旦アプリケーションが停止してしまうので、あまり良い対処ではないのですがこれでしのいでいます。

今回ブログを書くにあたって、普段使用しているだけのpumaやcapustranoの中の実装を探ってみて、いろいろ新しい発見があって楽しかったです。 

明日は、iOSエンジニアのtakaoさんの記事です。お楽しみに!

最後に、コマース事業部では事業も開発も挑戦することが多く、エンジニアを募集しています!
また、デザイナーも大大大募集しているので、お知り合いの方で興味がありそうなかたがいれば、ぜひ教えてあげてください。
もし興味があるかたがいれば、お気軽にご連絡ください。

www.wantedly.com
www.wantedly.com

参考
Pumaの使い方 まとめ - 猫Rails
RackサーバーのPumaについて調べてみる - ゆーじのろぐ