mount propagation

特定のマウント処理を他のディレクトリやnamespaceで認識できるようにするかどうかを、制御する仕組みとしてshared subtreeが存在する。

例としてプロセスのカレントディレクトリや、chrootで変更した配下に対して、外部ディレクトリのマウント処理を反映したい場合などが挙げられる。 また特定のマウントポイント配下に更にマウントポイントを追加した場合に、マウント元にディレクトリには反映されるかどうか などがある。

これらをshared subtreeのpropagationという仕組みによって、mountに関するインベントの伝搬を制御することが可能となる。

Peer group

propagationではpeer groupという概念が存在しpeer group間でマウントイベントが伝搬される。 peer groupは複数のmount pointの集合であり、以下を契機にメンバが追加される。

  • 新しいnamespaceの作成
    • これによってマウントポイントが新しいnamepsaceに複製されるので、それぞれのマウントポイントは同一のpeer groupに所属する
  • bind mountのソースとして利用されたとき

メンバが削除されるトリガーは以下のものがある - unmountの実行 - namespaceの削除 - namespace内のプロセスがすべて終了した - namespace内のプロセスすべて該当namespace外に移動した

4つのpropagation type

マウントポイントはpropagation typeという属性を持っている。 これはマウントイベントをpeer group内に伝搬するかどうかを制御するために設定され4つ種類がある。

  • shared
  • private
  • slave
  • unvindable

そしてデフォルトでは、親のマウントポイントのpropagation typeを継承する。

それぞれをマウントポイントに設定した場合にどのような挙動になるのかを確認する。

shared

同じピアグループ内でマウントイベントは伝搬され共有される。 マウントポイント配下にマウントポイントが追加削除された際は、同じpeer group内のマウントポイント配下でも反映される。

オプションとしてmountコマンドに--makes-sharedを付加する。

下記の例ではまず、/A/Bにbind マウントした。

$ mkdir /A /B
$ touch /A/test

$ tree /A /B C
/A
└── test
/B

$ mount --bind --make-shared /A /B

$ tree /A /B
/A
└── test
/B
└── test

各マウントポイントの情報は/proc/{PID}/mountinfoで参照できる. 下記の例ではshared:1となっておりsharedはpropagation typeがsharedで、peer groupが1であることを表している。この数字が同じであれば同じpeer groupに所属する。

# /proc/self/mountinfo 実行結果を抜粋
85 40 8:1 /A /B rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

/Cを作成し、/B/mntにマウントする。すると/A配下でもmnt/test2を参照することがき、マウント情報が伝搬され反映されていることが確認できる。

$ mkdir /C /B/mnt
$ touch /C/test2

$ tree /A /B /C
/A
└── test
/B
├── mnt
└── test
/C
└── test2

$ mount --bind --make-shared /C /B/mnt/

$ tree /A /B /C
/A
├── mnt
│   └── test2
└── test
/B
├── mnt
│   └── test2
└── test
/C
└── test2

マウント情報にはコマンド実行した/Aをマウントポイントとした情報以外に、伝搬された情報である/A/mntも記載されており、いずれのpeer group番号は1となっている。

# /proc/self/mountinfo 実行結果を抜粋
85 40 8:1 /A /B rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
91 85 8:1 /C /B/mnt rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
92 40 8:1 /C /A/mnt rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

private

aharedとは対象的にピアグループ内にイベントは共有されない。

/A/Bにbind mountして作業前の初期状態とする。 マウンポイントをprivateとするためには、オプションとして--make-privateをmountコマンドに使用する。

$ tree /A /B /C
/A
└── test
/B
/C
└── test2

$ mount --bind --make-private /A /B
tree /A /B /C
/A
└── test
/B
└── test
/C
└── test2

マウント情報を確認するとprivateではpeer groupに関する情報は記載されていないことがわかる。

# /proc/self/mountinfo 実行結果を抜粋
85 40 8:1 /A /B rw,relatime - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

/C/B/mnt/にマウントしても、/A/mnt/test2は参照できず/Aには反映されていない。

$ mount --bind /C /B/mnt/

$ tree /A /B /C
/A
├── mnt
└── test
/B
├── mnt
│   └── test2
└── test
/C
└── test2

マウント情報は実行したコマンドと同様の2種類のエントリが存在する。

# /proc/self/mountinfo 実行結果を抜粋
85 40 8:1 /A /B rw,relatime - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
87 85 8:1 /C /B/mnt rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

逆に/A/mnt/Cをマウントしてみるとこれも同じ結果となり反映されない

$ tree /A /B /C
/A
├── mnt
└── test
/B
/C
└── test2

$ mount --bind --make-private /A /B
$ tree /A /B /C
/A
├── mnt
└── test
/B
├── mnt
└── test
/C
└── test2
$ mount --bind /C /A/mnt/
$ tree /A /B /C
/A
├── mnt
│   └── test2
└── test
/B
├── mnt
└── test
/C
└── test2
85 40 8:1 /A /B rw,relatime - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
87 40 8:1 /C /A/mnt rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

slave

イベントの伝播は一方通行となる。同じpeer group内でマスタとなるマウントポイントが変更された場合に、スレーブ側のマウントポイントも変更される。 しかしその逆は発生しない。

マスタ側のディスクドライブマウントなどをスレーブ側で利用したい場合で、かつ逆にスレーブ側の変更で影響を与えたくない場合に利用する。

bindコマンドに--make-slaveオプションを利用して/A/Bにマウントする。

$ tree /A /B /C
/A
└── test
/B
/C
└── test2


$ mount --bind --make-slave /A /B
tree /A /B /C
/A
└── test
/B
└── test
/C
└── test2

マウント情報にはmaster:1

# /proc/self/mountinfo 実行結果を抜粋
85 40 8:1 /A /B rw,relatime master:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

/C/B/mntにsharedマウントすると、/A配下には反映されていない。

$ mkdir /B/mnt
$ mount --bind /C /B/mnt

$ tree /A /B /C
/A
├── mnt
└── test
/B
├── mnt
│   └── test2
└── test
/C
└── test2
85 40 8:1 /A /B rw,relatime master:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
87 85 8:1 /C /B/mnt rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

一旦/B/mntをunmountして元の状態に戻す。

$ umount /B/mnt/
$ tree /A /B /C
/A
├── mnt
└── test
/B
├── mnt
└── test
/C
└── test2

そして今度は逆に/A配下にマウントする。 この場合は/Aがmasterとなっているので、マウント情報は/Bに伝搬され/B/mnt/test2の存在が確認できる。

$ mount --bind /C /A/mnt/
$ tree /A /B /C
/A
├── mnt
│   └── test2
└── test
/B
├── mnt
│   └── test2
└── test
/C
└── test2

unvindable

privateと同様にマウントイベントの伝播はしないが、その挙動に加えてbind mountのソースとして利用することができなくなるという制約が追加される。

mountコマンドの実行時には--make-unbindableを付与する。ここでは/A/Bにマウントする。

$ tree /A /B /C
/A
└── test
/B
/C
└── test2

$ mount --bind --make-unbindable /A /B

$ tree /A /B /C
/A
└── test
/B
└── test
/C
└── test2

マウント情報にはunbindableであることが記されている。

85 40 8:1 /A /B rw,relatime unbindable - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

当然privateと同様の挙動のため、/C/B/mntにマウントしても/A/mntでは参照することはできない。

$ mkdir /B/mnt

$ tree /A /B /C
/A
├── mnt
└── test
/B
├── mnt
└── test
/C
└── test2

さらに/B/C/mntをマウントしようとしてもbind mountのソースとして利用できないため失敗する。

$ mkdir /C/mnt
$ tree /A /B /C
/A
├── mnt
└── test
/B
├── mnt
└── test
/C
├── mnt
└── test2

$ mount --bind /B /C/mnt/
$ mount: wrong fs type, bad option, bad superblock on /B,
       missing codepage or helper program, or other error

       In some cases useful info is found in syslog - try
       dmesg | tail or so.

unvindableは再起にマウントした際にマウントポイントが指数関数的に増加するのを防ぐ目的で利用されるようだ。

/A配下に2つのディレクトリを用意した。まずは/Aを配下のdir1にマウントする。

$ tree /A
/A
├── dir1
└── dir2

$ mount --rbind /A /A/dir1
/A
├── dir1
│   ├── dir1
│   └── dir2
└── dir2

そして次は/A/A/dir2にマウントする

$ mount --rbind /A /A/dir2
$ tree /A
/A
├── dir1
│   ├── dir1
│   └── dir2
│       ├── dir1
│       │   ├── dir1
│       │   └── dir2
│       └── dir2
└── dir2
    ├── dir1
    │   ├── dir1
    │   └── dir2
    └── dir2

マウントコマンドを2回実行したがこのときのmountinfoはこのようになっている。 手動実行分以外に再帰的なマウントポイントが増えていることがわかる。同時にこれを繰り返せばマウントポイントの量が指数関数的に増加するのも納得がいく。

そのために途中のマウントポイントをバインドマウント不可とすることで、爆発的な増加を防止する仕組みがunbindableというわけだ。

85 40 8:1 /A /A/dir1 rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
87 40 8:1 /A /A/dir2 rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
88 87 8:1 /A /A/dir2/dir1 rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
89 85 8:1 /A /A/dir1/dir2 rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota
90 89 8:1 /A /A/dir1/dir2/dir1 rw,relatime shared:1 - xfs /dev/sda1 rw,seclabel,attr2,inode64,noquota

参照

【書籍】 Programming Kubernetes

を読んだので内容の中で、新たな知見となった部分をメモ程度にまとめておく。

www.oreilly.com

この本の概要・対象読者

以下のような人たちは読んでおくと良いです。 - KubernetesAPIを利用したアプリケーション開発をしようとしている人 - Kubernetesのコントローラなどの開発をしようとするひと

多少内容が古く感じる部分はありますが、そういった部分は読み飛ばしてしまってよいと思います。

各章の概要

Chapter 3. Bacis of client-go

Kubernetesのクライアントの処理ををコードレベルから詳しく説明している。

中心となっているのはクライアント操作を紐解く中で重要になる以下の2つのリポジトリであった。 - https://github.com/kubernetes/client-go - https://github.com/kubernetes/api - https://github.com/kubernetes/apimachinery

Kubernetesを扱う上で、理解するべき点が多くの本の中で一番重要な章だと感じた。

とはいえ、アプリケーションを主業務としていてプラットフォームとしてKubernetesを使う人はここまで知る必要はなく、どちらかというとこの本のターゲットであるKubernetesや関連コンポーネントの開発に携わる人が知っておきたい内容といった感じ。

Chapter 4. Using Custom Resourcesa

カスタムリソースとは何かという点から、コントローラやクライアント視点からカスタムリソースがどういった機能を持つかについて書かれていた。 - バリデーション - リソース名の省略形 - 表示カラムの変更 - サブリソース

またA Developer’s View on Custom ResourcesではDynamic Client, Typed Clientの詳細や使い分けについて触れられている。

Chapter 5. Automating Code Generation

golanは単純な言語で型を意識せずに汎用的なアルゴリズムを実装することは難しいためコードジェネレータを使用する。 - https://github.com/kubernetes/code-generator

この章ではcode-generatorの使い方とそのタグが持つ意味を解説している。

Chapter 6. Solutions for Writing Operators

カスタムコントローラを記述するための、code-generatorより更に上位レイヤーの自動化ツールについてサンプルレポジトリを題材に使い方を紹介している。

紹介されているツールは以下の通り - code-generator - Kubebuilder - Operator SDK

ただ2022年現在この本の内容はすでに古く感じた。例えばOperator SDKに関してはすでにKuberbuilderと統合されているし、コマンド体型も変更されている。

実際に使用する際は各ツールのドキュメントを読んだほうが理解が進みそうであった。

Chapter 7. Shipping Controllers and Operators

カスタムリソースに限らずKubernetesリソースのパッケージングやCI/CDについて書かれた章である。

パッケージ周りでは2つのツールが紹介されていた。 - Helm - Kustomize

加えてカスタムコントローラをデプロイする際のベストプラクティスが紹介されているが、てアカウントの権限を最小限にしたり、テストを自動化したりなど パッケージングツールの話を含めて、業務で扱っている人にとっては当たり前の内容が多い印象であった。

Chapter 8. Custom API Servers

Kubernetesでオリジナルのリソースを追加したい場合はCRDとCustom API serverを実装する方法があるが、その後者について解説している。

正直この章の内容が本書の中で一番濃く感じた。Custom API Serverの実装はkube-apiserverと構造として近いものがあり、その理解の手助けとなる章であった。

主に対象リソースへのリクエストがkube-apiserverに到達してから、レスポンスを返すまでにどういった処理が実行されているか詳しく学ぶことができた。

仮想マシンの初期設定をするcloud-init

cloud-initとは

OpenStackやAWSのEC2などIaaSを利用しVM(Virtual Machine)の払い出しをした後、ユーザアカウントの作成やソフトウェアのインストールなどの初期設定を自動化したくなる。

構成管理ツールとしてAnsibleやChefが存在し、ロールベースの複雑なセットアップはこういったツールの担当となるが、構成管理ツールを利用するまでの最低限の初期セットアップはどうしても発生してしまう。 そんなときに利用するのがloud-initという仕組みで、これを利用することクラウドプロバイダーから払い出したVMの初期セットアップを自動化することができる。

clud-initは各クラウドプラットフォームに依存することなくVM起動後に対し指定したユーザの作成やssh鍵の設定、任意のコマンドを実行するための共通インターフェースを提供している。

またcloud-initはUbuntuでおなじみのCanonicalが主体となって開発するオープンソースプロジェクトで特定のディストリビューションに依存することなく利用することができる。

処理の流れ

セットアップに使用する情報の入力

cloud-initは対象VMにインストールするOSイメージにcloud-initパッケージが含まれている必要があるが、各ディストリビューションが公開してるcloud imageには最初からインストーされている。

cloud-initは入力となる設定ファイルを何かしらの方法で受け取りそれをもとにセットアッププロセスを実行する。 設定ファイルの取得元(データストアと表現される)は様々でかくクラウドプロバイダに依存したものから、CDROMイメージを利用する汎用的な仕組みまで存在する。

例えばAWSのEC2を例に上げると、インスタンス起動後にcloud-initのプロセスはhttp://169.254.169.254/${EC2-VERSION}/meta-data/にアクセスすることによって、実行に必要なデータを取得する。このあたりの処理はcloud-initの実装ではdatasourceとして抽象化されているため、メイン処理中では意識する必要はない。

入力となるデータは主に3つに分類される cloud-intでは設定内容はユーザから指定されたデータによって具体的にセットアップ内容を指定することができる。そのデータはメタデータ、ユーザデータ、ベンダデータに区別され、それぞれの内容を統合して最終的な実行内容を判断する。 - メタデータ - クラウドプロバイダーによって提示されるデータでVM名やアーキテクチャ、ディストリビューションなどプロパイダによって差異はあるが様々なインスタンス特有のデータがAPIを経由してアクセスできるようになっている。 - ユーザデータ - 実行してほしい指定したシェルスクリプトや、初期セットアップにに必要な引数などがユーザから渡される。 - ベンダデータ - クラウドプロバイダーから提供されているデータ。クラウドプロバイダーで必要となる特定の設定を挿入することができる。ユーザデータでベンダデータで提供されている項目を上書きすることができる。

セットアップの実行

ドキュメントによるとcloud-initは5つのboot stageから構成されており以下の順で実行される。

  1. Generator:
    • systemdの仕組みでcloud-initのGenerator以降の実行の必要があるかを決定する
    • 以下の場合はcloud-initは実行されない
      • /etc/cloud/cloud-init.disableが存在する場合
      • カーネル引数にcloud-init=disabledが指定されている場合
  2. Local
    • local datastorceを検索してネットワーク設定を適用する。
    • 以降の処理でネットワーク利用の利用が可能な状態にする。
  3. Network
    • ユーザデータを読み込みcloud_init_modulesのモジュールを実行する。
    • syslogやホスト名の設定が実施される。
  4. Config
    • cloud_config_modulesを実行する。
    • システムの起動前に必要なssh関連の設定, ディスク構成のセットアップ, タイムゾーンの設定などをが含まれる。
  5. Final
    • cloud_final_modulesを実行する。
    • システムの起動後に実行するスクリプトやパッケージの追加を実施する。

起動スクリプトの確認

上記までのセットアップの流れが実際にどのように記述されているのかをcloud-initをインストールした筐体で確認する。 まずcloud-init関連のサービスとして以下のものが登録されているようだ。

$ systemctl list-unit-files --type=service  |grep cloud-
cloud-config.service                                        enabled         disabled
cloud-final.service                                         enabled         disabled
cloud-init-hotplugd.service                                 static          -
cloud-init-local.service                                    enabled         disabled
cloud-init.service                                          enabled         disabled

上記のサービスは各boot stageに対応する。例えばcloud-init.servicecloud-init-local.serviceの後に実行され/usr/bin/cloud-init initを実行していることがわかる。

# /usr/lib/systemd/system/cloud-init.service
[Unit]
Description=Initial cloud-init job (metadata service crawler)
DefaultDependencies=no
Wants=cloud-init-local.service
Wants=sshd-keygen.service
Wants=sshd.service
After=cloud-init-local.service
After=systemd-networkd-wait-online.service
After=network.service
After=NetworkManager.service
Before=network-online.target
Before=sshd-keygen.service
Before=sshd.service
Before=systemd-user-sessions.service

[Service]
Type=oneshot
ExecStart=/usr/bin/cloud-init init
RemainAfterExit=yes
TimeoutSec=0

# Output needs to appear in instance console output
StandardOutput=journal+console

[Install]
WantedBy=cloud-init.target

また各ステージでの具体的な実行内容については/etc/cloud/cloud.cfgに定義されている。

# The modules that run in the 'init' stage
cloud_init_modules:
 - migrator
 - seed_random
 - bootcmd
 - write-files
 - growpart
 - resizefs
 - disk_setup
 - mounts
 - set_hostname
 - update_hostname
 - update_etc_hosts
 - ca-certs
 - rsyslog
 - users-groups
 - ssh

# The modules that run in the 'config' stage
cloud_config_modules:
 - ssh-import-id
 - keyboard
 - locale
 - set-passwords
 - spacewalk
 - yum-add-repo
 - ntp
 - timezone
 - disable-ec2-metadata
 - runcmd

# The modules that run in the 'final' stage
cloud_final_modules:
 - package-update-upgrade-install
 - write-files-deferred
 - puppet
 - chef
 - mcollective
 - salt-minion
 - reset_rmc
 - refresh_rmc_and_interface
 - rightscale_userdata
 - scripts-vendor
 - scripts-per-once
 - scripts-per-boot
 - scripts-per-instance
 - scripts-user
 - ssh-authkey-fingerprints
 - keys-to-console
 - install-hotplug
 - phone-home
 - final-message
 - power-state-change

各ステージで実行するモジュールは下記に実装されている。 - https://github.com/canonical/cloud-init/tree/main/cloudinit/config

まとめ

cloud-initは各クラウドプロバイダーを経由しクラウドプロバイダーやユーザからの設定の入力を共通化している。 そして実行タイミングの異なるステージで設定内容を反映した形で、セットアップスクリプトを実行するという動作になっている。

次回は実際にcloud-initを動作させてみる。

cloud-initのNoCloudを使ってお試し環境を簡単に構築する

以前cloud-initについて紹介をした。

blog.ty-tbs.com

cloud-initの仕組みはVMを決まったユーザや鍵を使ってセットアップしたり、指定スクリプトを実行したりとクラウドプロバイダ経由でなくても利用したい機能である。

自宅にOpenStackを構築していれば、DatastoreとしてOpenStackが有力な候補となるかもしれないが、「そんなもの自宅にはない」もしくは「もう少しライトにVMを使ってる」という人がほとんどだろう。

そこでNoCloudというDatastoreを使ってqemu/kvmで構築したVMに対してcloud-initを活用してセットアップする。

DatastoreとしてのNocloud

NoCloudはcloud-initのDatastoreとしては少し特殊で、ネットワークを経由せずに起動VMに対してデータを与える役割を担っている。

ではどうやってデータを渡すかというとVFATのローカルディスク経由もしくは、iso9660で外部から与える事ができる。

VM起動確認

まずcloud-initを確認するためのベースイメージを用意する。各ディストリビューションでAWSなどのクラウドプロバイダ上での動作を想定したcloud imageが配布されているが、これを利用すればcloud-initが最初からインストールされているため簡単に利用することができる。

今回はFedoraのcloud imageを利用することにした。 - https://alt.fedoraproject.org/cloud/

NoCloudを利用するためにはuser-datameta-dataをディスクイメージに含める必要がある。

まずmeta-dataを作成する。 ここで指定されている、intance-idについては初回起動かどうかを識別するためのものとドキュメントに記載がある。

Note: that the instance-id provided (iid-local01 above) is what is used to determine if this is “first boot”. So if you are making updates to user-data you will also have to change that, or start the disk fresh.

local-hostanmeはその名の初期ホスト名として利用される。

$ cat << 'EOF' >  meta-data
instance-id: test-vm
local-hostname: test-vm

次にuser-dataを作成する。

ここにはログイン情報などを記載するがせっかくなので、パスワードに加えてsshの公開鍵認証でログインできるように設定する。

今回は最小限のログイン情報を設定したが、様々な設定が可能なのでこちらのドキュメントを参照してほしい。

cat << 'EOF' > user-data
#cloud-config
users:
  - name: test
    groups: users, wheel
    lock_passwd: False
    plain_text_passwd: test
    ssh_pwauth: True
    ssh_authorized_keys:
      - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCqg4+5ISX6LuYfF+197BhGLaPGhtpsuIdl0YM26g7b/rt80l6KdccVqmDbv27dD6gfGx2l5DFFIGb1rPLIUnJ8NCsV3zQWivSRac/8thidRzHWLb/fLvmyOA2LqYeKv2SUubbtQVnm972w+gHAukALitmWp251WvCwzj7K2woMWNj63qFtplDnm8LLVZkhbC2iAvr23sNAz8LX+wsaX79A18mo6f7whTXn

この2つのファイルをイメージファイルに固めてvm起動時にcdromとして認識するようにする。 イメージの作成にはgenisoimageコマンドを利用したが、同じように作成すれば何を使っても問題ない。このときボリュームラベルにcidataもしくはCIDATAを設定しなれければいけないことに注意する。

genisoimage -output cloud-init-conf.iso -volid cidata -joliet -rock user-data meta-data

起動する前にダウンロードしてきたFedoraのcloud imageをそのまま使ってもよいが、毎回新しいイメージを用意するのは面倒なので、バッキングファイルとして残しておくことにする。

sudo qemu-img create -b Fedora-Cloud-Base-32-1.6.x86_64.qcow2 -f qcow2 test-vm.qcow2 20G

この状態でcdromに先程生成したisoデータを設定して、virt-installする。

virt-install --name test-vm --ram 16384 --vcpus 4 --arch x86_64 --os-type linux --hvm --virt-type kvm --file ./test-vm.qcow2 --cdrom ./cloud-init-conf.iso --boot hd --graphics bash --serial pty --console pty --autostart

これで設定に間違えがなければ、インストール後に設定したパスワードやssh鍵でログインすることができる。

cdromからは先程設定したデータが確認できた。

$ sudo mkdir /mnt/cdrom
$ sudo mount /dev/cdrom /mnt/cdrom/
mount: /mnt/cdrom: WARNING: source write-protected, mounted read-only.
$ ls /mnt/cdrom/
meta-data  user-data

入力したしたデータやスクリプト、cloud-initの実行結果などはVM内の/var/lib/cloud/配下で確認することができる。

おまけ

cloud-initではどのdatastoreを利用するのかds-identifyで判定している。 https://github.com/canonical/cloud-init/blob/main/tools/ds-identify#L835-L859 コードを読んでみるとNoCloudの判定のため引数やファイル、ファイルシステムのラベルが最初に設定したcidataもしくはCIDATAなどかをチェックしていることがわかる。

dscheck_NoCloud() {
    local fslabel="cidata CIDATA" d=""
    case " ${DI_KERNEL_CMDLINE} " in
        *\ ds=nocloud*) return ${DS_FOUND};;
    esac
    case " ${DI_DMI_PRODUCT_SERIAL} " in
        *\ ds=nocloud*) return ${DS_FOUND};;
    esac


    for d in nocloud nocloud-net; do
        check_seed_dir "$d" meta-data user-data && return ${DS_FOUND}
        check_writable_seed_dir "$d" meta-data user-data && return ${DS_FOUND}
    done
    # shellcheck disable=2086
    if has_fs_with_label $fslabel; then
        return ${DS_FOUND}
    fi


    # This is a bit hacky, but a NoCloud false positive isn't the end of the world
    if check_config "NoCloud" && check_config "user-data" && check_config "meta-data"; then
        return ${DS_FOUND}
    fi


    return ${DS_NOT_FOUND}
}

CNIの役割

最近のパブリッククラウドを利用している人はあまり意識しないかもしれないが、手動でKubernetesクラスタを構築したことのある人ならCNIという単語は聞いたことがあるだろう。 CNIプラグインはKubernetesであればpodの通信が通るネットワークの管理を行っているが、実際にCNI及びCNIプラグインの厳密な守備範囲について確認する。

CNIとは

CNIはコンテナネットワークに関するインターフェースの仕様を標準化したものである。Kubernetes関連でCNIという単語を聞いたことがある人が多いと思うが、実際はそれに限った話ではない。 コンテナを作成したらコンテナ単体が存在するネットワークなのか、あるいはアプリケーションで利用するコンテナ軍が接続されたものなのか様々あるだろうが、大抵の場合はネットワークに接続したくなるはずだ。

ネットワークの構築やコンテナに対する接続を構築するアプリケーションは一つの機能としてコンテナ構築とは別で外部に切り出されている。 この際にネットワークの定義の仕様を取り決めたものをCNI、実際にネットワークの構築や、コンテナへの接続を担当するアプリケーションをCNIプラグインという。

ここで注意したいのが、コンテナといえばDockerを思い浮かべる人が多いと思うが、Dockerでコンテナを作成した際にアタッチされるネットワークの構築にはCNIは使われておらずContainer Network Model(CNM)という別の概念を実装したlibnetworkが使われている。

すなわちコンテナ技術を使っているからといってネットワーク周りをCNIに準基する必要があるとそうではない。

CNIプラグインには様々な実装があり標準実装としてかんたんなものがこちらに用意されている。 他にはIPIPのトンネリングを利用したコンテナ間通信を行うCalicoやVXLANを利用するflannelなどさまざま存在する。

CNIの仕様

全文はこちらを参照。 CNIにおける用語は以下の通り。 - コンテナ - ネットワークで分断されたドメイン。一般的にはLinuxのnetwork namespaceが用いられるが、仮想マシンを1単位とする場合もある。このようにネットワーク的に分離しているものであればコンテナと呼ぶことになっているので、分離している技術に依存するものではない。 - ネットワーク - 相互に通信でき一意のアドレスで指定可能なエンドポイントをまとめたもの。コンテナは1つ以上のネットワークに対して追加削除できる。 - プラグイン - 指定されたネットワークの構成を担当するプログラム。 - ランタイム - CNIプラグインの実行をするためのプログラム

CNIでは上記の定義を前提に以下の5つの仕様が定義されている。 - ネットワークの構成を指定するためのフォーマット(json形式で構成を指定するが、その書式が定義されている) - https://www.cni.dev/docs/spec/#section-1-network-configuration-format - コンテナランタイムがネットワークプラグインに対してリクエストを送る際のフォーマット - https://www.cni.dev/docs/spec/#section-2-execution-protocol - 定義された構成を基にプラグインを実行するための手順 - https://www.cni.dev/docs/spec/#section-3-execution-of-network-configurations - 必要であればプラグインが他のプラグインに対して処理を委譲するがそのための手順 - https://www.cni.dev/docs/spec/#section-4-plugin-delegation - プラグインが実行結果をランタイムに返す際のフォーマット - https://www.cni.dev/docs/spec/#section-5-result-types

ひとまず全体の流れとしてはこの様になる。

ランタイムがプラグインを呼び出す際には環境変数もしくは標準入力を通した設定値を介してパラメータを設定する。 プラグインはJsonエンコードされた実行結果を標準入力、標準出力に書き込む。 また仕様ではプラグインを実行するネットワークネームスペースはランタイムのそれと同じでなければならないと定められている。

ランタイムがプラグインを実行する際に指定するパラメータいくつかあるが特に注目したいのはCNI_COMMANDである。 このパラメータはプラグインの挙動を決める大きな役割を担っているており以下の4つのうちどれかが指定される。

ADD

  • CNI_IFNAMEで定義されたインターフェースをCNI_NETNSで定義されたコンテナに対して作成する
  • CNI_NETNSで定義されたコンテナのCNI_IFNAMEで定義されたインターフェースを調整する。

DEL

  • CNI_IFNAMEで定義されたインターフェースをCNI_NETNSから削除する。
  • ADDで適用された内容をもとに戻す

CHECK

  • 既存のコンテナが期待通りのステータスになっているかどうかを確認する。
  • CHECKはADD処理の後に呼ばれ正常に適用できていることが確認される。

VERSION

  • プラグインがサポートするCNIバージョンの取得

CNIの動作確認

CNIの一連の流れを手元で動かして確認する。 containerdやCRI-Oを使ってコンテナを作成すれば、それでCNIが使われていることになるのだが、内部でgo-cniを使っているため、具体的にどういった引数を渡しているかなど追うのがわかりにくい。

CNIで登場するコンテナは説明した通り、Linuxのnetwork namspaceのようにネットワーク的に分断されているものと定義されているので、これを使うことにする。

CNIには標準実装となるプラグインが用意されていいるのでクローンしてビルドし利用する。(GOのビルド環境が必要) - https://github.com/containernetworking/plugins

$ ./build_linux.sh
Building plugins
  bandwidth
  firewall
  portmap
  sbr
  tuning
  vrf
  bridge
  dummy
  host-device
  ipvlan
  loopback
  macvlan
  ptp
  vlan
  dhcp
  host-local
  static

$ ls ./bin/
bandwidth  bridge  dhcp  dummy  firewall  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan  vrf

まずコンテナ(netwok namespace)を用意する。

$ sudo ip netns add container-1
$ ip netns list
container-1

何も追加していない状態のためループバックインタフェースしか見えていない

$ sudo ip netns exec container-1 ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

ここにCNIを利用してインターフェースを足してみる。 今回はビルドしたプラグインの中から接続したcontainer(namespace)がbridgeを介して接続されるbridge pluginを使ってみる。

ここに記載されているようにCNIの仕様ではいくつかのパラメータを環境変数を介して渡す必要がある。 紹介したCNI_COMMANDもここで渡している。今回はコンテナにインタフェースを追加するためADDを指定した。 ADDを利用するためにはこれらの環境変数を設定する必要がある。

https://www.cni.dev/docs/spec/#add-add-container-to-network-or-apply-modifications - CNI_COMMAND - CNI_CONTAINERID - CNI_NETNS - CNI_IFNAME

$ export CNI_PATH=$(readlink -f ./bin/)
$ export CNI_COMMAND=ADD
$ export CNI_CONTAINERID=container-1
$ export CNI_NETNS=/var/run/netns/container-1
$ export CNI_IFNAME=eth0

bridge pluginを利用する際のconfig例としてこのように記載されている。 そしてCNIプラグインへconfigを渡す際は標準入力から渡すことになっているので、以下のように実行する。 このとき先程設定した環境変数を渡すためにsudoを利用する場合は に-Eオプションを忘れずに。

$ cat  << 'EOF' | sudo -E ./bin/bridge
{
    "cniVersion": "0.3.1",
    "name": "mynet",
    "type": "bridge",
    "bridge": "mynet0",
    "isDefaultGateway": true,
    "forceAddress": false,
    "ipMasq": true,
    "hairpinMode": true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.10.0.0/16"
    }
}
EOF
{
    "cniVersion": "0.3.1",
    "interfaces": [
        {
            "name": "mynet0",
            "mac": "02:3a:b3:d6:cc:a7"
        },
        {
            "name": "vethf5f1a233",
            "mac": "5e:51:7c:b8:a9:44"
        },
        {
            "name": "eth0",
            "mac": "0e:c0:ab:d5:02:bb",
            "sandbox": "/var/run/netns/container-1"
        }
    ],
    "ips": [
        {
            "version": "4",
            "interface": 2,
            "address": "10.10.0.2/16",
            "gateway": "10.10.0.1"
        }
    ],
    "routes": [
        {
            "dst": "0.0.0.0/0",
            "gw": "10.10.0.1"
        }
    ],
    "dns": {}

そうすると上記のように標準出力に実行結果が返される。 そして再びipコマンドでコンテナ内のインタフェースを確認すると先程指定したものが追加されていることがわかる。またbrctlでは指定したbridgeが追加されている。

$ sudo ip netns exec container-1 ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
4: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether d2:40:0f:c1:3f:da brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.10.0.3/16 brd 10.10.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::d040:fff:fec1:3fda/64 scope link
       valid_lft forever preferred_lft forever

$ brctl show mynet0
bridge name     bridge id               STP enabled     interfaces
mynet0          8000.26dc84ed6e49       no              veth24b7b96d

指定したルート情報も挿入されている。

[yota@ubuntu01 plugins]$ sudo ip netns exec container-1 route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         _gateway        0.0.0.0         UG    0      0        0 eth0

もう1つコンテナを用意して同様にbdridgeに接続する。

$ sudo ip netns add container-2
$ export CNI_COMMAND=ADD
$ export CNI_CONTAINERID=container-2
$ export CNI_NETNS=/var/run/netns/container-2
$ export CNI_IFNAME=eth0

$  cat  << 'EOF' | sudo -E ./bin/bridge
{
    "cniVersion": "0.3.1",
    "name": "mynet",
    "type": "bridge",
    "bridge": "mynet0",
    "isDefaultGateway": true,
    "forceAddress": false,
    "ipMasq": true,
    "hairpinMode": true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.10.0.0/16"
    }
}
EOF
{
    "cniVersion": "0.3.1",
    "interfaces": [
        {
            "name": "mynet0",
            "mac": "26:dc:84:ed:6e:49"
        },
        {
            "name": "vetheb3b55cd",
            "mac": "5a:7e:be:be:4d:32"
        },
        {
            "name": "eth0",
            "mac": "da:dd:d9:9b:83:e6",
            "sandbox": "/var/run/netns/container-2"
        }
    ],
    "ips": [
        {
            "version": "4",
            "interface": 2,
            "address": "10.10.0.4/16",
            "gateway": "10.10.0.1"
        }
    ],
    "routes": [
        {
            "dst": "0.0.0.0/0",
            "gw": "10.10.0.1"
        }
    ],
    "dns": {}
}

$sudo  ip netns exec container-2 ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether da:dd:d9:9b:83:e6 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.10.0.4/16 brd 10.10.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::d8dd:d9ff:fe9b:83e6/64 scope link
       valid_lft forever preferred_lft forever

これでコンテナ間無事疎通が取れることを確認できた。

$ sudo  ip netns exec container-2 ping 10.10.0.3 -c3
PING 10.10.0.3 (10.10.0.3) 56(84) bytes of data.
64 bytes from 10.10.0.3: icmp_seq=1 ttl=64 time=0.104 ms
64 bytes from 10.10.0.3: icmp_seq=2 ttl=64 time=0.061 ms
64 bytes from 10.10.0.3: icmp_seq=3 ttl=64 time=0.061 ms

$ sudo  ip netns exec container-1 ping 10.10.0.4 -c3
PING 10.10.0.4 (10.10.0.4) 56(84) bytes of data.
64 bytes from 10.10.0.4: icmp_seq=1 ttl=64 time=0.145 ms
64 bytes from 10.10.0.4: icmp_seq=2 ttl=64 time=0.077 ms
64 bytes from 10.10.0.4: icmp_seq=3 ttl=64 time=0.073 ms

### メモ 後で消す
mynet0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 26:dc:84:ed:6e:49 brd ff:ff:ff:ff:ff:ff
    inet 10.10.0.1/16 brd 10.10.255.255 scope global mynet0
       valid_lft forever preferred_lft forever
    inet6 fe80::2cc7:a5ff:fe41:76d4/64 scope link

なお今回はbridgeを介した通信をするにあたって、iptablesに特定のルールを追加していないため、このあたりのカーネルパラメータを設定している。container間で疎通が取れないようであれば確認したほうがよい。

nsudo  sysctl -w net.bridge.bridge-nf-call-iptables = 0
nsudo  sysctl -w et.bridge.bridge-nf-call-arptables = 0

最後は作成したプラグイン経由で作成したインタフェースを削除してみる。 DELの実行では以下のパラーメタが必要となる。

https://www.cni.dev/docs/spec/#del-remove-container-from-network-or-un-apply-modifications - CNI_COMMAND - CNI_CONTAINERID - CNI_IFNAME

$ export CNI_COMMAND=DEL
$ export CNI_CONTAINERID=container-1
$ export CNI_NETNS=/var/run/netns/container-1 # netwrok namespace指定のために必要
$ export CNI_IFNAME=eth0

cat << 'EOF' |sudo -E ./bin/bridge
> {
    "cniVersion": "0.3.1",
    "name": "mynet",
    "type": "bridge",
    "bridge": "mynet0",
    "ipam": {}
}
> EOF

このように実行するとステータスコードが0で返り、指定したインタフェースが削除されていることが確認できた。

$ echo $?
0

$ sudo ip netns exec container-1 ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

まとめ

CNIが存在することによってコンテナネットワークの作成や削除を始めとしたインタフェースを共通化している。 そのおかげでプラグインを実装することによって様々なユースケースに応じたコンテナネットワークを柔軟に構成することができる。

Dockerはどのようにリソース制限しているか

Dockerを利用してコンテナを動かすと、様々なリソースをLinuxのnamespaceを利用してプロセスを独立させている。 コンテナを立ち上げる際にDockerではリソース使用量の制限を行うことができるようになっている。

例えばdocker run実行時に--memory=256mbを指定すればメモリ使用量は256MBに制限されるし、--cpuset-cpus="0-3,15-19"と指定すれば指定したcpu上で動作するように制限することができる。

これはLinuxのcgroupの機能を利用したもので、デフォルトではOCIに準基したruncがsystemdを介して設定をしているものになる。

今回はLinux上でDockerを利用してコンテナを動かした際に、どのようにcgroupが扱われているか確認する。

コンテナ動作時にcgroupの確認

実際にDockerでコンテナを動かしてリソースの制限をした際にcgroupがどのように機能しているか確認してみる。 今回は手持のテスト用イメージを利用したので、手元で試す際は適当なCentOSなどのイメージで必要なパッケージをインストールすると良い。

なお実行時のdocker関連のパッケージのバージョンは以下の通り。

$ rpm -qa |grep docker
docker-ce-cli-20.10.22-3.el7.x86_64
docker-ce-20.10.22-3.el7.x86_64
docker-scan-plugin-0.23.0-3.el7.x86_64
docker-ce-rootless-extras-20.10.22-3.el7.x86_64
$ sudo docker run --cpuset-cpus=""0-2,4-6" --name cpu-limit -d TEST-IMAGE tail -f /dev/null

この状態で起動したコンテナ内でstressを実行してホスト上で各CPUの使用状況を確認してみると、docker run実行時に指定したCPUの負荷のみ上昇していることが確認できる。

$ sudo docker exec -it cpu-limit bash
[root@0e48eee6ab06 /]# stress -c 10
stress: info: [21] dispatching hogs: 10 cpu, 0 io, 0 vm, 0 hdd

# 別窓でコンテナ中にて実行
$ sudo docker exec -it cpu-limit bash
$ htop

この状態でコンテナ内からcgroupを参照すると、以下のようにdocker run実行時に指定したCPUでのみ動作するように制限されていることがわかる。

$ cat /sys/fs/cgroup/cpuset/cpuset.cpus
0-2,4-6

なお上記の挙動はcgroup v1が動作するカーネルでの実行例で、cgroup v2カーネルではこの様なパスになる

$ cat /sys/fs/cgroup/cpuset.cpus
0-2,4-6

このようにdockerに渡したリソース制限に関するオプションは、最終的にはcgroupを利用して実現している。

ホスト側の視点

ホスト側ではコンテナはいくつかのnamespaceを分離したプロセスとして見えている。

ここからはホストでマウントしているcgroupのバージョンによって挙動が異なるのでバージョンを明確にする。

下記のカーネル3系の結果を見てみる。/proc/${pid}/ns配下を参照すると該当プロセスがどのnamepsaceに所属しているかを確認することができる。 また/proc/${pid}/cgroupを参照すると所属cgroupを確認することができる。実行結果かからcgroupの各サブシステムにおいて/system.slice/docker-${CONTAINER_IO}.scopeに所属していることが確認できる。

カーネル 3.10

# docker runで tail -f しているのでpidofでpidを特定
$ pidof tail
1652
$ pid=1652

$ sudo -E ls -l  /proc/${pid}/ns/
合計 0
dr-x--x--x. 2 root root 0  1月  4 00:22 .
dr-xr-xr-x. 9 root root 0  1月  4 00:22 ..
lrwxrwxrwx. 1 root root 0  1月  4 00:24 ipc -> ipc:[4026532129]
lrwxrwxrwx. 1 root root 0  1月  4 00:24 mnt -> mnt:[4026532127]
lrwxrwxrwx. 1 root root 0  1月  4 00:22 net -> net:[4026532132]
lrwxrwxrwx. 1 root root 0  1月  4 00:24 pid -> pid:[4026532130]
lrwxrwxrwx. 1 root root 0  1月  4 00:24 user -> user:[4026531837]
lrwxrwxrwx. 1 root root 0  1月  4 00:24 uts -> uts:[4026532128]

$ cat /proc/${pid}/cgroup
11:cpuset:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
10:memory:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
9:devices:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
8:freezer:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
7:perf_event:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
6:pids:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
5:hugetlb:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
4:cpuacct,cpu:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
3:blkio:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
2:net_prio,net_cls:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
1:name=systemd:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope

カーネル5系ではnamesaceの種類が増えている。またcgroup v2がマウントされている状態のため、subsystemごとではなくプロセスが所属しているcgroupは1つとなっている。

カーネル 5.17

$ pidof tail
1264
$ pid=1264

$ sudo -E ls -l  /proc/${pid}/ns/
total 0
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 cgroup -> 'cgroup:[4026532240]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 ipc -> 'ipc:[4026532172]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 mnt -> 'mnt:[4026532170]'
lrwxrwxrwx. 1 root root 0 Jan  4 00:22 net -> 'net:[4026532174]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 pid -> 'pid:[4026532173]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 pid_for_children -> 'pid:[4026532173]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 time -> 'time:[4026531834]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 user -> 'user:[4026531837]'
lrwxrwxrwx. 1 root root 0 Jan  4 01:07 uts -> 'uts:[4026532171]'

$ cat /proc/${pid}/cgroup
0::/system.slice/docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope

systemd経由のtransient cgroup

上記でホスト側からのcgroupのバージョン差によるcgroupの見え方の違いを確認したが、その時のグループはいずれも/system.slice/docker-${CONTAINER_IO}.scopeの形式となっていた。

これはsystemd経由で作成されたものである。またsystemctlによってunit fileして出力をgrepするとtransient unitとして登録されていることが確認できる。

# バージョンによってはunit名が省略して表示されるがcgroupで確認したものと同じ
$ systemctl list-unit-files |grep docker
docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope transient       -
docker.service                                                                enabled         disabled
docker.socket                                                                 disabled        enabled

$ systemctl cat docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope
# Warning: docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope changed on disk, the version systemd has loaded is outdated.
# This output shows the current version of the unit's original fragment and drop-in files.
# If fragments or drop-ins were added or removed, they are not properly reflected in this output.
# Run 'systemctl daemon-reload' to reload units.
# /run/systemd/transient/docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope
# This is a transient unit file, created programmatically via the systemd API. Do not edit.
[Unit]
Description=libcontainer container 0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a

[Scope]
Slice=system.slice
Delegate=yes
MemoryAccounting=yes
CPUAccounting=yes
IOAccounting=yes
TasksAccounting=yes

[Unit]
DefaultDependencies=no

# /run/systemd/transient/docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope.d/50-DevicePolicy.conf
# This is a drop-in unit file extension, created via "systemctl set-property"
# or an equivalent operation. Do not edit.
[Scope]
DevicePolicy=strict

# /run/systemd/transient/docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope.d/50-DeviceAllow.conf
# This is a drop-in unit file extension, created via "systemctl set-property"
# or an equivalent operation. Do not edit.
[Scope]
DeviceAllow=
DeviceAllow=char-pts rwm
DeviceAllow=/dev/char/5:2 rwm
DeviceAllow=/dev/char/5:1 rwm
DeviceAllow=/dev/char/5:0 rwm
DeviceAllow=/dev/char/1:9 rwm
DeviceAllow=/dev/char/1:8 rwm
DeviceAllow=/dev/char/1:7 rwm
DeviceAllow=/dev/char/1:5 rwm
DeviceAllow=/dev/char/1:3 rwm
DeviceAllow=char-* m
DeviceAllow=block-* m


# /run/systemd/transient/docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope.d/50-AllowedCPUs.conf
# This is a drop-in unit file extension, created via "systemctl set-property"
# or an equivalent operation. Do not edit.
[Scope]
AllowedCPUs=0-2 4-6

$ cat /run/systemd/transient/docker-0e48eee6ab061f7e56b1bf00f32e7aa943e52359d097c871b4fed3f65a71c28a.scope.d/50-AllowedCPUs.conf
# This is a drop-in unit file extension, created via "systemctl set-property"
# or an equivalent operation. Do not edit.
[Scope]
AllowedCPUs=0-2 4-6

Dcokerにおけるcgroupによるリソース制御に仕組みはDcoker run実行時に引数として渡した値が起点となっている。その後近年のバージョンではCRIインタフェースを実装しているcontainerdによって処理される。

docker daemonのexec-optsでcgroupを自分で作成するようにするか、systemdの管理とするかを選択できるようになっている。(そのためこれまでに確認した内容はあくまでデフォルトのsystemdが指定されている場合の動作) - https://docs.docker.com/engine/reference/commandline/dockerd/#options-for-the-runtime

cat /etc/docker/daemon.json | grep cgroup
  "exec-opts": ["native.cgroupdriver=systemd"],

ここで指定された後にcontainerdによって処理された後、低レイヤーランタイム(デフォルトではrunc)によって実際に反映されることになる。 runcに渡される際には--systemd-cgroupオプションによってsystemdによる管理としている。 - https://github.com/opencontainers/runc/blob/v1.1.4/docs/systemd.md

containerd内部ではruncを操作する際はgo-runcというライブラリが使われている。 コードを確認するとDockerからのオプションに応じて内部で--systemd-cgroupを渡していることが確認できる。 - https://github.com/containerd/go-runc/blob/42adae7462d417ea5178c9f8b24a55968ddada14/runc.go#L755-L757

func (r *Runc) args() (out []string) {
    if r.Root != "" {
        out = append(out, "--root", r.Root)
    }
    if r.Debug {
        out = append(out, "--debug")
    }
    if r.Log != "" {
        out = append(out, "--log", r.Log)
    }
    if r.LogFormat != bash {
        out = append(out, "--log-format", string(r.LogFormat))
    }
    if r.SystemdCgroup {
        out = append(out, "--systemd-cgroup")
    }
    if r.Rootless != nil {
        // nil stands for "auto" (differs from explicit "false")
        out = append(out, "--rootless="+strconv.FormatBool(*r.Rootless))
    }
    if len(r.ExtraArgs) > 0 {
        out = append(out, r.ExtraArgs...)
    }
    return out
}

Cgroup Namespaceaによるコンテナからの見え方の差

Linuxにはいくつかのnamespaceが実装されているが、カーネル4.6から搭載されるcgroup namespaceの有無によってコンテナ内でのcgroupの見え方が異なる。

先程試した2種類のバージョンで比較する。

まずdocker execでコンテナ内からpid 1(tail -f /dev/null)のcgroup情報を見てみる。

カーネル 3.10

/sys/fs/cgroupを見る限りはコンテナ内では対処プロセスは/(root)グループに所属しているように見える。

# ホスト上でcgroup v1が利用されていることを確認
$ findmnt -T /sys/fs/cgroup
TARGET         SOURCE FSTYPE OPTIONS
/sys/fs/cgroup tmpfs  tmpfs  ro,nosuid,nodev,noexec,seclabel,mode=755

$ sudo docker exec -it df9be6fb5e25 bash
# 以下コンテナ内

# /sys/fs/cgroup/cpu配下にグループ(ディレクトリ)は存在せずtasksを確認するとコンテナ内のプロセスはすべてルートに所属している。
$ cat /sys/fs/cgroup/cpu/tasks
1
7
61

しかしコンテナ内からホストのcgroup情報を参照する手段は存在する。

コンテナ内から/proc/1/cgroupを参照するとcgroupの各subsystemに関連づいているグループが表示される。 これは先程ホスト側で見えていたcgroupと全く同じ情報である。

$ cat /proc/1/cgroup
11:cpuset:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
10:memory:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
9:devices:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
8:freezer:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
7:perf_event:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
6:pids:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
5:hugetlb:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
4:cpuacct,cpu:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
3:blkio:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
2:net_prio,net_cls:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope
1:name=systemd:/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope

$ findmnt -t cgroup
TARGET                          SOURCE                                                                                              FSTYPE OPTIONS
/sys/fs/cgroup/systemd          cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd
/sys/fs/cgroup/net_cls,net_prio cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,net_prio,net_cls
/sys/fs/cgroup/blkio            cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,blkio
/sys/fs/cgroup/cpu,cpuacct      cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,cpuacct,cpu
/sys/fs/cgroup/hugetlb          cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,hugetlb
/sys/fs/cgroup/pids             cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,pids
/sys/fs/cgroup/perf_event       cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,perf_event
/sys/fs/cgroup/freezer          cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,freezer
/sys/fs/cgroup/devices          cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,devices
/sys/fs/cgroup/memory           cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,memory
/sys/fs/cgroup/cpuset           cgroup[/system.slice/docker-df9be6fb5e25ba4d36147c474be92e39518aabf379bb49dde5773e7489fc29e9.scope] cgroup ro,nosuid,nodev,noexec,relatime,seclabel,cpuset

カーネル 5.17

こちらも3.10と同じようにすべてのプロセスはルートグループに所属している。

# ホスト上でcgroup v2が利用されていることを確認
$ findmnt -T /sys/fs/cgroup
TARGET         SOURCE  FSTYPE  OPTIONS
/sys/fs/cgroup cgroup2 cgroup2 rw,nosuid,nodev,noexec,relatime,seclabel,nsdelegate,memory_recursiveprot

$ sudo docker exec -it 0e48eee6ab06 bash
# 以下コンテナ内

$ cat /sys/fs/cgroup/cgroup.procs
1
7
35

先程試した3系の結果に対してcgroup namespaceが道入されている5.17ではpid 1の所属するcgroupは/(root)となっていてホスト側で見えていた情報と異なっている。

このようにDcokerで作成したコンテナはcgroup namespaceが分割されることによってコンテナからホスト上のcgroupに関する情報は見えないようになっている。

ホスト情報とコンテナのコンテナのリソースを分割しコンテナからホスト情報へのアクセス遮断はセキュリティ的に大きなメリットがある。

$ cat /proc/1/cgroup
0::/

$ findmnt -t cgroup2
TARGET         SOURCE FSTYPE  OPTIONS
/sys/fs/cgroup cgroup cgroup2 ro,nosuid,nodev,noexec,relatime,seclabel,nsdelegate,memory_recursiveprot

関連・参照


【書籍】SRE サイトリライアビリティエンジニアリング

書籍 SRE サイトリライアビリティエンジニアリングを読んだ。

www.oreilly.com

書籍の内容

Googleの「SRE」という組織がどのような役割を果たしているのかや、従来のシステム管理者との違いについて書かれた書籍だった。 今でこそ様々な場面で耳にするSREという単語は、このGoogleの組織から来ている。

このSREという組織は従来の運用管理専門の組織とは異なり、システムの運用という観点はもちろん、開発フェーズの段階から介入し組織を横断して運用の効率化とシステム安定化を目指している。

各章は内容としてはSREチームに関連して様々な、観点からのベストプラクティス集といった感じで独立している。

感想など雑多に

まずサービスの安定稼働を目指すためには、その指標が必要不可欠である。そして4章ではその指標として、SLI,SLO,SLAをどのような値に設定するべきか記述されている。

この辺りは運用に関わるメンバーでもしっかりと、常日頃から目標値と現状を共有できているとチームとして一体感が生まれそうだと感じた。

AWSやGCPなどのクラウドサービスを利用して自社のプロダクトを運用する機会が多い昨今の状況では、運用と開発における境界は自ずと狭まりつつある(と思っている。特にマイクロサービスを代表されるアーキテクチャを取っていればなおさら)

そういったなかで、可用性工場のためデプロイメントの最適化やプロセス改善を行う専門のロールが生まれるのも納得がいく。

それなりにボリュームのある本のため、一度サクッと読んだ後は気になったものや業務に活かせそうなエピソードを選んでたまに本書を開く、といった読み方がおすすめ。

各章に踏み込んだ内容については、気に入った章をその内取り上げてまとめようかと思う。

ちなみに英語版は無料で読めます。

Google - Site Reliability Engineering