この記事は、NFLaboratories Advent Calendar 2024*1 2 日目の記事です。
みなさんこんにちは、研究開発部 研究開発担当のonaotoです。
今回の記事ではAWS Lambda(以降Lambda)でAnsibleを実行できるようにした方法について紹介しようと思います。
結論から書くとLambdaコンテナ内のlibc.soを修正して置き換えれば動きます。
はじめに
Lambdaはコンテナイメージを使ってデプロイします。また、実行環境にはPythonを使用しています。
本記事にはシステム全体で使用するライブラリのバイナリを書き換えるというような内容を含みます。本記事の内容によって被った損害・損失について、 弊社並びに筆者は一切の責任を負いかねますのであらかじめご了承ください。
LambdaのAnsible対応について
2024年11月29日現在AnsibleをLambdaで動かすことに成功している情報を見つけることはできませんでした。
以下のような質問が見つかりますが結局動かすことはできていません。
なぜLambdaでAnsibleが動かないのか
先にあげたリンクにもかかれていますが、Lambdaには/dev/shm
が存在しません。そのためAnsibleが内部的に使用しているmultiprocessingモジュールがプロセス間通信に失敗することが原因です。*2
try: self._final_q = FinalQueue() except OSError as e: raise AnsibleError("Unable to use multiprocessing, this is normally caused by lack of access to /dev/shm: %s" % to_native(e))
原因の深掘り
具体的にどこでエラーが発生しているのかコードを深掘りして原因を探ってみます。
まずはFinalQueue*3
from ansible.utils.multiprocessing import context as multiprocessing_context 中略 class FinalQueue(multiprocessing.queues.SimpleQueue): def __init__(self, *args, **kwargs): kwargs['ctx'] = multiprocessing_context super().__init__(*args, **kwargs)
multiprocessing.queues.SimpleQueue
を継承しているのでそちらを見てみます。*4
class SimpleQueue(object): def __init__(self, *, ctx): self._reader, self._writer = connection.Pipe(duplex=False) self._rlock = ctx.Lock() self._poll = self._reader.poll
ctx.Lock()
を呼び出しています。関数の中身を見る前にctxの実態を確認します。ctxはFinalQueueのコンストラクタで設定したmultiprocessing_context
です。*5
context = multiprocessing.get_context('fork')
ここで取得できるオブジェクトはmultiprocessingモジュールで定義されているForkContextです。*6
class ForkContext(BaseContext): _name = 'fork' Process = ForkProcess
それではctx.Lock()
の中身を見てみましょう。*7
def Lock(self): '''Returns a non-recursive lock object''' from .synchronize import Lock return Lock(ctx=self.get_context())
Lockクラスのコンストラクタを呼び出しているので中身を見てみましょう。*8
class Lock(SemLock): def __init__(self, *, ctx): SemLock.__init__(self, SEMAPHORE, 1, 1, ctx=ctx)
親クラスであるSemLockのコンストラクタを呼び出しています。そちらを見てみます。*9
class SemLock(object): _rand = tempfile._RandomNameSequence() def __init__(self, kind, value, maxvalue, *, ctx): if ctx is None: ctx = context._default_context.get_context() self._is_fork_ctx = ctx.get_start_method() == 'fork' unlink_now = sys.platform == 'win32' or self._is_fork_ctx for i in range(100): try: sl = self._semlock = _multiprocessing.SemLock( kind, value, maxvalue, self._make_name(), unlink_now)
_multiprocessing.SemLock()は拡張モジュールとして実装されているため、私の実力ではこれ以上実装を追いかけることはできませんでした。しかし、どうやらセマフォのロックを取得しているということがわかりました。しかも引数に名前を指定しているので名前付きセマフォを取得しているようです。
色々調べてみると名前付きセマフォを取得するにはlibcで実装されているsem_open()
を呼び出せば良いようです。
*10
glibcのsem_open()の実装を見てみます。*11
sem_t * __sem_open (const char *name, int oflag, ...) { int fd; sem_t *result; 中略 char tmpfname[] = SHMDIR "sem.XXXXXX"; int retries = 0; #define NRETRIES 50 while (1) { /* We really want to use mktemp here. We cannot use mkstemp since the file must be opened with a specific mode. The mode cannot later be set since then we cannot apply the file create mask. */ if (__mktemp (tmpfname) == NULL) 中略 { /* Create the file. Don't overwrite an existing file. */ if (__link (tmpfname, dirname.name) != 0)
tmpfnameで定義された名前でファイルを作っていることがわかります。
SHMDIR
がどのようなマクロなのかを確認してみます。*12
/* The directory that contains shared POSIX objects. */ #define SHMDIR _PATH_DEV "shm/"
さらに_PATH_DEV
がどのようなマクロなのかを確認してみます。*13
#define _PATH_DEV "/dev/"
つまり、sem_open.cで/dev/shm/sem.XXXXXX
(XXXXXXの部分はランダム文字列)というファイルとして名前付きセマフォを作ろうとしていることがわかりました。ここで/dev/shm
ディレクトリが存在しないためにエラーとなっているのです。
/dev/shmとはなにか
ところで今まで話にあがってきた/dev/shm
とは一体何者なのでしょうか?手元のLinuxで確認してみると以下のような結果となりました。
user@user-virtual-machine:~/glibc$ df -Th Filesystem Type Size Used Avail Use% Mounted on tmpfs tmpfs 1.3G 2.5M 1.3G 1% /run /dev/sda3 ext4 236G 209G 18G 93% / tmpfs tmpfs 6.1G 48M 6.1G 1% /dev/shm tmpfs tmpfs 5.0M 4.0K 5.0M 1% /run/lock /dev/sda2 vfat 512M 6.1M 506M 2% /boot/efi tmpfs tmpfs 1.3G 112K 1.3G 1% /run/user/1000
/dev/shm
はtmpfsであることがわかりました。
そして、さらに調べてみるとglibc2.2以上ではtmpfsが/dev/shm
にマウントされている前提でPosix共有メモリの実装がされていることがわかりました。
*14
glibc 2.2 and above expects tmpfs to be mounted at /dev/shm for POSIX shared memory (shm_open, shm_unlink). Adding the following line to /etc/fstab should take care of this:
修正方法
Lambdaにもユーザーが書き込み可能な領域として/tmp
が用意されています。名前付セマフォの実態はファイルなので/tmp/shm/
以下にセマフォを作るようにすることができればLambdaでもAnsibleの実行ができそうです。
正攻法で実現するなら、shm-directory.hを修正してlibcをビルドし直すことでこれを実現できそうですが残念ながらlibcをビルドする知識も時間もありませんでした。
どうしようかとlibcのソースコードを眺めていると、ふと/dev/shm/
という文字列がマクロで定義されており、tmpfname
という変数がlibcの.rodataセクションに文字列として配置されているのではないかと考えました。そこで、Lambda用のコンテナからlibc(今回はlibc.so.6でした)を抜き出してstringsを実行してみました。
user@user-virtual-machine:~/lambda$ strings libc.so.6 | grep /shm /dev/shm/ /dev/shm/sem.XXX
SHMDIR
もtmpfname
も文字列としてバイナリに埋め込まれていることがわかりました。次にバイナリエディタでlibc.so.6を開いてみます。
0x001B9B1Bと0x001C1E70に文字列が存在することがわかります。readelfでセクション情報を出力してみます。
user@user-virtual-machine:~/lambda$ readelf -S libc.so.6 There are 70 section headers, starting at offset 0x245510: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .note.gnu.pr[...] NOTE 0000000000000350 00000350 0000000000000050 0000000000000000 A 0 0 8 中略 [16] .rodata PROGBITS 000000000019d000 0019d000 0000000000026718 0000000000000000 A 0 0 32 [17] .stapsdt.base PROGBITS 00000000001c3718 001c3718 0000000000000001 0000000000000000 A 0 0 1 中略
.rodataセクションの範囲が0x0019d000から0x001C3717であることがわかります。上記の文字列のアドレスはこの範囲内なので.rodataセクションに配置されていることがわかりました。
プログラムとしては0x001B9B1Bと0x001C1E70にある文字列を参照しているだけなのでそこに格納される文字が変わったとしても問題なく動作するはずです。
dev
とtmp
は同じ長さなのでバイナリエディタで1文字ずつ置き換えることで他のコードに影響を与えることなくプログラムの修正をすることができます。
修正方法をまとめると
- Lambda用のDockerコンテナからlibcを抜き出す
- バイナリエディタで
/dev/shm
となっている箇所を/tmp/shm
に置き換える - Dockerfileで置き換えたlibcでもともとあったlibcを上書きする
- Lambdaのコード内で
/tmp/shm
ディレクトリを作成する
となります。
おわりに
以上の修正でLambdaでAnsibleを動かすことができました。ライブラリのバイナリを直接書き換えるという強引な方法でしたが弊社の環境では今のところ特に問題なく動作をしています。 Ansible以外にも/dev/shmに依存しているコードは同じ修正方法で動作するかもしれません。
ただし、glibcのライセンスはLGPLとなっているので、改変する際はライセンス通りに取り扱ってください。
本記事で紹介した問題以外にも、Ansibleでターゲットマシンへの接続にSSMを使うとLambda固有の別の問題が発生したのですがその話まで書くと長すぎるのでここでは割愛したいと思います。読みたい人がいれば書こうと思います。
それでは、ここまでお読みいただきありがとうございました。
*1:https://adventar.org/calendars/10492
*2:https://github.com/ansible/ansible/blob/68bfa378386f1f1b5ea9156324f2f5d7942d8a5c/lib/ansible/executor/task_queue_manager.py#L161-L164
*3:https://github.com/ansible/ansible/blob/stable-2.18/lib/ansible/executor/task_queue_manager.py#L78-L81
*4:https://github.com/python/cpython/blob/3.13/Lib/multiprocessing/queues.py#L357-L362
*5:https://github.com/ansible/ansible/blob/stable-2.18/lib/ansible/utils/multiprocessing.py#L15
*6:https://github.com/python/cpython/blob/3.13/Lib/multiprocessing/context.py#L303-L305
*7:https://github.com/python/cpython/blob/3.13/Lib/multiprocessing/context.py#L65-L68
*8:https://github.com/python/cpython/blob/3.13/Lib/multiprocessing/synchronize.py#L166-L169
*9:https://github.com/python/cpython/blob/3.13/Lib/multiprocessing/synchronize.py#L46-L65
*10:https://man7.org/linux/man-pages/man3/sem_open.3.html
*11:https://github.com/bminor/glibc/blob/glibc-2.40.9000/sysdeps/pthread/sem_open.c#L37-L221
*12:https://github.com/bminor/glibc/blob/glibc-2.40.9000/include/shm-directory.h#L26
*13:https://github.com/bminor/glibc/blob/glibc-2.40.9000/sysdeps/unix/sysv/linux/paths.h#L69
*14:https://www.kernel.org/doc/html/v5.9/filesystems/tmpfs.html
*15:アドレスは環境によって異なる可能性があります