安全で軽量なコンテナランタイム「gVisor」について

f:id:k-kty:20190505233414p:plain
gVisorのロゴ

「少し聞いたがことある」程度だったのですが、Software Engineering Dailyというポッドキャストのエピソードで耳にしたのを機に、gVisorについて調べてみました。そして日本語の情報もあまり多くなかったので軽く記事にしました。

gVisorとは

gVisorは、安全かつ軽量なコンテナのランタイムです。2018年のKubeConでGoogleから発表されました。

この記事では

  • なぜgVisorが開発されたのか
  • gVisorの仕組み・特徴

などを見ていきます。

なぜgVisorが開発されたのか

分離された環境を簡単に用意できるコンテナ技術は、開発においてもデプロイにおいても大変便利ですが、セキュリティ面での分離はできません。

カーネル(+仮想的なマシンリソース)を複数用意することになる仮想マシンに比べ、「一つのカーネルをうまく共有して使う」ようになっているコンテナにはパフォーマンス面でのメリットがあります。しかし、そのメリットと表裏一体となってセキュリティ面でのデメリットが存在します。カーネルの脆弱性の影響をもろにうけてしまうわけです。

実際、CVE-2016-5195というLinuxカーネルの脆弱性のPoCの中に、Dockerコンテナ内からホストマシンのrootを奪取してしまうというものがあったようです。

よって、悪意のある処理が含まれうるコンテナを実行する必要のある組織(例えばIaaSを提供している企業)は「ホストマシン上で直でコンテナを動かす」ということはできません。そして、各コンテナに対して仮想マシンを用意するなどしなくてはならず、リソース利用の効率やパフォーマンスの面で問題がありました。

そのジレンマへの一つのアプローチとしてgVisorが開発されました。

gVisorの仕組み・特徴

gVisorは「ユーザー空間で動くカーネル」のようなものです。コンテナ内のアプリケーションが出したシステムコールをインターセプトして処理します。

Linuxの各システムコールの実装に対応する実装がgVisor内にあり、アプリケーションが出したシステムコールは(gVisorによって横取りされ)その実装によって処理されます。そして、gVisorが必要に応じてホストOSに対してシステムコールを投げるようになっています。gVisorからホストOSに対して発行されるシステムコールの種類は意図的に絞られています。(Linuxのseccompというのがありますね)。

アプリケーションから「数多くの種類の」システムコールを「ホストのカーネルに直接」発行することができてしまう通常のコンテナランタイムと比べると、明らかに安全になっていることがわかります。システムのうちアプリケーションから直接アクセス可能な部分を最小限にする、すなわちアプリケーションとシステムの分離のレベルを上げることでセキュリティを向上させているわけです。

また、(仮想的なマシンリソースを用意する必要がある)仮想マシンを用いた仮想化に比べるとオーバーヘッドは小さく、そして柔軟性が高まっています。仮想マシンだと起動時にメモリの容量などを決めないといけないですが、gVisorを用いると(普通のプロセスと同様で)そんなことはありません。

使い方

おなじみのDockerやKubernetesで簡単に使えます。公式のガイドも整っています。

gVisorが提供するコンテナのランタイムはOpen Container Initiativeの仕様に準拠していて、docker run --runtime=runsc ...のように指定すれば使えるようです。

現在はx64上のLinuxのみのサポートのようです。(詳しい数値は少しわかりませんが...)エンタープライズのクラウドの世界では大多数ですよね?

実用例

GCPのCloud Runが先月発表されて盛り上がっています。(自分も少し触ってみて感動しています。)

そのCloud Runですが、ドキュメントによるとgVisorが用いられているみたいです。

パーフェクトマッチな用途ですね。

また、gVisorの発表(2018年)以前からGoogleの内部では使われてきたようです。

参考

この記事では大枠のみになってしまいましたが、いろいろと参考になるリソースがありますので、興味のある人は覗いてみてください。

公式ページでは使い方のほか、アーキテクチャや開発のモチベーションなども触れられています。

GoogleでPMをされているYoshi Tamuraさんのインタビューも参考になりました。憧れます。

冒頭でも紹介しましたがSoftware Engineering Dailyのエピソードも参考になりました。実はこちらもYoshi Tamuraさんのインタビューです。

レポジトリはこちら。メモリ安全性や型安全性が評価され、Goを用いて実装されたようです。Dockerの実装もGoですしKubernetesの実装もGoですし大人気ですね。

おまけ: Kata Containers

コンテナとセキュリティに関連するプロジェクトとしてはKata Containersというのも有名なようです。こちらではKata Containers is an open source container runtime, building lightweight virtual machines that seamlessly plug into the containers ecosystem.と謳っていて、「軽量な仮想マシン」を用いることで安全なコンテナの実行環境を用意できるようです。gVisorとは少し違ったアプローチでとても興味深いので気が向いたときに調べてみようかと思います。

Bcryptを用いてパスワードをハッシュ化する (Node.js)

ログイン機能のあるアプリケーションを開発する場合、(大体の場合は)ユーザーにパスワードを設定してもらうことになります。当然ですがそれらのパスワードを平文でデータベースに保存したりするのはセキュリティ的にご法度で、ハッシュ化して保存をする必要があります。

この記事では、bcrypt というパッケージを用いて、Node.jsでパスワードをハッシュ化する方法・そしてそれを用いてパスワードをチェックする方法を紹介します。ちなみに、bcryptはハッシュ化アルゴリズムの名前でもありパッケージの名前でもあります。以下混同して使用しています。

準備

まずはbcryptをインストールします。TypeScriptの型定義ファイルももちろんあります。

$ npm install bcrypt --save
$ npm install @types/bcrypt --save-dev # TypeScriptを利用している場合

ちなみに執筆時では bcrypt@3.0.5 が入りました。

ハッシュ化

まずは平文のパスワードをハッシュ化します。会員登録時やパスワード変更時に行う処理ですね。

以下のように、パスワードを「ラウンド数」とともに bcrypt.hash 関数に渡します。

ラウンド数を大きくすればするほど安全なハッシュを生成することができますが、計算に要する時間が長くなります(ラウンド数が1増えると時間は2倍ほどになるそうです)。こちらによると、ラウンド数10でのハッシュ化には2.0GHzのCPUで0.1秒ほどかかるようです。

ここではラウンド数を10に設定しています。通常はこれくらいで(もう少し小さくても?)大丈夫そうです。

const bcrypt = require('bcrypt');

const saltRounds = 10;

bcrypt.hash(password, rounds)
  .then((hashedPassword) => {
    // ...
  });

チェック

平文のパスワード(password)とハッシュ化されたパスワード(hashedPassword)を比較し、その平文のパスワードが正しいかどうかをチェックします。ログイン時の処理ですね。正しければisCorrectPasswordtrueに、そうでなければfalseになります。

bcrypt.compare(password, hashedPassword)
  .then((isCorrectPassword) => {
    // ...
  });

おまけ

  • bcryptの計算の概要について大雑把に説明します。まずランダムでソルトを作成しています。そして、平文パスワードとソルトを組み合わせてハッシュ化し、そのハッシュとソルトを組み合わせてハッシュ化し、そのハッシュとソルトを組み合わせてハッシュ化し...というのを何回も繰り返しているようです。その回数を調整しているのがラウンド数というわけです。
  • 上の例でのhashedPasswordには、ラウンド数やソルトの情報も含まれています。compare関数は「ラウンド数やソルトの情報を抜き出し、それをもとに平文パスワードをソルトともに指定回数ハッシュ化し、比較する」ということをやってます。
  • 上の例の通り、bcryptは、コスト(ラウンド数)を設定できるようになっています。これにより、コンピューターの計算能力が伸びて攻撃側の能力が上がったとしても(ラウンド数を上げることで)同じアルゴリズムを利用し続けることができるというメリットがあります。
  • bcryptには、hashSync 関数や compareSync 関数などの同期的な関数も存在します。しかし、簡単なスクリプト等以外には使用はするべきではありません。上にも書きましたが、これらの関数は計算量の大きい処理をするので、ある程度時間がかかります。その間イベントループがブロックされてしまい、他の処理が(ほとんど)できなくなります。サーバーアプリケーションの場合はリクエストに対する処理ができなくなってしまいます。ちなみに、hash 関数や compare 関数を呼び出した際はスレッドプールを利用して(メインスレッド外で)処理をするので、イベントループのブロックはおきません。そして、複数のCPUコアを有効活用できます。

Cのプログラムをx86-64のマシン上でPowerPC向けにコンパイルする

x86-64のマシン + Ubuntu 16.04で、PowerPC 64bit向けにC言語のプログラムをコンパイルする方法を紹介します。俗に言うクロスコンパイルっていうやつです。

以下のように、必要なものをaptで入れます。binutilsにはアセンブラやローダが入っています。

$ sudo apt install gcc-powerpc-linux-gnu binutils-powerpc-linux-gnu

これで環境は整ったので、実際に動かしてみます。まず、以下のファイルをa.cとして保存します。

int fact(int n) {
  if (n == 1) return 1;
  return n * fact(n - 1);
}

以下のようなコマンドでコンパイルします。わかりやすさのために最適化オプションを無効にし、アセンブリを出力しています。

$ powerpc64-linux-gnu-gcc -S a.c -O0

以下のような(見慣れた?)PowerPCのアセンブリを得ることができました。

 .file   "a.c"
    .machine power7
    .section    ".toc","aw"
    .section    ".text"
    .align 2
    .globl fact
    .section    ".opd","aw"
    .align 3
fact:
    .quad   .L.fact,.TOC.@tocbase,0
    .previous
    .type   fact, @function
.L.fact:
    mflr 0
    std 0,16(1)
    std 31,-8(1)
    stdu 1,-128(1)
    mr 31,1
    mr 9,3
    stw 9,176(31)
    lwz 9,176(31)
    cmpwi 7,9,1
    bne 7,.L2
    li 9,1
    b .L3
.L2:
    lwz 9,176(31)
    addi 9,9,-1
    extsw 9,9
    mr 3,9
    bl fact
    mr 9,3
    mr 10,9
    lwz 9,176(31)
    mullw 9,9,10
    extsw 9,9
.L3:
    mr 3,9
    addi 1,31,128
    ld 0,16(1)
    mtlr 0
    ld 31,-8(1)
    blr
    .long 0
    .byte 0,0,0,1,128,1,0,1
    .size   fact,.-.L.fact
    .ident  "GCC: (Ubuntu/IBM 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"

とても簡単で最高ですね。

ちなみに、今回入ったアプリケーションたちのバージョンはこのような感じでした。

$ powerpc64-linux-gnu-gcc --version
powerpc64-linux-gnu-gcc (Ubuntu/IBM 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609
...
$ powerpc64-linux-gnu-as --version
GNU assembler (GNU Binutils for Ubuntu) 2.26.1
...
$ powerpc64-linux-gnu-ld --version
GNU ld (GNU Binutils for Ubuntu) 2.26.1
...

URLから画像のサイズを取得する(Node.js)

サーバーサイドアプリケーションを書いていると、「あるURLでアクセスできる画像の(縦横の)サイズを知りたい」という場面がまれにあると思います。Nodejsにおいて、そういった場合にどうしたらよいかを紹介します。

準備

image-size を利用します。また、ここでは axios を用いますが、node-fetch など他のライブラリでも同様にできると思います。標準ライブラリの https.requesthttp.request を用いることもできますが、いろいろとめんどくさいと思います(そもそもhttpかhttpsかで使うライブラリを分けなくてはいけない?)。

ちなみに執筆時点でのバージョンは axios@0.18.0, image-size@0.7.3 です。

$ npm i image-size axios

実装

以下のような関数を用意します。axios.get のオプションにおいて responseType: 'arraybuffer' と指定してあげないと、 res.data が文字列になったりしてうまくいきません(ContentTypeなどをもとにうまくやってほしいとも思ってしまいますが...)。

この関数に画像のURLを投げれば、width プロパティ(number)と height プロパティ(number)をもったオブジェクト(のプロミス)を返してくれます。大体のメジャーなフォーマットに対応してくれています。

const imageSize = require('image-size');
const axios = require('axios');

function fetchSize(imgUrl) {
  return axios.get(imgUrl, {
    responseType: 'arraybuffer',
  })
    .then(res => imageSize(res.data))
    .then(i => ({
      width: i.width,
      height: i.height,
    }));
}

TypeScriptの場合

簡単ですが、TypeScriptだと以下のようになります。

import imageSize from 'image-size';
import axios from 'axios';

function fetchSize(imgUrl: string): Promise<{ height: number; width: number; }> {
  return axios.get(imgUrl, {
    responseType: 'arraybuffer',
  })
    .then(res => imageSize(res.data))
    .then(i => ({
      width: i.width,
      height: i.height,
    }));
}

NginxとDockerでリバースプロキシサーバーを作る

Dockerコンテナ内でnginxを動かし、リバースプロキシサーバーを作る簡単な例を紹介します。

プロダクションで利用する際はnginxの設定をもっとしっかりしないといけませんが、この記事ではそのあたりは端折ります。

コード

Dockerfile

nginx.confを /etc/ginx に追加するだけのものすごく簡単なものです。イメージについては2019年4月9日現在の最新バージョンである nginx:1.15.10 を使用しています。

FROM nginx:1.15.10
COPY nginx.conf /etc/nginx/

nginx.conf

nginxの設定ファイルです。/proxy/ からはじまるパスへのリクエストを http://example.com に振り分けるようにします。それ以外は /usr/share/nginx/html (ドキュメントルート)から配信するようにしています。

また、ログに $upstream_addr を追加するようにしています。これでプロキシ先のアドレスがログに含まれるようになります。

ちなみに、nginxの公式イメージでは、 /var/log/nginx/access.log/dev/stdout (標準出力)のエイリアスになっています。(そのイメージをもとに作っている)今回のイメージでも同様です。標準出力に吐き出されたログは docker logs コマンドなどでみることができます。

user nginx;
worker_processes 1;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
  worker_connections  1024;
}

http {
  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  log_format main '"$request" $status $upstream_addr';

  access_log /var/log/nginx/access.log  main;

  server {
    location / {
        root /usr/share/nginx/html;
    }

    location /proxy/ {
        proxy_pass http://example.com/;
    }
  }
}

起動

イメージを作成してコンテナを実行します。-p 3000:80 で、ホストのポート3000でコンテナのポート80にアクセスできるようにしています。

$ docker run -d -p 3000:80 $(docker build .)

確認

ブラウザなどで http://localhost:3000/ にアクセスすると Welcome to nginx! のページが、 http://localhost:3000/proxy/ にアクセスすると Example Domain のページが見れ、正しく動いていることがわかります。

また、以下のようにコンテナのログを確認してみます。

$ docker logs <コンテナid>
"GET / HTTP/1.1" 200 -
"GET /proxy/ HTTP/1.1" 200 93.184.216.34:80

うまくいっていることがわかります。(example.comのIPアドレスは変わるかもしれません。)

初期化されたMySQLのDockerコンテナを用意する

サーバーサイドアプリケーションの開発環境などにおいて、「初期化された(データベースやテーブルが作成された)MySQLのDockerコンテナを用意したい」といった場面があると思います。そういった際にどうすればよいか、簡単な例を通して説明します。

コード

init.sql

実行したいSQL文たちをファイルとして保存します。

CREATE DATABASE db1;
USE db1;
CREATE TABLE table1(id INT);
INSERT INTO table1 VALUES(1);

Dockerfile

SQL文が書かれたファイルを /docker-entrypoint-initdb.d/ ディレクトリ下にもってきます。MySQLのDockerオフィシャルイメージにおいて、このディレクトリ下に置かれたファイル内に記載されたSQL文がデータベース起動時に実行されるようになっています。

ここでは、2019/4/8現在最新の mysql:8.0.15 イメージを用いています。

FROM mysql:8.0.15
COPY init.sql /docker-entrypoint-initdb.d/init.sql

実行

Dockerファイルとinit.sqlが存在しているディレクトリ内で以下のコマンドを実行します。

$(docker build . -q) はDockerファイルをもとにイメージを作成し、イメージのIDを返しています。そしてそのイメージをもとにコンテナを立ち上げています。簡単のために、 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 によってパスワードを無効化しています。

$ docker run -d -e MYSQL_ALLOW_EMPTY_PASSWORD=1 $(docker build . -q)

確認

データベース内にアクセスして確認してみます。

$ docker exec -ti <コンテナ名> mysql
mysql> select * from db1.table1;
+------+
| id   |
+------+
|    1 |
+------+
1 row in set (0.00 sec)

うまく初期化され、データが入っていることがわかります。

複数ファイルに分割したい場合

今回はSQL文が書かれたファイルを1つ用意しましたが、わかりやすさのためにこれを複数に分けたい場合もあると思います(テーブルの作成とダミーデータの挿入を分けるなど)。そういった際には、init001.sql, init002.sql ... といったようにファイルを分割し、それらを /docker-entrypoint-initdb.d/ 以下に持ってくれば大丈夫です。辞書順で実行してくれます。

NginxのリバースプロキシでのDNS名前解決における落とし穴

問題

Nginxのリバースプロキシでは、プロキシ先として(IPアドレスの他に)ホスト名を指定することができます。 その際、設定ファイルのlocationコンテキストは以下のようになると思います。

location /hoge/ {
  proxy_pass http://example.com/;
}

しかし、これには大きな落とし穴があります。DNSの名前解決がnginxの起動時にしか行われず、TTLは無視され、初回起動時に解決されたIPアドレスがずっと使われてしまうのです。 もちろん、ホスト名に対応するIPアドレスに変更があったときにエラーになってしまいます。

自分の場合では、プロキシ先としてAWSのELBのホスト名を指定しており、(ELBのアドレスは固定ではないので)この問題が原因で502エラーを吐き出してしまっていました。

解決策?

nginxにはresolverというディレクティブがあります(公式ドキュメント)。By default, nginx caches answers using the TTL value of a response とあるので、TTLが尊重されるみたいです。うまく行きそうな気がします。

試しにresolverにはGoogle Public DNS8.8.8.8を利用してみます。

location /hoge/ {
  resolver 8.8.8.8;
  proxy_pass http://example.com/;
}

しかし、これでもうまくいきません。もはやバグですね。

解決策

このようにして、一旦ホスト名を変数に格納して使うと、うまくいくようです。

location /hoge/ {
  set $target example.com;
  resolver 8.8.8.8;
  proxy_pass http://$target/;
}

なんだかなあって感じです。