STERFIELD

2023/08/25

軽量でセキュアなコンテナイメージを使ってみよう

軽量でセキュアなコンテナイメージを使ってみよう

軽量でセキュアなコンテナイメージを使ってみよう

はじめに

みなさんは開発にDockerコンテナを使っていますか?ここでいうコンテナとはDockerやDocker互換のコンテナエンジンで動作させられるものを指します。 当社でもDockerコンテナを積極的に利用して開発環境から本番までシームレスな開発、運用ができるよう導入を進めています。

さてそんなDockerコンテナですが、コンテナイメージを管理するためのDockerfileは適切に管理できているでしょうか。 ファイルシステムが差分構成になっていることなどDocker特有の仕組みがあり、ネット上にあるサンプルをそのまま使っているだけで自分たちで中身を書くところまではできてない人が多いのではと想像します。

本記事ではそのような状態から一歩レベルアップするため、Distrolessというベースイメージを使うようDockerfileを書き換えるやり方とその結果どういうことが得られるかを解説していきます。

Distrolessとは

そもそもDistrolessとはどういうもので、どういう利点があるのでしょうか? 開発元でGoogleのAIであるBardに聞いてみた内容を引用します。

引用:Bard(Google AI)

質問 distrolessを使う利点はなんですか?

回答 Distroless は、Google によって開発された、Linux オペレーティングシステムを最小化した Docker コンテナイメージです。Distroless は、アプリケーションを実行するために必要な最小限のファイルとライブラリのみを含むため、軽量でセキュリティに優れています。

Distroless を使用する主な利点は次のとおりです。

  • 軽量:Distroless は、従来の Linux ベースの Docker コンテナイメージよりも軽量です。これは、Distroless がアプリケーションを実行するために必要な最小限のファイルとライブラリのみを含むためです。
  • セキュリティに優れている:Distroless は、セキュリティに優れています。これは、Distroless がアプリケーションを実行するために必要な最小限のファイルとライブラリのみを含むため、攻撃対象領域が狭いためです。
  • スケーラブル:Distroless はスケーラブルです。これは、Distroless が軽量であるため、多くのコンテナを実行してもシステムに負荷がかかりにくいためです。

Distroless は、軽量でセキュリティに優れた Docker コンテナイメージを必要とするアプリケーションに最適です。たとえば、Web サーバ、データベースサーバー、アプリケーションサーバなどです。

「軽量でかつセキュリティに優れている」というところに着目してDistrolessを使って進めていきます。

プログラム

実際の運用イメージにあわせるため、下記のようなExpressを使ったNode.jsアプリを動作させるコンテナを作るDockerfileを対象にします。

  • app/app.ts
    import express from "express";
    
    const app = express();
    const port = 3000;
    
    app.get("/", (_req, res) => {
      res.send("Hello World!");
    });
    
    app.listen(port, () => {
      console.log(`Example app listening on port ${port}`);
    });
    
  • package.json
    {
      "dependencies": {
        "express": "^4.18.2",
        "typescript": "^5.1.6"
      },
      "devDependencies": {
        "@types/express": "^4.17.17"
      },
      "scripts": {
        "build": "tsc"
      }
    }
    
  • tsconfig.json
    {
      "compilerOptions": {
        "target": "es2016",
        "module": "commonjs",
        "outDir": "./dist",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
    
        /* Type Checking */
        "strict": true /* Enable all strict type-checking options. */,
        // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
        // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
        // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
        // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
        // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
        // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
        // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
        // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
        // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
        // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
        // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
        // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
        // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
        // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
        // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
        // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
        // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
        // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
    
        /* Completeness */
        // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
        "skipLibCheck": true /* Skip type checking all .d.ts files. */
      },
      "include": ["./src/**/*"]
    }
    

Dockerfile(変更前)

FROM node:18-slim
WORKDIR /app
COPY . .
RUN npm install && npm run build

CMD [ "node", "./dist/app.js" ]

変更前のDockerfileは上記の通りで、公式で提供されているNode.jsのイメージをそのまま使うだけのDockerfileになります。

Dockerfile(変更後)

FROM node:18-slim as builder
WORKDIR /app
COPY . .
RUN npm install && npm run build

FROM gcr.io/distroless/nodejs:18
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/dist/app.js ./dist/app.js
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
USER nonroot
CMD [ "./dist/app.js" ]

Distrolessを使うように書き換えたものがこちらになります。その中でも特に2点について下記で解説します。

変更内容のポイント

  1. マルチステージビルド

    Distrolessには実行に必要な最低限のパッケージしか含まれていないため、npmコマンドどころかshell環境自体が存在しません。 そのため、Dockerのマルチステージビルド機能を利用します。詳細な仕組みの説明は割愛しますが、Dockerfileの前半部分(1行目〜4行目)で 標準Node.jsイメージを使ってアプリケーションをビルド(ライブラリのインストールとTypescriptのコンパイル)、 後半部分(6行目〜12行目)でdistrolessに前半でビルドしたファイルのコピーするという2段階構成にします。 このように変更することでnpmコマンドや、ソースファイルのようなアプリケーション実行に必要がないファイルをイメージファイルに含めないことを実現します。

  2. 非root化

    Distrolessとは直接関係ないですが、Docker公式にもベストプラクティスとして書かれている非root化も一緒に行ってます。 Distrolessイメージではrootユーザー以外にnonrootというユーザーが存在しているため、ファイルのコピー時とCMD実行前にnonrootを指定することでroot権限を持たないユーザーでプログラムを実行できるようになります。

イメージのサイズの比較

まずはビルドしたイメージのサイズを比較してみます。

  • 変更前→express-slim
  • 変更後→express-distroless

となります。

➜ docker image ls
REPOSITORY           TAG       IMAGE ID       CREATED        SIZE
express-slim         latest    afe678706d58   37 hours ago   312MB
express-distroless   latest    6a1421f17b86   37 hours ago   207MB

3割程度小さくなっていることがわかります。 サイズが小さくなることでビルド後のイメージを保存する先のストレージ容量の節約や、デプロイ時間の短縮が期待できます。

脆弱性の数の比較

次にTrivyというコンテナイメージの脆弱性スキャナーをつかって検出される脆弱性の個数を比較してみます。

➜ trivy image express-slim
(省略)
Total: 51 (UNKNOWN: 0, LOW: 50, MEDIUM: 0, HIGH: 1, CRITICAL: 0)
(省略)

➜ trivy image express-distroless
(省略)
Total: 23 (UNKNOWN: 0, LOW: 11, MEDIUM: 8, HIGH: 4, CRITICAL: 0)
(省略)

今回は細かい内容までは追わずに単純な個数で比較しますが、脆弱性の数が半分以下になっていることがわかります (HIGHが多いのが気になりますが・・)

その他TIPSなど

開発中はSSHできないと不便

:debugタグをつけたイメージを利用することで一般的なコンテナイメージと同様にdocker runコマンドでsshログインが可能になります。

FROM gcr.io/distroless/nodejs:18
↓
FROM gcr.io/distroless/nodejs:18:debug

軽量なコンテナイメージとしてはAlpineのほうが有名なのになぜDistrolessを採用しているのか

Alpineは軽量にするため特殊構成をとっているディストリビューションであり、標準Cライブラリに一般的なglibcではなくmuslを採用しています。 Node.jsではmuslを採用しているプラットフォームで動作させる場合のサポートレベルはExperimentalとなっています(2023年8月現在)。 この点から、実運用時の安定性のことを考えて一般的なディストリビューションであるDebianベース(=標準Cライブラリがglibc)のDistrolessを使いました。

まとめ

今回はNode.jsのアプリケーションを動かすコンテナをDistrolessを使って構築してみましたが、変更自体も簡単で、記事内で解説したとおり軽量かつセキュアなコンテナにするという目的が達成できそうです。

その要因として、Node.jsインストール済みのイメージがDistrolessで提供されていることが大きかったのですが、たとえばPHPのように標準で提供されてないアプリケーションのコンテナイメージを構築する場合、自前でミドルウェアを追加する作業が必要になり、軽量かつセキュアというメリットがあまり活かせないことになりそうです。

用途に応じた使い分けが必要だと感じました。

参考リンク

Author Profile

著者近影

ARIKAWA

バックエンドエンジニアです。 自転車が好きです。

SHARE

合わせて読みたい