😃

M1 MacにおけるDocker Desktopを使わないMultipassを使ったKubernetes環境の構築

2022/01/31に公開
2

Docker Desktopが2022年2月より有料化されるという情報が出てから、
Docker Desktopの代わりとなるツールを色々試していましたが、ようやく個人的に納得のいく代替手段が見つかったので記事にしてみました。

Docker Desktopを継続利用しない理由

私が今までDocker Desktopを利用していたのは k8s 環境を簡単に構築できるからという理由でした。
しかし、k8sとしては以下のような動きをしています。

  • v1.20からDockerの利用を非推奨にしている
  • v1.24からDockerを完全非対応とする (予定)

k8sが何故Dockerを切り離そうとしているのかは、簡単に言うと今までk8sがDockerに対応するために
dockershim と呼ばれるブリッジを別途整備していたからで、これを k8s としては今後整備しなくなるためです。
何故dockershimを利用しなくなるのかについてはネット上の他の記事でも詳しく解説されているので簡潔に言うと、
k8sとコンテナランタイムが通信するインターフェースであるCRIにcontainerdが直接対応しているためになります。
containerdはDockerも利用しているコンテナランタイムで、
今までDockerを利用している場合は、dockershimを経由してcontainerdと通信をしていたのですが、
dockershimを経由しなければその分パフォーマンスも上がるのと、何よりdockershimのメンテが大変だからというのが理由のようです。

dockershim自体のメンテはMirantis社が引き継ぐようですが、
このような背景をk8s利用者として見ると今後Docker Desktopを継続利用していくのは
いつk8s対応が難しくなるかというリスクと付き合うことになるのと、
Dockerそのものがk8sを利用する上では必要なくなるという点からも、敢えて課金までして継続する理由がないと判断したからになります。

macOSにおけるDocker Desktopの代替候補

そこで、macOSにおけるDocker Desktopの代替候補をいくつか調査してみました。

Minikube

Minikubeはk8s公式で提供されているk8s環境構築ツールです。
Homebrewでもインストールが可能なのと、minikube startするだけでk8s clusterを構築できるツールなのですが、
M1 Macで利用する上ではVMドライバとして事実上Docker以外の選択肢がないというのが課題となりました。

Intel Macで利用する上ではHyperkitやVirtual Boxなど、Docker以外のドライバも利用可能なのですが、
M1 MacではこれらのドライバがM1対応していないために利用することができません。
一応Parallelsを利用すれば対応は可能なのですが、Parallelsは有料なので利用者全員に課金させるのは課題があります。
そのため、Minikubeは乗り換え候補から外しました。

Rancher Desktop

Rancher社が開発しているk8s環境を構築できるツールです。
macOSではVMとしてlimaを採用しており、
使用感としてはほぼDocker Desktopと変わらないです。
そのため、これで動けば間違いなく乗り換え候補となったのですが、以下の点が課題となりました。

  • hostPathを使ったvolume対応が不完全
  • kubectlが定期的にタイムアウトする

私の場合、ローカル環境ではk8s上でDBを構築するため、データ永続化のためにhostPathを使ってデータを保存していたのですが、
Rancher DesktopではVMとして利用しているlimaの制限で、macOSホストへのデータ永続化が上手くできません。
これはlimaがsshfsを使ってmacOSホストをマウントしているのが原因のようで、MySQLやPostgreSQLのデータディレクトリをそのマウント先のボリュームにしているとデータディレクトリの初期化に失敗します。

kubectlが定期的にタイムアウトするのはVMが確保しているCPUやメモリにもよるのだと思いますが、
デフォルト設定のCPU:2とメモリ:4GBでもタイムアウトすることがありました。
これももしかしたらlimaの問題かもしれませんが、Docker Desktopでは起こったことがない問題だったので気になりました。

後者は細かい問題ではありましたが、結局のところデータ永続化については解決することが難しかったので乗り換え候補からは外しました。

colima

こちらもk8s環境を簡単に構築できるツールなのですが、名前からもわかるようにlimaを採用しています。
つまり、Rancher Desktopで起こり得るデータ永続化問題がcolimaでも起こります。
そのため、こちらも乗り換え候補からは外しました。

Multipass

MultipassはmacOS上でVM環境を構築することができるツールです。
multipass launchというコマンドを実行するだけで簡単にUbuntu環境を構築できます。
VM上でk8s環境を構築するのは若干面倒ですが、VM上で構築することでlimaで起こっていたデータ永続化問題も解決ができるのと、
ポートフォワーディングを利用することであたかもmacOS上で動作しているように見せることも可能です。

構築するためにはいくつかの手順をスクリプト化する必要はありますが、
各種問題を解決できる一番無難な乗り換え候補であったのと、VM上で動かすという点からもOS間の互換性に悩まされる可能性も今後少なくなるだろうと考え、今回はMultipassを採用することにしました。

Docker Desktopで利用していた各種機能をMultipassで置き換える

乗り換え候補が決まったところで、MultipassのVM上に必要なミドルウェアについて整理していきます。
Docker Desktopを利用していたときに主に利用していた機能は以下の3つです。

  • k8s cluster
  • Docker CLI

k8s clusterについては今回のテーマなのでいわずもがなですが、
Docker CLIもイメージのビルドや動作検証のために利用していました。
特にイメージのビルドは重要です。
M1 Mac環境を利用するようになってからは、amd64だけでなくarm64のイメージのビルドも必要になるため、
マルチプラットフォームなイメージを作成できる BuildKit (docker buildx) をよく利用していました。
というわけで、これらと同等の機能を実現するためにMultipass上にインストールするミドルウェアについて紹介します。

k3sのインストール

k8s clusterについては k3s を利用して構築します。
インストールについては、VM上で以下のコマンドを実行することでインストールできます。

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --no-deploy traefik" sh -

これだけでk8sに必要な環境が構築できてしまいました。
INSTALL_K3S_EXEC に指定してある指定は何かというと、デフォルトでは traefik がk8s clusterにインストールされるのですが、
こいつが 80 と 443 ポートを占有してしまうため、インストールをしないようにしています。

podmanとbuildahのインストール

イメージのビルドと検証のためだけにDocker Engineをインストールするのは過剰に思えたので乗り換え候補を探したところ、
Red Hat社が開発している podman と buildah が良さそうだったのでこれらを利用することにしました。

Dockerのようにコンテナを動かすツールはいくつかあり、containerd上でDocker CLI互換な機能を提供するnerdctlもその1つではありましたが、
nerdctlは動作させるためには containerd をVM上で動かす必要があります。
もっともそれ自体は apt-get でインストールが可能なので簡単なのですが、加えてマルチプラットフォームイメージのビルドのために
BuildKit (buildkitd) を予め起動しておく必要があります。
一方、podmanやbuildahを動かすには予め何かしらの何かしらのデーモンを起動する必要がないため、
リソース的にも優しそうなのと、インストール手順としても簡潔に済むと考えこちらを採用しました。

Multipassのセットアップ手順

では実際にこれらの設定やインストールを含めた、Multipassのセットアップ手順について紹介します。

1. Multipassのインストール

MultipassはHomebrewでインストールすることができます。

brew install --cask multipass

2. MultipassのVM作成と起動

VMは以下のコマンドで作成と起動(初回起動)が可能です。

multipass launch -n example-k8s -c 2 -m 4G -d 50G --cloud-init examples/cloud-init.yaml

各オプションは -c がCPU数、-m がメモリ、-d がストレージ容量です。
CPUとメモリは最低でも上記の値以上を指定するのがおすすめです。(ストレージはお好みで増減してみてください)
未指定だとデフォルトでCPUが1のメモリも1GBしか確保されず、 k8s の kubectl が定期的にタイムアウトしてしまいます。

最後のオプションにある --cloud-init の指定は、cloud-initを利用してVMの初期設定を行っています。
今回は k3s のインストールと podman、buildahのインストールをこれで行っています。
参考までに、cloud-init.yamlの内容を紹介します。

---
repo_update: true
repo_upgrade: true
timezone: Asia/Tokyo
locale: ja_JP.utf8

packages:
  - gcc
  - make
  - cmake
  - uidmap
  - unzip
  - qemu-user-static

runcmd:

  # k3sのインストール
  - curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --no-deploy traefik" sh -

  # podmanとbuildahのインストール
  - . /etc/os-release
  - echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
  - curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key" | sudo apt-key add -
  - apt-get update
  - apt-get -y install podman buildah

ちなみに、初回起動以降は、multipass start (VM名) だけで起動することが可能になります。

3. kubeconfigの設定

kubectl はDocker Desktopを利用していたときは当然macOS上で実行していたかと思いますので、同じようにmacOS上で kubectl を実行できるように、kubeconfigの設定を行う必要があります。
kubeconfigとは $HOME/.kube/config に作成されている k8s のクラスタ、コンテキスト、ユーザー情報がまとまった設定ファイルです。
kubectlはこの情報を元にどのサーバーに対して通信を行うかを決定しています。
そこで、VM上の k8s 環境へ通信できるように以下のスクリプトを実行して kubeconfig を更新します。

※実行時には kubectl, kubectx, jq が必要なのでHomebrewで予めインストールしておいてください。

スクリプト実行に必要なツールのインストール

brew install kubectl kubectx jq

kubeconfig更新スクリプト

#!/bin/bash

set -eo pipefail

readonly MULTIPASS_INSTANCE_NAME="example-k8s"
readonly MULTIPASS_CONTEXT_NAME="example.local"
readonly MULTIPASS_INSTANCE_IP=$(multipass info ${MULTIPASS_INSTANCE_NAME} --format=json | jq -r ".info[\"${MULTIPASS_INSTANCE_NAME}\"].ipv4[0]")

# コンテキストが定義されているかどうかの確認
readonly EXISTS_CONTEXT=$(kubectl config get-contexts ${MULTIPASS_CONTEXT_NAME} > /dev/null 2>&1; echo $?)

# コンテキストが定義されていない場合
if [[ ${EXISTS_CONTEXT} -ne 0 ]]; then 

  # k3sのkubeconfigファイルを取得する
  multipass exec ${MULTIPASS_INSTANCE_NAME} \
    -- sudo cat /etc/rancher/k3s/k3s.yaml \
    | sed -E "s;127.0.0.1;${MULTIPASS_INSTANCE_IP};g;s;: default;: ${MULTIPASS_CONTEXT_NAME};g" \
    > ${K3S_CONFIG_PATH}

  # kubeconfigを既存ファイルとマージする
  mkdir -p $HOME/.kube
  [[ -e $HOME/.kube/config ]] && cp -f $HOME/.kube/config $HOME/.kube/config.bak
  KUBECONFIG=$HOME/.kube/config.bak:${K3S_CONFIG_PATH} kubectl config view --flatten > $HOME/.kube/config
  chmod 0600 $HOME/.kube/config
  rm -f ${K3S_CONFIG_PATH}

fi

# コンテキストを切り替える
kubectx ${MULTIPASS_CONTEXT_NAME}

上記スクリプトを実行しておくことで、macOSから kubectl を実行できるようになります。

4. Usersディレクトリのマウントを行う

これで k8s を利用する上で必要な設定自体は整いました。
次にmacOSローカルにあるソースコードやk8sのマニフェストファイルへアクセスできるようにする必要があります。
これについては、以下のようなスクリプトを作ってマウントします。

Usersディレクトリのマウント用スクリプト

#!/bin/bash

set -eo pipefail

readonly MULTIPASS_INSTANCE_NAME="example-k8s"

# macOSホストの/Usersディレクトリを再マウントする
multipass umount ${MULTIPASS_INSTANCE_NAME} || true
multipass mount /Users ${MULTIPASS_INSTANCE_NAME}:/Users

/Users をマウントすることで、macOSと同じディレクトリ構成でアクセスが可能になります。(当然、各種ソースコードやマニフェストファイルが/Users配下に配置されている必要があります)
また、何故わざわざスクリプト化をするのかといえば、たまにスタンバイから復帰した時に
マウント先のディレクトリの中のファイルが一部アクセスできなくなるときがあるからです。
そういうときに、このスクリプトを実行して再マウントを行うと元に戻るため、手順を簡略化するためにスクリプト化をしておきます。

以上で、k8s環境の構築自体は終わりです。

5. podmanとbuildahを使えるようにする

次に、イメージのビルドや検証に利用する podman と buildah を使えるようにするためのセットアップを行います。
コマンド自体はどちらも利用可能になっているものの、buildah によるマルチプラットフォーム対応のイメージのビルドには少し注意点があるので解説します。

以下に、ECRへイメージをPushするための各スクリプトを紹介します。

podman

#!/bin/bash

set -eo pipefail

if [[ $(uname -s) != 'Darwin' ]]; then

  echo 'This script supports macOS only.'
  exit 1

fi

readonly MULTIPASS_INSTANCE_NAME="example-k8s"

# VM上の podman を実行する
multipass exec ${MULTIPASS_INSTANCE_NAME} -- podman "$@"

このスクリプトは podman をmacOSから呼び出すためのラッパースクリプトです。
プロジェクトのbinディレクトリに配置し、そのbinディレクトリに direnv 等でPATHを通しておきます。
こうすることで、macOSホスト側のプロジェクトのディレクトリにいるときのみ、対象VM内にある podman コマンドが使えるようになります。

login.sh

#!/bin/bash

set -eo pipefail

if [[ $(uname -s) != 'Darwin' ]]; then

  echo 'This script supports macOS only.'
  exit 1

fi

# ECRへログインんする
# ※AWS_ACCOUNT_IDとAWS_DEFAULT_REGIONは環境変数として設定してください
aws ecr get-login-password | podman login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com

このスクリプトはECRへ podman を使ってログインするためのスクリプトです。
macOS上で実行している理由は、VM上にはAWS CLIとcredentialsファイルをインストールしていないからです。
もちろん、VM上にAWS CLIをインストールして ~/.aws をVM上へマウントするといったやり方も全然ありです。
私のケースではmacOS上にcredentails情報がある方が都合が良かったので、このような構成にしています。

ちなみに細かい話ですが、docker loginと違ってログイン先URLのプロトコルの記述は不要です。
記述するとエラーになってしまいますのでその点は注意しましょう。

buildah.sh

#!/bin/bash

set -eo pipefail

if [[ $(uname -s) != 'Darwin' ]]; then

  echo 'This script supports macOS only.'
  exit 1

fi

readonly PROJECT_ROOT=$(cd ${BASH_SOURCE[0]%/*} && pwd)
readonly MULTIPASS_INSTANCE_NAME="example-k8s"
readonly DOCKER_TAG="$1"
readonly DOCKERFILE_DIR="$2"
readonly TARGET_ARCHS=(amd64 arm64)

# ECRへのログイン
${PROJECT_ROOT}/scripts/login.sh

# イメージのビルド
multipass exec ${MULTIPASS_INSTANCE_NAME} -- bash -l <<EOF
  set -e

  cd ${PROJECT_ROOT}/${DOCKERFILE_DIR}

  buildah manifest rm ${DOCKER_TAG} || true
  buildah manifest create ${DOCKER_TAG}

  for TARGET_ARCH in ${TARGET_ARCHS[@]}
  do
    envsubst "$(env | awk '!/^(HOME|PATH)/' | awk -F= '{print "$$" $1}')" < Dockerfile | buildah bud --layers=true --platform linux/\${TARGET_ARCH} --build-arg TARGETPLATFORM=linux/\${TARGET_ARCH} -t ${DOCKER_TAG}-\${TARGET_ARCH} -f - .
    buildah push ${DOCKER_TAG}-\${TARGET_ARCH} docker://${DOCKER_TAG}-\${TARGET_ARCH}
    buildah manifest add --os=linux --arch=\${TARGET_ARCH} ${DOCKER_TAG} ${DOCKER_TAG}-\${TARGET_ARCH}
  done

  buildah manifest push --all ${DOCKER_TAG} docker://${DOCKER_TAG}
EOF

こちらが buildah でビルドするためのスクリプトになります。
使い方としてはこんな感じです。

./scripts/buildah.sh (イメージのリポジトリパス):(タグ) (Dockerfileがあるディレクトリへのパス)

これで指定したDockerfileのイメージの amd64, arm64のイメージを作成します。
ポイントとなるのは、

sudo podman run --privileged --rm docker.io/tonistiigi/binfmt --install all > /dev/null 2>&1

ここで binfmt をインストールしないと、マルチプラットフォームなイメージのビルドができません。
binfmtはQEMUを介して他プラットフォームの実行をエミュレートするものなのですが、
毎回ビルド時にインストールしているのは、Multipassを再起動した時に再インストールが必要になるためです。
実行自体は一瞬で終わるため、漏れがないように毎回実行するようにしています。

この方法だと上手くいかないケースがあったため、VM作成時に qemu-user-static をインストールする方法に変更しています。

次に、マニフェストの作成を行います。

buildah manifest rm ${DOCKER_TAG} || true
buildah manifest create ${DOCKER_TAG}

docker buildxを使ってマルチプラットフォームイメージを作成していた人から見るとこれはなんだと言う感じだと思うのですが、
buildahでは manifest (マニフェスト) という単位でイメージをまとめ、それを最後にPushすることでイメージのマルチプラットフォーム対応が可能になります。
そのため、これを自動的にやってくれるdocker buildxに比べると少々コマンドも増えて面倒なのが欠点ではあります。
ちなみに、最初にrmを実行しているのは既に同じ名前のマニフェストがあれば削除するためにやっています。

次に、イメージのビルドとマニフェストへの追加です。

for TARGET_ARCH in ${TARGET_ARCHS[@]}
do
  envsubst "$(env | awk '!/^(HOME|PATH)/' | awk -F= '{print "$$" $1}')" < Dockerfile | buildah bud --layers=true --platform linux/\${TARGET_ARCH} -t ${DOCKER_TAG}-\${TARGET_ARCH} -f - .
  buildah push ${DOCKER_TAG}-\${TARGET_ARCH} docker://${DOCKER_TAG}-\${TARGET_ARCH}
  buildah manifest add --os=linux --arch=\${TARGET_ARCH} ${DOCKER_TAG} ${DOCKER_TAG}-\${TARGET_ARCH}
done

上記スクリプトでは、プラットフォームごとに同じイメージのビルドを走らせるので、ループさせています。
マニフェストに紐づける際は、ビルドしたプラットフォームのイメージごとにECRへ予めプッシュしておき、そのPushされたイメージのリポジトリとタグ名、OSやArchを指定した上でマニフェストへ紐づける必要があるため、それをこのスクリプトで行っています。

最後に、マニフェストのpush処理です。

buildah manifest push --all ${DOCKER_TAG} docker://${DOCKER_TAG}

これにより、マルチプラットフォームイメージの作成とECRへのPushが出来ました。
これらもスクリプト化することで、簡単に実行できるようにしておきます。

6. ポートフォワーディングを行う

最後に、VM内のk8s clusterで起動したServiceへ、macOSホスト上(127.0.0.1)からアクセス可能にするためにポートフォワーディングを行います。
具体的には、macOSホストから127.0.0.1でアクセス可能にしたいServiceは、type: LoadBalancer として定義しておきます。
その上で、以下のようなスクリプトをmacOS上で実行することでポートフォワーディングを行います。

#!/bin/bash

set -eo pipefail

if [[ $(uname -s) != 'Darwin' ]]; then

  exit 0

fi

readonly COMMAND="$1"
readonly PORT_FORWARDING_PID_NAME="example.local.pid"
readonly MULTIPASS_INSTANCE_NAME="example-k8s"
readonly TARGET_NAMESPACE="example-local"
readonly PID_FILE="${BASH_SOURCE[0]%/*}/work/${PORT_FORWARDING_PID_NAME}"

# -------------------------------
# ポートフォワーディングの開始
# -------------------------------
start_forwarding() {

  # External IPが決定するまで待つ
  while [[ $(kubectl get svc -n ${TARGET_NAMESPACE} | grep --color=none LoadBalancer | grep "<pending>" | wc -l) -ne 0 ]]; do : ; done;

  local TARGET_PORT_DEFINES=$(kubectl get svc -n ${TARGET_NAMESPACE} | grep --color=none LoadBalancer | awk '{ print $5 }')
  local SSH_FORWARDING_OPTIONS=""

  for TARGET_PORT_DEFINE in ${TARGET_PORT_DEFINES[@]}
  do
    TARGET_PORTS=($(echo -n ${TARGET_PORT_DEFINE} | tr ',' ' '))

    for TARGET_PORT in ${TARGET_PORTS[@]}
    do
      PORT_NUMBER=$(echo -n ${TARGET_PORT} | awk -F: '{ printf $1 }')
      SSH_FORWARDING_OPTIONS="${SSH_FORWARDING_OPTIONS} -L ${PORT_NUMBER}:127.0.0.1:${PORT_NUMBER}"
    done
  done

  if [[ -z ${SSH_FORWARDING_OPTIONS} ]]; then
    echo "No target port numbers"
    return 0
  fi

  # この SUDO_PASSWORD という変数は direnv で読み込むようにしておく
  echo "${SUDO_PASSWORD}" | sudo -S -i ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "/var/root/Library/Application Support/multipassd/ssh-keys/id_rsa" \
  -N ${SSH_FORWARDING_OPTIONS} \
  ubuntu@$(multipass info ${MULTIPASS_INSTANCE_NAME} --format=json | jq -r ".info[\"${MULTIPASS_INSTANCE_NAME}\"].ipv4[0]") > /dev/null 2>&1 &
  echo $! > ${PID_FILE}

}

# -------------------------------
# ポートフォワーディングの停止
# -------------------------------
stop_forwarding() {

  if [[ ! -e ${PID_FILE} ]]; then

    return 0

  fi

  local PID=$(cat ${PID_FILE})
  echo "${SUDO_PASSWORD}" | sudo -S pkill -P ${PID} || true
  rm -f ${PID_FILE}

}

case "${COMMAND}" in
start)
  start_forwarding
  ;;
stop)
  stop_forwarding
  ;;
*)
  echo "Please specify command."
  exit 1
esac

使い方としてはこんな感じです。

# ポートフォワーディング開始
./scripts/port_forwarding.sh start

# ポートフォワーディング停止
./scripts/port_forwarding.sh stop

何をやっているのかというと、kubectl get svc でサービスの情報を取得し、その内容からLoadBalancerのtypeのサービスに指定されたポート番号をポートフォワーディングしています。
また前提として、このスクリプトはサービスで公開しているポート番号と、macOS側でListenしたいポート番号が同じポート番号である必要があります。
もし異なるポート番号を対応づけたい場合は、その処理も記述する必要があります。

まとめ

以上で、M1 MacにてMultipassを使ったDocker Desktopを使わないk8s環境およびイメージのビルドに必要な設定を紹介しました。
こうしてみると、Docker Desktopは大分面倒な手順を省略していてくれたので、大変便利なものだったんだな…というのを改めて実感することも出来ました。
Dockerとk8sの関係性が今後どうなるかはわかりませんが、課金可能な人は引き続き課金しても良いと思いますし、
私みたいにちょっと気になると考える人は、今回紹介した手順を参考にDocker Desktopを使わない環境構築をしてみることをお勧めいたします。

Discussion

hirogahiroga

初めまして。大変参考になりました!
もう一度読もうと思って検索したときになかなか見つけられず...自分の記事でもないのにこんな事を言って申し訳ないのですが、タイトルに (Multipass)などと入っているとより多くの方に見てもらえるかもしれないです。

kkoudevkkoudev

アドバイスありがとうございます!
確かにMultipassの内容が多いのにタグすらもないのは変でしたのでタイトル含めて更新させていただきました!