NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

AWS LambdaでAnsibleを実行してみた

この記事は、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

SHMDIRtmpfnameも文字列としてバイナリに埋め込まれていることがわかりました。次にバイナリエディタでlibc.so.6を開いてみます。

*15

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にある文字列を参照しているだけなのでそこに格納される文字が変わったとしても問題なく動作するはずです。 devtmpは同じ長さなのでバイナリエディタで1文字ずつ置き換えることで他のコードに影響を与えることなくプログラムの修正をすることができます。

修正方法をまとめると

  1. Lambda用のDockerコンテナからlibcを抜き出す
  2. バイナリエディタで/dev/shmとなっている箇所を/tmp/shmに置き換える
  3. Dockerfileで置き換えたlibcでもともとあったlibcを上書きする
  4. Lambdaのコード内で/tmp/shmディレクトリを作成する

となります。

おわりに

以上の修正でLambdaでAnsibleを動かすことができました。ライブラリのバイナリを直接書き換えるという強引な方法でしたが弊社の環境では今のところ特に問題なく動作をしています。 Ansible以外にも/dev/shmに依存しているコードは同じ修正方法で動作するかもしれません。

ただし、glibcのライセンスはLGPLとなっているので、改変する際はライセンス通りに取り扱ってください。

本記事で紹介した問題以外にも、Ansibleでターゲットマシンへの接続にSSMを使うとLambda固有の別の問題が発生したのですがその話まで書くと長すぎるのでここでは割愛したいと思います。読みたい人がいれば書こうと思います。

それでは、ここまでお読みいただきありがとうございました。