CUBE SUGAR CONTAINER

技術系のこと書きます。

返り値のチェックでシェルスクリプトが止まらないようにする

シェルスクリプトで set -e しておくとコマンドの返り値が非ゼロ (エラー) のときにスクリプトを止めることができる。 この機能を使うと、コマンドの実行結果がエラーになった状態で処理が突き進んでしまうことを防止できる。 ただ、この機能は便利な反面、スクリプトが意図せず止まってしまうこともある。 今回は、それを回避する方法について。

使った環境は次の通り。

$ sw_vers     
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin18)
Copyright (C) 2007 Free Software Foundation, Inc.

もくじ

set -e について

シェルスクリプトで set -e しておくと、コマンドの返り値が非ゼロ (エラー) のときに、処理をそこで止めることができる。 例えば以下のスクリプトでは bash -c "exit 1" のところで止まる。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの返り値が非ゼロなので異常終了
bash -c "exit 1"  # ここで停止する

# ここまで到達しない
echo "Hello, World"

上記を実行してみよう。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'

最後まで到達しないことが分かる。

一応、コマンドがゼロを返すパターンについても確認しておく。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの返り値がゼロなので正常終了
bash -c "exit 0"

# エラーがないので、ここまで到達する
echo "Hello, World"

上記を実行すると、今度は最後まで到達する。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 0'
+ echo 'Hello, World'
Hello, World

返り値が非ゼロになるコマンドを実行したい

ただ、この機能を有効にしていると困るときもある。 例えば、返り値が非ゼロになるコマンドをどうしても実行したい場合について。

まずは正攻法から。 次のように set +e を使って一時的に返り値のチェックを無効化できる。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

set +e  # 一時的に機能を無効化する
bash -c "exit 1"  # 返り値が非ゼロになるコマンドを実行する
set -e  # コマンドの実行が終わったら再び有効化する

# ちゃんとここまで到達する
echo "Hello, World"

上記を実行してみよう。 ちゃんと返り値が非ゼロになるコマンドも実行できていることがわかる。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 1'
+ set -e
+ echo 'Hello, World'
Hello, World

別解

ただ、上記は割と冗長なので、以下のようにもできるようだ。 一連のコマンドの返り値が必ずゼロになるようにするアプローチ。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 一連のコマンドとして返り値が必ずゼロになる
bash -c "exit 1" || true

# ちゃんとここまで到達する
echo "Hello, World"

上記を実行してみよう。 ちゃんと返り値が非ゼロになるコマンドも実行できている。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'
+ true
+ echo 'Hello, World'
Hello, World

返り値を使って条件分岐したい

続いては、特定のコマンドの返り値を使って条件分岐したいというパターン。

以下のサンプルコードでは、何もケアをしていないため返り値が非ゼロになると処理が止まってしまう。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの実行結果を元に処理を分岐させたい
bash -c "exit 1"  # しかし set -e があると停止してしまう

# 以下の分岐まで到達しない
if [ $? -eq 0 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 想定通り、非ゼロが返る時点で終了してしまう。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'

ゼロか非ゼロかしか見ない場合

まず、コマンドの返り値がゼロか非ゼロかしか見ないのであれば if に直接コマンドを埋め込んでしまえば良い。 if は元々内側のコマンドの返り値がゼロか非ゼロかで分岐する構文になっているため。

このアプローチを採用したサンプルコードは次の通り。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# if であれば直接コマンドを埋め込んでしまえば良い
if bash -c "exit 1"; then
  echo "ok"
else
  echo "ng"
fi

上記を実行した結果が次の通り。 set -e の影響を受けることなく意図通りに処理されていることがわかる。

$ bash errchk.sh
+ set -e
+ bash -c 'exit 1'
+ echo ng
ng

それ以外の返り値も確認したい場合

それ以外の返り値も確認したい場合には、ちょっと面倒になる。 まず、基本となるアプローチとしては直前に実行したコマンドの返り値が得られる変数 $? を使うことになる。 しかし、以下のように非ゼロになりうるコマンドの前後で無効化・有効化をしてしまうとうまくいかない。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# こうしたいんだけどダメなパターン
set +e
bash -c "exit 100"
set -e  # これもコマンドの実行なので $? が上書きされてしまう

# 分岐が意図通りにいかない
if [ $? -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 本来なら "ok" になってほしいところが "ng" になってしまった。 これは set -e によって $? の値が上書きされてしまったため。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 100'
+ set -e
+ '[' 0 -eq 100 ']'
+ echo ng
ng

そのため、次のようにコマンドの返り値を別の変数に退避しておく必要がある。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 返り値のチェックを止める影響を最小化する
set +e
bash -c "exit 100"
RET=$?  # コマンドの実行が終わったら返り値を変数に退避させる
set -e  # 退避したらエラーのチェックを戻す

# 分岐では退避させた変数を見る
if [ ${RET} -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 今度はちゃんとうまくいった。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 100'
+ RET=100
+ set -e
+ '[' 100 -eq 100 ']'
+ echo ok
ok

別解 (別プロセスのシェルで実行できる場合)

なお、別プロセスのシェルで実行できる処理であれば、コマンド置換を使って以下のように書くこともできる。 この方法であれば set -e されていない別シェルで実行しているのと同じ扱いになるので、一時的に set +e する必要がない。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンド置換を使って返り値を求める
RET=$(bash -c "exit 100"; echo $?)

# 分岐では退避させた変数を見る
if [ $RET -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 ちゃんとうまくいっているようだ。

$ bash errchk.sh 
+ set -e
++ bash -c 'exit 100'
++ echo 100
+ RET=100
+ '[' 100 -eq 100 ']'
+ echo ok
ok

また、このやり方であれば以下のように条件分岐の中に埋め込むこともできる。 これは if 文に test コマンドを使わず直接実行したいコマンドを埋め込んだ最初のパターンに近い簡便さになってると思う。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 条件分岐に埋め込むパターン
if [ $(bash -c "exit 100"; echo $?) -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

別解 (set +e を使わないアプローチ)

もうひとつ、最初の非ゼロのコマンドを実行するときの別解に似たやり方が次の通り。 コマンドの実行結果をワンライナーで変数に退避させることで set +e が不要になっている。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 変数を初期化する
RET=0
# 一連のコマンドとして返り値をゼロにするアプローチ
bash -c "exit 100" || RET=$?

# 分岐では退避させた変数を見る
if [ $RET -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

実行結果は次の通り。

$ bash errchk.sh 
+ set -e
+ RET=0
+ bash -c 'exit 100'
+ RET=100
+ '[' 100 -eq 100 ']'
+ echo ok
ok

いじょう。

色々な Unix 系 OS の crypt(3) について調べたら面白かった話

今回は、色々な Unix 系 OS の crypt(3) について調べたら、過去の経緯などが分かって面白かったという話について。 crypt(3) というのは、標準 C ライブラリの libc ないし libcrypt で実装されている関数のこと。

調査した Unix 系 OS というのは、具体的には以下の通り。

  • GNU/Linux (Ubuntu 18.04 LTS)
$ uname -sr
Linux 4.15.0-65-generic
  • FreeBSD 12.0
$ uname -or
FreeBSD 12.0-RELEASE
  • OpenBSD 6.5
$ uname -sr
OpenBSD 6.5
  • macOS Mojave
$ uname -sr
Darwin 18.7.0

もくじ

TL; DR

  • crypt(3) が対応しているハッシュ化・暗号化方式は意外と OS によってバラバラ
  • crypt(3) でハッシュ化が使われ始めたのは Unix の歴史の中では意外と新しい
  • Python の passlib を使うとプラットフォームに依存せず MCF な文字列を生成できる

crypt(3) について

まずは crypt(3) の基本的な説明から。

前述した通り、crypt(3) は標準 C ライブラリの libc または libcrypt で実装されている関数を指している。 用途としては、ローカルのパスワード認証でシステムにログインするためのパスワードを、一般的にはハッシュ化するのに使う。

典型的には、shadow(5) の中に記述されている謎の文字列を生成するのに使われている。 補足しておくと shadow(5) は /etc/shadow のパスにある設定ファイルを指している。 システムは、ここに書かれている内容と、ユーザが入力した内容を比べることでログインさせて良いか判断する。

$ sudo cat /etc/shadow | grep ^$(whoami): | cut -d : -f 2
$6$hqcnshac$cIZ4Q88Lh/Mz9AybBGKuw8dYuTc/qZbicFX5NVmPVeGVYlwceHYTmuelzQp9i4m.5llRraxzCSdO5aAdmxHUF.

なお、この文字列はハッシュ化されているとはいえログインに使うパスワードから作られたものなので漏えいしないように気をつけよう。 上記は Vagrant で作ったパスワードが自明な環境の出力結果を載せている。

そして、この謎の文字列は、現在では次のようなフォーマットが一般的となっている。

$<id>$<salt>$<encrypted>

<id> はハッシュ化の方式を、<salt> はハッシュ化に使ったソルト、そして <encrypted> がハッシュ化されたパスワードを表している。 なんでハッシュ化 (Hashing) なのに暗号化 (Encrypted) なんだ?というのは後ほど分かってくる。

ちなみに、次のように <id><salt> の間に <option> を挟み込んだフォーマットも用いられることがある。

$<id>$<option>$<salt>$<encrypted>

なお、ここまでに使われている名前の後ろについた (3) みたいなカッコつきの数字は、man page のセクション番号を示している。 Unix の文脈では、何について述べているのかを明確にする目的でよく用いられる。 その内容について詳しく知りたいときは、システム上で次のようにすればマニュアルが読める。

$ man <section> <name>

例えば、今回扱う crypt(3) であれば、次のようにする。

$ man 3 crypt

GNU/Linux で crypt(3) を SHA-512 と一緒に使ってみる

何はともあれ、まずは GNU/Linux で crypt(3) を使ってみることにしよう。

以下の通り、パスワードの文字列を SHA-512 でハッシュ化するサンプルコードを用意した。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  // パスワードに使う文字列
  char *password = "password";
  // ハッシュ化の方式とソルト
  char *salt = "$6$salt$";
  // crypt(3) でハッシュ化された文字列
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドする。 libcrypt を使うため -l crypt オプションをつける。

$ gcc -Wall -o sha512.o crypt-sha512.c -l crypt

できたファイルを実行してみよう。 すると、先ほど見たような文字列が出力される。

$ ./sha512.o
$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.

このように、crypt(3) を使うことで shadow(5) に書かれる文字列を生成できる。

なお、C 言語がダルいときは次のように Python のインターフェースから使う手もある。

$ python3
...(snip)...
>>> import crypt
>>> crypt.crypt('password', '$6$salt$')
'$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.'

ここからが本題なんだけど、実は対応しているハッシュ化・暗号化の方式は OS によって異なる。 例えば、GNU/Linux で crypt(3) のマニュアルを読むと次のように記述されている。

ID  | Method
─────────────────────────────────────────────────────────
1   | MD5
2a  | Blowfish (not in mainline glibc; added in some
    | Linux distributions)
5   | SHA-256 (since glibc 2.7)
6   | SHA-512 (since glibc 2.7)

基本的には MD5 と SHA2 系で、ディストリビューションによっては Blowfish があるかも、という感じ。 それも、古い glibc (GNU 実装の libc) では SHA2 系も使えないことがわかる。 なお、ここでいう Blowfish は、共通鍵暗号方式の Blowfish をベースに考案されたハッシュ関数の Bcrypt を厳密には指している。

FreeBSD がサポートしているアルゴリズムについて

続いて FreeBSD でも試してみよう。 GNU/Linux でビルドしたのと同じソースコードを用意する。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "$6$salt$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

そして、これをビルドする。

$ cc -Wall -o sha512.o crypt-sha512.c -l crypt

実行すると、先ほどと全く同じ文字列が出力されることがわかる。

$ ./sha512.o
$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.

では、他にはどんなハッシュ化・暗号化方式に対応しているのだろうか。 マニュアルを確認してみよう。

$ man 3 crypt

すると、次のような記述が見つかる。 なるほど glibc と比較すると Blowfish が標準で入っていたり NT-Hash という方式に対応していることがわかる。

1.   MD5
2.   Blowfish
3.   NT-Hash
4.   (unused)
5.   SHA-256
6.   SHA-512

ただ、もうちょっとよく読むと、実は crypt(3) が出力するフォーマットには以下の 3 種類があるとも書かれている。

  • Extended
  • Modular
  • Traditional

ここまで見てきた $ で区切られたフォーマットは、どうやら Modular Crypt Format (MCF) というらしい。

そして、以下のような経緯があるようだ。

  • crypt(3) が実装されたのは AT&T UNIX V6 で rotor ベースのアルゴリズムを使っていた
  • 現在のスタイルになったのが V7 で DES ベースのアルゴリズムを使っていた
  • Traditional というのは V7 で実装された DES ベースのアルゴリズムを指している
  • DES ベースのアルゴリズムは今でも使える

注目すべきは、過去そして現在に至るまで DES ベースのアルゴリズムをサポートしているところ。 DES はハッシュ化に使われる一方向関数のアルゴリズムではなく、可逆な暗号化・復号に用いられるアルゴリズムだ。

AT&T UNIX V7 がリリースされたのは 1979 年のこと。 どうやら、crypt(3) でハッシュ化するというアイデアは、Unix の歴史の中ではそれなりに最近のものらしい。 上記のような経緯であれば、マニュアルの中で crypt(3) した文字列を encrypted と表現していたのも理解できる。

なお、FreeBSD に Modular Crypt Format (MCF) と MD5 が実装されたのは 1994 年のようだ。

svnweb.freebsd.org

FreeBSD で Traditional crypt (DES ベースの暗号化) を試してみる

それでは、実際に FreeBSD を使って Traditional crypt の挙動を試してみることにしよう。

Traditional crypt を試すには、先ほどからソルトの文字列を変更するだけで良い。 具体的には、$ もしくは _ から始まらない 2 文字をソルトとして指定する。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  // 2 文字のソルト
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドしよう。

$ cc -Wall -o des.o crypt-des.c -l crypt

そして、実行してみる。 すると、先頭にソルトがついた短い文字列が得られる。 $ が含まれないことからも、あきらかに MCF とはフォーマットが異なる。

$ ./des.o
XXq2wKiyI43A2

上記が昔の Unix で使われていたフォーマットらしい。

実は Traditional crypt は GNU/Linux でも使える

マニュアルには記述がないんだけど、実は glibc も Traditional crypt をサポートしている。 さっきのソースコードを GNU/Linux でも用意しよう。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

そしてビルドする。

$ gcc -Wall -o des.o crypt-des.c -l crypt

そして、実行してみる。 すると、さっきの FreeBSD と同じ文字列が得られた。

$ ./des.o
XXq2wKiyI43A2

OpenBSD では Blowfish しか使えない

GNU/Linux と FreeBSD がサポートしているアルゴリズムが分かったところで、次は OpenBSD も見てみよう。 実は、意外な結果が得られる。

まずは、Traditional crypt から試してみよう。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドして実行してみよう。

$ cc -Wall -o des.o crypt-des.c
$ ./des.o
can not crypt: Invalid argument

すると、Invalid argument とエラーになってしまった。

では、SHA-512 を使った MCF はどうだろうか。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "$6$salt$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドして実行してみる。

$ cc -Wall -o sha512.o crypt-sha512.c
$ ./sha512.o
can not crypt: Invalid argument

こちらもエラーになってしまった。

では、次に以下のようなサンプルコードを用意する。 ハッシュ化の方式は 2b で、その指定の直後に 12 という数字が入るなど何だかこれまでとちょっと異なる。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  // Blowfish 2b
  char *salt = "$2b$12$0123456789012345678901$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

実は上記は Blowfish のバージョン 2b というアルゴリズムになっている。

上記をビルドしてみよう。

$ cc -o blowfish.o crypt-blowfish.c

そして、実行すると、MCF っぽい文字列が得られる。

$ ./blowfish.o 
$2b$12$012345678901234567890urJuVDjqhVLN4JtdA/LMQb5YGJelo1WO

ソルトの文字列が長いのは、Blowfish 2b のソルトに必ず 22 文字という制約があるため。 また、得られた MCF っぽい文字列は、ソルトとハッシュ化された文字列の間が $ で区切られていないのも特徴的。 ちなみに、ハッシュ化の方式の指定の直後にある 12 という数字は、ハッシュ化を何度繰り返すかというストレッチングの指定だった。 ちなみに、2 の階乗で表現されているため 12 なら 212 = 4096 回らしい。

OpenBSD はバージョン 5.8 から他の方式を捨てた

どうして DES ベースの Traditional crypt や、MCF でも SHA-512 が使えなかったのだろうか。 理由は単純で、OpenBSD はバージョン 5.8 で他の方式をバッサリ捨てて Blowfish 一本に絞ったから。

リポジトリを見ると OpenBSD 5.7 には、まだ Traditional crypt で使うようなコードも残っている。

cvsweb.openbsd.org

しかし、OpenBSD 5.8 になるとコードがバッサリと切り捨てられて Blowfish で使う bcrypt(3) のコードしかない。

cvsweb.openbsd.org

ちなみに、現在の crypt(3) では、それに伴ってマニュアルも改定されている。 そこにはもう Encryption/Encrypted という単語はなく、Hashing という表現があるのみ。

man.openbsd.org

後方互換を捨てたからこそ使える表現なのかもしれない。 古くて脆弱なアルゴリズムを切り捨てる姿勢には、何よりセキュリティを重視する OpenBSD らしさを感じた。

なお、そもそも Bcrypt を考案したのが OpenBSD の開発チームらしい。

https://www.usenix.org/legacy/event/usenix99/provos/provos.pdf

macOS は Traditional crypt しか使えない

最後に、macOS についても確認しておこう。

まずは MCF の SHA-512 から。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "$6$salt$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドする。

$ cc -Wall -o sha512.o crypt-sha512.c

そして実行する、と何かがおかしい。 MCF にしては短すぎるし $ も少ない。

$ ./sha512.o
$6FMi11BJFsAc

実は、上記では MCF を指定したつもりのソルトの先頭 2 文字が Traditional crypt で使うソルトとして認識されている。

改めて Traditional crypt のソースコードを用意しよう。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

ビルドして実行すると、今度はちゃんと動作する。

$ cc -Wall -o des.o crypt-des.c
$ ./des.o
XXq2wKiyI43A2

ええーっ、て感じだけど macOS ではそもそも MCF が使えないようだ。

プラットフォームに依存しない MCF の生成について

ここまで見てきた通り、crypt(3) の実装はプラットフォームに依存する。 そこで、異なるプラットフォーム向けに MCF な文字列を生成する方法についても紹介しておく。

使ったのは Traditional crypt しか使えない macOS Mojave さん。

$ uname -sr
Darwin 18.7.0
$ python -V
Python 3.7.4

Python の passlib というサードパーティ製のパッケージをインストールする。 Blowfish が使いたいときは bcrypt についても入れておく。

$ pip install passlib bcrypt

インストールできたら Python のインタプリタを起動する。

$ python

試しに SHA-512 で生成してみよう。

>>> from passlib.hash import sha512_crypt
>>> sha512_crypt.hash('password', rounds=5000, salt='salt')
'$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.'

ちゃんと、先ほど見たのと同じ文字列が得られた。

ちなみに、先ほど指定した rounds というオプションはストレッチング回数を指している。 実は MCF の SHA-512 はデフォルトで 5000 回ストレッチングされている。

それ意外のストレッチング回数を指定すると、次のように <option> の領域に rounds の指定が入るようになる。 これはその回数ストレッチングされた文字列ですよ、ということ。

>>> sha512_crypt.hash('password', rounds=5001, salt='salt')
'$6$rounds=5001$salt$tlQV3WMrkQpSw9nT3Wa2jOpUhHPxDtgcpasv4rOXxXXufOxtxgrzd4O67kS/localLJb2M2o0fCRdv/yfX0SJ/'

Traditional crypt が生成する文字列が得たいなら次のようにする。

>>> from passlib.hash import des_crypt
>>> des_crypt.hash('password', salt='XX')
'XXq2wKiyI43A2'

Blowfish なら次の通り。

from passlib.hash import bcrypt
>>> bcrypt.hash('password', rounds=12, salt='0123456789012345678901')
'$2b$12$012345678901234567890urJuVDjqhVLN4JtdA/LMQb5YGJelo1WO'

いじょう。

続: Docker コンテナ内で Docker ホストと同じユーザを使う

以前、Docker コンテナ内で Docker ホストと同じユーザを使う方法として、以下のような記事を書いた。 ちょっと強引だけど /etc/passwd/etc/group をコンテナからマウントすることで不整合をなくしてしまう、というもの。

blog.amedama.jp

ただ、上記のやり方だと不都合が出る場合もあったので、別のやり方についても試してみた。 どちらかというと、今回のやり方の方が後々の面倒がなくて良いかもしれない。 方法というのは、コンテナを起動するタイミングでコンテナ内にユーザを使って各種 ID などを引き継がせてしまう、というもの。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-65-generic

もくじ

ユーザ情報の書かれたファイルを読み込み専用でマウントする方法の問題点について

Docker のインストール方法については前述した記事の中でも扱っているので省略する。

先のエントリでは、次のようにして /etc/passwd/etc/group を読み込み専用でマウントすることで同じユーザを使えるようにしていた。

$ sudo docker run \
    --rm \
    -u "$(id -u $(whoami)):$(id -g $(whoami))" \
    -v $(pwd):/mnt/working \
    -v /etc/passwd:/etc/passwd:ro \
    -v /etc/group:/etc/group:ro \
    -v /home:/home \
    -it ubuntu:18.04

この方法を使うと、たしかに Docker コンテナ内で Docker コンテナと同じユーザが使えるようになる。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant)

ただ、この方法には問題がないわけでもない。 例えば、次のようにして一旦 root ユーザで入り直す。

$ sudo docker run \
    --rm \
    -v $(pwd):/mnt/working \
    -v /etc/passwd:/etc/passwd:ro \
    -v /etc/group:/etc/group:ro \
    -v /home:/home \
    -it ubuntu:18.04

そして、試しにユーザのパスワードハッシュを次のようにして usermod(8) で書き換えてみる。

# usermod -p python3 -c "import crypt; from getpass import getpass; print(crypt.crypt(getpass(), crypt.METHOD_SHA512))" vagrant
usermod: cannot open /etc/passwd

しかし、エラーになってしまった。 パスワードハッシュは、現在では一般的に /etc/shadow に書き込まれるようになっている。 しかし、後方互換性を考えて /etc/passwd に書かれていても動作する。 そのため、結局のところ /etc/passwd にも書き込み権限が必要とされるらしい。 このように、読み込み専用でマウントしてしまうことは、後々面倒な問題を招きやすいようだ。

動的にユーザを追加・編集する

そこで、もう一つのやり方を試してみることにする。 具体的には、コンテナを起動するタイミングでユーザを追加して ID などを編集してしまう、というもの。

まずは Supervisord の設定ファイルを用意しておく。 これはコンテナが終了しないようにするためだけのもの。 何らか継続するプロセスであれば別のものを使っても良い。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true
EOF

続いて Dockerfile を用意する。 必要なパッケージなどをインストールして設定ファイルをコピーしているだけなので、そんなに見るべきところはない。 コンテナが起動するときは docker-entrypoint.sh というシェルスクリプトを実行している。 この中でユーザを追加・編集することになる。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      supervisor \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Boot process
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp
CMD bash -E /var/tmp/docker-entrypoint.sh
EOF

続いてが肝心のコンテナが起動するときに呼び出されるシェルスクリプト。 この中では変数を元にユーザを追加して ID の類を編集している。 そして、最終的に Supervisord が起動するようになっている。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

assert_env_defined () {
  if [ ! -v $1 ]; then
    echo "please define '$1' environment variable"
    exit 1
  fi
}

: "Add non administrative user" && {

  : "Add user" && {
    assert_env_defined USER_NAME
    set +e  # avoid error check temporarily
    id ${USER_NAME}  # existence check
    if [ $? -ne 0 ]; then
      useradd -s /bin/bash -m ${USER_NAME}
    fi
    set -e  # enable error check
  }

  : "Set home directory" && {
    export HOME=/home/${USER_NAME}
  }

  : "Change user id" && {
    assert_env_defined USER_ID
    if [ $(id -u ${USER_NAME}) -ne ${USER_ID} ]; then
      usermod -u ${USER_ID} ${USER_NAME}
    fi
  }

  : "Change group id" && {
    assert_env_defined GROUP_ID
    if [ $(id -g ${USER_NAME}) -ne ${GROUP_ID} ]; then
      usermod -g ${GROUP_ID} ${USER_NAME}
    fi
  }

  : "Add common groups" && {
    usermod -aG sudo ${USER_NAME}
  }

}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

上記のコンテナを起動するための Docker Compose の設定ファイルを用意しておこう。

$ cat << 'EOF' > docker-compose.yml
version: "3"
  
services:
  override:
    image: example/override-ids
    build: .
    container_name: override
    environment:
      - USER_NAME
      - USER_ID
      - GROUP_ID
    volumes:
      - ./:/mnt/working
EOF

まずはコンテナイメージをビルドする。

$ sudo docker-compose build

そして、次のように現在のユーザ名や ID の類をシェル変数ごしに教えてやりながらコンテナを起動する。

$ USER_NAME=$(whoami) \
  USER_ID=$(id -u) \
  GROUP_ID=$(id -g) \
  sudo -E docker-compose up
...
override    | + : 'Start supervisord'
override    | + supervisord -c /etc/supervisord.conf
override    | 2019-10-02 14:54:43,036 CRIT Supervisor running as root (no user in config file)
override    | 2019-10-02 14:54:43,038 INFO supervisord started with pid 14

起動したコンテナにログインする。 このとき su(1) を使って追加されたユーザにログインし直す。

$ sudo docker exec -it override su - $(whoami)

すると、次のようにユーザ名や ID が引き継がれたように見える状態でコンテナが起動する。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant),27(sudo)

試しに、何かファイルを作ってみよう。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt

ちゃんと Permission Denied にならずにファイルが作られた。

Docker ホストのファイルも、以下のようにユーザがちゃんと状態で表示されている。 これは、コンテナとホストでユーザ名や識別子が一致しているため。

$ ls -lF
total 20
-rw-rw-r-- 1 vagrant vagrant 466 Oct  2 14:39 Dockerfile
-rw-rw-r-- 1 vagrant vagrant 215 Oct  2 14:51 docker-compose.yml
-rw-rw-r-- 1 vagrant vagrant 978 Oct  2 14:46 docker-entrypoint.sh
-rw-rw-r-- 1 vagrant vagrant  13 Oct  2 14:53 greet-docker.txt
-rw-rw-r-- 1 vagrant vagrant  28 Oct  2 14:39 supervisord.conf

ちなみに、このやり方なら Docker for Mac でも使える。 めでたしめでたし。

OpenSSH の秘密鍵から公開鍵を復元する

OpenSSH だと公開鍵がなくなっても秘密鍵から復元できることを知った。 今回はそれを試してみる。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-65-generic
$ ssh -V
OpenSSH_7.6p1 Ubuntu-4ubuntu0.3, OpenSSL 1.0.2n  7 Dec 2017

もくじ

公開鍵ペアを作る

ひとまず以下のようにして公開鍵のペアを作る。

$ ssh-keygen \
    -t rsa \
    -b 2048 \
    -P '' \
    -f $HOME/.ssh/id_rsa.example \
    -C example@example.jp
Generating public/private rsa key pair.
Your identification has been saved in /home/vagrant/.ssh/id_rsa.example.
Your public key has been saved in /home/vagrant/.ssh/id_rsa.example.pub.
The key fingerprint is:
SHA256:qrL+HHbnpNI57R+hgQM+eem2BCnAln1b632Kp1/BSw4 example@example.jp
The key's randomart image is:
+---[RSA 2048]----+
|                 |
|. o              |
|.+ ... .         |
|.. ..+oo. .      |
|  . *.=.SE.+     |
|   . =.o.o+.o    |
|    o.*++..=     |
|  .o.*+*o.+.     |
| .o++.o=*+.      |
+----[SHA256]-----+

これで、以下のように公開鍵のペアができた。

$ ls ~/.ssh | grep id_rsa
id_rsa.example
id_rsa.example.pub

公開鍵の中身は以下の通り。

$ cat ~/.ssh/id_rsa.example.pub 
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPYjc9UeA6iZM6LhPy5BfXxby0TWyDMbItVk4reJfNEWgpng0tqZaKm2Kp4XizZupfDVMFf2MmCfS3lARO4lDXkmi6YoOoFCSmq4vjuIiykQchjrbRBoTAvNk8IX8Z9lWt7juhbeWL9LGM7tyo7aM+jrB1MteBr8rXB/ugnwmEe3rgLgq64N7zklhLwZKlN8gofH4UGO1MMTq9lPjzFr+Edq/1vIPOiQPtXjjsPCVHryaK+Kyy39qW2I7AeYMCCX5w/JxFAFC/kBLOgWIDOFoHaCxrsl5bXv1augUsHyy6yftc+H7f+Z/xJzWj0l/LRU6iqGMTcOgXtZG8w9vFrObD example@example.jp

ひとまず、作った公開鍵は移動しておく。

$ mv ~/.ssh/id_rsa.example.pub{,.orig}

秘密鍵から公開鍵を復元する

次のように ssh-keygen(1) は -y オプションを使うことで秘密鍵から公開鍵を復元できる。 秘密鍵のファイルは -f オプションで渡す。

$ ssh-keygen -y -f ~/.ssh/id_rsa.example > ~/.ssh/id_rsa.example.pub

内容を確認すると、コメントを除いて中身が復元できていることがわかる。

$ cat ~/.ssh/id_rsa.example.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPYjc9UeA6iZM6LhPy5BfXxby0TWyDMbItVk4reJfNEWgpng0tqZaKm2Kp4XizZupfDVMFf2MmCfS3lARO4lDXkmi6YoOoFCSmq4vjuIiykQchjrbRBoTAvNk8IX8Z9lWt7juhbeWL9LGM7tyo7aM+jrB1MteBr8rXB/ugnwmEe3rgLgq64N7zklhLwZKlN8gofH4UGO1MMTq9lPjzFr+Edq/1vIPOiQPtXjjsPCVHryaK+Kyy39qW2I7AeYMCCX5w/JxFAFC/kBLOgWIDOFoHaCxrsl5bXv1augUsHyy6yftc+H7f+Z/xJzWj0l/LRU6iqGMTcOgXtZG8w9vFrObD

ただ、残念ながらコメントまでは復元できない。 例えば、以下のように -C オプションをつけてもファイルには反映されなかった。

$ ssh-keygen -y -C example@example.jp -f ~/.ssh/id_rsa.example > ~/.ssh/id_rsa.example.pub

いじょう。

NVIDIA Container Toolkit を使って Docker コンテナで GPU を使う

今回は NVIDIA Container Toolkit を使って Docker コンテナから Docker ホストの GPU を使う方法について書く。 これまで Docker コンテナで GPU を使う方法は、nvidia-docker と nvidia-docker2 という二つの世代を経てきた。 それも、ここに来てやっと一息ついたかな、という印象がある。 GPU の基本的なサポートが Docker 本体側に取り込まれて (v19.03 以降)、GPU ベンダーはそのドライバを提供する形に落ち着いた。 そして、従来の nvidia-docker2 は非推奨 (Deprecated) な方法となっている。

なお、GPU ベンダーがドライバを提供する、と前述した通り NVIDIA のリソースが一切不要になったわけではない。 そのため、インストール手順やインターフェースの使い勝手という観点では nvidia-docker2 から大して変わっていない、というのが実状ではあるだろう。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-1044-gcp
$ lspci -vv | grep -i nvidia
00:04.0 3D controller: NVIDIA Corporation Device 1eb8 (rev a1)
    Subsystem: NVIDIA Corporation Device 12a2
    Kernel modules: nvidiafb
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

もくじ

Docker をインストールする

まずは Docker をインストールする。

必要なパッケージをインストールしておく。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common

Docker のリポジトリを APT に登録する。

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt update

そして、Docker のコンポーネントをインストールする。

$ sudo apt -y install docker-ce docker-ce-cli containerd.io
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

NVIDIA Graphics Driver をインストールする

続いて Docker ホストに NVIDIA の Graphics Driver をインストールする。

ドライバのリポジトリを APT に登録する。

$ sudo add-apt-repository ppa:graphics-drivers/ppa
$ sudo apt update

推奨されているドライバをインストールする。

$ sudo apt -y install ubuntu-drivers-common
$ sudo ubuntu-drivers autoinstall

NVIDIA Container Toolkit をインストールする

次に Docker ホストに NVIDIA Container Toolkit をインストールする。 この中に Docker で NVIDIA の GPU を使うのに必要なランタイムなどが含まれている。

まずはリポジトリを APT に登録する。

$ curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
$ curl -s -L https://nvidia.github.io/nvidia-docker/$(. /etc/os-release;echo $ID$VERSION_ID)/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
$ sudo apt update

その上でツールキットをインストールする。

$ sudo apt -y install nvidia-container-toolkit

ここまで終わったら、一旦マシンを再起動しておく。

$ sudo shutdown -r now

インストール後の確認作業

まずは nvidia-container-cli info コマンドを使って、ちゃんと GPU が認識できていることを確認しよう。 また、同時に使用できるドライバと CUDA のバージョンが確認できる。

$ nvidia-container-cli info
NVRM version:   435.21
CUDA version:   10.1

Device Index:   0
Device Minor:   0
Model:          Tesla T4
Brand:          Tesla
GPU UUID:       GPU-bcf7de51-87c5-4b98-f8c1-b1a9072696ca
Bus Location:   00000000:00:04.0
Architecture:   7.5

ちなみに nvidia-container-cli list を使うと Docker コンテナ側に注入されるファイル一覧が確認できる。 これらのファイルがコンテナからアクセスできるようになることで GPU が使えるようになる。

$ nvidia-container-cli list
/dev/nvidiactl
/dev/nvidia-uvm
/dev/nvidia-uvm-tools
/dev/nvidia-modeset
/dev/nvidia0
/usr/bin/nvidia-smi
/usr/bin/nvidia-debugdump
/usr/bin/nvidia-persistenced
/usr/bin/nvidia-cuda-mps-control
/usr/bin/nvidia-cuda-mps-server
/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-cfg.so.435.21
/usr/lib/x86_64-linux-gnu/libcuda.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-opencl.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-ptxjitcompiler.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-fatbinaryloader.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-compiler.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-opticalflow.so.435.21
/usr/lib/x86_64-linux-gnu/libnvcuvid.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-eglcore.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-glcore.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-tls.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-glsi.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-fbc.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-ifr.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-rtcore.so.435.21
/usr/lib/x86_64-linux-gnu/libnvoptix.so.435.21
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libGLESv2_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libGLESv1_CM_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-glvkspirv.so.435.21
/run/nvidia-persistenced/socket

Docker コンテナから GPU を使ってみる

それでは、実際に Docker コンテナから GPU を使ってみることにしよう。

まずは、特に何もケアせずに Ubuntu のコンテナを起動して nvidia-smi コマンドを実行してみる。

$ sudo docker run \
    --rm \
    -it ubuntu \
    nvidia-smi
docker: Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "exec: \"nvidia-smi\": executable file not found in $PATH": unknown.

プレーンな Ubuntu のコンテナには、NVIDIA の GPU を扱うためのファイル群が含まれていない。 そのため、この実行は上記の通り失敗に終わる。

続いては --gpus オプションを指定して同じコマンドを実行してみよう。 すると、次のようにちゃんとコマンドの実行が成功する。

$ sudo docker run \
    --rm \
    --gpus all \
    -it ubuntu \
    nvidia-smi
Sun Sep 22 05:31:27 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 435.21       Driver Version: 435.21       CUDA Version: N/A      |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   44C    P0    26W /  70W |      0MiB / 15109MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

同じように CentOS のイメージでも動作するか確認してみよう。 こちらも上手くいく。

$ sudo docker run \
    --rm \
    --gpus all \
    -it centos \
    nvidia-smi
Sun Sep 22 06:10:59 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 435.21       Driver Version: 435.21       CUDA Version: N/A      |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   71C    P0    32W /  70W |      0MiB / 15109MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

上記から、もはや NVIDIA が提供している公式イメージも必須ではなくなったことがわかる。

上記は、前述した通り必要なファイルがコンテナからアクセスできるようになることで実現されている。 まず、何もオプションを付けずに実行したときの /usr/lib/x86_64-linux-gnu/ ディレクトリを確認してみよう。

$ sudo docker run \
    --rm \
    -it ubuntu \
    ls /usr/lib/x86_64-linux-gnu/
audit              libgnutls.so.30   libpanelw.so.5
coreutils          libgnutls.so.30.14.10  libpanelw.so.5.9
gconv              libhogweed.so.4   libpcreposix.so.3
libapt-pkg.so.5.0     libhogweed.so.4.4     libpcreposix.so.3.13.3
libapt-pkg.so.5.0.2      libidn2.so.0          libsemanage.so.1
libapt-private.so.0.0     libidn2.so.0.3.3     libstdc++.so.6
libapt-private.so.0.0.0    liblz4.so.1         libstdc++.so.6.0.25
libdb-5.3.so          liblz4.so.1.7.1      libtasn1.so.6
libdebconfclient.so.0     libmenu.so.5          libtasn1.so.6.5.5
libdebconfclient.so.0.0.0  libmenu.so.5.9      libtic.so.5
libffi.so.6           libmenuw.so.5     libtic.so.5.9
libffi.so.6.0.4          libmenuw.so.5.9   libunistring.so.2
libform.so.5          libnettle.so.6    libunistring.so.2.1.0
libform.so.5.9        libnettle.so.6.4      libzstd.so.1
libformw.so.5         libp11-kit.so.0   libzstd.so.1.3.3
libformw.so.5.9           libp11-kit.so.0.3.0      perl
libgmp.so.10          libpanel.so.5     perl-base
libgmp.so.10.3.2     libpanel.so.5.9

特に GPU 関連のファイルが含まれている形跡はない。

続いて --gpus オプションをつけて同じコマンドを実行してみよう。

$ sudo docker run \
    --rm \
    --gpus all \
    -it ubuntu \
    ls /usr/lib/x86_64-linux-gnu/
audit              libgnutls.so.30.14.10    libpanel.so.5
coreutils          libhogweed.so.4     libpanel.so.5.9
gconv              libhogweed.so.4.4       libpanelw.so.5
libapt-pkg.so.5.0     libidn2.so.0            libpanelw.so.5.9
libapt-pkg.so.5.0.2      libidn2.so.0.3.3       libpcreposix.so.3
libapt-private.so.0.0     liblz4.so.1         libpcreposix.so.3.13.3
libapt-private.so.0.0.0    liblz4.so.1.7.1      libsemanage.so.1
libdb-5.3.so          libmenu.so.5            libstdc++.so.6
libdebconfclient.so.0     libmenu.so.5.9      libstdc++.so.6.0.25
libdebconfclient.so.0.0.0  libmenuw.so.5     libtasn1.so.6
libffi.so.6           libmenuw.so.5.9     libtasn1.so.6.5.5
libffi.so.6.0.4          libnettle.so.6      libtic.so.5
libform.so.5          libnettle.so.6.4        libtic.so.5.9
libform.so.5.9        libnvidia-cfg.so.1      libunistring.so.2
libformw.so.5         libnvidia-cfg.so.435.21  libunistring.so.2.1.0
libformw.so.5.9           libnvidia-ml.so.1       libzstd.so.1
libgmp.so.10          libnvidia-ml.so.435.21   libzstd.so.1.3.3
libgmp.so.10.3.2     libp11-kit.so.0     perl
libgnutls.so.30           libp11-kit.so.0.3.0        perl-base

すると、libnvidia-* といった GPU 関連の共有ライブラリが確認できるようになる。

Docker Compose の対応について

残念なお知らせだけど、まだ現時点 (2019/09/22) では Docker Compose が上記の --gpus オプションに対応できていない。

$ sudo apt -y install python3-pip
$ sudo pip3 install docker-compose
$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

Docker Compose と一緒に万全の体制で使うには、もう少し時間を必要としそうだ。

Support for NVIDIA GPUs under Docker Compose · Issue #6691 · docker/compose · GitHub

補足

今回使った GPU マシン環境は次のようにして用意した。

$ gcloud compute instances create gcp-gpu-t4-x1 \
  --preemptible \
  --zone us-central1-a \
  --machine-type n1-standard-2 \
  --accelerator type=nvidia-tesla-t4,count=1 \
  --maintenance-policy TERMINATE \
  --restart-on-failure \
  --image-project ubuntu-os-cloud \
  --image-family ubuntu-1804-lts

いじょう。

リモートサーバ上の Docker コンテナで JupyterLab を使う

今回のエントリは、以下のエントリの改訂版となる。 起動するアプリケーションを Jupyter Notebook から JupyterLab にすると共に、いくつか変更を加えた。

blog.amedama.jp

JupyterLab は従来の Jupyter Notebook を置き換えることを目的とした後継プロジェクト。 基本的な部分は Notebook を受け継ぎつつも、より扱いやすいインターフェースを提供している。

今回使う環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

Docker をインストールする

まずはリポジトリを更新すると共に、過去にインストールした Docker 関連コンポーネントがあれば削除しておく。

$ sudo apt update
$ sudo apt -y remove docker docker-engine docker.io containerd runc

Docker のインストール用リポジトリを APT に登録する。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common \
    python3-pip
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt update

Docker の主要コンポーネントをインストールする。

$ sudo apt -y install docker-ce docker-ce-cli containerd.io
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

ついでに Docker Compose もインストールしておく。

$ sudo pip3 install docker-compose
$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

JupyterLab を起動するイメージを用意する

今回はデーモンを Supervisord 経由で起動することにした。 JupyterLab だけを起動するなら別に直接起動してもいいんだけど、拡張性などを考えて。 %% で囲まれている部分は後で環境変数経由で置き換える。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true

[program:jupyterlab]
command=%%CONDA_HOME%%/envs/%%CONDA_VENV_NAME%%/bin/jupyter-lab
user=%%USERNAME%%
stdout_logfile=/var/log/supervisor/jupyter-lab.log
redirect_stderr=true
autostart=True
autorestart=True
EOF

続いて Supervisord を起動するエントリーポイントとなるシェルスクリプト。 この中では主に設定ファイルの準備をしている。 JupyterLab の Web UI にトークンなしでアクセスできるようにするなど。 JupyterLab の Web UI には SSH Port Forwarding 経由でアクセスするため、アクセス制御については SSH の部分で担保する。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

: "Set home directory" && {
  export HOME=/home/${USERNAME}
}

: "Replace template variables" && {
  sed -i.back \
    -e "s:%%USERNAME%%:${USERNAME}:g" \
    -e "s:%%CONDA_HOME%%:${CONDA_HOME}:g" \
    -e "s:%%CONDA_VENV_NAME%%:${CONDA_VENV_NAME}:g" \
  /etc/supervisord.conf
}

: "Jupyter configuration" && {
  if [ ! -e /home/${USERNAME}/.jupyter ]; then
    sudo -iu ${USERNAME} ${CONDA_HOME}/envs/${CONDA_VENV_NAME}/bin/jupyter notebook --generate-config
    sudo -iu ${USERNAME} sed -i.back \
      -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
      -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = '*':" \
      -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
      -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = \"${JUPYTER_HOME}\":" \
      /home/${USERNAME}/.jupyter/jupyter_notebook_config.py
  fi
}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

インストールしたい Python のパッケージの一覧を書いておくファイル。

$ cat << 'EOF' > requirements.txt
jupyterlab
EOF

続いては、肝心の Dockerfile となる。 Python の実行環境については Miniconda でやってしまうことにした。 インストール先や仮想環境は CONDA_HOMECONDA_VENV_NAME で変更できる。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

ENV CONDA_HOME=/opt/conda \
    CONDA_VENV_NAME=venv \
    JUPYTER_HOME=/mnt/jupyter

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      supervisor \
      ca-certificates \
      wget

# Install miniconda
RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O /tmp/miniconda3.sh \
 && /bin/bash /tmp/miniconda3.sh -b -p ${CONDA_HOME} \
 && rm /tmp/miniconda3.sh \
 && ${CONDA_HOME}/bin/conda clean -tipsy \
 && ln -s ${CONDA_HOME}/etc/profile.d/conda.sh /etc/profile.d/

# Create virtual environment
COPY requirements.txt /tmp
RUN . /etc/profile.d/conda.sh \
 && conda create -y -n ${CONDA_VENV_NAME} python=3.7 \
 && conda activate ${CONDA_VENV_NAME} \
 && pip install -r /tmp/requirements.txt

# Expose container ports
EXPOSE 8888

# Boot process
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp
CMD bash /var/tmp/docker-entrypoint.sh
EOF

起動するときのオプションがなるべく増えないように Docker Compose の設定ファイルも作っておく。 以下では docker-compose.yml のあるディレクトリを JupyterLab の作業ディレクトリとして /mnt/jupyter にマウントしている。 必要に応じて場所は書き換えると良いと思う。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  jupyter-lab:
    build:
      context: .
    environment:
      - USERNAME
      - JUPYTER_HOME=/mnt/jupyter
    image: example/jupyterlab-container
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /home:/home
      - ./:/mnt/jupyter
    ports:
      - "127.0.0.1:8888:8888"
EOF

また、パーミッション問題を避けるために上記では /etc/passwd/etc/group をコンテナにマウントしてしまっている。 このテクニックについては以下を参照のこと。

blog.amedama.jp

準備ができたらイメージをビルドする。

$ sudo docker-compose build

コンテナを起動する

コンテナを起動する前に、念のため SSH に使うポート以外をファイアウォールで閉じておく。 コンテナが bind するアドレスはループバックアドレスなのでインターネットには晒されないけど念のため。

$ sudo ufw allow 22
$ sudo ufw default DENY
$ yes | sudo ufw enable
$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)             

JupyterLab のコンテナを起動する。

$ sudo docker-compose run \
    --name jupyter-lab-container \
    --rm \
    -e USERNAME=$(whoami) \
    --service-ports \
    jupyter-lab

起動ログにエラーがなく、次のように 127.0.0.1:8888 で Listen していれば上手くいっている。

$ ss -tlnp | grep 8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*       

JupyterLab の Web UI にアクセスする

これでリモートサーバ上では JupyterLab が動いたけどループバックアドレスなので疎通がない。 そこで SSH Port Forwarding を使ってリモートサーバ上のポートをローカルに引き出してくる。

今回は Vagrant を使って環境を作っているので次のようにする。

$ vagrant ssh-config > ssh.config
$ ssh -F ssh.config -L 8888:localhost:8888 default

ブラウザで localhost:8888 にアクセスする。

$ open http://localhost:8888

以下のような Web UI が見えればおっけー。 Jupyter Notebook よりも、ちょっと IDE っぽい見た目。

f:id:momijiame:20190920031044p:plain

いじょう。

Docker コンテナ内で Docker ホストと同じユーザを使う

Docker コンテナで Docker ホストのボリュームをマウントすると、パーミッションの問題が生じることがある。 これは、ホストで使っているユーザとコンテナで使っているユーザで UID と GID の不一致が起こるため。

今回は、それらの問題を解決する方法の一つとして Docker コンテナとホストで同じユーザを使う方法を試してみる。 具体的には、Docker ホストの /etc/passwd/etc/group をコンテナからマウントしてしまう。 そのため、このやり方は Docker for Mac や Docker for Windows では使えない。

典型的なユースケースは、パッケージやライブラリといった実行環境はコンテナ内に隠蔽しつつ、その他はホストの環境をなるべく使い回したいというものだろう。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

コンテナとホストでユーザが異なる場合の問題点について

まずはコンテナとホストでユーザの UID と GID が異なる場合の問題点について見ておく。

例えば Docker ホストでは次のユーザを使っているとする。

$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)

試しに、カレントディレクトリにファイルを作っておこう。

$ echo "Hello, World" > greet.txt
$ ls -lF
total 4
-rw-rw-r-- 1 vagrant vagrant 13 Sep 19 09:10 greet.txt

続いて Docker コンテナを起動する。 カレントワーキングディレクトリを /mnt/working にマウントしておく。

$ sudo docker run \
    --rm \
    -v $(pwd):/mnt/working \
    -it busybox

この状態ではコンテナに root ユーザでログインしている。

# whoami
root
# id
uid=0(root) gid=0(root) groups=10(wheel)

マウントしたディレクトリに移動してファイルを作ってみる。

# cd /mnt/working/
# echo "Hello, World" > greet-docker-root.txt

ディレクトリにあるファイルを確認すると、次のようになっている。 自分で作ったファイルは root:root の権限がついている。 先ほど Docker ホストから作ったファイルは 1000:1000 という UID と GID がそのまま表示されている。 これは Docker コンテナ側で、それらの ID を持ったユーザが存在しないために起きている。

# ls -lF
total 8
-rw-r--r--    1 root     root            13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r--    1 1000     1000            13 Sep 19 09:10 greet.txt

Docker ホスト側でも同様に ls してみた結果は次の通り。 こちらでも Docker コンテナで作ったファイルは root:root となっている。 ホストで作ったファイルは現在のユーザの権限がついている。

$ ls -lF
total 8
-rw-r--r-- 1 root    root    13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r-- 1 vagrant vagrant 13 Sep 19 09:10 greet.txt

念のため、root 以外でも異なる UID と GID を持ったユーザを作って確認しておこう。 次の Dockerfile では example ユーザが 10000:10000 という UID と GID を持つようにしてある。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Non administrative username
ENV USERNAME=example

# Add non administrative user
RUN useradd -m ${USERNAME}

# Add example group
RUN groupadd -g 10000 example-users

# Change user id and group id
RUN usermod -u 10000 -g 10000 ${USERNAME}

# Change user
USER ${USERNAME}

# Boot initial process
CMD bash

EOF

イメージのビルドとコンテナの起動のために、次のように Docker Compose 用の設定ファイルも用意しておく。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  example:
    build:
      context: .
    image: example/permission
    volumes:
      - ./:/mnt/working
EOF

まずはイメージをビルドする。

$ sudo docker-compose build

続いてコンテナを起動する。

$ sudo docker-compose run --rm example

起動したコンテナでは、次の通り 10000:10000 の UID と GID を持ったユーザでログインしている。

$ whoami
example
$ id
uid=10000(example) gid=10000(example-users) groups=10000(example-users)

マウントしたディレクトリに移動してファイルを作ってみよう。 すると、ディレクトリに権限がないため書き込むことができない。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt
bash: greet-docker.txt: Permission denied

毎回 sudo するという手もなくはないけど、めんどくさい。

コンテナで /etc/passwd/etc/group をマウントしてみる

先ほどの問題を解決すべく、コンテナから /etc/passwd/etc/group をマウントしてみる。 コンテナ側からは書き換えできないように、念のため ro (Read Only) フラグをつけておく。 /home についてはお好みで。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  example:
    image: ubuntu:18.04
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /home:/home
      - ./:/mnt/working
EOF

起動時に -u オプションで実行時のユーザの UID と GID を指定できる。 次のようにすれば現在のユーザをそのまま引き継げる。

$ sudo docker-compose run \
    -u "$(id -u $USER):$(id -g $USER)" \
    --rm \
    example

コンテナ内でも、ホスト側と同じユーザとして見えることがわかる。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)

これは、ユーザとグループに関する情報がコンテナとホストで共有されているため。

$ grep vagrant /etc/passwd 
vagrant:x:1000:1000:,,,:/home/vagrant:/bin/bash

先ほどと同じようにマウントされているディレクトリに移動してファイルを作ってみよう。 コンテナとホストで UID と GID が一致しているため、ちゃんと書き込むことができている。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt
$ ls -lF 
total 20
-rw-rw-r-- 1 vagrant vagrant 324 Sep 19 09:18 Dockerfile
-rw-rw-r-- 1 vagrant vagrant 183 Sep 19 11:08 docker-compose.yml
-rw-r--r-- 1 root    root     13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r-- 1 vagrant vagrant  13 Sep 19 11:11 greet-docker.txt
-rw-rw-r-- 1 vagrant vagrant  13 Sep 19 09:10 greet.txt

今回のやり方では、コンテナのプラットフォーム間でのポータビリティは低下するというデメリットがある。 もし、プラットフォーム間のポータビリティも落としたくない場合は、ENTRYPOINT のスクリプトでユーザを編集する必要があるだろう。 とはいえ、そこを割り切って手っ取り早く解決する方法の一つとしては使い所を選べばありだと感じた。

参考

qiita.com