← Blog
·10 blog.minutes

DockerとKubernetes:現代開発者のための実践ガイド

コンテナ化、Dockerfileのベストプラクティス、Kubernetesの基礎、そしてオーケストレーターが実際に必要になる時を知るための、無駄のないガイド。

すべての開発者はいつか同じ壁にぶつかる。自分のマシンでコードを書き、完璧に動作し、ステージングにプッシュすると、システム依存関係の欠落や異なるライブラリバージョンに関する謎のエラーで爆発する。古典的な「自分のマシンでは動く」問題は何十年もソフトウェア開発を悩ませてきた。Dockerはそれを解決しただけでなく、解決策を非常にシンプルにしたので、今ではそれを使わない言い訳はもうない。

しかしDockerが解決するのはパッケージングの問題だけである。アプリケーションをコンテナ化した後も、それを本番で実行する必要がある——潜在的に複数のサーバーにまたがり、ロードバランシング、ゼロダウンタイムデプロイ、ヘルスチェック、自動復旧を伴う。そこでKubernetesの出番である。そしてそこでほとんどの開発者が複雑さに迷い込む。Kubernetesは、習得に時間を要するまったく新しい抽象化の語彙を導入するからだ。

このガイドはノイズを排除する。コンテナが実際に内部で何であるか、効率的でセキュアなDockerfileの書き方、実際のアプリケーションをデプロイするために知っておくべきKubernetesの概念、ローカル開発におけるDocker ComposeとKubernetesのトレードオフ、そして最も重要なことに、各ツールを使うべき時と使わないでおくべき時を扱う。

コンテナが実際に何であるか

コンテナは軽量な仮想マシンではない。これが最も一般的な誤解であり、誤ったメンタルモデルにつながる。仮想マシンはハイパーバイザーの上で完全なゲストOSを実行し、独自のカーネル、独自のメモリ割り当て、独自のデバイスドライバを持つ。コンテナはホストカーネルを共有し、隔離されたユーザースペースプロセスとして実行する。この隔離はLinuxカーネル機能——プロセス隔離のための名前空間、リソース制限のためのcgroups、効率的なイメージレイヤーのためのオーバーレイファイルシステム——によって提供される。

この区別は、観察される動作を説明するので重要である。コンテナは起動するカーネルがないためミリ秒で起動する。重複したカーネルや冗長なシステムプロセスがないため、使用するメモリが少ない。しかし同時に、Linux上で実行されるコンテナはホストと異なるカーネルバージョンを実行できず、WindowsコンテナはWindowsホストを必要とする(または古いバージョンではHyper-V Linux VM)。macOSでは、Docker Desktopがまさにこの理由で軽量VM内でLinuxコンテナを実行する。

イメージは読み取り専用のテンプレートである——ファイルシステムとメタデータのスナップショット。コンテナはそのイメージの実行中のインスタンスであり、その上に書き込み可能なレイヤーがある。イメージを一度構築して、そこから数十のコンテナを実行できる。これがDockerの世界における基本的な操作単位であり、これを明確に理解することで他のすべてが容易になる。

Dockerfileのベストプラクティス

Dockerfileはイメージを構築するためのレシピである。各命令は新しいレイヤーを作成し、レイヤーはキャッシュされる。つまり、命令の順序はビルド速度、イメージサイズ、セキュリティに直接影響する。実際のプロジェクトで最も重要な原則を紹介する。

変更頻度でレイヤーを並べる

Dockerは各レイヤーをビルド後にキャッシュする。前回のビルドからレイヤーが変更されていなければ、Dockerはキャッシュバージョンを再利用する。つまり、めったに変更されない命令を上部に、頻繁に変更される命令を下部に置くべきである。システム依存関係(apt-get、apk add)はほぼ変更されない。アプリケーション依存関係(npm install、pip install)はロックファイルを更新したときに変更される。アプリケーションのソースコードはコミットのたびに変更される。

# Bad: source code before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .                 # busts the cache for everything below
RUN npm ci                # runs on every build, even if package.json did not change
EXPOSE 3000
CMD ["node", "dist/index.js"]

# Good: stable-first layer ordering
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                # cached unless package.json changes
COPY . .                  # only the source changes bust this layer
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

その差は劇的である。悪いDockerfileはすべてのコミットで全依存関係を再構築する。良いDockerfileはロックファイルが変更されたときだけ依存関係を再構築する。これは通常、すべてのコミットではなくプルリクエストごとに1回である。500の依存関係を持つNode.jsプロジェクトでは、これでビルドあたり2分節約できる。

マルチステージビルド

マルチステージビルドでは、1つのDockerfileを使用してアプリケーションを構築し、最小限のランタイムイメージを生成できる。ビルドステージにはコンパイラ、開発依存関係、ビルドツールが含まれる。ランタイムステージはコンパイルされた出力のみをコピーする。これにより本番イメージを小さく保ち、攻撃対象領域を減らす。

# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage — starts from a fresh, minimal base
FROM node:20-alpine AS runner
WORKDIR /app

# Only what is needed to run
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package.json ./
COPY --from=builder /build/node_modules ./node_modules

EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

ランタイムステージにはTypeScriptコンパイラ、ソースファイル、開発依存関係は含まれない。典型的なアプリケーションでは、イメージが800MBから200MB未満に縮小される。COPY --from=builder構文が重要な洞察である——以前のステージからファイルを引き出し、そのステージのレイヤーを引き継がない。

非rootユーザーとして実行する

コンテナはデフォルトでrootとして実行される。これはセキュリティリスクである:攻撃者がアプリケーションを悪用した場合、コンテナ内でrootアクセス権を持つ。修正はDockerfileの1行で非rootユーザーに切り替えるだけである。ほとんどのベースイメージには、この目的のためにnodeまたはnobodyユーザーが用意されている。

セキュリティ以外にも、マルチステージビルドと適切なレイヤー順序はCI/CDパイプラインの速度も向上させる。イメージビルドで節約された毎分は、開発者がデプロイを待たない時間である。1日5回デプロイする10人の開発者のチームでは、ビルドあたり2分節約することで年間60時間以上の開発者時間が回復する。

Kubernetesの基礎

Kubernetesはコンテナオーケストレーターである。マシン(ノード)のクラスターを受け取り、それらにコンテナをスケジューリングし、実行し続け、ネットワーキングを処理し、システムの望ましい状態を記述するための宣言的APIを提供する。Kubernetesに欲しいものを伝える——APIサーバーの3つのレプリカ、ポート8080の公開、ローリングアップデート戦略——そしてそれが実現される。

学習曲線は本物である。なぜならKubernetesは階層化された抽象化のセットを導入するからだ。最も頻繁にやり取りする3つは、Pod、Deployment、Serviceである。

Pod

PodはKubernetesにおける最小のデプロイ可能単位である。ネットワーク名前空間とストレージボリュームを共有する1つ以上のコンテナを表す。実際には、ほとんどのPodは単一のコンテナを実行する。サイドカーパターン(メインコンテナとロギングまたはプロキシコンテナ)はマルチコンテナPodを使用するが、日常的なアプリケーションデプロイでは、Podごとに1つのコンテナを使用する。

Podを直接作成することはほとんどない。Podは一時的であり、いつでも終了および再スケジュールされる可能性がある。Podを手動で作成し、それが実行されているノードがダウンした場合、Podは永久に失われる。ここでDeploymentの出番である。

Deployment

Deploymentは同一のPodのセット(ReplicaSet)を管理する。ローリングアップデート、スケーリング、セルフヒーリング、ロールバックを処理する。これはステートレスアプリケーションをデプロイするために使用するリソースである。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  labels:
    app: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
        - name: api
          image: myregistry/api-server:v1.2.3
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 250m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000

このDeploymentはAPIサーバーの3つのレプリカを宣言する。Kubernetesは常に3つのPodが実行されていることを保証する。Podがクラッシュすると、Kubernetesは代替を作成する。ローリングアップデート中(イメージタグの変更)、KubernetesはPodを1つずつ置き換え、ゼロダウンタイムを確保する。livenessProbeはKubernetesにPodが正常であるタイミングを伝え、readinessProbeはPodがトラフィックを受信する準備ができたタイミングを伝える。

Service

Podは動的なIPアドレスを持つ。Podが再作成されるたびに新しいIPを取得する。Serviceは、セレクタに一致するPod間でトラフィックをロードバランスする安定したネットワークエンドポイントを提供する。これがシステムの他の部分がアプリケーションを見つけて通信する方法である。

apiVersion: v1
kind: Service
metadata:
  name: api-server
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

このServiceは、安定したクラスター内部IP上のポート80を、ラベルapp: api-serverを持つPodのポート3000にマッピングする。クラスター内の他のサービスは、DNS名api-serverで到達できる。外部トラフィックの場合は、Serviceの上にLoadBalancerまたはIngressリソースを使用する。

Kubernetesはデプロイ先のプラットフォームではない。あなたのデプロイを記述するプラットフォームである。命令型と宣言型の違いは、あなたができる最も重要なメンタルシフトである。

ローカル開発:Docker Compose vs Kubernetes

チームが犯す最大の間違いは、本番でKubernetesを使っているからといってローカル開発にも必要だと仮定することである。Docker ComposeとKubernetesは異なる目的を果たし、ローカル作業に間違った方を選ぶと不必要な摩擦が生じる。

Docker Composeはローカル開発用に設計されている。単一のマシンで実行され、コンテナを数秒で起動し、必要なもの——Webサーバー、データベース、Redisインスタンス、そしておそらくキューワーカー——に直接マッピングするシンプルなYAML形式を持つ。サービスを定義すると、docker compose upですべてがオンラインになり、ログがターミナルにストリーミングされ、ポートがlocalhostにマッピングされ、ホットリロードがそのまま機能する。

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/app
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

このComposeファイルは、ホットリロード、永続ストレージ付きローカルデータベース、サービス間の適切なネットワーキングを備えた動作する開発環境を提供する。約30行のYAMLで、10秒以内に起動する。

Minikube、Kind、k3sはKubernetesをローカルで実行できるが、かなりのオーバーヘッドを追加する。より多くのメモリを必要とし、起動に時間がかかり、単一の機能を反復するときにはまったく不要な複雑さ(イングレスコントローラー、サービスメッシュ、ストレージクラス)を導入する。Kubernetesをローカルで実行することは、Podのエビクションポリシー、水平Podオートスケーリング、カスタムリソース定義など、Kubernetes固有の動作をテストするのに有用だが、日常的な開発におけるComposeの代替にはならない。

  • ローカル開発にはDocker Composeを使用する。高速でシンプルで、実行するコンテナに直接マッピングされる。
  • 本番インフラがConfigMap、Secret、カスタムコントローラーなどのKubernetes機能を使用する場合、統合テストにはKubernetes(MinikubeまたはKind経由)を使用する。
  • GPUアクセス、特殊なハードウェア、または本番を正確にミラーリングする共有ステージング環境が必要な場合にのみ、リモート開発クラスターを使用する。
  • 2つの環境があるという理由だけで、ローカルで2つのKubernetesクラスターを実行しない。Composeは単一の--profileフラグでそれを処理する。
  • チームがアプリケーションコードを書くよりもKubernetes設定のデバッグに多くの時間を費やしているなら、ヘッドライトを超えて走り出している。Composeに戻り、それがないことの苦痛がそれを維持する苦痛を上回った場合にのみ複雑さを追加する。

よくある落とし穴とその回避方法

概念を理解した後でも、特定の間違いがチーム間で繰り返し発生する。2日間のデバッグセッションをスキップできるように、覚えておく価値のあるものを紹介する。

イメージタグの混乱が最も一般的な本番問題である。Kubernetes Deploymentでlatestをイメージタグとして使用すると、どのノードでどのバージョンが実行されているかがわからなくなる。Kubernetesはイメージがノードに存在しない場合にのみプルするため、あるノードのlatestが別のノードのlatestとは異なるバージョンになる可能性がある。セマンティックバージョンタグまたはコミットSHAを常に使用する。さらに良いのは、完全修飾イメージダイジェストを使用することである——それが不変であることが保証された唯一のものである。

リソースのrequestsとlimitsは頻繁に省略されるか、恣意的に設定される。requestsを設定しないと、KubernetesはPodをインテリジェントにスケジューリングできず、ノードが過負荷になる。limitsを設定しないと、1つのPodのメモリリークが同じノード上の他のPodをクラッシュさせる可能性がある。両方を設定し、実際の使用量に基づいてチューニングするために、Vertical Pod Autoscalerを推奨モードで使用する。

ConfigMapとSecretは環境変数またはファイルとしてマウントされる。環境変数は便利だが、変更を反映するにはPodの再起動が必要である。ファイルベースのマウントは再起動なしで更新できるが(ファイルにアクセスするときにPodが新しいコンテンツを読み取る)、多くのアプリケーションは起動時に設定をキャッシュする。アプリケーションがどのパターンを使用しているかを把握し、それに応じて設定アプローチを設計する。

Kubernetesの永続ボリュームは魔法ではない。PersistentVolumeClaimはストレージを要求するが、基盤となるストレージクラスはクラウドプロバイダー用に設定する必要がある。デフォルトのストレージクラスは、ローカルSSDとは異なるパフォーマンス特性を持つネットワーク接続ストレージを使用する場合がある。データベースのパフォーマンスが重要な場合は、本番稼働前にストレージクラスをベンチマークする。

Kubernetesでのロギングとデバッグは、単一サーバーよりも難しい。Podは一時的なので、Podが削除されるとログは消える。ライブテーリングにはkubectl logs --tail=50 -f pod-nameを使用するが、本番デバッグには集中ログソリューション(Loki、Elasticsearch、またはクラウドロギングサービス)が必要である。同様に、kubectl exec -it pod-name -- shでPodにexecして実行中のコンテナを検査できるが、実行中のコンテナ内で行った変更は再起動時に失われることを忘れてはならない。

本当にKubernetesが必要か?

これは誰も尋ねたくない質問である。なぜならKubernetesはエンジニアの履歴書によく見え、運用の成熟度を示すからだ。しかしKubernetesは特定の問題に対する解決策である:複数のマシンにまたがって複数のコンテナ化サービスを実行し、自動復旧、スケーリング、ローリングデプロイを実現することだ。1台のサーバーで1つか2つのサービスを実行しているなら、Kubernetesはオーバーキルである。

ここに簡単な意思決定フレームワークを示す。1秒あたり1万リクエスト未満を処理する単一のアプリケーションをデプロイしている場合、本番でのDocker Compose(そう、Composeは多くのワークロードで本番でも問題なく動作する)を備えた単一のサーバーで十分対応できる。CaddyやNginxのようなリバースプロキシを追加し、自動バックアップを設定すれば、単一の開発者が全体を理解し維持できる本番システムができる。

独立してデプロイする必要がある複数のサービスがある場合、サービスごとのスケーリングが必要な場合(APIは10レプリカ必要だがワーカーは2レプリカで十分)、日常業務としてゼロダウンタイムデプロイが必要な場合、またはチームにインフラを主な責任とする人が少なくとも1人いる場合に、Kubernetesに移行する。これらの条件が満たされる前は、Kubernetesの運用コスト——クラスター管理と開発者の認知負荷の両方——は正味マイナスである。

多くの中間的な立場から恩恵を受けるチームもいる。ローカル開発にはDocker Composeを、本番にはAWS App Runner、Google Cloud Run、Fly.ioのようなマネージドコンテナプラットフォームを使用する。これらのプラットフォームは、Kubernetesのコントロールプレーンを管理せずに、コンテナデプロイ、自動HTTPS、スケーリングを提供する。コンテナ化の利点のほとんどを、Kubernetesの学習曲線なしで得られる。

最良のインフラ戦略とは、チームが機能を出荷できるようにするものである。DockerとKubernetesはツールであり、アイデンティティではない。役立つときに使い、役立たないときはスキップする。