AnsibleとCapistranoの実行時はシェルの状態を確認しよう

モニターの買い替えを悩んで一生決まらないSREチームの村上です。
悩んでるうちに寒くなってきてしまったので、モニターより先に加湿器を買うことになりそうです。

さて、リンクバルでは初代Amazon Linuxで稼働しているEC2インスタンスがいくつか存在するのですが、メンテナンスサポート期限の終了が近づいていることもあり、Ubuntuへの入れ替えを進めている最中です。
「いまだに初代Amazon Linuxなんて使ってるのかよ」というツッコミは一旦置いといてください。

その入れ替えの際、bashの設定の違いにより、SSH依存のツール(AnsibleとCapistrano)の挙動も変わってしまうということがありました。
今回はbashの設定についておさらいしつつ、上記の事象の原因や解決方法について書いていこうと思います。

目次

まずはbashの設定ファイルについておさらい

bashには設定ファイルがいくつか存在するのですが、

  • ログインシェルかどうか
  • インタラクティブシェルかどうか

によって、読み込むファイルの種類や順序が変わってきます。
違いを簡単に紹介していきましょう。

ログインシェル

  1. /etc/profileを読み込む
  2. /etc/profileから/etc/profile.d/*.shの各ファイルを参照して読み込む(無い場合もある)
  3. ~/.bash_profile, ~/.bash_login, ~/.profileを順に探し、最初に見つかったものだけを読み込む

「非ログインかつインタラクティブ」なシェル

  1. ~/.bashrcがあれば読み込む
  2. ~/.bashrcから/etc/bashrcを参照して読み込む(無い場合もある)

ログインシェルの場合、インタラクティブであっても~/.bashrcは読み込まれません。(後述)

「非ログインかつ非インタラクティブ」なシェル

  • 環境変数BASH_ENVに指定されたファイルを読み込む

ざっくりこんな感じです。
「ファイルが多くて違いが分からないよ」という人のために雑な分け方をすると

  • ログインした時には「*profile」系の設定ファイルが読み込まれる
  • 「*rc」系の設定ファイルはその他もろもろの条件次第で読み込まれる(雑すぎる)

という感じです。

「ログインシェルだったら~/.bashrcは読み込まれないって言ってるけど、SSHでサーバーログインしたら普通に読み込まれてるじゃん」と思った方もいるでしょう。
実は、大抵の場合は~/.bash_profile~/.profileに以下のような記述があるので、SSHログインで~/.bash_profileを読み込んだ際に、自動的に~/.bashrcも参照して読み込まれているだけだったりします。
(つまり、bashの初期化処理として読み込まれたのではなく、シェルスクリプトの処理の結果として読み込まれただけ。)

if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

このあたりの実際の挙動は環境によってけっこう違いがあります。
上記のような記述をあえてコメントアウトしてからログインすると、ファイルの読み込みの違いが分かりやすいかと思います。

Ubuntu20.04の初期設定について

では、今回の入れ替えで採用したUbuntu20.04のbashはどうなっていたのかというと、便利設定を施したデフォルトの~/.bashrcを用意してくれています。ありがたい。
一部紹介すると、

alias ll='ls -alF'
alias la='ls -A'
alias l='ls -CF'

というような使いやすいaliasを設定してくれていたり、他にもプロンプトのカラーを設定してくれていたりします。
また、このファイルの最後の方には

# Alias definitions.
# You may want to put all your additions into a separate file like
# ~/.bash_aliases, instead of adding them here directly.
# See /usr/share/doc/bash-doc/examples in the bash-doc package.

if [ -f ~/.bash_aliases ]; then
    . ~/.bash_aliases
fi

という記述がされているので、追加設定などは~/.bash_aliasesファイルに分離するように誘導してくれています。
デフォルトの~/.bashrcが100行以上あり、そこにユーザー固有の設定を追加すると見通しが悪くなりそうなので、お言葉に甘えて~/.bash_aliasesを設置することにしました。
今回入れ替えるサーバーの~/.bash_aliasesでは、rbenvやnvmに関するする設定、および環境変数のexportなどを記述しています。

移行前後の環境についてちょっと整理すると、以下のような状態になっています。

  • 移行元のAmazon Linux : ~/.bashrcにユーザー設定を記述。
  • 移行先のUbuntu : ~/.bashrcはあるけど、~/.bash_aliasesにユーザー設定を記述。

ちなみに、このデフォルトの~/.bashrc/etc/skel/.bashrcがコピーされたものです。
新規ユーザーが追加された際に、/etc/skel/配下のファイルがユーザーのhomeディレクトリに自動で作成されるようになっています。

OS入れ替えたらbashがなんかうまくいかない

Ansibleが動きませんね、、、

で、ここからが本題。
新規にUbuntu20.04のインスタンスを立ててAnsibleでサーバー設定をしようとしたのですが、nvmのインストールまわりでエラーが出てしまいました。

"stderr": "bash: nvm: command not found"

該当のコードはこちら↓

---
- name: "install nvm for {{ nvm_user }}"
  git:
    repo: https://github.com/nvm-sh/nvm.git
    version: "{{ nvm_version }}"
    dest: "{{ nvm_dir }}"

- name: check node install
  shell: bash -lc "nvm ls | grep {{ node_version }}"  # ここでエラー

Amazon Linuxの方では同様のコードで動いたはずなのに、Ubuntuではうまくいかず。
一つ目のタスクでnvmのインストールは完了していますが、二つ目のタスク実行で「nvmが見つからないよ」というエラーになってしまいました。
(nvmを有効にする設定は~/.bash_aliasesに記述済みです。)

ログインか?インタラクティブか?

インストール済みのものが実行できないということは、PATHが通っていないとか設定の読み込みができていないとかが原因になるでしょう。
nvmの設定は~/.bash_aliasesに記述しているので、これが読み込まれているかどうかが鍵です。
そこで、シェルの状態を把握するためのタスクを追加してみました。

- name: check login shell
  shell: bash -lc 'shopt -q login_shell && echo "Login shell" || echo "Not login shell"'
  register: check_login

- name: debug
  debug:
    msg: "{{ check_login.stdout }}"

- name: check interactive
  shell: bash -lc '[[ $- == *i* ]] && echo "Interactive" || echo "Not interactive"'
  register: check_interactive

- name: debug
  debug:
    msg: "{{ check_interactive.stdout }}"

このタスクを実行したところ、Login shellNot interactiveという結果になりました。
(-l オプションをつけているので、当然と言えば当然の結果。)
Ansible実行時のnvmコマンドは、ログインシェルで実行されているようです。

  1. ログインシェルということは~/.bash_profileが読み込まれます。
  2. さらに、~/.bash_profile -> ~/.bashrc -> ~/.bash_aliasesと順に読み込まれるはずである。
  3. つまり、~/.bash_aliasesに記述したnvmの設定が読み込まれるはずである。

そのはずである。そう思っていたのですが残念ながらその通りにはなりませんでした。
思い込みで相手に期待するのは良くないことですね。きちんとLinuxの仕様に耳を傾けましょう。

デフォルト~/.bashrcの落とし穴

ということで、改めて~/.bashrcを確認すると、ファイル上部に以下の記述がありました。

# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;
esac

「インタラクティブじゃなかったらこれ以上実行しないよ」宣言ですね。
この記述があるために~/.bashrcの読み込みが途中で終わってしまい、~/.bash_aliasesは反映してもらえませんでした。

Amazon Linuxの方では自前で~/.bashrcを用意しており、このような記述はなかったので、素直に~/.bashrcを読み込んでnvmが使えるようになっていたようです。

では、Ubuntuの方ではどうしようかという話になります。
調べた際によく出てきた解決方法は以下の二つです。

  1. If not running interactively…の箇所の記述をコメントアウトする。(非インタラクティブシェルでも~/.bashrcを全部読み込むようにする)
  2. ~/.bash_aliasesを読み込む設定を、If not running interactively…より上に持ってくる。(~/.bashrcの読み込みを中止する前に~/.bash_aliasesを読み込んでしまう)

コメントアウトはなんかよからぬ挙動の変化があるのではないか、という不安があったので2の方法を採用しました。~/.bash_aliasesが読み込まれさえすればどちらでもOKです。
この設定変更によってAnsibleはうまく動いてくれるようになりました。

Capistranoはまた違う動きをする

お次はCapistranoです。
Ansibleは「ログインかつ非インタラクティブ」だったけれど、Capistranoが実行するシェルはどうなっているのでしょうか。
こちらは調べると公式のドキュメントが見つかりました。

By default Capistrano prefers to start a non-login, non-interactive shell, to try and isolate the environment and make sure that things work as expected, regardless of any changes that might happen on the server side.

ということで、capistranoは「非ログインかつ非インタラクティブ」なシェルのようです。
なので、~/.bashrcすら読み込まれないことになりそうです。

、、、、?
いやいや、それはおかしいんじゃないかと。

だって、Amazon LinuxでCapistranoは正常に実行できましたよ。
ということは、yarn installが通過しているはずですよ。
yarnが使えるということは、~/.bashrcが読み込まれてnvmが使えるようになっているいるはずですよ。
「非ログインかつ非インタラクティブ」なのに~/.bashrcが読み込まれているのはどういうことだ?

よく分からないけどUbuntuでもとりあえずCapistrano実行してみました。
そしてCapistrano実行完了しました。エラーなく完了できてしまいました。
なんでだよ。

ということで、ここからさらに「なんで~/.bashrcが読み込まれたんだよ」の調査に入ることになりました。

ネットワーク越しだと話が変わるんだなこれが

bashのドキュメントを確認してみると

Bash attempts to determine when it is being run with its standard input connected to a network connection, as when executed by the historical remote shell daemon, usually rshd, or the secure shell daemon sshd. If Bash determines it is being run non-interactively in this fashion, it reads and executes commands from ~/.bashrc, if that file exists and is readable.

「標準入力がネットワークに接続されて起動された非インタラクティブなシェルは、~/.bashrcを読み込む」らしい。(意訳だけど合ってるかな)

ちょっと話を遡りますが、ログインシェルだと、~/.bash_profileは読み込んで~/.bashrcは読み込みません。
このあたりの条件を複合して考えると、

  • 「非ログイン、非インタラクティブ、ネットワーク越し」だと、~/.bashrcは読み込まれる

ということになりそうです。Capistranoの挙動はこれで説明がつきますね。

加えて、先の対応にて
「非インタラクティブシェルでも~/.bashrcから~/.bash_aliasesを読み込む」
ように変更済みだったため、~/.bash_aliasesが読み込まれてyarnが使えるようになったのです。

このことを確認するために、~/.bashrcの設定を元に戻してからCapistranoを実行してみました。

......
Caused by:
SSHKit::Command::Failed: yarn exit status: 127
yarn stdout: Nothing written
yarn stderr: /usr/bin/env: ‘yarn’: No such file or directory

~/.bashrcの元の設定では「非インタラクティブシェルで~/.bash_aliasesが読み込まれない」ので、期待通りyarnが使えずにエラーになりました。
条件は違えど、AnsibleもCapistranoも~/.bashrcを読み込んでくれているようです。

AnsibleとCapistranoの挙動のまとめ

Ansibleについて

  1. Ansibleのタスクでshell: bash -lc “”を実行すると、「ログインかつ非インタラクティブ」なシェルとして実行される
  2. ログインシェルなので、~/.bash_profileが読み込まれる
  3. ~/.bash_profileから~/.bashrcが読み込まれる

Capistranoについて

  1. Caspitranoは「非ログインかつ非インタラクティブ」なシェルとして実行される
  2. しかし、ネットワーク越しの実行なので~/.bashrcは読み込まれる

ということになります。
つまり、「両者とも~/.bashrcを読み込んでくれるけど、非インタラクティブシェルなのでうまいこと調整してあげないといけない」ということが解決の鍵だったようですね。

おわりに

~/.bash_profile~/.bashrcは触れることが多いですが、ついついググって出てきた通りに設定してしまいがちです。
また、コンテナ化やサーバーレスといった流れの中でさらに見えにくくなっていく箇所でもあります。
クラウドのマネージドサービスやコンテナのような便利な基盤を積極的に取り入れつつ、OSについての基本的な知識も高めていきたいですね。

参考