💬

Cloud-init初回boot直後にapt-get updateすると失敗した原因と対処

2024/01/08に公開

こんにちは。MedOps Technologiesの杉田です。主にインフラを担当しています。「原理に立ち返るインフラ管理」を心がけて一つ一つの課題を解決しています。

弊社ではProxmoxを採用しており、VMの構成をTerraformで記述しています。Telmate/proxmoxのproxmox_vm_qemuリソースのCloud-initによるProvisionを使っているのですが、remote-execでapt-get updateしようとしたら以下のようなエラーが出たため、原因を特定しました。

E: Could not get lock /var/lib/apt/lists/lock. It is held by process 576 (apt-get)
N: Be aware that removing the lock file is not a solution and may break your system.
E: Unable to lock directory /var/lib/apt/lists/

記事を書き終えてから気づいたのですが、そもそもTerraformでProvisionした先はAnsible使いましょう、という話ですね。書いてしまったものを捨てるのも悲しいですし、一定の知見は得られたので、そのまま公開することにしました。

問題のプログラム

今回の問題のスクリプトです。
一部だけ取り出してますので、変数宣言等は適宜補ってください。

createvm.tf
resource "proxmox_vm_qemu" "debian-vm" {
  vmid = var.vmid
  name        = "debian-vm-${var.username}"
  clone       = "debian-12-generic-amd64" # 社内のDebian VMテンプレート
  os_type     = "cloud-init"

.
.

  ipconfig0  = "ip=${var.ip_address}/24,gw=${var.gateway}"
  ciuser     = "${var.username}"
  sshkeys = "${data.local_file.ssh-public.content}"

  provisioner "remote-exec" {
    connection {
      type = "ssh"
      host = var.ip_address
      user = "${var.username}"
      private_key = "${data.local_file.ssh-private.content}"
    }
    inline = [
      "sudo apt-get update", # 問題の箇所 実際はシェルスクリプトをscriptsで私ているが、簡単のためinlineで提示
    ]
  }
}

これでterraform applyすると以下のようになります。

proxmox_vm_qemu.debian-vm (remote-exec): Connected!
proxmox_vm_qemu.debian-vm (remote-exec): Reading package lists... 0%
proxmox_vm_qemu.debian-vm (remote-exec): Reading package lists... 0%
proxmox_vm_qemu.debian-vm (remote-exec): Reading package lists... 34%
proxmox_vm_qemu.debian-vm (remote-exec): Reading package lists... Done
proxmox_vm_qemu.debian-vm (remote-exec): E: Could not get lock /var/lib/apt/lists/lock. It is held by process 576 (apt-get)
proxmox_vm_qemu.debian-vm (remote-exec): N: Be aware that removing the lock file is not a solution and may break your system.
proxmox_vm_qemu.debian-vm (remote-exec): E: Unable to lock directory /var/lib/apt/lists/

エラーメッセージの分析

3つのメッセージを意訳すると以下のようになります。

  • プロセス576(apt-get)が使用しているため、/var/lib/apt/lists/lockをロックできない
  • ロックファイルを消去することは解決策ではなくシステムを壊す可能性があることに気をつけなさい
  • /var/lib/apt/listsディレクトリをロックできない

恐らくapt-get updateはパッケージリストの破損を防ぐために排他制御が実装されているのだとは思いますが、具体的にはどういうことなのでしょうか。

ソースコードを読む

debian/aptのソースを読んでみましょう。

apt-get updateコマンドを実行すると、比較的初期にprivate-update.ccDoUpdate()がコールされます。そこから、update.ccListUpdate(..)が呼ばれ、ソースリストのアップデートが行われます。

update.cc
1  bool ListUpdate(pkgAcquireStatus &Stat, 
2                  pkgSourceList &List, 
3                  int PulseInterval)
4  {
5     pkgAcquire Fetcher(&Stat);
6     if (Fetcher.GetLock(_config->FindDir("Dir::State::Lists")) == false)
7        return false;
8 
9     // Populate it with the source selection
10    if (List.GetIndexes(&Fetcher) == false)
11       return false;
12 
13    return AcquireUpdate(Fetcher, PulseInterval, true);
14 }

5行目でFetcher.GetLock()が呼ばれており、これはaquire.ccpkgAquire::GetLockに実装されています。ここで/var/apt/list/lockの排他制御がコードされていると解釈して良さそうです。

aquire.cc
1  bool pkgAcquire::GetLock(std::string const &Lock)
2  {
3     if (Lock.empty() == true)
4        return false;
5 
6     // check for existence and possibly create auxiliary directories
7     string const listDir = _config->FindDir("Dir::State::lists");
8     string const archivesDir = _config->FindDir("Dir::Cache::Archives");
9 
10    if (Lock == listDir)
11    {
12       if (SetupAPTPartialDirectory(_config->FindDir("Dir::State"), listDir, "partial", 0700) == false)
13          return _error->Errno("Acquire", _("List directory %s is missing."), (listDir + "partial").c_str());
14    }
15    if (Lock == archivesDir)
16    {
17       if (SetupAPTPartialDirectory(_config->FindDir("Dir::Cache"), archivesDir, "partial", 0700) == false)
18          return _error->Errno("Acquire", _("Archives directory %s is missing."), (archivesDir + "partial").c_str());
19    }
20    if (Lock == listDir || Lock == archivesDir)
21    {
22       if (SetupAPTPartialDirectory(_config->FindDir("Dir::State"), listDir, "auxfiles", 0755) == false)
23       {
24          // not being able to create lists/auxfiles isn't critical as we will use a tmpdir then
25       }
26    }
27 
28    if (_config->FindB("Debug::NoLocking", false) == true)
29       return true;
30 
31    // Lock the directory this acquire object will work in
32    if (LockFD != -1)
33       close(LockFD);
34    LockFD = ::GetLock(flCombine(Lock, "lock"));
35    if (LockFD == -1)
36       return _error->Error(_("Unable to lock directory %s"), Lock.c_str());
37 
38    return true;
39 }

36行目に"Unable to lock directory %s"とあり、今回の3番目のメッセージはここから出力されているのがわかります。%sは今回は/var/lib/apt/listのことかと思います。
34行目にはさらにGetLock関数があり、flCombine(Lock, "lock")ファイルを引数に渡していることから、/var/lib/apt/list/lockファイルの排他制御をもって、/var/lib/apt/listディレクトリ全体の排他制御を実装していることがわかります

このGetLockfileutl.ccにコードされています。

以下、必要な部分以外を削って引用します。

fileutl.cc
1   int GetLock(string File,bool Errors)
2   {
3      int FD = open(File.c_str(),O_RDWR | O_CREAT | O_NOFOLLOW,0640);
4   
5      // (中略: ファイルオープンのエラーハンドリング)
6   
7      struct flock fl;
8      fl.l_type = F_WRLCK;
9      fl.l_whence = SEEK_SET;
10     fl.l_start = 0;
11     fl.l_len = 0;
12     if (fcntl(FD,F_SETLK,&fl) == -1)
13     {
14        int Tmp = errno;
15 
16        if ((errno == EACCES || errno == EAGAIN))
17        {
18           fl.l_type = F_WRLCK;
19           fl.l_whence = SEEK_SET;
20           fl.l_start = 0;
21           fl.l_len = 0;
22           fl.l_pid = -1;
23           fcntl(FD, F_GETLK, &fl);
24        }
25        else
26        {
27           fl.l_pid = -1;
28        }
29        close(FD);
30        errno = Tmp;
31 
32        // (中略: NFSマウントされたファイルへの対応)
33   
34        if (Errors == true)
35        {
36           if (fl.l_pid != -1)
37           {
38              auto name = GetProcessName(fl.l_pid);
39              if (name.empty())
40                 _error->Error(_("Could not get lock %s. It is held by process %d"), File.c_str(), fl.l_pid);
41              else
42                 _error->Error(_("Could not get lock %s. It is held by process %d (%s)"), File.c_str(), fl.l_pid, name.c_str());
43           }
44           else
45              _error->Errno("open", _("Could not get lock %s"), File.c_str());
46 
47           _error->Notice(_("Be aware that removing the lock file is not a solution and may break your system."));
48        }
49 
50        return -1;
51     }
52 
53     return FD;
54  }
  • 3行目でlockファイルを開いています。O_CREATを渡しており、ファイルがなければ生成されます。
  • 7-11行目でflockの構造体を構成しています。fl.l_type = F_WRLCKと指定していることから、12行目で排他ロックを要求していることがわかります。
  • fcntl(FD,F_SETLK,&fl)が失敗し、errnoがEACCESまたはEAGAINの場合、他のプロセスがファイルをロックしているという意味です(参考)。ちなみにPython等他の環境ではEACCESPermission Denyを表すことが多く、注意が必要です(参考)。
  • なので、22行目でfl.l_pid = -1とプロセスIDを-1に仮設定しておいて、23行目のfcntl(FD, F_GETLK, &fl)で現在ロックしているプロセス情報を取りに行きます。
  • EACCES, EAGAIN以外の場合はfl.l_pid = -1が設定され、以下この値で固定されます。
  • EACCES/EAGAINの場合、fl.l_PID-1以外の値となるはずなので、36行目のif文でmatchします。
  • そして42行目で本件の1つ目のエラーメッセージが出力されます。
  • さらに47行目で本件の2つ目のメッセージが出力されます。

ここまで来れば、冒頭の

恐らくapt-get updateはパッケージリストの破損を防ぐために排他制御が実装されているのだとは思いますが、具体的にはどういうことなのでしょうか。

という仮説が裏付けられました。

では何が起こっているのか

Terraformのremote-execがapt-get updateを実行しようとして時点で、既にprocess 576(apt-get)が走っていると考えられます。

ゆえに**「Cloud-initがVM立ち上げ時にapt-getを実行しているのではないか」という仮説が立ちます**。

Cloud-initのDocumentを読めば一目瞭然で、Cloud-initはデフォルトでは初回動作時にapt-get updateapt-get upgradeを実行していることがわかりました。

対処方法

Ansibleを使わない対処法を2つ考えました。良い子は真似しないでね!

その1: Cloud-initが終わるまで待つ

Cloud-initが終了すると/var/lib/cloud/instance/boot-finishedファイルが作成されるので、これを検出してからapt-getを実行するというソリューションが成立します(参考)。

#!/bin/bash
until [ -f /var/lib/cloud/instance/boot-finished ]; do
  sleep 1
done
sudo apt-get update

ワンライナーバージョン↓

$ until [ -f /var/lib/cloud/instance/boot-finished ]; do; sleep 1; done && sudo apt-get update

その2: Cloud-initでapt-getを実行しない

そもそもCloud-initの初期設定でapt-getを実行しなければ良い、というソリューションです。

以下のようなcloud-config.ymlを用意します。

cloud-config.yml
#cloud-config
users:
  - name: username
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys: ssh-ed25519 XXXX
package_update: false
package_upgrade: false

proxmox_vm_qemuの場合、cloud-config.yml/var/lib/vz/snippets/以下にコピーして、cicustomに指定します。

createvm.tf
resource "proxmox_vm_qemu" "debian-vm" {
  vmid = var.vmid
  name        = "debian-vm-${var.username}"
  clone       = "debian-12-generic-amd64" # 社内のDebian VMテンプレート
  os_type     = "cloud-init"

.
.

  ipconfig0  = "ip=${var.ip_address}/24,gw=${var.gateway}"
  
  # 以下のように書き換える
  cicustom   = "user=local:snippets/debian-cloudinit.yml"

  provisioner "remote-exec" {
    connection {
      type = "ssh"
      host = var.ip_address
      user = "${var.username}"
      private_key = "${data.local_file.ssh-private.content}"
    }
    inline = [
      "sudo apt-get update",
    ]
  }
}

まとめ

今回はCloud-initとapt-getの排他制御についてまとめました。
長くなってしまいましたが、ここまで読んでいただきありがとうございます。この記事が皆さんのお役に立てば嬉しいです。

Discussion