본문으로 건너뛰기
  1. 포스트/

[HPC From Scratch] 에피소드 7: Rocky Linux에서 Lmod와 Apptainer 설정하기

Will Paik
작성자
Will Paik
대규모 GPU 클러스터를 최적화하는 HPC 엔지니어. 밤에는 방구석 미니 슈퍼컴퓨터를 조립하며(가끔은 태워 먹으며) 그 과정을 기록합니다.
HPC From Scratch - 이 글은 시리즈의 일부입니다.
파트 7: 이 글

클러스터에는 스케줄러, 계정 관리, 그리고 /shared/sw에 전체 소프트웨어 스택까지 갖춰져 있습니다. 하지만 PATH를 직접 export하지 않으면 아무것도 쓸 수 없고, Docker 컨테이너는 여전히 실행할 방법이 없습니다.

HPC From Scratch로 다시 돌아왔습니다. 에피소드 6에서는 Slurm 계정 관리, QOS, 페어 쉐어(Fair Share) 스케줄링을 설정해서 누가 언제 무엇을 썼는지 클러스터가 정확히 추적하도록 만들었습니다. /shared/sw의 소프트웨어 스택은 이미 소스에서 빌드되어 있습니다. Python, OpenMPI, GCC 등입니다. 문제는 접근 방식입니다. 지금 Python을 쓰려면 /shared/sw/python/3.12.12/bin을 PATH에 직접 추가해야 하고, 로그아웃하는 순간 그 설정은 사라집니다. 이번 에피소드에서는 서로 다르지만 연관된 두 문제를 해결하는 도구 두 가지를 다룹니다. 소프트웨어 버전을 깔끔하게 관리하는 Lmod, 그리고 누구도 root 권한이 없는 클러스터에서 컨테이너를 실행하는 Apptainer입니다.

1. Lmod가 필요한 이유
#

공유 클러스터를 운영하면, 작은 홈랩이라도 소프트웨어 버전 충돌이 금방 나타납니다. 한 사용자는 레거시 워크플로우 때문에 Python 3.10이 필요하고, 다른 사용자는 3.12가 필요합니다. 모듈 시스템(Module System)이 없으면 둘 다 PATH를 직접 export해야 하고, 서로의 환경을 건드리게 되며, 지금 어떤 python3가 실제로 활성화되어 있는지 아무도 알 수 없습니다.

Lmod는 모듈 시스템입니다. 이름과 버전으로 소프트웨어 패키지를 로드하고 언로드할 수 있는 깔끔한 인터페이스를 모든 사용자에게 제공합니다. 명령어 하나로 올바른 바이너리와 라이브러리가 환경에 들어오고, 다른 명령어로 흔적 없이 정확하게 제거됩니다. 소프트웨어 자체는 여전히 예전처럼 /shared/sw에 있습니다. Lmod는 그 위에 얹는 관리 계층입니다.

모듈 시스템은 수십 년 동안 HPC(고성능 컴퓨팅, High-Performance Computing) 클러스터의 표준이었습니다. 원조인 Environment Modules는 Tcl로 작성되었습니다. 텍사스 고등 컴퓨팅 센터(Texas Advanced Computing Center, TACC)가 만든 Lmod가 대부분의 최신 클러스터에서 이를 대체한 이유는 두 가지입니다. 소프트웨어 패키지 사이의 의존성을 자동으로 처리하고, 정확한 모듈 이름을 미리 알아야 할 필요 없이 module spider로 전체 소프트웨어 트리를 검색할 수 있습니다.

2. Lmod의 동작 방식
#

Lmod는 MODULEPATH라는 디렉토리 트리를 읽습니다. 그 트리의 각 하위 디렉토리는 소프트웨어 종류 하나를 의미합니다. python, openmpi, gcc 같은 식입니다. 그 안의 각 파일은 버전 하나이고, Lua 스크립트로 작성됩니다. module load python/3.12.12을 실행하면 Lmod는 해당 .lua 파일을 읽어서 그 안에 적힌 지시사항을 적용합니다. PATH에 추가하거나, LD_LIBRARY_PATH를 설정하거나, 의존성을 로드하는 식입니다.

이 클러스터의 구조는 다음과 같습니다.

MODULEPATH includes /shared/sw/modulefiles

/shared/sw/modulefiles/
├── python/
│   └── 3.12.12.lua     ⬅ module load python/3.12.12
├── gcc/
│   └── 12.5.0.lua      ⬅ module load gcc/12.5.0
└── openmpi/
    └── 5.0.9.lua       ⬅ module load openmpi/5.0.9

버전 번호 관련 참고: 위 버전들(3.12.12, 12.5.0, 5.0.9)은 이 클러스터의 /shared/sw에 실제로 설치된 버전입니다. modulefile을 작성하기 전에 ls /shared/sw/python/, ls /shared/sw/gcc/, ls /shared/sw/openmpi/로 본인 환경의 버전을 확인하세요.

Lua 파일 자체는 짧습니다. base 경로를 선언하고 Lmod에게 어떤 환경 변수를 수정할지 알려줍니다. 의존성도 선언할 수 있습니다. OpenMPI modulefile 안의 depends_on("gcc/12.5.0")은 OpenMPI를 로드할 때 해당 GCC 버전을 자동으로 로드하고, OpenMPI를 언로드할 때 같이 언로드하라는 뜻입니다.

3. 사전 준비
#

이번 에피소드의 명령어를 실행하기 전에 다음을 확인하세요.

  • 에피소드 1부터 6까지 완료되어 있어야 합니다
  • /shared/sw에 소프트웨어 빌드가 채워져 있어야 합니다
  • /opt/ansible/hosts.ini의 Ansible 인벤토리에 접근할 수 있어야 합니다 (ansible all_nodes -m ping이 성공해야 합니다)

아래 내용은 전부 playbook 파일이 아니라 Ansible 애드혹(ad-hoc) 명령어(ansible all_nodes -m ... -a "...")를 씁니다. 각 단계가 한 줄씩 그대로 보이도록 한 것입니다. 재사용 가능한 idempotent playbook 버전이 필요하면 GitHub 저장소에 같은 단계를 묶어둔 playbook이 있습니다.

4. Lmod 설치하기
#

Rocky Linux 10은 자체 레포지토리에 Lmod 8.7.65를 바로 제공합니다. 현재 업스트림 최신 버전은 9.2.6으로, 메이저 버전 하나가 앞서 있습니다. 이 차이는 실제로 존재하지만, 그것만으로 소스 빌드를 해야 할 이유는 아닙니다. 이 시리즈에서 소스 빌드를 하는 경우는 distro 패키지에 실제로 필요한 기능이 빠져 있을 때입니다. 에피소드 5의 Slurm RPM에 PMIx와 cgroup v2 지원이 빠져 있었던 것처럼 말입니다. 여기에는 그런 차이가 없습니다. 이번 에피소드나 다음 에피소드 어디에도 9.x에만 있는 Lmod 기능이 필요하지 않습니다. 그래서 레포지토리에서 설치하며, 다섯 노드 전체에 한 명령어로 처리합니다.

[wpaik@arbiter ~]$ ansible all_nodes -b -K -m dnf -a "name=Lmod state=present"

-b는 작업을 root로 실행하고, -K는 sudo 비밀번호를 한 번 입력받아 모든 노드에 재사용합니다. 설치된 내용을 확인합니다.

[wpaik@arbiter ~]$ ansible all_nodes -m shell -a "rpm -q Lmod"
interceptor-01 | CHANGED | rc=0 >>
Lmod-8.7.65-2.el10_2.x86_64

RPM이 자체적으로 설정하는 것들
#

커스텀 설정을 추가하기 전에 알아둘 것이 있습니다. Lmod RPM은 단순히 바이너리만 떨어뜨려 놓는 게 아닙니다. rpm -ql Lmod를 보면 자체 profile.d 스크립트와 기본 MODULEPATH 위치를 같이 설치한다는 걸 알 수 있습니다.

/etc/profile.d/00-modulepath.sh   ⬅ sets a default MODULEPATH
/etc/profile.d/modules.sh         ⬅ sources Lmod's init, defines the `module` command
/etc/modulefiles                  ⬅ a default search path (unused here)
/usr/share/modulefiles            ⬅ another default search path (unused here)
/usr/share/lmod/8.7.65/...        ⬅ the actual Lmod install
/usr/share/lmod/lmod              ⬅ symlink to 8.7.65/, same versioning convention as a source build

이 패키지는 자체 기본 MODULEPATH 항목(/etc/modulefiles, /usr/share/modulefiles)을 만들지만, 둘 다 쓰지 않습니다. 우리 소프트웨어는 /shared/sw/modulefiles에 있으니 따로 추가해야 합니다.

공유 MODULEPATH 추가하기
#

profile.d 스크립트는 알파벳 순서로 실행됩니다. 00-modulepath.sh가 먼저 실행되고, 그 다음 modules.sh가 실행되면서 실제로 module 명령어를 정의합니다. module use를 호출하는 건 이 둘이 끝난 다음에 와야 합니다. 그렇지 않으면 호출할 module 명령어 자체가 아직 없습니다. 알파벳상 뒤에 오는 파일명을 쓰면 이 순서를 보장할 수 있고, 여기에 Lmod가 실제로 로드됐는지 확인하는 가드까지 추가합니다.

# /etc/profile.d/z00-shared-modulepath.sh
# only when Lmod is loaded
if [ -n "$LMOD_CMD" ]; then
    module use /shared/sw/modulefiles
fi

LMOD_CMD는 Lmod 자체 init이 실행된 다음에 설정됩니다. 스크립트 순서가 의도대로 됐을 거라고 가정하는 대신 이 변수를 확인하면, 어떤 이유로든 Lmod init이 실행되지 않은 셸에서도 “command not found” 에러 대신 아무 일도 하지 않고 안전하게 넘어갑니다.

이 파일을 로컬에서 한 번 만들고, 모든 노드에 배포합니다.

[wpaik@arbiter ~]$ ansible all_nodes -b -m copy -a "src=/etc/profile.d/z00-shared-modulepath.sh dest=/etc/profile.d/z00-shared-modulepath.sh mode=0644"

여기서 src는 이 ansible 명령어를 실행하는 머신인 arbiter에서 읽어서, all_nodes에 속한 모든 노드의 같은 경로로 배포됩니다. arbiter 자신도 포함되는데, 이미 파일이 있는 상태라 별다른 변화 없이 끝납니다.

컴퓨트 노드에서 확인합니다.

[wpaik@interceptor-01 ~]$ module --version
Modules based on Lua: Version 8.7.65 2024-03-04 14:23:01

[wpaik@interceptor-01 ~]$ echo $MODULEPATH
/shared/sw/modulefiles:/etc/modulefiles:/usr/share/modulefiles

버전 고정하기
#

일상적인 dnf update로 Lmod가 의도치 않게 업그레이드되는 걸 막기 위해 /etc/dnf/dnf.confexcludepkgs로 고정합니다. 이 옵션 이름은 Red Hat 공식 문서가 RHEL 9와 RHEL 10 양쪽에서 쓰는 이름입니다.

[wpaik@arbiter ~]$ ansible all_nodes -b -m shell -a "grep -q '^excludepkgs=' /etc/dnf/dnf.conf || sed -i '/^\[main\]/a excludepkgs=Lmod' /etc/dnf/dnf.conf"

grep -q ... || sed -i ... 패턴은 줄이 아직 없을 때만 추가하므로, 이 명령어를 다시 실행해도 두 번째부터는 아무 일도 일어나지 않습니다. 이번 에피소드 후반부에서 Apptainer를 고정할 때 이 점을 기억해두세요. excludepkgs=를 무작정 덮어쓰는 명령어는 먼저 고정된 패키지를 지워버립니다. dnf.conf는 같은 키에 마지막으로 쓴 값만 유지하기 때문입니다. 아래 Apptainer 섹션에서는 기존 줄을 덮어쓰지 않고 이어붙입니다.

[wpaik@arbiter ~]$ ansible all_nodes -m shell -a "grep excludepkgs /etc/dnf/dnf.conf"
interceptor-01 | CHANGED | rc=0 >>
excludepkgs=Lmod

5. Modulefiles 디렉토리 이름 바꾸기
#

/shared/sw/modules 디렉토리는 위에서 쓴 경로와 맞추기 위해 /shared/sw/modulefiles로 바뀌어야 합니다. NFS(Network File System) 서버인 arbiter에서만 일어나는 단일 노드 작업이라 Ansible이 필요 없습니다.

[wpaik@arbiter ~]$ ls /shared/sw/
cmake  cuda  gcc  miniconda3  modules  openmpi  parallel  python  R  rclone

[wpaik@arbiter ~]$ mv /shared/sw/modules /shared/sw/modulefiles

이전에 이미 손으로 이름을 바꿔놨다면 modules가 없어서 이 명령어는 “No such file or directory"로 그냥 실패합니다. 확실하지 않으면 먼저 ls /shared/sw/로 확인하세요.

6. Modulefile 작성하기: Python
#

Modulefile은 /shared/sw/modulefiles/<family>/<version>.lua 경로에 있습니다. 먼저 설치된 Python 버전을 확인합니다.

[wpaik@arbiter ~]$ ls /shared/sw/python/
3.12.12/

디렉토리를 만들고 modulefile을 작성합니다.

[wpaik@arbiter ~]$ mkdir -p /shared/sw/modulefiles/python
[wpaik@arbiter ~]$ vi /shared/sw/modulefiles/python/3.12.12.lua
help([[
Python 3.12.12 installed in /shared/sw/python/3.12.12
]])

whatis("Name:        Python")
whatis("Version:     3.12.12")
whatis("Description: Python programming language")

local base = "/shared/sw/python/3.12.12"
prepend_path("PATH",            pathJoin(base, "bin"))
prepend_path("LD_LIBRARY_PATH", pathJoin(base, "lib"))
prepend_path("MANPATH",         pathJoin(base, "share/man"))

pathJoin은 경로를 깔끔하게 연결해주는 Lmod 내장 함수입니다.

컴퓨트 노드에서 테스트합니다.

[wpaik@interceptor-01 ~]$ module load python/3.12.12
[wpaik@interceptor-01 ~]$ python3 --version
Python 3.12.12
[wpaik@interceptor-01 ~]$ which python3
/shared/sw/python/3.12.12/bin/python3

7. Modulefile 작성하기: OpenMPI
#

OpenMPI는 특정 GCC 버전으로 컴파일되었습니다. modulefile에 이 의존성을 선언해야 Lmod가 올바른 GCC를 자동으로 로드합니다. GCC modulefile은 Python과 같은 패턴을 따릅니다. base 경로를 바꾸고 whatis 필드만 업데이트하면 됩니다.

설치된 버전을 확인합니다.

[wpaik@arbiter ~]$ ls /shared/sw/gcc/
12.5.0/
[wpaik@arbiter ~]$ ls /shared/sw/openmpi/
5.0.9/

OpenMPI modulefile을 작성합니다.

[wpaik@arbiter ~]$ mkdir -p /shared/sw/modulefiles/openmpi
[wpaik@arbiter ~]$ vi /shared/sw/modulefiles/openmpi/5.0.9.lua
whatis("Name: OpenMPI")
whatis("Version: 5.0.9")
whatis("URL: https://www.open-mpi.org/")

local version = "5.0.9"
local base    = pathJoin("/shared/sw/openmpi",version)
depends_on("gcc/12.5.0")
prepend_path("PATH", pathJoin(base,"bin"))
prepend_path("CPATH", pathJoin(base,"include"))
prepend_path("LD_LIBRARY_PATH", pathJoin(base,"lib"))

Python modulefile과 다른 점이 두 가지 있습니다. 핵심은 depends_on("gcc/12.5.0")입니다. OpenMPI를 로드하면 Lmod가 gcc/12.5.0이 이미 로드되어 있는지 확인하고, 없으면 로드합니다. OpenMPI를 언로드하면 다른 것이 의존하고 있지 않은 이상 GCC도 같이 언로드됩니다. 또 하나는 CPATH입니다. 컴파일 시점에 mpi.h 같은 헤더 파일을 컴파일러가 어디서 찾을지 알려줍니다. PATH와 LD_LIBRARY_PATH만으로도 mpirun은 동작하지만, 이 모듈을 대상으로 직접 MPI 코드를 컴파일하려면 CPATH도 설정되어 있어야 합니다.

테스트합니다.

[wpaik@interceptor-01 ~]$ module load openmpi/5.0.9
[wpaik@interceptor-01 ~]$ module list

Currently Loaded Modules:
  1) gcc/12.5.0  2) openmpi/5.0.9

[wpaik@interceptor-01 ~]$ mpirun --version
mpirun (Open MPI) 5.0.9

OpenMPI만 요청했는데 GCC가 자동으로 로드됩니다.

8. 설정 확인하기
#

컴퓨트 노드에서 다음을 차례로 실행합니다.

# See everything available in MODULEPATH
[wpaik@interceptor-01 ~]$ module avail

# Search all modules, even ones not currently in MODULEPATH
[wpaik@interceptor-01 ~]$ module spider python

# Load and check
[wpaik@interceptor-01 ~]$ module load python/3.12.12
[wpaik@interceptor-01 ~]$ module list

# Unload
[wpaik@interceptor-01 ~]$ module unload python/3.12.12

# Clear everything
[wpaik@interceptor-01 ~]$ module purge

Slurm 배치 작업에서는 job script에 module load 호출을 추가하면 됩니다. RPM 자체 profile.d 스크립트와 우리가 만든 z00-shared-modulepath.sh가 새 로그인 셸에서 자동으로 source되기 때문에, script 안에서도 module을 쓸 수 있습니다.

#!/bin/bash
#SBATCH --job-name=mpi-test
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=4

module purge
module load openmpi/5.0.9

mpirun -np 8 ./my_mpi_program

job script 맨 앞의 module purge는 제출한 사용자가 인터랙티브하게 로드해둔 모듈을 전부 지우고 깨끗한 상태에서 시작하게 해줍니다.

9. 트러블슈팅
#

module: command not found

새 터미널 세션을 여세요. profile.d 스크립트는 새로 로그인할 때만 적용됩니다. 그래도 안 되면 RPM이 설치되어 있는지 확인하세요: rpm -q Lmod.

module avail/shared/sw/modulefiles가 보이지 않음

echo $MODULEPATH로 확인하세요. /shared/sw/modulefiles가 없다면 해당 노드에 /etc/profile.d/z00-shared-modulepath.sh가 있는지, 그리고 알파벳 순서상 modules.sh보다 뒤에 오는지 확인하세요.

module load openmpi/5.0.9가 “not found"로 실패함

modulefile 경로를 확인하세요: ls /shared/sw/modulefiles/openmpi/. NFS 공유가 마운트되어 있는지 확인하세요: df -h | grep shared.

depends_on이 잘못된 GCC 버전을 로드함

버전 없이 depends_on("gcc")만 쓰면 기본으로 지정된 GCC가 로드됩니다. 소프트웨어가 컴파일된 정확한 버전을 쓰려면 depends_on("gcc/12.5.0")처럼 명시하세요.

Lmod가 의도치 않게 업그레이드됨

고정 상태를 확인하세요: grep excludepkgs /etc/dnf/dnf.conf. 목록에 Lmod가 없다면 섹션 4의 버전 고정 단계에 있는 sed 명령어를 다시 실행하세요.

10. 공유 클러스터에서의 컨테이너: Docker로는 안 되는 이유
#

Lmod는 소프트웨어 버전 문제를 해결합니다. 다음 문제는 완전히 격리된 환경이 필요한 소프트웨어입니다. 특정 OS 유저스페이스, 충돌하는 라이브러리 버전, 또는 Docker 이미지로만 배포되는 도구 같은 경우입니다. 다른 곳에서는 Docker가 당연한 답이지만, 공유 HPC 클러스터에서는 통하지 않습니다.

Docker는 머신에서 root 권한을 가진 데몬(dockerd)을 통해 동작합니다. 컨테이너를 시작하려면 사용자의 요청이 이 데몬을 거칩니다. 데몬이 모든 걸 결정하고, root로 동작하기 때문에 이 데몬과 통신할 수 있는 건 사실상 호스트에서 root처럼 행동할 수 있습니다. 사용자가 한 명인 워크스테이션이라면 받아들일 수 있는 신뢰 모델이지만, 여러 사용자가 공유하는 클러스터에서는 그렇지 않습니다. 모든 사용자에게 Docker 데몬 접근 권한을 주는 건 모든 사용자에게 root를 주는 것과 같습니다.

Apptainer(이전 이름 Singularity)는 정확히 이 문제를 풀기 위해 만들어졌습니다. 핵심 설계 원칙은 컨테이너 밖에서 누구든, 컨테이너 안에서도 똑같은 사람이라는 것입니다. 데몬도 없고, 권한 상승도 없고, 컨테이너 런타임을 통한 권한 상승 경로도 없습니다. 그래서 HPC 센터들이 Docker 대신 이걸 표준으로 채택했습니다.

Docker vs. Apptainer

클러스터 환경에서 중요한 다른 실질적인 차이점도 몇 가지 있습니다.

  • 단일 파일 대 레이어 이미지. Apptainer는 컨테이너를 파일 하나(.sif, Singularity Image Format)로 패키징합니다. Docker 이미지는 데몬과 로컬 이미지 저장소가 관리하는 레이어 묶음입니다. .sif 파일은 scp로 다른 머신에 복사해서 바로 실행할 수 있습니다.
  • 격리보다 통합. Docker는 기본적으로 컨테이너를 호스트와 격리합니다. 별도의 네트워크 네임스페이스, 별도의 파일시스템입니다. Apptainer는 기본값이 반대입니다. 호스트 네트워크를 공유하고, 홈 디렉토리를 마운트하고, GPU와 고속 인터커넥트에 직접 접근할 수 있게 해줍니다.
  • Docker 호환성. Apptainer는 Docker 이미지를 바로 pull해서 실행할 수 있습니다. Docker Hub 생태계를 포기하는 게 아니라, 데몬만 포기하는 것입니다.

11. Apptainer 설치하기
#

Rocky Linux 10 레포지토리는 Apptainer 1.5.0을 제공하는데, 업스트림 최신인 1.5.3보다 patch 버전이 조금 낮습니다. 이 차이는 무시할 수준이라 다섯 노드 전체에 dnf로 바로 설치합니다.

[wpaik@arbiter ~]$ ansible all_nodes -b -K -m dnf -a "name=apptainer state=present"
[wpaik@interceptor-01 ~]$ apptainer --version
apptainer version 1.5.0

바이너리는 /usr/bin/apptainer에 들어가고, 이미 모든 사용자의 PATH에 있습니다. 모듈이나 경로 설정이 따로 필요 없습니다.

섹션 4에서 Lmod를 고정했던 것과 같은 방식으로 고정하되, 기존 줄을 덮어쓰지 않고 이어붙입니다. 여기서 excludepkgs=apptainer를 새로 쓰면 이미 있던 Lmod 고정이 지워집니다. dnf.conf의 INI 형식은 한 섹션 안에서 같은 키의 마지막 값만 유지하기 때문입니다.

[wpaik@arbiter ~]$ ansible all_nodes -b -m shell -a "grep -q 'excludepkgs=.*apptainer' /etc/dnf/dnf.conf || sed -i '/^excludepkgs=/ s/$/,apptainer/' /etc/dnf/dnf.conf"

grep -q 'excludepkgs=.*apptainer' || sed -i ... 가드 덕분에 이 명령어를 두 번 실행해도 중복으로 추가되지 않습니다. 두 패키지가 같은 줄에 들어갔는지 확인합니다.

[wpaik@arbiter ~]$ ansible all_nodes -m shell -a "grep excludepkgs /etc/dnf/dnf.conf"
interceptor-01 | CHANGED | rc=0 >>
excludepkgs=Lmod,apptainer

12. 로컬에서 컨테이너 이미지 빌드하기
#

컨테이너 이미지는 일반적으로 개인 워크스테이션같은 로컬 머신에서 빌드합니다. 그렇게 만들어진 .sif 파일을 클러스터로 복사하면 아무 제약 없이 실행됩니다.

기본 예시입니다. hello.def는 Apptainer definition 파일입니다.

Bootstrap: docker
From: ubuntu:24.04

%files
    hello.py /opt/hello.py

%post
    apt-get update && apt-get install -y python3

%runscript
    python3 /opt/hello.py "$@"

%labels
    Author Will Paik
    Version 1.0

그리고 hello.py입니다.

import platform
import sys

print(f"Running inside Apptainer container")
print(f"Python: {sys.version.split()[0]}")
print(f"Hostname: {platform.node()}")

Bootstrap: dockerFrom: ubuntu:24.04은 Apptainer에게 Docker Hub에서 base layer를 pull하라고 알려줍니다. %files는 빌드 시점에 로컬 파일을 이미지 안으로 복사합니다. %post는 이미지 안에서 명령어를 실행해서 소프트웨어를 설치합니다. %runscript는 완성된 이미지를 apptainer run으로 실행했을 때 일어날 일을 정의합니다.

[wpaik@workstation ~]$ apptainer build hello.sif hello.def
INFO:    Starting build...
...
INFO:    Build complete: hello.sif

완성된 이미지를 클러스터로 복사합니다.

[wpaik@workstation ~]$ scp hello.sif wpaik@carrier:/scratch/wpaik/

클러스터에서 실행합니다. 완성된 .sif를 실행합니다.

[wpaik@interceptor-01 ~]$ apptainer run /scratch/wpaik/hello.sif
Running inside Apptainer container
Python: 3.12.12
Hostname: interceptor-01

13. Docker Hub에서 Pull하기
#

이미 관리되고 있는 Docker 이미지가 있는 소프트웨어라면 definition 파일을 작성할 필요가 전혀 없습니다. apptainer pull이 Docker 이미지를 바로 .sif로 변환해줍니다.

[wpaik@interceptor-01 ~]$ apptainer pull docker://pytorch/pytorch:latest
INFO:    Converting OCI blobs to SIF format
...
INFO:    Creating SIF file...

현재 디렉토리에 pytorch_latest.sif가 생성됩니다. Apptainer는 OCI 레이어를 SIF 형식으로 변환하고 이미지 파일을 생성합니다.

14. shell, exec, run의 차이
#

컨테이너를 실행하는 명령어는 세 가지이고, 안에 들어간 다음 일어나는 일이 서로 다릅니다.

apptainer shell은 컨테이너 안의 인터랙티브 셸로 진입시켜줍니다.

[wpaik@interceptor-01 ~]$ apptainer shell pytorch_latest.sif
Apptainer> python3 --version
Python 3.11.9
Apptainer> exit

apptainer exec는 컨테이너 안에서 특정 명령어 하나를 실행하고 바로 호스트 셸로 돌아옵니다.

[wpaik@interceptor-01 ~]$ apptainer exec pytorch_latest.sif python3 --version
Python 3.11.9

apptainer run은 컨테이너의 %runscript에 정의된 내용을 실행합니다. Docker Hub에서 pull했고 %runscript가 따로 없다면 이미지가 물려받은 Docker ENTRYPOINT/CMD를 실행합니다.

[wpaik@interceptor-01 ~]$ apptainer run hello.sif
Running inside Apptainer container
Python: 3.12.12
Hostname: interceptor-01

한 번만 테스트할 때는 보통 exec가 맞습니다. 정해진 기본 동작이 필요하면 run이 맞는 도구입니다. shell은 인터랙티브하게 둘러볼 때 씁니다.

15. 자주 쓰는 런타임 옵션
#

실무에서 쓰는 거의 모든 경우는 몇 가지 플래그로 충분합니다.

--nv는 호스트의 NVIDIA 드라이버와 CUDA 라이브러리를 컨테이너 안에 노출시켜줍니다. GPU를 쓰는 워크로드라면 반드시 필요합니다.

[wpaik@corsair-01 ~]$ apptainer exec --nv pytorch_latest.sif python3 -c "import torch; print(torch.cuda.is_available())"
True

-B / --bind는 호스트 경로를 컨테이너 안의 지정된 위치에 마운트합니다.

[wpaik@interceptor-01 ~]$ apptainer exec -B /scratch:/scratch pytorch_latest.sif ls /scratch

이게 없으면 컨테이너는 자기 자신의 파일시스템과 몇 가지 기본 항목만 봅니다. 홈 디렉토리와 현재 작업 디렉토리는 자동으로 바인드됩니다.

--env는 이미지를 수정하지 않고 컨테이너 안에 환경 변수를 설정합니다.

[wpaik@interceptor-01 ~]$ apptainer exec --env OMP_NUM_THREADS=4 pytorch_latest.sif python3 my_script.py

이 세 가지 옵션이면 일상적인 사용의 대부분을 처리할 수 있습니다. 나머지는 apptainer help exec에서 확인할 수 있습니다.

16. Apptainer가 맞는 경우와 안 맞는 경우
#

잘 맞는 경우:

  • Docker 이미지로만 배포되는 소프트웨어 (PyTorch, TensorFlow 같은 최신 ML 도구 대부분, 흔히 쓰는 생물정보학 도구들)
  • 충돌하는 의존성 스택. 같은 클러스터에서 작업 두 개가 서로 다른 CUDA 버전을 필요로 하는 경우 등
  • 재현성. .sif 파일 하나만 보관해두면 환경을 다시 빌드하지 않고도 몇 년 뒤에 다시 실행할 수 있습니다
  • Slurm 배치 작업 안에서 실행하기. Apptainer가 기본적으로 호스트의 파일시스템과 네트워크와 통합되기 때문입니다

알아둬야 할 제약:

  • user namespace가 그룹 하나만 매핑하기 때문에, 추가 그룹 멤버십이 컨테이너 안에서 완전히 보이지 않습니다.
  • 이 빌드 모드의 Apptainer에서는 암호화 기능(암호화된 SIF 이미지)이 아직 지원되지 않습니다.
  • 일부 오버레이 작업에는 FUSE 기반의 약간의 오버헤드가 있습니다. Apptainer 공식 문서는 일반적인 HPC 워크로드에서는 의미 있는 수준이 아니라고 설명하고, 이 클러스터에서 간단히 테스트해본 결과도 그 설명과 일치했습니다.

이 제약들은 홈랩이나 dnf로 설치했다는 사실과는 관계가 없습니다. 실제 HPC 센터에서도 똑같이 적용됩니다.

17. 트러블슈팅
#

Lmod와 Apptainer

apptainer: command not found

패키지가 설치되어 있는지 확인하세요: rpm -q apptainer. 바이너리는 /usr/bin/apptainer에 있어야 하고, 이미 PATH에 들어가 있어야 합니다.

--nv 플래그를 써도 GPU가 노출되지 않음

먼저 호스트에 NVIDIA 드라이버가 설치되어 있고 정상 동작하는지 확인하세요. 컨테이너 밖에서 nvidia-smi를 실행해보면 됩니다. 그건 되는데 컨테이너에서 여전히 GPU가 안 보이면, apptainer exec --nv pytorch_latest.sif nvidia-smi로 더 구체적인 에러를 확인하세요.

느린 연결에서 apptainer pull이 멈추거나 실패함

ML 프레임워크 이미지는 Docker Hub에서 pull할 때 용량이 클 수 있습니다. 먼저 작은 이미지로 외부 연결을 확인하세요: apptainer pull docker://alpine.

Apptainer가 의도치 않게 업그레이드됨

고정 상태를 확인하세요: grep excludepkgs /etc/dnf/dnf.conf. 목록에 apptainer가 없다면 섹션 11의 버전 고정 단계에 있는 sed 명령어를 다시 실행하세요.

18. 다음은
#

이제 클러스터에는 소프트웨어를 깔끔하게 관리하는 데 필요한 두 가지가 다 갖춰졌습니다. module load로 버전을 관리하는 Lmod, 그리고 격리된 환경이 필요하거나 컨테이너로만 배포되는 소프트웨어를 위한 Apptainer입니다. 이 둘만 있으면 “내 컴퓨터에서는 되는데 클러스터에서는 안 되는” 문제 대부분이 사라집니다.

에피소드 8에서는 MPI로 넘어갑니다. 이번 에피소드에서 등록한 OpenMPI 모듈을 써서 interceptor-01interceptor-02에 실제 멀티노드 작업을 돌려보고, OSU Micro-Benchmarks로 클러스터를 벤치마크해서 1GbE 인터커넥트가 실제 MPI 트래픽 아래에서 어떤 모습인지 확인합니다. Ansible playbook과 예시 definition 파일을 포함한 이번 에피소드의 모든 파일은 GitHub 저장소에 있습니다.


즐거운 컴퓨팅 되세요!

HPC From Scratch - 이 글은 시리즈의 일부입니다.
파트 7: 이 글