http経由でスクリプトを取得し実行するcloud-initのmoduleを作ってみる

さくらのアドベントカレンダー2022 12日目の記事です。(すみません。12日に書いたものではないです)

https://qiita.com/advent-calendar/2022/sakura

さくらインターネットの稲垣です。
本記事ではcloud-initのモジュールの作成の仕方と、そのモジュールを使えるマイアーカイブの作成について紹介します。


今回は、urlを指定するとスクリプトをダウンロードして実行するモジュールを作成します。

## 開発環境

Ubuntu 20.04で開発を行いました。

## moduleのスクリプトを作成する

### cloud-initをgit cloneする

```
git clone https://github.com/canonical/cloud-init.git
cd cloud-init/
```

### モジュールのソースコードを作成する

cloudinit/config/ディレクトリに`cc_`というプレフィクスをつけたファイルを作成します。

今回は`cc_http_scripts.py`というファイルで作成しました。

```
vim cloudinit/config/cc_http_scripts.py
```

内容を以下のように編集します。([公式ドキュメント](https://cloudinit.readthedocs.io/en/latest/topics/module_creation.html)をベースに変更しています。)

基本的にやりたいことをhandle関数に書いていきます。
`meta`の`id`には`cc_http_scripts`を、`activate_by_schema_keys`には`["http_scripts"]`を指定します。

```python
# This file is part of cloud-init. See LICENSE file for license information.
"""Example Module: Shows how to create a module"""
import sys
import urllib.request
import subprocess
from subprocess import PIPE, STDOUT, CalledProcessError
from logging import Logger

from cloudinit.cloud import Cloud
from cloudinit.config import Config
from cloudinit.config.schema import MetaSchema, get_meta_doc
from cloudinit.distros import ALL_DISTROS
from cloudinit.settings import PER_INSTANCE

MODULE_DESCRIPTION = """\
Description that will be used in module documentation.

This will likely take multiple lines.
"""

meta: MetaSchema = {
    "id": "cc_http_scripts",
    "name": "Example Module",
    "title": "Shows how to create a module",
    "description": MODULE_DESCRIPTION,
    "distros": [ALL_DISTROS],
    "frequency": PER_INSTANCE,
    "activate_by_schema_keys": ["http_scripts"],
    "examples": [],
}

__doc__ = get_meta_doc(meta)


def handle(
    name: str, cfg: Config, cloud: Cloud, log: Logger, args: list
) -> None:
    http_scripts = cfg.get("http_scripts", [])
    for http_script in http_scripts:
        if "url" not in http_script:
            raise ValueError(f"Missing required key 'url' from {http_script}")

        url = http_script.get("url")
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req) as res:
            script = res.read()
            p = subprocess.run(
                ["/bin/sh"],
                input=script,
                stdout=PIPE,
                stderr=STDOUT,
            )
            if p.stdout:
                sys.stdout.write(p.stdout.decode("utf-8"))

```

設定ファイルのテンプレートを編集します。

```
vim config/cloud.cfg.tmpl
```

`cloud_config_modules:`に`http_scripts`を追記します。

```diff
$ git diff
diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
index fdd5a357..22bf6bd9 100644
--- a/config/cloud.cfg.tmpl
+++ b/config/cloud.cfg.tmpl
@@ -160,6 +160,7 @@ cloud_config_modules:
 {% if variant in ["ubuntu", "unknown", "debian"] %}
  - byobu
 {% endif %}
+ - http_scripts

 # The modules that run in the 'final' stage
 cloud_final_modules:
```

変更内容をコミットしておきます。

```
git add -A
git commit -m 'Add http_scripts'
```

### debパッケージを作成する

まずは、依存パッケージをインストールします。

```
sudo apt update
sudo apt install make devscripts debhelper dh-python dh-systemd python3-pytest python3-pytest-cov python3-pytest-mock python3-responses python3-configobj python3-debconf python3-jinja2 python3-jsonpatch python3-jsonschema python3-oauthlib python3-serial python3-setuptools
```

Makefileにdebパッケージを作成するターゲットが用意されていためそちらを実行します。
また上記依存パッケージ以外にも必要なパッケージがある場合はエラー文中に表示してくれます。

```
make deb
```

以下のようにdebファイルが作成されていることが分かります。
```
$ ls -lah *.deb
-rw-r--r-- 1 ubuntu ubuntu 502K Dec 30 02:16 cloud-init_22.4-51-g75953781-1~bddeb_all.deb
lrwxrwxrwx 1 ubuntu ubuntu   44 Dec 30 02:16 cloud-init_all.deb -> cloud-init_22.4-51-g75953781-1~bddeb_all.deb
```

## カスタムcloud-initをインストールしたアーカイブを作成する

先ほど作成したdebをインストールしてさくらのクラウドのマイアーカイブを作成します。

### さくらのクラウドでマイアーカイブ作成用サーバーを作成する
ベースになるディスクを作成するだけなので最小限のスペックで作成しました。

| 項目 | スペック |
| --- | --- |
| CPU | 1コア |
| メモリ | 1GB |
| ディスク容量 | 20GB |
| ネットワーク | 共有 |
| アーカイブ | Ubuntu Server 20.04.2 LTS 64bit (cloudimg) |


### マイアーカイブ作成用サーバーにカスタムcloud-initをインストールする

debを作成した環境でscpを使いファイルをマイアーカイブ作成用サーバーに転送します。

```
scp cloud-init_22.4-51-g75953781-1~bddeb_all.deb ubuntu@<ip address>:~/
```

マイアーカイブ作成用サーバーでカスタムcloud-initをインストールします。
```
sudo apt install ./cloud-init_22.4-51-g75953781-1~bddeb_all.deb
```

### cloud-initをリセットする

以下のようにcloud-initが実行済みになっているためリセットします。

```
$ cloud-init status
status: done
```

```
$ sudo cloud-init clean
$ cloud-init status
status: not run
```

### シャットダウンしさくらのクラウドコンパネでマイアーカイブを作成する

```
sudo shutdown -h now
```

アーカイブソースにディスクを選択しマイアーカイブ作成用サーバーのディスクを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316641/4c22ea2e-f694-b9c1-f9d2-046aadbcb455.png)

## カスタムcloud-initインストール済みのマイアーカイブを使ってみる

ディスクからマイアーカイブへのコピーが完了するとマイアーカイブが利用可能になります。

### マイアーカイブを使ってサーバーを作成する

現在さくらのクラウドではマイアーカイブをソースとする場合にコンパネからuser-dataを挿入することができません。
そのため、サーバーを起動せずに作成し、usacloudを利用してuser-dataを挿入してサーバーを起動します。

※ [usacloud](https://docs.usacloud.jp/usacloud/installation/start_guide/)はさくらのクラウド用の公認CLIクライアントです。
※ 以下usacloudセットアップ済みの前提で進みます。

先ほど作成したアーカイブをマイアーカイブから選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316641/9dcd07e7-4de3-c209-b6b0-0307bb998221.png)


その他の設定項目で「作成後すぐに起動」を外しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316641/09b53215-6803-2dc0-00ae-8c3a1aebfe00.png)

usacloudでサーバーが作成されていることを確認します。

```
usacloud iaas server list
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316641/bd367383-4199-084f-bda4-84e2ec0ac152.png)


### user-dataを作成しサーバーを起動する

user-dataを記述したファイルを作成します。

今回作成したモジュールを利用してdockerとk0sをインストールしてみます。
```
cat <<EOF > user-data
#cloud-config
users:
- name: ubuntu
  ssh_import_id: gh:<GitHub のユーザー名>
  lock_passwd: false
  shell: /bin/bash
  sudo: ALL=(ALL) NOPASSWD:ALL
  uid: 1000

http_scripts:
- url: https://get.docker.com
- url: https://get.k0s.sh
EOF
```

user-dataを指定して起動します。

```
usacloud iaas server boot my-cloud-init --zone=is1b --user-data=./user-data
```

### カスタムcloud-initが動作していることを確認する

以下のコマンドで共有セグメントに接続しているIPアドレスでsshコマンドを実行できます。

```
usacloud iaas server ssh my-cloud-init
```

user-dataで指定したgithubに登録している公開鍵に対応する秘密鍵でsshができました。

```
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-80-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of 2022年 12月 30日 金曜日 04:33:19 JST

  System load:  0.0               Processes:             96
  Usage of /:   7.7% of 19.21GB   Users logged in:       0
  Memory usage: 17%               IPv4 address for ens3: <ip address>
  Swap usage:   0%


0 updates can be applied immediately.

New release '22.04.1 LTS' available.
Run 'do-release-upgrade' to upgrade to it.


Last login: Fri Dec 30 04:26:45 2022 from <ip address>
ubuntu@sv-113402280888:~$
```

cloud-initが実行されていることを確認します。
`--wait`オプションをつけることで完了するまで待つことができます。

```
$ cloud-init status
status: running
$ cloud-init status --wait
................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
status: done
```

#### ログを確認する

cloud-initのログは`/var/log/cloud-init.log`と`/var/log/cloud-init-output.log`があります。
`cloud-init.log`にはcloud-init自体のログが`cloud-init-output.log`はモジュールのログが出力されます。

```
$ grep http_scripts /var/log/cloud-init.log
2022-12-29 19:48:51,729 - modules.py[DEBUG]: Running module http_scripts (<module 'cloudinit.config.cc_http_scripts' from '/usr/lib/python3/dist-packages/cloudinit/config/cc_http_scripts.py'>) with frequency once-per-instance
2022-12-29 19:48:51,729 - handlers.py[DEBUG]: start: modules-config/config-http_scripts: running config-http_scripts with frequency once-per-instance
2022-12-29 19:48:51,729 - util.py[DEBUG]: Writing to /var/lib/cloud/instances/nocloud/sem/config_http_scripts - wb: [644] 24 bytes
2022-12-29 19:48:51,730 - helpers.py[DEBUG]: Running config-http_scripts using lock (<FileLock using file '/var/lib/cloud/instances/nocloud/sem/config_http_scripts'>)
2022-12-29 19:51:03,066 - handlers.py[DEBUG]: finish: modules-config/config-http_scripts: SUCCESS: config-http_scripts ran successfully
```

`https://get.docker.com`のログ(最大10行)
```
$ grep "Executing docker install script" /var/log/cloud-init-output.log -A10
# Executing docker install script, commit: 4f282167c425347a931ccfd95cc91fab041d414f
+ sh -c apt-get update -qq >/dev/null
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get install -y -qq apt-transport-https ca-certificates curl >/dev/null
+ sh -c mkdir -p /etc/apt/keyrings && chmod -R 0755 /etc/apt/keyrings
+ sh -c curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
+ sh -c chmod a+r /etc/apt/keyrings/docker.gpg
+ sh -c echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu focal stable" > /etc/apt/sources.list.d/docker.list
+ sh -c apt-get update -qq >/dev/null
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-scan-plugin >/dev/null
+ version_gte 20.10
+ [ -z  ]
```

`https://get.k0s.sh`のログ
```
$ grep "k0s" /var/log/cloud-init-output.log
Downloading k0s from URL: https://github.com/k0sproject/k0s/releases/download/v1.25.4+k0s.0/k0s-v1.25.4+k0s.0-amd64
k0s is now executable in /usr/local/bin
```

dockerとk0sがインストールされていることを確認できました。

```
$ sudo docker version
Client: Docker Engine - Community
 Version:           20.10.22
 API version:       1.41
 Go version:        go1.18.9
 Git commit:        3a2c30b
 Built:             Thu Dec 15 22:28:08 2022
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.22
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.18.9
  Git commit:       42c8b31
  Built:            Thu Dec 15 22:25:58 2022
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.14
  GitCommit:        9ba4b250366a5ddde94bb7c9d1def331423aa323
 runc:
  Version:          1.1.4
  GitCommit:        v1.1.4-0-g5fd4c4d
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

$ k0s version
v1.25.4+k0s.0
```

ちなみに... ubuntuユーザーが初期化されるわけではないのでdebファイルが残っていたり、最初の起動時に指定した公開鍵が`.ssh/authorized_keys`に残っていたりします。
マイアーカイブ化する前にubuntuユーザーは削除するのが良いのかなと思います。

```
$ ls
cloud-init_22.4-51-g75953781-1~bddeb_all.deb
$ head -n1 .ssh/authorized_keys
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD0x++fAvsnjj5/BviVPDSEEg9fxTvvMhFRVqCNG0uwNP+sQ6Ng9joTcQONFeTRDIK/VwYAhQvhSnQztu3ybp/0vLFXvbBUtp1sQLZiI5l/aUh+Nfo+Kq26T0cBtXVOp4Leyug3nYK+T/kFLU2u/utbzJbD9bhn/1+gUdY2VLo28bSdz+9jlXSo9YzisETZm2PuAUH2cMc1dk1O0UpmvuPKGX+iEiiJHsb1dWj7HfGnJquYHJ2hQSXVpsR2o0hsGapBFbpiq/MHqzSZA71OxHIujqlrnQ1SVQsW7S0w5oNZVoMK1IYw055//iytNjLc9f8QpcWXG9sY4sOySbFUUiNg7j1aoDjWxpPA0lvLPOXeQNZXT+z7zqd52fz+QrsjwNvGErod3UP0WFPy5/tJrnmeZxbNSVmfX+O8npswGs7Y4Nb1AGgNpKK2vTkaCFVHgfwJ5oLTYdbh3AFrC2Do3Zomk2XHxXFxxZRHAixKMe/79Nxi2F95/6EEDMK+89ReeI8= ubuntu@vm
```
## さいごに

今回は、urlを指定するとスクリプトをダウンロードして実行するcloud-initのモジュールを作成する流れを紹介させていただきました。
cloud-initでこんなことできたらいいのに！というアイデアをお持ちの方はぜひモジュール自作をしてみてはいかがでしょうか。
もしこの記事がその助けになれば幸いです。

余談ですが、このhttp_scriptsに実行時に環境変数も指定できる機能を追加し[canonical/cloud-initにPR](https://github.com/canonical/cloud-init/pull/1930)を出してみました。マージされるかはわかりませんが、もしマージされれば各ディストリビューションが配布しているcloudimgに標準で取り込まれることになります。その際はぜひ使っていただけたらなと思います。

## 参考

- [cloud-init 22.4.2 documentation: Module Creation](https://cloudinit.readthedocs.io/en/latest/topics/module_creation.html)
- [Python の HTTP クライアントは urllib.request で十分](https://qiita.com/hoto17296/items/8fcf55cc6cd823a18217)
- [Usacloudドキュメント](https://docs.usacloud.jp/usacloud/references/global/)
- [canonical/cloud-init/cloudinit/config/ 各種モジュール実装](https://github.com/canonical/cloud-init/tree/main/cloudinit/config)