コンテンツにスキップ

プラクティス集@Docker

はじめに

本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。


01. コンテナイメージを適切に分割する

プロセス単位で分割する

▼ なぜプロセス単位なのか

  • プロセスを疎結合にし、プロセス間で影響を与え合わないようにする
  • 必要なコンポーネントだけスケーリングできるようになる
  • 必要なコンポーネントだけアップグレードできるようになる

▼ 分割方法

アプリを稼働させるには、最低限、webサーバーミドルウェア、アプリ、DBMSが必要である。

これらのプロセスは、同じコンテナに共存させることなく、個別のコンテナで稼働させ、ネットワークで接続する。

プロセス単位のコンテナ

▼ コンテナのプロセスの関係

コンテナでは、起点となるinitプロセスがなく、コンテナの起動コマンドが最初の親プロセスになる。

この親プロセスは、子プロセスを実行する。

container_processes

なお、1コンテナに2つのプロセスがあると、コンテナの終了処理 (SIGTERM) を実行する場合の終了順序を考えないといけない。

プロセス管理ツール (例:systemd、supervisor) を使用すると、終了順序を考えやすくなる。


PID=1問題に対処する

▼ サーバーのプロセス

サーバーの場合、initプロセスがPID=1として稼働している。

container_pid_1_problem_1

initプロセスは、配下のいずれかの親プロセスを終了したとする。

container_pid_1_problem_2

すると、これの子プロセスも連鎖的に終了してくれる。

container_pid_1_problem_3

▼ コンテナのプロセス

コンテナの場合、ホスト上のinitプロセスがPID=1として動いており、またコンテナのアプリやミドルウェアのプロセスもPID=1として稼働している。

host_container_pid_1

アプリやミドルウェアのプロセスは、いずれかの親プロセスを終了しても、これの子プロセスも連鎖的に終了できない。

そのため、子プロセスが残骸として残ってしまう。

container_pid_1_problem_4

▼ 対処方法

tiniを使用し、アプリやミドルウェアのプロセスをPID=1で動かさないようにする。

FROM node:16-alpine3.15
WORKDIR /app

RUN apk add --no-cache tini

COPY package.json yarn.lock ./
RUN yarn install --prod --frozen-lockfile

COPY . .

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "index.js"]


コンテナ終了時にプロセスをGraceful Shutdownを実行できるようにする

コンテナの終了時、コンテナランタイムはコンテナにSIGTERMを送信する。

その後、SIGKILLを送信し、プロセスを終了させる。

そのため、SIGTERMを受信した段階でリクエストを受信しなくなるように、プロセスを設定しておく。

多くのツールがSIGTERMでGraceful Shutdownを実行するように設計されているため、特に対処不要である。

ただし、SIGTERM以外でGraceful Shutdownを実行するツールがあるため、その場合はDockerfileのSTOPSIGNAL処理を使用してシグナルを上書きするとよい。

例えば、NginxはSIGQUITでGracefulShut Downする仕様のため、STOPSIGNAL処理でSIGQUITを定義しておく。

FROM alpine

...

# SIGQUITを実行する
STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]


02. 脆弱性に対処する

実行ユーザーを非特権化する

コンテナのプロセスの実行ユーザーにRoot権限の認可スコープを付与すると、もし実行ユーザーが乗っ取られた場合に、全てのファイルが操作されうる。

これを防ぐために、コンテナのプロセスの実行ユーザーを別途作成し、これに非特権な認可スコープを付与する。

FROM alpine:3.12

# 実行ユーザーを作成し、必要最低限の認可スコープを付与する。
RUN adduser -D foouser \
  `# ディレクトリに作成した実行ユーザーを設定する。` \
  && chown -R foouser /app-data

# コンテナのプロセスの実行ユーザーを指定する。
USER foouser

ENTRYPOINT ["/app"]


ユーザーにroot権限を付与しない

▼ root権限を付与しない理由

悪意のあるユーザーがコンテナに接続した場合に、root権限を与えてしまうと、悪意のある操作を実行されてしまう。

そこで、接続するユーザーにroot権限を付与しないようにする。

sudoコマンドを実行させない

root権限を付与しないために、コンテナ内でsudoコマンドを実行できないようにするべきである。

ただし、多くのコンテナイメージでsudoコマンドを実行するためのパッケージは基本インストールされていないため、sudoコマンドを実行しようとするとエラーになる。

どうしてもsudoコマンドを実行したい場合は、gosuパッケージを使用する。

gosuパッケージの代わりに、sudoパッケージをインストールし、作成したユーザーにroot権限を割り当てる方法もある。

gosuパッケージの代わりに、dockerコマンドの-uオプションを使用し、実行ユーザーにroot権限で付与する方法もある。


信頼できるベースイメージを選ぶ

インストールされているパッケージを把握できるベースイメージを使用する。


Docker in Dockerの脆弱性に対処する

コンテナの中でコンテナを作成するという入れ子構造のこと。

中へのコンテナが外へのコンテナのアクセス権限が必要である。

Docker in Dockerは、特権モードが必要になり、安全性に問題がある。

一部のコンテナイメージビルドツール (例:Kaniko) では、Docker in Dockerを回避できるようになっている。


コンテナイメージを署名する

コンテナイメージをビルドインのコマンド (例:docker trustコマンド) やコンテナイメージ署名ツール (例:cosign) を使用して、信頼された (ログイン済みの) イメージリポジトリから信頼されたコンテナイメージをプルできるようにする。

docker trustコマンドの内部では、notaryが使用されている。


本番環境ではバインドマウントを使用しない

バインドマウントは、コンテナのファイルシステムを介してホストにアクセスできてしまうため、本番環境では非推奨にした方がよい。

代わりに、ボリュームマウントやCOPYでファイルをコンテナイメージに詰め込むようにすることになる。


03. イメージタグにlatestを設定しない

latestタグを指定しない方が良い理由

イメージタグにlatestを設定すると、誤ったバージョンのイメージタグを選択してしまい、障害が起こりかねない。

そこで、イメージはセマンティックバージョニングでタグ付けし、コンテナでは特定のバージョンをプルように設定する。

なお、各コンテナイメージのアーキテクチャに割り当てられたダイジェスト値を指定することもできるが、DockerがホストのCPUアーキテクチャに基づいてよしなに選んでくれるので、ダイジェスト値は指定しない。


タグの種類

イメージのタグには種類があり、追跡できるバージョンアップが異なる。

バージョン例 追跡できるバージョンアップ
2.0.9 バージョンを直指定し、追跡しない。
2.0 2.0.X』のマイナーアップデートのみを追跡する。
2 2.X』と『2.0.X』のマイナーアップデートのみを追跡する。
latest メジャーアップデートとマイナーアップデートを追跡する。


CPUアーキテクチャの種類

コンテナは全てのマシンで稼働できるわけではなく、コンテナイメージごとに対応できるホストのCPUアーキテクチャ (例:Intel、AMD、ARM) がある。

項目 表記 補足
Intel amd64 (x86_64) IntelとAMDは互換性があるので、CPUアーキテクチャがIntelであっても、AMD対応のコンテナイメージを使用できる。
Arm arm64arm/v5arm/v7
Amd amd64

例えば、MacBook 2020にはIntel、またMacBook 2021 (M1 Mac)にはARMベースの独自CPUが搭載されているため、ARMに対応したコンテナイメージを選択する必要がある。

ただし、コンテナイメージがホストのCPUアーキテクチャに対応しているか否かを開発者が気にする必要はない。

docker pull時に、ホストのCPUアーキテクチャに対応したコンテナイメージが自動的に選択されるようになっている。

コンテナの現在のCPUアーキテクチャは、以下のいずれかの方法で確認できる。

$ docker inspect <コンテナ名>

{
    ...

        "Architecture": "arm64",

    ...
}
$ docker exec -it <起動中コンテナ名> /bin/bash -- uname -m

arm64


04. イメージサイズを削減する

不要なファイルを含めない

.dockerignoreファイル

イメージのビルド時に無視するファイルを設定する。

開発環境のみで使用するファイル、.gitignoreファイル、READMEファイル、などはコンテナの稼働には不要である。

.env.example
.gitignore
README.md

▼ キャッシュを削除する

Unixユーティリティをインストールするとキャッシュが残る。

キャッシュが使用されることはないため、削除してしまう。

*実装例*

FROM centos:8

RUN dnf upgrade -y \
  && dnf install -y \
      curl \
  `# メタデータ削除` \
  && dnf clean all \
  `# キャッシュ削除` \
  && rm -rf /var/cache/dnf


ファイルサイズの小さなベースイメージを使用する

▼ ベースイメージの種類

ベースイメージの種類名 接尾辞 説明 OSの有無 ユーティリティの有無 パッケージマネージャー系統の有無
distribution型 scratch パッケージを何もインストールしていない。
alpine 最小限のパッケージのみをインストールしている。 Alpine Linux apk
busterbullseyejessiestretch 最小限のパッケージのみをインストールしている。 Debian Debian系 (dpkg、apt-get、apt)
slim 最もオススメの接尾辞。使用頻度の高いパッケージのみをインストールしている。 イメージによる イメージによる
接尾辞なし 使用頻度の高いパッケージのみでなく、小さいパッケージもインストールしている。 イメージによる イメージによる
distroless型 接尾辞なし 最小限のパッケージのみをインストールしている。 イメージによる 有 (非常に少ない) イメージによる

▼ できる限りOSイメージをベースとしない

OSベンダーが提供するベースイメージを使用すると、不要なバイナリファイルが含まれてしまう。

原則として、1つのコンテナで1つのプロセスしか実行せず、OS全体のシステムは不要なため、OSイメージをベースとしないようにする。

*実装例*

以下はCentOSをベースイメージに使っており、よくない例である。

# CentOSイメージを、コンテナにインストール
FROM centos:8

# PHPをインストールするために、EPELとRemiリポジトリをインストールして有効化。
RUN dnf upgrade -y \
  && dnf install -y \
      https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm \
      https://rpms.remirepo.net/enterprise/remi-release-8.rpm \
  && dnf module enable php:remi-7.4 \
  `# フレームワークの要件のPHP拡張機能をインストール` \
  && dnf install -y \
      php \
      php-bcmath \
      php-ctype \
      php-fileinfo \
      php-json \
      php-mbstring \
      php-openssl \
      php-pdo \
      php-tokenizer \
      php-xml \
  && dnf clean all \
  && rm -Rf /var/cache/dnf

# DockerHubのComposerイメージからバイナリファイルを取得
COPY --from=composer:<バージョン> /usr/bin/composer /usr/bin/composer

*実装例*

以下はCentOSをベースイメージに使っており、よくない例である。

# CentOSイメージを、コンテナにインストール
FROM centos:8

# nginxをインストール
RUN dnf upgrade -y \
  && dnf install -y \
     nginx \
     curl \
  && dnf clean all \
  && rm -Rf /var/cache/dnf

COPY infra/docker/web/nginx.conf /etc/nginx/nginx.conf

CMD ["/usr/sbin/nginx", "-g", "daemon off;"]

EXPOSE 80


イメージレイヤー数を削減する

▼ イメージレイヤーの増え方

イメージレイヤー数が多くなると、コンテナイメージが大きくなる。

Dockerfileの各命令によって、コンテナイメージレイヤーが1つ増えてしまうため、同じ命令に異なるパラメーターを与える時は、『&&』で1つにまとめてしまう方が良い。

例えば、以下のような時、RUN処理ごとにレイヤーが増える。

RUN yum -y isntall httpd
RUN yum -y install php
RUN yum -y install php-mbstring
RUN yum -y install php-pear
RUN rm -Rf /var/cache/yum

&&を使用する

&&を使い、イメージのレイヤー数を減らせる。

イメージレイヤーが少なくなり、コンテナイメージを軽量化できる。

RUN yum -y install httpd php php-mbstring php-pear \
  && rm -Rf /var/cache/dnf

あるいは、これは以下の様にも書ける。

RUN yum -y install \
     httpd \
     php \
     php-mbstring \
     php-pear \
  && rm -Rf /var/cache/dnf

▼ ヒアドキュメントを使用する

RUN <<EOF
  yum -y install httpd php php-mbstring php-pear
  rm -Rf /var/cache/dnf
EOF


マルチステージビルドを採用する

▼ マルチステージビルドとは

1つのDockerfile内に複数の独立したステージを定義する。

以下の手順で作成する。

(1)

シングルステージビルドに成功するDockerfileを作成する。

(2)

ビルドによって作成されたバイナリファイルがどこに配置されるかを場所を調べる。

(3)

Dockerfileで、2つ目のFROM処理を宣言する。

(4)

1つ目のステージで、バイナリファイルをコンパイルするのみで終了させる。

(5)

2つ目のステージで、Unixユーティリティをインストールする。

また、バイナリファイルを1つ目のステージからコピーする。

▼ コンパイル済バイナリファイルを再利用する場合

*実装例*

# 中間イメージ
FROM golang:1.7.3 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go /go/src/github.com/alexellis/href-counter/
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

# 最終イメージ
FROM alpine:latest
# コンテナからHTTPSリクエストを送信するために、Root証明書をインストールする
RUN apk --no-cache add ca-certificates \
  && update-ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]

*実装例*

FROM maven:3.5.0-jdk-8-alpine AS builder
COPY ./pom.xml pom.xml
COPY ./src src/
RUN mvn clean package

FROM openjdk:8-jre-alpine
# ビルドの成果物はtargetディレクトリにある
COPY --from=builder target/app.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

▼ 実行環境別にステージを分ける場合

実行環境別にステージを分けることにより、その環境に必要なファイルのみが含まれるようにする。

*実装例*

#===================
# Global ARG
#===================
ARG NGINX_VERSION="1.19"
ARG LABEL="Hiroki <example@gmail.com>"

#===================
# Build Stage
#===================
FROM nginx:${NGINX_VERSION} as build

RUN apt-get update -y \
  && apt-get install -y \
     curl \
     vim \
  `# キャッシュ削除` \
  && apt-get clean

#===================
# Develop Stage
#===================
FROM build as develop
LABEL mantainer=${LABEL}

COPY ./infra/docker/www/develop.nginx.conf /etc/nginx/nginx.conf

CMD ["/usr/sbin/nginx", "-g", "daemon off;"]

#===================
# Production Stage
#===================
FROM build as production
LABEL mantainer=${LABEL}

COPY ./infra/docker/www/production.nginx.conf /etc/nginx/nginx.conf

CMD ["/usr/sbin/nginx", "-g", "daemon off;"]


ADD処理よりもCOPY処理を使用する

ADD処理はCOPY処理とは異なり、インターネットからファイルをダウンロードして解凍した上で、コピーする。

解凍によって意図しないファイルがDockerfileに組み込まれる可能性があるため、COPY処理が推奨である。


05. ログを適切に扱う

標準出力/標準エラー出力に出力する

ログをファイルとして出力すると、ホスト上にログファイルが保管され、ストレージ容量が必要になる。

ログファイルをホスト外で管理するために、標準出力/標準エラー出力に出力する。


構造化ログとして出力する

ログの分析ツール (例:Grafana Loki、ElasticSearch) では、ログが構造化されていることが前提になっている。

そのため、構造化ログ (例:JSON) として出力する。


06. イメージレジストリを設定する

要件 説明
ストレージ容量 イメージレジストリのストレージのサイズを決める。
世代数 イメージストリを残しておく世代数 (例:5) をポリシーとして決めておくと良い。世代数を超えた場合、古いイメージレジストリから削除していく。


07. 適切なネットワークを使用する

状況に応じて、ネットワークタイプを選ぶ。

bridgeネットワーク hostネットワーク
安全性 ホストコンテナ間をネットワークを分離できるため、安全性が高い -
性能 - ホストコンテナ間のネットワークを分離しないため、スループットを向上させられる。


08. CIパイプライン上でコンテナイメージを用意する

CIパイプラインに適切なステップを用意する

  • コンテナイメージの静的解析を実施する
  • コンテナイメージからコンテナをビルドする
  • コンテナイメージをプッシュする


イメージレジストリのレートリミットに対処する

ビルド時にイメージのプルが頻発すると、DockerHubのレートリミット上限にすぐ達してしまう。

そのため、イメージのキャッシュが有効である。

DockerHub以外のイメージレジストリを使って、レートリミットを回避してもよい。


静的解析を実施する

▼ Dockerfileの文法の誤りテスト

Dockerのビルトインのコマンド (例:docker buildコマンド) を使用する。

Dockerfileの文法の誤りを検証する。

▼ Dockerfileのベストプラクティス違反テスト

外部のベストプラクティス違反テストツール (例:hadolint) を使用する。Dockerfileのベストプラクティス違反を検証する。

▼ Dockerfileの脆弱性診断

外部の脆弱性診断ツール (例:hadolint) を使用する。

報告されたCVEに基づいて、Dockerfileの実装方法の実装や使用パッケージに起因するコンテナイメージの脆弱性を検証する。

補足として、イメージスキャン (例:trivy) は既にビルドされたコンテナイメージを検証するため、ここには含めない。

▼ コンテナストラクチャテスト

ビルド後のコンテナの構造を検証するツール (例:container-structure-test) を使用する。

ファイル (例:期待するファイルが存在するか) やバイナリ (コンテナ起動時のENTRYPOINTが正しく動作するか、など) が存在するかを検証する。


09. 単体テストと機能テストを実施する

DBコンテナが起動するまで待つ

コンテナの起動と、トラフィック処理可能な準備のタイミングが一致しない場合がある。

その場合、トラフィック処理可能になるまで待機する必要がある。

services:
  app: ...

  db:
    container_name: foo-mysql
    image: mysql:5.7
    healthcheck:
      test:
        [
          "CMD",
          "mysqladmin",
          "ping",
          "-h",
          "localhost",
          "-u",
          "root",
          "-p$MYSQL_ROOT_PASSWORD",
        ]
      interval: 30s
      timeout: 10s
      retries: 5