nginx サーバの SSL 安全性評価で A+ を獲得

前回の続きです。
前回は、SSL証明書を間違えて設定していた問題を解決した話でした。

ngzm.hateblo.jp

今回は、さらにSSLの安全性評価を A+ にすることが目標です。

参考サイト

SSLの安全性評価を A+ にする方法については、すでに沢山の方がブログなどで発信されていますので、まずは、今回参考にしたサイトを以下にご紹介しておきます。

設定前の評価

設定前の安全性評価は「B」でした。

f:id:ngzm:20180306152936p:plain

特に「Key Exchange」の数値が低く対策が必要な感じです。

設定内容

冒頭でご紹介したサイトを参考にしながら、自分のサイトを A+ にした設定内容を示します。

1) ssl_protocols の設定

nginx の "ssl protocols" で、"sslv3" など脆弱なプロトコルを無効にします。*1

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

2) ssl_ciphers の設定

以下の値で設定します。こちらは httpsだからというだけで安全?調べたら怖くなってきたSSLの話!? - Qiita を参考にしています。

ssl_ciphers ECDHE+RSAGCM:ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:!EXPORT:!DES:!3DES:!MD5:!DSS;
ssl_prefer_server_ciphers on;

3) ssl_dhparam 設定

DH交換鍵の設定。この鍵は、次のOpenSSLコマンドで生成します。生成に結構時間を要します。

$ openssl dhparam 2048 -out dhparam.pem

これを ssl_dhparam に設定します。

ssl_dhparam /etc/nginx/ssl/dhparam.pem;

4) HSTS ヘッダ

このヘッダ情報は、非暗号化の http 通信を抑制するために設定します。このヘッダで 必ず SSL での接続するようにクライアントに指示します。

add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains;';

設定後の評価

ここまで設定した結果、安全性評価は無事に「A+」となりました。
なんだかすっきりしました。

f:id:ngzm:20180306183410p:plain

SSLって以外に奥が深いですね。一度基礎からきちんと勉強しておく価値がありそうです。

*1:nginx v1.12 とか v1.13 など最近のバージョンでは、デフォルトで "ssl_protocols TLSv1 TLSv1.1 TLSv1.2" の状態なので、バージョンが v1.12 より新しければ、この設定は特に不要である。

nginx の SSL設定をしくって Facebook アプリ登録でエラーとなった件

現在開発中のアプリを 「Facebook for Developers」でアプリ登録をした際に、SSL (本当はTLSですが、便宜上SSLと表記します)に関するエラーで少々ハマってしまったので、その内容を記録しておこうと思います。なお、SSL をご存知の方にとっては、至極当たり前の結末なので、あくまでも初心者向けということでご容赦ください。

エラー内容

現在開発中のアプリでは、Facebook の「ログイン」サービスを利用しますが、その際に「Facebook for Developers」から自分のアプリを登録する必要があります。

developers.facebook.com

この「Facebook for Developers」では、アプリの様々な設定を行いますが、ログインサービスの利用を一般にオープンにするためには「プライバシーポリシーのURL」というフィールドを設定しなければなりません。早速プライバシーポリシーのページを用意し、このフィールドにURLを登録しようとしたところ、次のエラーが出てしまいました。

Facebook プラットフォームに準拠するものにするには、有効なプライバシーポリソーURLを入力してください。リクエストエラー: SSL Error: Can't validate SSL Certificate. Either it is self-signed (which will cause browser warnings) or it is invalis.

エラーメッセージの特に英語の部分をみると、どうも「SSL証明書がオレオレじゃないの?」とか「じゃなかったら無効な証明書だよ」と言っています。何か SSL の設定が悪いようです。

原因調査

このアプリはウェブアプリで、Webサーバは nginx を使用しています。

SSL証明書は「Let's Encript」で発行したもので、決して「オレオレ証明書」ではありません。また、ChromeFirefox などのブラウザで動かしても、これまでSSLに関連した警告は特に表示されませんでした。*1

SSL サイト安全性チェック

まずは、SSLに着目して調査したところ、SSLのサイトにも安全性が高いもの、低いものがあることがわかりました。そして、それをチェックしてくれる 「Qualys SSL LABS SSL Server Test」というサービスがあるとのこと、ありがたや。

www.ssllabs.com

早速チェックしてみたら「B」評価でした。

f:id:ngzm:20180306140839p:plain

よく見ると「Key Exchange」の部分が黄色表示で問題があります。あと、もうひとつ "This server's certificate chain is incomplete. Grade capped to B." というメッセージがあります。

証明書チェインが不完全?

で、この "This server's certificate chain is incomplete. Grade capped to B." メッセージについて調べると、 This server's certificate chain is incomplete. ... | Qualys Community というコミュニティサイトがヒット、その中に次の投稿がありました。

When user connects to web server, web server sends certificate chain: leaf server certificate and intermediate certificate. What happens leaf certificate is signed by intermediate, intermediate is signed by already in browser stored root certificate, so certification validation is complete and because certificate chain is trusted your leaf server certificate is trusted. When modern browsers receive intermediate certificate they store it in browser cache. Now lets say your server only sends leaf certificate and does not send intermediate certificate (most probably in your case). What will happen? It depends if user has already visited some other web site and retrieved intermediate from that site and store it in browser cache. If it did then browser will use that intermediate certificate from browser cache to validate the chain of trust. But if end user did not visit any other web site using the same intermediate certificate then user will get fatal error in browser like ssllabs displays "incomplete chain".

要するに、SSL 証明書には「leaf server certificate(サーバ証明書)」と「intermediate certificate(中間証明書)」があって、もしブラウザなどクライアントで、中間証明書がキャッシュ含めて入手できないとルート証明書までのチェインが手繰れなくてエラーになります的なことが書いてあります。

ということで、”This server's certificate chain is incomplete. Grade capped to B." というメッセージは、「証明書チェインが不完全ですよ」ということで、もっと意訳すると「中間証明書が無くないすか?」ということではないかと想定されます。

nginx SSL証明書の設定誤りを発見!

これまでの調査を元に、自サイトの nginx の SSL 設定部分を再確認しましたところ、むちゃやばい間違いを発見しました。

ssl_certificate /etc/letsencrypt/live/[my-site-url]/cert.pem;
ssl_certificate_key /etc/letsencrypt/[my-site-url]/privkey.pem;

Let's Encript で SSL 証明書を発行すると、/etc/letsencrypt/live 以下に 4つのファイルができます。

  1. cert.pem
  2. chain.pem
  3. fullchain.pem
  4. privkey.pem

"cert.pem" はサーバ証明書が入ったもの、"chain.pem" は中間証明書、"fullchain.pem" はサーバ証明書と中間証明書が両方入ったもの、"privkey.pem" は証明書と対の秘密鍵です。

nginx の場合、"ssl_certificate" にサーバ証明書、"ssl_certificate_key" に秘密鍵を指定するのですが、中間証明書を設定できるフィールドが用意されていないので、このような場合は "ssl_certificate" にサーバ証明書と中間証明書が両方入った "fullchain.pem" を設定しなければなりません。*2

一方、私のサイトでは、サーバ証明書だけが入った "cert.pem" が設定されており、これでは中間証明書が入手できずにエラーとなってしまうことが判明しました。

対策する

ここまでくると、対策は簡単です。"ssl_certificate" を "fullchain.pem" に変更して、Webサーバを再起動します。

ssl_certificate /etc/letsencrypt/live/[my-site-url]/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/[my-site-url]/privkey.pem;

これで、もういちど「Facebook for Developers」の「プライバシーポリシーのURL」を設定してみます.... と、無事に設定することができました!!
Facebook で発生したエラーは、中間証明書が入手できなかったことが原因であるということで決まりです。

なお、この状態で「Qualys SSL LABS SSL Server Test」で SSLの安全性評価を再実行しました。

f:id:ngzm:20180306152936p:plain

相変わらず「B」評価ですが、”This server's certificate chain is incomplete. Grade capped to B." のメッセージは消えていることが確認できます。

ちなみに「Qualys SSL LABS SSL Server Test」の最高評価は「A+」らしいです。このままだと「Key Exchange」の部分とか問題が残ってますし、せっかくなので、最高評価の A+ を勝ち取るまで nginx と SSL をセットアップしてみたいと思います。

2018年3月7日追記。

最高評価の A+ を勝ち取るまで nginx と SSL をセットアップした内容をブログに書きましたのでリンクしておきます。

ngzm.hateblo.jp

まとめ

  • SSL にも安全性の問題があるので、単にサイトを SSL 化にしただけで安心してはいけない。
  • SSL のサイトを構築した場合は、「Qualys SSL LABS SSL Server Test」などで安全性をチェックしよう。
  • nginx にSSL証明書を設定する場合は、必ず中間証明書とサーバ証明書の両方入っているものを使用すること!間違えたらやばいところ。

以上です。

*1:これは自分が使用しているブラウザのキャッシュに何かのタイミングで必要な中間証明書が残っていたためにたまたま問題が無かっただけということですが...

*2:Apache の場合、2.4.8より前のバージョンでは、サーバ証明書と中間証明書、秘密鍵のそれぞれのフィールドが用意されていたようですが、2.4.8以降は、中間証明書のフィールドが無くなったようなので、nginx と同様 "fullchain.pem" を使用する必要があります。

Vue.js 開発環境を手作りしてみた手順(自分メモ)

Vue.js の開発環境構築は、もっぱら vue-cli で "vue init webpack hogehoge" という方法が一般的ですが、最近 React から Vue に乗り換えた私は、「とりあえず最初は、開発環境を手作りしたい」とうか、そうしないとしっくりこないので、手探りながら一から構築してみた時のメモです 。あくまでも自分用です。

f:id:ngzm:20171004234025p:plain

概要

おおよその構成

Vue.js を利用したクライアントアプリのビルドに "webpack"、ES2015 のトランスパイラは "babel"、Vue コンポーネントファイルのビルドに "vue-loader" を導入する。その他 CSS のビルドや、静的なファイルコピー、アプリ実行用サーバも仕込む。あと、Eslint も設定してみた。

大まかな流れ

  1. npm init で土台作り
  2. Vue.js インストール
  3. webpack 入れる
  4. babel 導入
  5. css-loader と style-loader 導入
  6. copy-webpack-plugin 導入
  7. webpack-dev-server で動かす
  8. vue-loader 導入
  9. eslint でコードチェック

Vue.js アプリ開発環境構築 基礎編

基礎編は、vueコンポーネントファイルを用いない Vue.js アプリがビルドできる環境を構築する。

1) npm init で土台作り

最初に node をインストール。nodebrew を使用すると吉。
続いて、npm init で開発環境のベースを作る。

$ npm init

いろいろ対話するが、アプリ名とか適宜入力すればOK。

2) Vue.js インストール

作成したアプリのディレクトリに移動した上で、普通に Vue.js をインストール。

$  npm install --save vue

3) webpack を入れる

$ npm install --save-dev webpack

4) Babel を導入

# webpack から babel を利用する場合の基本モジュール
$ npm install --save-dev babel-loader babel-core

# ES2015 をトランスパイルするためのもの
$ npm install --save-dev babel-preset-es2015

babel の設定ファイル ".babel.rc" を作成

$ echo '{ "presets": ["react", "es2015"] }' > .babelrc
$ cat .babelrc

{ "presets": ["react", "es2015"] }

ここで、babel で js をトランスパイルするための webpack 設定をおこなう。

$ vim webpack.config.js

ここまでの webpack.config.js の中身は次のとおり。

var path = require('path');

module.exports = {
  entry: './src/app.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'public')
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader'
      }
    ]
  }
};

ここで試しにサンプルアプリをビルドさせてみると、Vue.js のTemplateがコンパイルできないとかの以下のエラーがでた。

Vue warn: You are using the runtime-only build of Vue where the template option is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

調査したところ、vue.js のビルドは、コンパイラ付のものを import する必要があるとのこと。解決するには、webpack.config.js に ビルドの alias を追加すること。*1

ということで、webpack.config.js に下記の設定を追加。これで動作OK。

  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.js'
    }
  },

5) css-loader と style-loader 導入

$ npm install --save css-loader style-loader

webpack.config.js に下記の loader を追加。

module: {
  loaders: [
    ...
      // ここから
      {
        test: /\.css$/,
        exclude: /node_modules/,
        loader: ['style-loader', 'css-loader'],
      },
      // ここまで
    ],
  },

6) copy-webpack-plugin 導入

$ npm install -save-dev copy-webpack-plugin

webpack.config.js に copy-webpack-plugin の設定を追加。

  plugins: [ 
    new CopyWebpackPlugin([{
      from: path.resolve(__dirname, 'src', 'index.html'),
    }]),
  ],

7) webpack-dev-server で動かす

$ npm install -save-dev webpack-dev-server

またも webpack.config.js に 設定を追加。

  devServer: {
    contentBase: 'public',
    port: 3000,
    host: 'localhost',
    historyApiFallback: true,
  },

"npm start" でアプリを動かせるように、package.json に次の設定を追加。

  "scripts": {
    "start": "webpack-dev-server", <--- 追加
    "build": "webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

ここまでのまとめ

ここまでで webpack.config.js は次のとおりとなりました。

const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'public')
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        include: /src/,
        loader: 'babel-loader'
      },
      {
        test: /\.css$/,
        exclude: /node_modules/,
        loader: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new CopyWebpackPlugin([{
      from: path.resolve(__dirname, 'src', 'index.html'),
    }]),
  ],
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.js'
    },
  },
  devServer: {
    contentBase: 'public',
    port: 3000,
    host: 'localhost',
    historyApiFallback: true,
  },
};

Vue.js アプリ開発環境構築 vue ファイルビルド編

ここからは、さらに vueコンポーネントファイルを用いた Vue.js アプリもビルドできるように環境設定を追加する。

8) vue-loader 導入

vueコンポーネントファイル をビルドさせるには、 vue-loader が必要ということで、早速導入。

$ npm install -save-dev vue-loader

さらに vue-template-compiler がないとbuildでエラーとなるのでインストールする

$ npm install --save-dev vue-template-compiler

vue-loader の設定を webpack.config.js に追加。

  module: {
    loaders: [
      // ここから追加
      {
        test: /\.vue$/,
        exclude: /node_modules/,
        loader: 'vue-loader',
        options: {
          loaders: {
          }
        }
      },
      // ここまで

9) eslint でコードチェック

Vue.js を利用したアプリでも eslint でリントチェックできるようにしてみる。 最初は、eslint の基本設定。今回は対話で設定するケース。

$ eslint --init
? How would you like to configure ESLint? Answer questions about your style
? Are you using ECMAScript 6 features? Yes
? Are you using ES6 modules? Yes
? Where will your code run? Browser
? Do you use CommonJS? No
? Do you use JSX? No
? What style of indentation do you use? Spaces
? What quotes do you use for strings? Single
? What line endings do you use? Unix
? Do you require semicolons? Yes
? What format do you want your config file to be in? JavaScript
Successfully created .eslintrc.js file in /Users/foo/bar/vue-sample/

これで ".eslintrc.js" が生成されるはず。ただしこのままでは、vue template がきちんとチェックできないので、eslint-for-vue を導入する。

$ npm install --save-dev eslint-plugin-vue@beta

.eslintrc.js に設定を追加。今回はこのような設定内容となりました。

module.exports = {
    "env": {
        "browser": true,
        "commonjs": true,
        "es6": true
    },
    "extends": [
      "eslint:recommended",
      "plugin:vue/recommended" <--- eslint-for-vue の設定追加
    ],
    "parserOptions": {
        "sourceType": "module"
    },
    "rules": {
        "indent": [
            "error",
            2
        ],
        "linebreak-style": [
            "error",
            "unix"
        ],
        "quotes": [
            "error",
            "single"
        ],
        "semi": [
            "error",
            "always"
        ],
        "vue/valid-v-if": "error" <--- eslint-for-vue の設定追加
    }
};

まとめ

今回作成した webpack.config.js はこちら

const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'public')
  },
  module: {
    loaders: [
      {
        test: /\.vue$/,
        include: /src/,
        loader: 'vue-loader',
        options: {
          loaders: {
          }
        }
      },
      {
        test: /\.js$/,
        include: /src/,
        loader: 'babel-loader'
      },
      {
        test: /\.css$/,
        exclude: /node_modules/,
        loader: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new CopyWebpackPlugin([{
      from: path.resolve(__dirname, 'src', 'index.html'),
    }]),
  ],
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.js'
    },
  },
  devServer: {
    contentBase: 'public',
    port: 3000,
    host: 'localhost',
    historyApiFallback: true,
  },
};

package.json はこうなりました。

{
  "name": "vue-tutorial01",
  "version": "1.0.0",
  "description": "Vue.js tutorial project",
  "author": "hoge hoge fuga fuga",
  "private": true,
  "main": "index.js",
  "license": "ISC",
  "scripts": {
    "start": "webpack-dev-server",
    "build": "webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "eslint": "^4.7.1",
    "vue": "^2.4.4"
  },
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-es2015": "^6.24.1",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^0.28.7",
    "eslint-config-standard": "^10.2.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-node": "^5.1.1",
    "eslint-plugin-promise": "^3.5.0",
    "eslint-plugin-standard": "^3.0.1",
    "eslint-plugin-vue": "^3.13.0",
    "file-loader": "^0.11.2",
    "style-loader": "^0.18.2",
    "vue-loader": "^13.0.5",
    "vue-template-compiler": "^2.4.4",
    "webpack": "^3.6.0",
    "webpack-dev-server": "^2.8.2"
  }
}

所感

なんというか、"vue init webpack-simple" で開発環境を構築した場合とほぼ同じような感じになってしまった。しかし、今回は自分の理解のために頑張ったということで良かろう。

次回からは、素直に "vue init webpack-simple" で環境構築することにしよう。

以上。

*1:本家の次の情報を参考にしました

インストール — Vue.js

Docker で node.js を動かすときは PID 1 にしてはいけない

これは、node.js on Docker の構成で 2〜3日ハマってしまった時の話です。忘れないように記録しておきます。なお、将来は改善・改良されているかもしれませんのでご注意ください。

何が起こったのか

node.js の Docker コンテナを、"docker stop" でコンテナを止めようとしても正常に停止せず、10秒くらい経過した後に強制終了してしまうという症状が発生しました。いつも等しくそうなるので、状態とかタイミングとかそういった要因ではなく、そもそも根本的に何かがおかしいと考えられます。

1. node on Docker の構成

Docker コンテナ上で node.js が動いているだけの極めてシンプルな構成でこの問題が発生しました。

f:id:ngzm:20170822140126p:plain

node.js で動くアプリは、"Hello World" を出すだけの超簡単な hello.js です。こんな感じです。

const http = require('http');

http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World\n');
}).listen(3000);

hello.js アプリを Docker に仕込むための Docker ファイルは以下の通りです。ベースとなる Docker イメージは、Docker Hub で node.js が提供する node:8.4 (現在のlatest) を利用しました。

FROM node:latest
ADD ./hello.js /usr/src/app/
EXPOSE 3000
CMD ["node", "/usr/src/app/hello.js"]

普通にビルドして、生成したイメージからコンテナを作って起動します。たぶん … 無事に動作するはずです。

$ docker build -t nodetest .
$ docker run --name hello_node -P 3000:3000 nodetest

2. 問題を発生させる

動いている node.js アプリのコンテナを docker stop で停止しますと、今回の問題が発生します。

$ docker stop hello_node

恐らく、10秒ほど経過した後にコンテナが停止しプロンプトが戻ってくると思いますが、この停止に10秒かかることが問題です。

3. docker stop で何が起こっているか?

“docker stop” コマンドを実行すると、Docker はそのコンテナのルートプロセス(すなわち、PID=1 のプロセス)に SIGTERM シグナルを投げます。

このシグナルを受信したプロセスは、該当するシグナルハンドリング処理を実行します。多くのプログラムでは、SIGTEM を受けた場合、自身を安全に停止するシグナルハンドリング処理が実装されています。

f:id:ngzm:20170822140136p:plain

SIGTERM を投げた後、Docker は コンテナ(ルートプロセス)が終了するまで デフォルト10秒間待ちます。そして、10秒経過しても終了しない場合は、コンテナのルートプロセスに対して SIGKILL を投げます。SIGKILL は強制終了を指示するものなので、これを受信したプロセスは、直ちにABORTします。

f:id:ngzm:20170822140148p:plain

この時、プロセスの終了に必要な処理は全てスキップされますので ABORT 後は正常な状態であることが保証されません。したがって、次回起動時にうまく立ち上がらなくなる等、思わぬ障害のリスクがあります。

今回の node.js のコンテナも、10秒後に停止しています。これはすなわち SIGKILL で強制終了していることになり、このままではやばい感じです。

原因調査

A. 物理サーバ環境と Docker 環境での違いを検証

ここで、そもそも Docker に起因する問題なのか? それとも node.js の問題なのか?を切り分けたいと思います。

ということで、仮想化しない物理サーバ環境と Docker 環境の両方で node.js の hello アプリを動かし、そのプロセスに対してそれぞれ SIGTERM を送信してみます。

(1)物理サーバ環境で検証

#### Terminal 1 で node アプリを起動
$ node hello.js

#### Terminal 2 で node に SIGTERM を送信
$ ps -ef | grep node
ubuntu 8526 4061 3 05:21 pts/0 00:00:00 node hello.js

$ kill -TERM 8526
#### 成功、node hello.js は、直ちに問題なく終了した

うまくいきました。
物理サーバ環境において、SIGTERM を受信した node.js プロセスは、直ちに自分自身を終了させています。これにより、node.js は SIGTERM を受信した際のシグナルハンドリングがきちんと実装されていることが分かります。

(2) Docker コンテナ環境で検証

#### nodeアプリが入った Docker コンテナを起動
$ docker run --name hello_node -d nodetest

$ docker ps
CONTAINER ID IMAGE    COMMAND                CREATED         STATUS        PORTS     NAMES
ef2b7ab09468 nodetest "node /usr/src/app..." 50 seconds ago  Up 49 seconds 3000/tcp hello_node

#### 起動したコンテナにアタッチ
$ docker exec -it hello_node /bin/bash

#### ---- hello_node コンテナ内 ----
# ps -ef
UID  PID PPID C  STIME TTY   TIME     CMD
root 1   0    1  01:53 pts/0 00:00:00 node /usr/src/app/hello.js
root 16  0    0  01:54 pts/1 00:00:00 /bin/bash

# kill 1
### しなない!!

ダメです!!
幾ら SIGTERM を投げても反応しません。Docker コンテナの内の node は、何かの理由で SIGTERM を受信できないか、もしくはシグナルを無視しているように見えます。

B. プロセスID 1 に注目してさらに調査

Linux についてご存知の方は、Dockerコンテナ内の node.js プロセスIDが “1” であることに、限りない怪しさを覚えるかもしれません。

f:id:ngzm:20170822140159p:plain

普通の Linux 環境において、プロセスID = 1 は init プロセスです。これは カーネルから起動されますが、(特に Linux においては)プロセスD=1 に対してシグナルを送ることはいろいろ制限されています(see ”man 2 kill" on Linux)。

ということで、今度は、特に、プロセスID 1 に何か特殊な理由が無いか?ということを留意しながら、さらに node.js のソースコードを確認したり、同様の問題がなかったか?という調査を行いました。


・・
・・・

見つけました

github.com

上記ページの「Handling Kernel Signals」というところに、下記の記載があります。

Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker. For example, a Node.js process running as PID 1 will not respond to SIGTERM (CTRL-C) and similar signals. As of Docker 1.13, you can use the –init flag to wrap your Node.js process with a lightweight init system that properly handles running as PID 1.

docker run -it –init node

You can also include Tini directly in your Dockerfile, ensuring your process is always started with an init wrapper.

まじか。。そもそもnode.js は PID 1 で動くように設計されていないとか、なので Docker コンテナで動かすときは、SIGTERM とか Ctrl+C とか効かないとか、まさに、今回の問題について言及されています。

もう一つ、"lightweight init system" というツールがあって、Docker で動かすときは、それを PID 1 として動作させ、node はその子プロセス にすればいいじゃん的なことも書いてあります。

f:id:ngzm:20170822140208p:plain

というか、"Tini" という便利なものがあったのか!! しかも、Docker 1.13 からは “–init” オプションでこれが自動的にラップするように組み込まれているということです。知らなかった。

GitHub - krallin/tini: A tiny but valid `init` for containers

対処内容

問題の原因が分かったので、もう簡単に対策ができます。ということで、早速 Docker 本体にも組み込まれている “Tini” を活用します。

結局、今回は、以下のように Dockerfile に Tiny の設定を追加するだけで、万事 OK となりました。

FROM node:latest

# Add Tini
ENV TINI_VERSION v0.15.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

# MyApp
ADD ./test3.js /usr/src/app/
EXPOSE 3000
CMD ["node", "/usr/src/app/hello.js"]

まとめ

  • node は PID1 で動作するようにデザインされていないので、 docker にいれるときは、直接 Docker の管理プロセス(つまりPID1)にするのはやめよう!

  • Docker で PID 1 で Linux の init みたいに制御してくれる Tiny という便利なツールがあるので、それを活用するといいかも

そもそも、node やその他 サーバプログラムをDocker が直接起動するプロセス(つまり PID 1 となるプロセス)とするのは、いろいろ問題があるなぁと思います。

以前、Unicorn が SIGTERM ではなく SIGQUIT を期待しており、Docker stop で異常終了してしまう問題にも遭遇しています。

なので、個別に作成したスーパバイザ Shell でラップするとか、今回ご紹介した Tiny をかませるとか、その方が安全ではないか?と、最近はそう思っています。

なお、PID 1 とする Tiny のような lightweight init プログラムは、例えば、自分自身が SIGTERM を受信したときに、そのシグナルを、子プロセスまできちんと伝播させたり、子プロセスがゾンビにならないよう wait したり、そういった init の作法に則ったものでなければなりません。その辺は十分ご注意ください。

おまけ

Docker で使える lightweight init たち

ここでは、Docker コンテナで PID 1 のプロセスとして使える Tiny をご紹介しました。その後調べたところ、この他にも、幾つか似たようなものがありましたので、まとめとして、以下にリンクしておきます。

Tiny

本記事でご紹介した lightweight init 、前途した通りDocker にも組み込まれており “docker run” の “–init” オプションで自動的にラップできます。

github.com

dumb-init

“Yelp” で使用している lightweight init

github.com

my_init

“baseimage-docker” という本来の Linux に近いプロセス環境を Docker で提供している。そのイメージで使用している my_init、こちらはマルチプロセスを管理できるようです。

github.com

inits_on_docker/init_node.sh

ちなみに、私も Shell で簡易に作成してみました。node.js 専用ですが、せっかくだし、ご紹介させてください。(つっこみ歓迎)

inits_on_docker/init_node.sh at master · ngzm/inits_on_docker · GitHub

#!/bin/bash
#
# init_node.sh
# - init process for aplications using node.js.
# 
# Usage
# - add your Dockerfile as follows.
#   ------------
#   ADD ./init_node.sh /usr/local/bin
#   RUN chmod +x /usr/local/bin/init_node.sh
#   CMD ["init_node.sh", [path_to_your_app]]
#   ------------
#

echo "start init_node.sh"

# set application path
path_to_your_app=${1:-''}
if [ -z ${path_to_your_app} ]; then
  echo "require first argment for path_to_your_app"
  echo "quit this container"
  exit 1
fi

if [ ! -e ${path_to_your_app} ]; then
  echo "${path_to_your_app} is not exists"
  echo "quit this container"
  exit 2
fi

# Application PID initialize
your_app_pid=0

# SIGINT handler
int_handler() {
  echo "int_handler called"
  if [ ${your_app_pid} -ne 0 ]; then
    kill -INT ${your_app_pid}
  fi
}

# SIGTERM handler
term_handler() {
  echo "term_handler called"
  if [ ${your_app_pid} -ne 0 ]; then
    kill -TERM ${your_app_pid}
  fi
}

# trap SIGINT - usually caught by Ctrl+c
trap 'int_handler' INT

# trap SIGTERM - sent when 'docker stop'
trap 'term_handler' TERM

# run application
echo "run ${path_to_your_app}"

node ${path_to_your_app} &
your_app_pid="${!}"

# wait untill the application (child process) will be killed
wait ${your_app_pid}
your_app_pid=0

echo "finish init_node.sh"

補足とか

何もこんなに苦労しなくても最初から “npm run” で起動するようにしておけば問題なく起動・終了ができそうです!という情報もあって、一応動作させてみたら、よさげに見えました。確かにこれだと、node の PID は 1 ではなくなるのでいい感じです。

ただし、本当にこれで、子プロセス、孫プロセスまで管理できているのか?すなわち、ゾンビにならずに、また強制終了せずに健全に起動、終了しているのか?といったところまでは裏付けができていません。なので継続して調べていきたいと思います。

誰か知っている人が居たらぜひ教えてください。

最後に苦情です

docker-node/README のページ、本家のトップページで思いっきり PID 1 で起動するインストラクションしてるじゃん

docker-node/README.md at master · nodejs/docker-node · GitHub

あと、こんなシビアな問題があるなら、もっと目立つところで情報公開してください

ubuntu ファイアーウォール :Docker が UFW 管理外のポートを開けてしまう問題

UFW とは ファイアーウォールを設定・管理するツールで、Ubuntuでは標準的に利用します。このツールを使用すれば、iptables を使うよりはるかに簡単にファイアーウォールを構築できます。
UFW - Community Help Wiki

Docker はご存知ですね。
Docker - Build, Ship, and Run Any App, Anywhere

遭遇した問題

今回 UFW と docker の両方を使用するサーバで、UFW で許可していないポート番号がいつのまにか空いてしまった問題に遭遇しました。 次のような感じです。

  • ubuntu xenial サーバに対し、UFW で 22番と443番だけアクセス可能、その他は遮断というファイアーウォールをセットアップ
  • 同サーバに Docker 入れて、nginx コンテナ起動、この時 “-p 80:80” オプションを付与
  • 外部からこのサーバにアクセスしたら、UFW で開けていないはずの 80番ポートのページが見れてしまった
  • UFW の設定を再確認してみたが、やっぱし 80番ポートは Allow してない

なんだかやばい感じです。
ちな、UFW の設定内容は以下の通りです。

$ sudo ufw status verbose

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To         Action      From
--         ------      ----
443        ALLOW IN    Anywhere
22         LIMIT IN    Anywhere
443 (v6)   ALLOW IN    Anywhere (v6)
22 (v6)    LIMIT IN    Anywhere (v6)

問題の原因

Docker の問題

調査したところ、ひとつの原因は … Docker でした。docker run の -p や -P オプションでコンテナを起動すると、Docker は、その指定されたポート番号について、ファイアーウォールに穴を開ける処理をします。

UFWの問題

では、どうして UFW で確認できなかったのでしょうか? それは … 直接 iptables のルール一覧を確認すれば分かるかもしれません。

$ sudo iptables -L

Chain INPUT (policy DROP)
target     prot opt source               destination
ufw-before-logging-input  all  --  anywhere             anywhere
ufw-before-input  all  --  anywhere             anywhere
ufw-after-input  all  --  anywhere             anywhere
ufw-after-logging-input  all  --  anywhere             anywhere
ufw-reject-input  all  --  anywhere             anywhere
ufw-track-input  all  --  anywhere             anywhere

Chain FORWARD (policy DROP)
target     prot opt source               destination
DOCKER-USER  all  --  anywhere             anywhere
DOCKER-ISOLATION  all  --  anywhere             anywhere

... 省略 ...

Chain DOCKER (2 references)
target     prot opt source               destination
ACCEPT     tcp  --  anywhere             172.18.0.4           tcp dpt:https
ACCEPT     tcp  --  anywhere             172.18.0.4           tcp dpt:http

Chain DOCKER-ISOLATION (1 references)
target     prot opt source               destination
DROP       all  --  anywhere             anywhere
DROP       all  --  anywhere             anywhere
RETURN     all  --  anywhere             anywhere

Chain DOCKER-USER (1 references)
target     prot opt source               destination
RETURN     all  --  anywhere             anywhere

Chain ufw-after-forward (1 references)
target     prot opt source               destination

... 省略 ...

Chain ufw-user-input (1 references)
target     prot opt source               destination
ACCEPT     tcp  --  anywhere             anywhere             tcp dpt:https
ACCEPT     udp  --  anywhere             anywhere             udp dpt:https

... 省略 ...

UFW が設定したルールには、"ufw-[hogehoge]“ という名前が付いています。一方、"DOCKER” で始まるチェーンも見ることができますが、こちらは明らかに Docker によって追加されたルールですね。

どうやら UFW は 自分が設定したルール、すなわち “ufw-[hogehoge]” についてのみ管理対象としているようです。従って、Docker など他のプログラムや、手作業で追加したルールについては関与していません。これこそがもう一つの原因であり、運用する上で大変注意が必要である事項であります。

ちなみに、Docker が勝手にポートを開けるのを抑える方法

ubuntu の場合だと、”/etc/default/docker” の ”DOCKER_OPTS" オプションに、"–iptables=false" を追加すればいいようです。

DOCKER_OPTS="--dns 8.8.8.8 --dns 8.8.4.4 --iptables=false" 

なお、オプションを有効にするため Docker の再起動を忘れないようにしてください。

まとめ

-p や -P オプション(also EXPOSE)をつけて “docker run” すると Docker が 該当するポートを許可する設定を iptables に追加する。

UFW は自分で設定したルール以外は関与しない。このため、UFW で確認できる設定内容がファイアーウォールの全てではないことに注意すること。

ファイアーウォールについて、最終的には iptables コマンドでも確認した方が良いと思われます。

参考