사고 개요
2026년 5월 11일, GitHub Actions에 self-hosted runner를 처음 도입하던 날이었다.
CI 파이프라인에서 alembic upgrade head를 실행하는 단계가 있었다. 로컬에서는 잘 된다. PR을 올리고 CI를 돌렸더니:
Fatal Python error: Segmentation fault
Current thread 0x... (most recent call first):
File ".../alembic/runtime/migration.py", line ...
...
Segmentation fault (core dumped)
SIGSEGV. 세그폴트. 로컬에선 재현이 안 된다.
12번의 CI 실행 시도 끝에 원인을 찾았다.
초기 시도들 (실패)
시도 1~3: 환경 변수 의심
처음에는 CI 환경의 환경 변수 설정이 다를 거라 생각했다. PYTHONPATH, DATABASE_URL, PYTHONDONTWRITEBYTECODE 등을 맞춰봤다. 변화 없음.
시도 4~5: Python 버전 의심
로컬은 Python 3.12.7, CI는 Python 3.12.13이었다. patch 버전 차이가 영향을 줄 수 있다고 생각해서 CI에 python-version: "3.12.7" 고정을 시도했다.
여전히 SIGSEGV.
시도 6~7: alembic 버전 의심
Alembic의 버전 불일치일 수도 있다고 생각했다. requirements.txt를 pinning했다. 여전히 같은 에러.
시도 8: 더 자세한 스택 트레이스 얻기
PYTHONFAULTHANDLER=1 python -c "import alembic.config; alembic.config.main(['upgrade', 'head'])"
Fatal Python error: Segmentation fault
Thread 0x... (most recent call first):
File ".../greenlet/_greenlet.cpython-312-...so", line ...
File ".../alembic/runtime/migration.py", line ...
File ".../sqlalchemy/pool/impl.py", line ...
greenlet이 스택에 있었다. 단서가 생겼다.
원인 후보: greenlet C 확장 + Python 3.12 계열 CI 런타임
CI에서의 alembic 실행 방식을 보면:
# conftest.py 또는 CI 스크립트
import subprocess
import asyncio
async def setup_database():
# 테스트 DB 마이그레이션
result = subprocess.run(
["alembic", "upgrade", "head"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(result.stderr)
asyncio.run(setup_database())
asyncio.run() 내부에서 subprocess.run()을 호출하고, 그 subprocess에서 Alembic이 SQLAlchemy + greenlet을 초기화한다.
여기서 조심해야 할 점이 있다. Python 프로세스가 SIGSEGV로 죽으면 Python 예외처럼 정확한 원인 라인을 얻기 어렵다. 이 사례에서 확인한 사실은 다음 세 가지였다.
- 스택 트레이스에
greenlet/_greenlet...so가 반복해서 등장했다 - 로컬 Python 3.12.7에서는 재현되지 않았고 CI Python 3.12.13에서만 재현됐다
- Alembic CLI subprocess 호출을 in-process Alembic API 호출로 바꾸자 문제가 사라졌다
따라서 “Python 3.12.13 자체가 원인"이라고 단정하기보다는, CI 런타임의 Python 3.12 계열 환경에서 greenlet C 확장과 Alembic/SQLAlchemy 초기화 경로가 충돌했다고 보는 것이 더 정확하다. asyncio.run()은 직접적인 세그폴트 원인이라기보다, 테스트 준비 코드가 동기 Alembic CLI를 비동기 하네스 안에서 우회 호출하게 만든 구조적 냄새에 가까웠다.
핵심은 Alembic을 굳이 별도 Python 프로세스로 띄울 필요가 없었다는 점이다. 같은 런타임에서 Python API로 호출하면 실패 지점도 줄고, CI와 로컬의 실행 경로도 단순해진다.
해결: subprocess 대신 in-process alembic API
subprocess로 alembic을 호출하는 대신, 같은 프로세스 내에서 alembic Python API를 직접 호출하는 방식으로 전환했다.
# 수정 전: subprocess 방식
import subprocess
result = subprocess.run(
["alembic", "upgrade", "head"],
capture_output=True,
text=True,
)
# 수정 후: in-process API 방식
from alembic import command
from alembic.config import Config
def run_migrations():
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", str(settings.DATABASE_URL))
command.upgrade(alembic_cfg, "head")
subprocess를 제거하면 Alembic 실행 경로가 단순해진다. 별도 CLI 프로세스의 환경 변수, import path, 런타임 초기화 순서에 의존하지 않고, 테스트가 사용하는 설정 객체와 같은 프로세스에서 마이그레이션을 실행한다. 이 사례에서는 그 변경만으로 SIGSEGV가 사라졌다.
더 깊이: 왜 CLI subprocess 우회가 문제인가
Python의 asyncio.run()은 다음과 같이 동작한다.
# asyncio.run() 의 단순화된 내부 구조
def run(coro):
loop = events.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
run_until_complete() 실행 중 subprocess.run()을 호출하면 이벤트 루프 안에서 블로킹 IO가 발생한다. 이것만으로 곧장 SIGSEGV가 난다고 말할 수는 없다. 하지만 테스트 준비 코드가 비동기 흐름과 동기 CLI 실행을 섞고 있다는 신호다.
CLI subprocess 방식은 다음 문제를 만든다.
- CI와 로컬의
PATH, Python executable, site-packages 차이를 숨긴다 - 실패가 Python 예외가 아니라 프로세스 종료 코드와 stderr로만 전달된다
- Alembic 설정, DB URL, import path가 테스트 프로세스와 CLI 프로세스 사이에서 어긋날 수 있다
- greenlet 같은 C 확장이 개입한 세그폴트에서는 원인 좁히기가 더 어려워진다
Alembic은 Python API를 제공한다. 테스트 코드 안에서 이미 Python으로 DB를 준비하고 있다면, CLI subprocess보다 alembic.command.upgrade()가 더 단순하고 관찰 가능한 실행 경로다.
부작용으로 발견된 다른 문제들
이 디버깅 과정에서 두 가지 추가 문제를 발견했다.
SQLModel.metadata 전역 싱글톤 오염
alembic을 in-process로 실행하면서 SQLModel.metadata가 전역 싱글톤이라는 점이 문제가 됐다. 여러 마이그레이션 테스트를 순서대로 실행할 때, 이전 마이그레이션에서 등록된 인덱스 정보가 metadata에 남아 있어 다음 마이그레이션에서 batch_alter_table이 의도치 않은 인덱스를 생성하는 경우가 있었다.
해결: 각 마이그레이션 테스트 전에 SQLModel.metadata.clear()를 호출하거나, 별도의 MetaData() 인스턴스를 사용한다.
fileConfig(disable_existing_loggers=True)가 pytest caplog를 죽인다
alembic.ini의 기본 설정:
[alembic]
...
[loggers]
keys = root,sqlalchemy,alembic
# 기본값: True
[handler_console]
...
alembic의 fileConfig(alembic_cfg.config_file_name)를 호출하면, 기본적으로 disable_existing_loggers=True가 적용된다. 이 설정은 기존에 등록된 모든 로거를 비활성화한다. pytest의 caplog 픽스처가 사용하는 로거도 포함해서.
결과적으로 caplog로 로그를 캡처하려 해도 아무것도 잡히지 않는 현상이 발생한다.
해결: alembic 설정을 로드할 때 disable_existing_loggers를 명시적으로 False로 지정한다.
from logging.config import fileConfig
# alembic env.py 또는 conftest.py
fileConfig(
alembic_cfg.config_file_name,
disable_existing_loggers=False, # 명시적으로 False
)
최종 conftest.py 패턴
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def engine():
test_db_url = "postgresql://test:test@localhost:5432/test_db"
engine = create_engine(test_db_url)
# subprocess 대신 in-process API
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", test_db_url)
command.upgrade(alembic_cfg, "head")
yield engine
command.downgrade(alembic_cfg, "base")
engine.dispose()
subprocess 없음. asyncio.run() 없음. greenlet 충돌 없음.
greenlet 버전 업그레이드 옵션
대안으로 greenlet을 최신 버전으로 업그레이드하면 Python 3.12 호환성이 개선될 수 있다.
pip install --upgrade greenlet
greenlet 3.x는 Python 3.12의 변경된 내부 구조를 반영했다. 하지만 의존성 버전을 올리는 것보다 subprocess를 제거하는 것이 더 근본적인 해결이다. subprocess로 alembic을 실행하는 것은 원래부터 불필요한 방식이다.
정리
| 항목 | 내용 |
|---|---|
| 현상 | alembic upgrade head가 CI에서만 SIGSEGV로 종료 |
| 원인 | CI Python 3.12 계열 환경에서 greenlet C 확장이 포함된 Alembic/SQLAlchemy 실행 경로가 SIGSEGV 발생 |
| 해결 | subprocess 제거, in-process alembic.command.upgrade() 사용 |
| 부작용 발견 1 | SQLModel.metadata 전역 싱글톤 — 마이그레이션 테스트 간 오염 가능 |
| 부작용 발견 2 | fileConfig(disable_existing_loggers=True) 기본값이 pytest caplog 비활성화 |
| 시도 횟수 | 12번 |
Alembic은 Python API를 직접 제공한다. 테스트 준비 코드에서는 subprocess로 CLI를 호출하지 않아도 된다. 원인을 C 확장 내부까지 완전히 증명하지 못했더라도, 실행 경로를 단순화해서 재현 가능한 실패면 Python 예외로 드러나게 만드는 것이 더 좋은 디버깅 출발점이다.