🦴

Docker: 開発用コンテナで sshd を起動してサーバー化する

2024/07/14に公開

はじめに

私はねぇ、システムの Python 環境に直接 pip install をかましまくった後で、クリーンな状態に戻そうと思い /usr/lib/python3/dist-packages の中身を全部削除して、システムを破壊したことがあるんだ。それはとても甘美な経験だったと記憶しているよ。

しかしそれ以来、すべての開発環境を仮想環境にインストールしないと安心できない呪いにかかってしまったんだ

実際のところ、本記事の内容は DevContainer を使えばよりドラマチックかつエロティックに解決できるのだが、私は頭が悪くナウでヤングなツールのツールに振り回されるのが大嫌いな老害なので枯れた技術に逃げることがある。

枯れた技術の素晴らしいところは何年経っても変わらないところである。一度覚えたらずっと使える。今回の内容の核となる以下のドキュメントは 10 年近く編集されていない(それはそれで不安である)。

https://docs.docker.jp/engine/examples/running_ssh_service.html

SSH でアクセスできる開発用の Docker コンテナがあれば、サーバーを触るときのインターフェースと統一できるので私にとっては認知負荷が大きく下がる。ちょこっと編集したいからコマンドラインから SSH 接続して vim 叩こう、ができるととても安心感がある[1]

というわけで作ってみたのだが、おそらく誰の参考にもならない。

https://github.com/wsuzume/docker-sshd-example

本記事は上記の Docker 公式ドキュメントと以下の記事の、組み合わせと応用である。

https://zenn.dev/wsuzume/articles/26b26106c3925e
https://zenn.dev/wsuzume/articles/d390844c8a2ae9

検証した環境

クライアント

  • macOS Ventura

サーバー

  • Ubuntu 22.04 LTS
  • Docker version 26.1.4

ベースとなる Docker イメージ

  • Debian系(Ubuntuなど)

免責事項

SSH でアクセスできるコンテナを立てておくと、当然そこがセキュリティホールになりうるので自己責任でやってください。VSCode のサーバー立てても Extension にマルウェアや脆弱性が仕込まれてたりする可能性もあるわけなので危険性は変わらないですけど。

クリアすべき課題

クリアすべき課題は大きく分けて以下の2つである。

  1. Docker コンテナで sshd を起動し、外部からアクセスできるようにする
  2. コンテナ起動時にホスト側のユーザーと権限設定を合わせることで、ホスト側ボリュームをマウントしたときに作成されるファイルの権限が自然なものになるようにする
  3. 上記を手間をかけないでできるようにする

もちろん他にも付随して細々とした設定を行う必要はある。では順に解決しよう。

Docker コンテナで sshd を起動し、外部からアクセスできるようにする

冒頭で紹介した以下のドキュメントのほぼコピペで、まずはコンテナ内の root ユーザーへのアクセスを試す。この作業をする前に、当然サーバー側のファイアウォールやサーバー外のセキュリティ設定をいじってコンテナ側で sshd が待機するポートを開けておかねばならないので注意すること。

https://docs.docker.jp/engine/examples/running_ssh_service.html

ディレクトリ構成
docker-sshd-example/
├── Dockerfile
└── docker-compose.yml
Dockerfile
# ベースイメージは Debian か Ubuntu をベースにしているものにする
FROM ubuntu:22.04

USER root

ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update --yes \
    && apt-get upgrade --yes \
    && apt-get install --yes --no-install-recommends \
        # - `tini` is installed as a helpful container entrypoint,
        #   that reaps zombie processes and such of the actual executable we want to start
        #   See https://github.com/krallin/tini#why-tini for details
        tini \
        openssh-server \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

RUN mkdir /var/run/sshd
RUN echo 'root:screencast' | chpasswd

# 以下の設定書き換えコマンドはバージョンによって微妙に変える必要がある
RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

CMD ["tini", "-g", "--"]
docker-compose.yml
services:
  sshd:
    build: .
    image: sshd_example
    container_name: sshd_container
    ports:
      - "2222:22"
    # -D はフォアグラウンド実行、 -d はデバッグモードのオプション
    # アクセスに成功するまではつけておくとよい
    # デバッグモードでは一度アクセスを受け付けると
    # それ以降のアクセスを受け付けなくなるので、常用するときは外す
    command: ["/usr/sbin/sshd", "-D", "-d"]

以下のコマンドで起動できる。ユーザーに docker の実行権限があれば sudo はいらない。

ホスト側で sshd コンテナを起動する
sudo docker compose build
sudo docker compose up
クライアント側から ssh でコンテナにアクセスする
# IPアドレスは適宜調整すること
# -v はデバッグオプションなのでアクセスに成功するまではつけておくとよい
ssh -v -p 2222 root@192.168.0.21

うまくいかないときはコンテナ内の /etc/ssh/sshd_configsudo docker container exec -it sshd_container bash などで覗きに行き、設定が正しいかどうかを確認するとよい。特に root でのパスワードログインなので PermitRootLoginPasswordAuthentication が有効でないと弾かれる。

上記はデバッグモードを有効にしてあるので、もしうまくいかなければエラーメッセージを適当な生成AIにぶち込めばうまくいかない理由をある程度は教えてくれる。

コンテナ起動時にホスト側のユーザーと権限設定を合わせる

上記でのアクセスはコンテナ側が root なので、ボリュームをマウントするなどして作業を永続化した際に、コンテナ側で生成したファイルのオーナーが root となっていちいち操作に制限がかかり非常に厄介である。また、Dockerfile 内に書かれたパスワードでログインできてしまうことも問題で、自分だけが使う閉じた環境ならともかく会社などで使うには性善説が過ぎる。

Docker コンテナは --priviledged オプションをつけて起動しない限り jail break されることはそうそうないが、root でコンテナに入られるといろいろなツールをインストールされて踏み台化される危険はあるし、root 以外のユーザーでも Linux カーネルの既知の脆弱性を利用すれば root への昇格は可能なので、そもそも他のユーザーにはコンテナに入らせないことを基本とすべきである。

コンテナ側にアクセスできるユーザーの情報を焼き込んでしまってもよいが、そうすると複数のサーバーや複数の Linux アカウントで同じ開発環境を作りたいときに、アクセスするすべてのユーザーの情報を Docker イメージに焼き込む必要があって、非常に面倒臭いというか実質そのような管理は不可能である。そんなことをするならば開発用の AWS インスタンスを個人ごとに立てたほうがまだ幾分賢い。

したがって普段使いするならばアクセスできるユーザーの情報はコンテナの起動時に指定できることがマストになる。

このような需要があって作成したスタートアップスクリプトが以下である。git clone で簡単にイメージに導入できる。

https://zenn.dev/wsuzume/articles/d390844c8a2ae9

今回の用途に使えるように、上記記事の時点より以下の2つの変更を追加した。

  1. PRESERVE_ROOT=yes を指定することで、目的のユーザーを作成した上で root ユーザーとしてコマンドを実行することができる。
  2. もともとの Jupyter 公式スクリプトに存在していた run-hooks.sh を復活させ、コンテナ起動時にユーザーが特定のディレクトリに置いたスクリプトを自動で実行できるようにした。

上記の変更を加えたことで、以下が可能になった。

  1. 目的のユーザーを作成した上で、root ユーザーで sshd を起動できる。
  2. sshd の設定をコンテナ起動時に行うことができる。

sshd の設定には以下の記事で作成した設定用スクリプトを手直しして使う。

https://zenn.dev/wsuzume/articles/26b26106c3925e

ディレクトリ構成
docker-sshd-example/
├── .env
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── docker-compose.yml
├── second-hook.d
│   ├── chpasswd.sh
│   └── ssh_setting.sh
└── work
    └── home
        └── josh
            └── .ssh
                ├── .gitignore
                └── authorized_keys

.ssh にある .gitignoreauthorized_keys を誤って push しないようにするためのお守りである。この work/home/josh をコンテナ内の /home/josh にマウントする。以後、コンテナ内ホームディレクトリ /home/josh における作業はホスト側の work/home/josh に永続化される。

マウントするに当たってはディレクトリやファイルの権限をきちんと設定しておかないと sshd に怒られるので、以下のコマンドで権限を調整する。

cd work
chmod 750 home
cd home
chmod 700 .ssh
cd .ssh
chmod 600 authorized_keys

権限を設定したら authorized_keys でユーザー(ここでは josh)へのアクセスに用いる公開鍵を ssh-keygen -t ed25519 などで作成して書き込んでおく。

ssh-keygen -t ed25519
cat ~/.ssh/id_ed25519.pub >> work/home/josh/.ssh/authorized_keys

また、docker compose がデフォルトで読み取る .env に、コンテナ起動時に設定するユーザーの情報を書き込んでおく[2]。この手順も Makefile に自動化しておいた。

Makefile
DEV_USER ?= $(shell id -un)
DEV_UID ?= $(shell id -u)
DEV_GROUP ?= $(shell id -gn)
DEV_GID ?= $(shell id -g)

create_env:
	echo "DEV_USER=${DEV_USER}" > .env
	echo "DEV_UID=${DEV_UID}" >> .env
	echo "DEV_GROUP=${DEV_GROUP}" >> .env
	echo "DEV_GID=${DEV_GID}" >> .env
make create_env

準備ができたら各ファイルを以下のように修正する。

まず、Dockerfileroot でのアクセス設定を削除し、スタートアップスクリプトを追加する。

Dockerfile
FROM ubuntu:22.04

USER root

ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update --yes \
    && apt-get upgrade --yes \
    && apt-get install --yes --no-install-recommends \
        sudo git ca-certificates \
        # - `tini` is installed as a helpful container entrypoint,
        #   that reaps zombie processes and such of the actual executable we want to start
        #   See https://github.com/krallin/tini#why-tini for details
        tini \
        openssh-server \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

# for sshd
RUN mkdir /var/run/sshd

# スタートアップスクリプト
RUN git clone https://github.com/wsuzume/devel-entrypoint.git \
    && cd devel-entrypoint \
    && /bin/bash install.sh

# 起動時に実行するスクリプトをコピー
COPY ./second-hook.d/ /usr/local/bin/second-hook.d/

# Configure container entrypoint
ENTRYPOINT ["tini", "-g", "--"]

次に docker-compose.yml には以下の変更を加える。

  • ホームディレクトリをマウントする。
  • 起動時に作成するユーザーの情報を環境変数 INIT_USER などで与える。
  • GRANT_SUDO=yes により、コンテナ内ではパスワードなしで sudo を可能にする
  • PRESERVE_ROOT=yes により、コマンドを root ユーザーで実行する
  • init.sh 経由で sshd を呼び出すことでスタートアップスクリプトを有効にする

sshd 起動時の -d オプションはデバッグモード指定なのでアクセスに成功したら削除すること。

docker-compose.yml
services:
  sshd:
    build: .
    image: sshd_example
    container_name: sshd_container
    ports:
      - "2222:22"
    volumes:
      - ./work/home/josh:/home/josh
    environment:
      - INIT_USER=${DEV_USER}
      - INIT_UID=${DEV_UID}
      - INIT_GROUP=${DEV_GROUP}
      - INIT_GID=${DEV_GID}
      - GRANT_SUDO=yes
      - PRESERVE_ROOT=yes
    command: ["init.sh", "/usr/sbin/sshd", "-D", "-d"]

以下はスタートアップスクリプトである init.sh から実行されるスクリプトである。init.shset -e オプションが付与されているので、スクリプト中でエラーが発生するとそこで異常終了することに注意しつつスクリプトを書く必要がある。

chpasswd.sh はユーザーのパスワードを変更する。GRANT_SUDO=yes の指定により、作成されたユーザーについてはコンテナ内で sudo をパスワードなしで実行できるので、極論、そのユーザーのパスワードは誰も知る必要がない。したがってパスワードはコンテナ起動時に毎回ランダムで生成する。

second-hook.d/chpasswd.sh
#!/bin/bash

echo "Random password set: ${INIT_USER}"
PASSWORD=$(openssl rand -base64 12)
echo "${INIT_USER}:${PASSWORD}" | chpasswd

ssh_setting.sh/etc/ssh/sshd_config の設定を簡易化するスクリプトである。単純に sed で置換するよりはロバストである。設定はお好みで。

second-hook.d/ssh_setting.sh
#!/bin/bash

# SSH の設定
SSH_CONFIG="/etc/ssh/sshd_config"
SSH_CONFIG_BACKUP="/etc/ssh/sshd_config.bk"

SSH_PORT_NUMBER="22"

function change_setting () {
  TARGET=$1
  KEYWORD=$2
  VALUE=$3

  EXIST=$(grep "^${KEYWORD}" ${TARGET} || true)
  EXIST_COMMENT=$(grep "^#${KEYWORD}" ${TARGET} || true)

  if [ "${EXIST}" != "" ]; then
    sed -i '/^'${KEYWORD}'/c '${KEYWORD}' '${VALUE}'' ${TARGET}
  elif [ "${EXIST_COMMENT}" != "" ]; then
    sed -i '/^#'${KEYWORD}'/c '${KEYWORD}' '${VALUE}'' ${TARGET} 
  else
    echo -e "${KEYWORD} ${VALUE}" >> ${TARGET}
  fi
}

if [ -f ${SSH_CONFIG_BACKUP} ]; then
  echo "SSH setting is already done."
else
  echo "Modify ${SSH_CONFIG}"

  cp -i ${SSH_CONFIG} ${SSH_CONFIG_BACKUP}
  
  # Port
  change_setting ${SSH_CONFIG} Port ${SSH_PORT_NUMBER}
  grep "^Port" ${SSH_CONFIG}

  # PermitRootLogin
  change_setting ${SSH_CONFIG} PermitRootLogin no
  grep "^PermitRootLogin" ${SSH_CONFIG}

  # PasswordAuthentication
  change_setting ${SSH_CONFIG} PasswordAuthentication no
  grep "^PasswordAuthentication" ${SSH_CONFIG}

  # UsePAM
  change_setting ${SSH_CONFIG} UsePAM no
  grep "^UsePAM" ${SSH_CONFIG}

  # PubkeyAuthentication
  change_setting ${SSH_CONFIG} PubkeyAuthentication yes
  grep "^PubkeyAuthentication" ${SSH_CONFIG}

  # ChallengeResponseAuthentication
  change_setting ${SSH_CONFIG} ChallengeResponseAuthentication no
  grep "^ChallengeResponseAuthentication" ${SSH_CONFIG}

  # PermitEmptyPasswords
  change_setting ${SSH_CONFIG} PermitEmptyPasswords no
  grep "^PermitEmptyPasswords" ${SSH_CONFIG}

  # SyslogFacility
  change_setting ${SSH_CONFIG} SyslogFacility AUTHPRIV
  grep "^SyslogFacility" ${SSH_CONFIG}

  # LogLevel
  change_setting ${SSH_CONFIG} LogLevel VERBOSE
  grep "^LogLevel" ${SSH_CONFIG}

  # TCP Port Forwarding
  #change_setting ${SSH_CONFIG} AllowTcpForwarding no
  #grep "^AllowTcpForwarding" ${SSH_CONFIG}

  # AllowStreamLocalForwarding
  #change_setting ${SSH_CONFIG} AllowStreamLocalForwarding no
  #grep "^AllowStreamLocalForwarding" ${SSH_CONFIG}

  # GatewayPorts
  #change_setting ${SSH_CONFIG} GatewayPorts no
  #grep "^GatewayPorts" ${SSH_CONFIG}

  # PermitTunnel
  #change_setting ${SSH_CONFIG} PermitTunnel no
  #grep "^PermitTunnel" ${SSH_CONFIG}
fi

以上の設定が終わったら、イメージをリビルドしてコンテナを立ち上げる。

イメージをリビルドしてコンテナを立ち上げる
sudo docker compose build
sudo docker compose up
クライアント側からアクセスする
# IPアドレスやユーザー名は適宜調整すること
# -v はデバッグオプションなのでアクセスに成功したら外してもよい
ssh -v -p 2222 -i ~/.ssh/id_ed25519 josh@192.168.0.21

試しに起動したコンテナに対して VSCode から SSH アクセスしたら普通に使えました。これだよ。求めていたのは。

おしまい

Docker で開発環境作るときはいつもユーザーの権限調整とか面倒だったけど、かなり手順を簡易化できたと思う。開発が捗るね!

脚注
  1. この開発環境で作業するにはこのパソコンの VSCode から起動して、あれっ、なんか接続用のプラグインが無限ループしてない?ちょっと原因はなんですか、会社のプロキシに引っかかったかな?そもそもどうやってエラーを確認すればあ゛ぁ゛あ゛ぁ゛ぁ゛ぁ゛ぁ゛ぁ゛あ゛ぁ゛ぁ゛ぁ゛ぁ゛あ゛あ゛あ゛ぁ゛ぁ゛!! 開発が!!進まねぇだろうが!!!となったときの保険である。 ↩︎

  2. 明示的に指定する場合は --env-file オプションを用いる。複数ユーザーを切り替えたい場合も環境変数ファイルを複数作成して切り替えるだけでよい。同じサーバーで複数のコンテナを起動する場合は、docker compose -p [project_name] --env-file [env_file] up のようにプロジェクト名も明示的に指定することで docker compose による制御の衝突を避けることができる。 ↩︎

Discussion