ox0xo infosec tutorial

Docker基礎

2022-11-06

この投稿はDockerを触ったことが無い社内メンバー向けに書きました。 VirtualBoxやVMWareで簡単なLinuxサーバを構築できるレベルの前提知識を想定しています。 この演習を済ませれば今までVM上に展開していたアプリをDockerコンテナにリプレースできるようになります。

まずはDockerのアーキテクチャを読んで概要をイメージしてください。 全体像を掴んだら演習を始めましょう。

環境設定

インストールしたばかりのプレーンなUbuntu22.04にDockerの実行環境を用意しましょう。 基本的にはDocker Docsの手引きに従うだけです。

sudo apt-get remove docker docker-engine docker.io containerd runc
sudo apt-get update
sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin

ただし、このままではコマンドの実行にsudoが必要なのでpost-installの手順に従いカレントユーザーをdockerグループに追加しておきましょう。 これでsudo無しでdockerコマンドを使用できます。

sudo gpasswd -a $USER docker
newgrp docker

演習1:シンプルコンテナ

DockerHubのイメージをベースにしてシンプルなコンテナを構築する練習をしましょう。

ディレクトリ構成は以下の通りです。

01
├── Dockerfile
└── wwwroot
    └── index.html

Dockerfileです。

FROM httpd
COPY ./wwwroot /usr/local/apache2/htdocs

FROMはベースイメージを指定する命令です。 ここではDockerHubからApache公式のhttpdコンテナイメージを指定しています。

このベースイメージは空のhttpdサーバなのでコンテンツは後乗せです。 COPY命令を使って./wwwroot/*を/usr/local/apache2/htdocsにコピーします。 このときwwwroot/index.htmlは/usr/local/apache2/htdocs/index.htmlにコピーされることに注意してください。 /usr/local/apache2/htdocs/wwwroot/index.htmlではありません。

コピーされるindex.htmlです。

<!doctype html>
<html>
  <head>
    <title>Docker101-01 Apache httpd Server</title>
  </head>
  <body>
  </body>
</html>

docker buildコマンドでカレントディレクトリのDockerfileからイメージをビルドします。

docker build -t docker101-01 .
docker images

-tオプションによりビルドしたイメージにはタグdocker101-01が付与されます。 docker imagesコマンドでビルドされたイメージを確認できます。

なぜイメージが2つあるのでしょうか。 それはDockerのイメージファイルが階層構造になっているからです。 FROMで参照したhttpdの上にレイヤーを重ねてファイルをCOPYしました。 このようにすればhttpdをベースにした様々なコンテナを作る時に楽ができます。

Dockerイメージはコンテナのひな型です。 実際にコンテナを生成して利用するためにはdocker runコマンドを使います。

docker run -d -p 8080:80 --name 101-01 --rm docker101-01

ここでは4つのオプション付きで先ほど作ったdocker101-01イメージからコンテナを生成しています。

-d : バックグラウンドで起動
-p 8080:80 : localhost:8080をコンテナの80にポートフォワード
–name 101-01 : コンテナ名は101-01
–rm : 終了した時にコンテナを削除

docker psコマンドでコンテナの状態を確認してください。 つぎにcurlコマンドでコンテナにアクセスしてください。 最後にdocker stopコマンドでコンテナを停止して下さい。 –rmオプションの働きにより停止したコンテナは速やかに破棄されます。

docker ps
curl localhost:8080
docker stop 101-01

演習2:カスタムコンテナ

Webアプリケーションの構築では任意ポートの公開やアプリケーションのインストールが必要になる場合があります。 演習2ではそんなケースに対応できる方法を練習しましょう。

ディレクトリ構成です。

docker101-02/
├── Dockerfile
└── webapp
    ├── app.py
    ├── requirements.txt
    └── templates
        └── index.html

app.pyです。 本題から外れるので説明を省きますがPythonのFlaskを使います。

from flask import Flask, render_template
app = Flask(__name__)

@app.route('/index')
def index():
    return render_template('index.html')

app.run('0.0.0.0', port='8080')

このスクリプトは0.0.0.0:8080/indexでリクエストを待ち受けテンプレートのindex.htmlを返します。 テンプレートとして./templates以下のファイルが参照されます。

requirements.txtです。 Flaskは標準モジュールではないのでpip installするために使います。

flask

Flaskから参照されるindex.htmlです

<!doctype html>
<html>
  <head>
    <title>Docker101-02 Flask WebApp</title>
  </head>
  <body>
  </body>
</html>

Dockerfileです。

FROM python:3.11-slim
COPY ./webapp /opt/webapp
RUN apt-get update && pip install --upgrade pip && pip3 install -r /opt/webapp/requirements.txt
EXPOSE 8080
CMD ["python3", "/opt/webapp/app.py"]

pythonの実行環境としてpyhon:3.11-slimをベースイメージに選びます。 3.11-slimはタグと呼ばれるDockerイメージのバージョン識別子です。 shin “Docker imageの違い”に分かりやすい説明があります。

COPY命令で./webappを/opt/webappに設置してください。 次にRUN命令でaptとpipを実行します。 COPYやRUNでイメージに変更を加えるとその分レイヤーが増えます。 レイヤーが増えるとイメージが肥大するのでコマンドは可能な限りまとめてください。 イメージサイズを小さく保つヒントはDockerfile Best Practicesにまとめられています。

EXPOSE命令でポートを開放します。 pythonスクリプトに書いた通り8080を公開してください。

最後にCMD命令でpython3 /opt/webapp/app.pyを実行させます。 CMDはコンテナが起動した瞬間に実行されます。 RUNはイメージをビルドする過程で実行されるので使い分けてください。

コンテナの起動手順は演習01と同じです。

docker build -t docker101-02 .
docker run -d -p 8080:8080 --name 101-02 --rm docker101-02

この演習では/indexを指定してHTTPリクエストする必要があります。

docker ps
curl localhost:8080/index
docker stop 101-02

演習3:コンテナ間通信

一般的にアプリケーションはバックエンド、フロントエンド、ロードバランサなどが連携して機能します。 複数のコンテナを連携させる練習をしましょう。

ディレクトリ構成です。 httpdとnginxでDockerfileが2つ必要です。

03
├── httpd
│   ├── Dockerfile
│   └── wwwroot
│       └── index.html
└── nginx
    ├── Dockerfile
    └── nginx.conf

httpdのDockerfileは演習01と同じ内容です。

FROM httpd
COPY ./wwwroot /usr/local/apache2/htdocs

コピーされるindex.htmlです。

<!doctype html>
<html>
  <head>
    <title>Docker101-03 Apache httpd via Nginx</title>
  </head>
  <body>
  </body>
</html>

nginxのDockerfileです。

From nginx:stable
COPY ./nginx.conf /etc/nginx/nginx.conf

nginx.confです。

worker_processes 4;
events {
    worker_connections 1024;
}
http {
        server {
                listen 80 default_server;
                location / {
                        proxy_pass http://httpd;
                }
        }
}

ポート80で受け取ったリクエストをhttpdに渡します。 httpdはFQDNです。 名前解決の方法はこの後分かります。

httpdとnginxをビルドします。 カレントディレクリが03だと仮定した時のコマンドを示しています。 それぞれのDockerfileが存在するディレクトリを指定してdocker buildコマンドを実行してください。

docker build -t docker101-httpd ./httpd
docker build -t docker101-nginx ./nginx

docker networkコマンドでhttpdとnginxがお互いに通信するためのネットワークを作成します。 このネットワークは–nameで指定したコンテナ名を名前解決します。 nginx.confでproxy_passに指定したhttpdはこのネットワークを介することでアクセスできます。

docker network create docker101-03
docker network ls

その後httpdとnginxのコンテナを起動してください。 コンテナを起動する際は–networkオプションでネットワークを指定してください。

docker run -d --name httpd --network docker101-03 --rm docker101-httpd
docker run -d -p 8080:80 --name nginx --network docker101-03 --rm docker101-nginx

docker psコマンドでコンテナの状態を確認してください。 localhost:8080からnginx:80にポートフォワードが設定されています。 httpdにはポートフォワードが設定されていないので通信経路が存在しないように見えます。 しかしnginxが通信をプロキシしているのでnginx経由でhttpdのコンテンツにアクセス出来ます。 確認が終わったらコンテナを終了してください。

docker ps
curl localhost:8080
docker stop nginx
docker stop httpd

演習4:オーケストレーション

並列処理するコンテナの数が増えると管理の手間が無視できなくなります。 オーケストレーションツールが必要です。 その中で最も簡単なdocker composeの使い方を練習しましょう。

ディレクトリ構造です。 今回はhttpdとnginxのDockerfileは不要です。

04
├── docker-compose.yml
├── httpd
│   └── wwwroot
│       └── index.html
├── nginx
│   └── nginx.conf
└── webapp
    ├── Dockerfile
    ├── contents
    │   ├── app.py
    │   └── requirements.txt
    └── templates
        └── index.html

httpd/wwwroot/index.htmlです。

<!doctype html>
<html>
  <head>
    <title>Docker101-04 Apache httpd via docker-compose</title>
  </head>
  <body>
  </body>
</html>

webapp/templates/index.htmlです。

<!doctype html>
<html>
  <head>
    <title>Docker101-04 Flask WebApp via docker-compose</title>
  </head>
  <body>
  </body>
</html>

nginx.confです。

worker_processes 4;
events {
    worker_connections 1024;
}
http {
        server {
                listen 80 default_server;
                location / {
                        proxy_pass http://httpd;
                }
                location ~ ^/app/.*$ {
                        rewrite ^/app/(.*)$ $1 break;
                        proxy_pass http://webapp:8080;
                }
        }
}

0.0.0.0:80/app/indexへのアクセスをwebapp:8080/indexへ転送する設定を加えました。 本題から外れるため説明は省きますが正規表現によるパスの書き換えはModule nginx_http_proxy_moduleを確認してください。

webappのDockerfileです。

From python:3.11-slim
COPY ./contents /opt/webapp
RUN apt-get update && pip install --upgrade pip && pip3 install -r /opt/webapp/requirements.txt
EXPOSE 8080
CMD ["python3", "/opt/webapp/app.py"]

COPYするファイルのディレクトリ構成を変更しました。 テンプレートは軽微な変更が頻繁に行われるかもしれないのでDockerfileから除外しました。 requirements.txtとapp.pyは頻繁に変更されないのでDockerfileに残しています。 この意味はdocker-compose.ymlの解説で明らかになります。

docker-compose.ymlです。 httpd、webapp、そしてnginxをまとめて定義しています。 いくつかポイントがあります。

version: '3.9'
services:
  httpd:
    image: httpd
    restart: always
    volumes:
      - ./httpd/wwwroot:/usr/local/apache2/htdocs:ro
  webapp:
    build:
      context: webapp
      dockerfile: Dockerfile
    restart: always
    volumes:
      - ./webapp/templates:/opt/webapp/templates:ro
  nginx:
    image: nginx:stable
    restart: always
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - 8080:80
    depends_on:
      - httpd
      - webapp

volumesを使えばコンテナからローカルファイルシステムをマウント出来ます。 ファイル更新後にイメージをリビルドしなくても最新のファイルを読めます。 webappのDockerfileでtemplatesをCOPYしなかった理由はこれです。 更新を前提としたファイルはイメージに書き込まずにコンテナからマウントしましょう。 任意のパスをマウントするのはDocker compose特有の機能でありDocker標準の機能では実現できません。 詳しくはDocker Docs Volumeを確認してください。

次はimageおよびbuild:contextとbuild:dockerfileに注目してください。 COPYの代わりにvolumesを使ったことでhttpdとnginxのDockerfileがベースイメージを指定するFROMだけになりました。 その場合Dockerfileを作らずimageを使ってベースイメージを指定できます。 webappのようにDockerfileを必要とする場合はイメージをビルドする際のカレントディレクトリをcontextで指定し、Dockerfileの名前も指定します。

restartを指定すればコンテナが停止するたびに再起動されます。 これはホストが再起動した時にも適用されます。 ホストが再起動した後に複数のコンテナを起動する一連の手順が自動化されるので楽になります。

nginxはアップストリームのサービスが存在しなければ正常に起動出来ません。 depends_onで依存関係を指定する事が出来ます。 この場合はhttpdとwebappが起動するまで待ってからnginxが起動します。

準備が整ったらコンテナを起動します。 docker composeコマンドはdocker-compose.ymlに基づき全てのリソースのビルドと起動を行います。 04_defaultネットワークが作成され3つのコンテナが起動したことを確認してください。

docker compose up -d
docker ps
docker network ls
curl localhost:8080
curl localhost:8080/app/index

確認のためにhttpd/wwwroot/index.htmlを適当に変更してからHTTPリクエストしてください。 コンテナイメージを操作せずコンテンツを書き換えることができます。

webappはFlaskの仕様によりアプリが起動した時点のコンテンツの状態が参照されます。 変更を反映させるにはアプリを起動しなおす必要があります。 webapp/templates/index.htmlを適当に変更してからコンテナを起動しなおしてください。

docker compose down
docker compose up -d

httpdよりは手間が掛かりますがイメージのリビルド無しでコンテンツが更新されました。

演習5:トラブルシュート

演習5はオプションです。 コンテナに問題が発生した時にログから原因を特定し応急処置する方法を練習しましょう。

ディレクトリ構成は演習4と同じです。

05
├── docker-compose.yml
├── httpd
│   ├── Dockerfile
│   └── wwwroot
│       └── index.html
├── nginx
│   ├── Dockerfile
│   └── nginx.conf
└── webapp
    ├── Dockerfile
    ├── contents
    │   ├── app.py
    │   └── requirements.txt
    └── templates
        └── index.html

webapp/contents/app.pyを以下の通り変更してください。 OSコマンドインジェクションの脆弱性を注入しています。

import subprocess
from flask import Flask, render_template, request
app = Flask(__name__)

@app.route('/index')
def index():
    return render_template('index.html')

@app.route('/write')
def write():
    cmd = request.args.get('cmd')
    subprocess.run([cmd], shell=True)
    return render_template('index.html')

app.run('0.0.0.0', port='8080')

新たに/writeでHTTPリクエストを受け付けるようになっています。 このとき0.0.0.0:8080write?cmd=lsをリクエストすればOSコマンドのlsが実行されます。

コンテナを起動してOSコマンドインジェクションを試してください。

docker compose up -d
docker ps
curl localhost:8080/app/write?cmd="echo%20malware%20>%20malware2.txt"

docker logsコマンドでwebappコンテナに不審なリクエストが送られたことを確認できます。

docker logs 05-webapp-1

docker execコマンドでwebappコンテナにコマンドを発行します。 インタラクティブtty(-it)で/bin/bashを起動するのでsshのように利用できます。 lsすればOSコマンドインジェクションにより作られたmalware2.txtを確認できます。 rmでファイルを削除して応急処置を完了してください。

docker exec -it 05-webapp-1 /bin/bash
ls -l
rm malware2.txt


Content