Rootless Podman의 SQLite 오류: 경로·쓰기 권한·UID 매핑

컨테이너에서 SQLite 파일을 열지 못할 때 먼저 드라이버의 오류 이름과 숫자를 보관하세요. unable to open database file과 attempt to write a readonly database는 다음 확인 대상이 다릅니다. DB 파일만 쓰기 가능하게 바꾸면 해결된다는 가정도 충분하지 않습니다. 아래에서는 SQLite의 파일 동작을 실제로 확인한 부분과 Podman의 사용자 매핑을 해석하는 부분을 나눕니다.

같은 임시 DB에서 확인한 세 가지 결과

2026-09-22, Linux 일반 사용자 계정에서 아래 코드를 실행했습니다. Python 3.12.3·SQLite 3.45.1, DELETE 저널 모드였으며 빈 테이블과 임시 파일만 사용했습니다. 존재하지 않는 부모 경로, 읽기 전용 연결, 쓰기 불가능한 부모 디렉터리를 따로 검사한 결과입니다.

case 실패한 동작 오류 이름 sqlite_errorcode 기본 코드
missing-parent connect SQLITE_CANTOPEN 14 14
readonly-connection INSERT / mode=ro SQLITE_READONLY 8 8
readonly-parent INSERT / parent 0500 SQLITE_READONLY_DIRECTORY 1544 8

1544의 기본 코드는 1544 & 255 = 8입니다. 이 실행에서는 DB 파일 권한을 그대로 둬도 부모 디렉터리에 저널을 만들 수 없어 INSERT가 실패했습니다. 따라서 부모 쓰기 실패를 항상 CANTOPEN 14로 분류하면 안 됩니다. READONLY_DIRECTORY 설명과 실제 드라이버의 확장 코드를 함께 봅니다.

오류 번호와 정리 결과를 얻는 실행 코드

sqlite-errors.py로 저장해 Linux의 일반 사용자로 python3 sqlite-errors.py를 실행합니다. sudo/root로 실행하면 예제가 거부합니다. Python 3.11 이상이 필요한 이유는 sqlite_errorcode·sqlite_errorname 속성을 사용하기 때문입니다. 설치 패키지나 기존 DB 경로를 받지 않으며 표준 라이브러리와 새 임시 디렉터리만 사용합니다.

"""Three SQLite boundaries using only disposable files, never an existing DB."""
import json
import os
import sqlite3
import stat
import sys
import tempfile
from contextlib import closing
from pathlib import Path

if sys.version_info < (3, 11) or not sys.platform.startswith('linux'):
    raise SystemExit('Use Python 3.11+ on Linux for this permission example')
if os.geteuid() == 0:
    raise SystemExit('Run as an ordinary user, without sudo/root')

observed = []
def attempt(label, path, sql=None, *, uri=False):
    try:
        with closing(sqlite3.connect(path, uri=uri)) as con:
            if sql is not None:
                con.execute(sql)
                con.commit()
    except sqlite3.Error as exc:
        observed.append({'case': label, 'name': exc.sqlite_errorname,
                         'code': exc.sqlite_errorcode,
                         'primary': exc.sqlite_errorcode & 255})
    else:
        raise RuntimeError(label + ': expected an error; review this environment')

with tempfile.TemporaryDirectory(prefix='sqlite-boundary-') as temp:
    root = Path(temp)
    db = root / 'sample.db'
    with closing(sqlite3.connect(db)) as con:
        if con.execute('PRAGMA journal_mode=DELETE').fetchone()[0] != 'delete':
            raise RuntimeError('This example requires DELETE journal mode')
        con.execute('CREATE TABLE t(x)')
        con.commit()
    attempt('missing-parent', root / 'missing' / 'sample.db')
    attempt('readonly-connection', db.as_uri() + '?mode=ro',
            'INSERT INTO t VALUES (1)', uri=True)
    original_mode = stat.S_IMODE(root.stat().st_mode)
    try:
        root.chmod(0o500)
        attempt('readonly-parent', db, 'INSERT INTO t VALUES (1)')
    finally:
        root.chmod(original_mode)
    restored = stat.S_IMODE(root.stat().st_mode) == original_mode

print(json.dumps({'python': sys.version.split()[0], 'sqlite': sqlite3.sqlite_version,
                  'journal_mode': 'delete', 'cases': observed,
                  'directory_permissions_restored': restored,
                  'temporary_directory_removed': not root.exists()}, indent=2))

정상적인 예제 실행은 세 SQLite 예외를 수집한 뒤 종료 코드 0으로 끝납니다. 출력의 cases를 위 표와 비교하고, directory_permissions_restored와 temporary_directory_removed가 모두 true인지 확인합니다. 실제 실행에서는 두 값이 참이었고, 실행기에서도 임시 작업 디렉터리가 비었음을 확인했습니다. 예상한 예외가 발생하지 않거나 실행 자체가 실패했다면 이를 재현 성공으로 세지 않습니다.

디렉터리 권한은 finally에서 복원하고 연결과 임시 파일은 문맥 관리자로 정리합니다. 강제 종료나 시스템 중단까지 정리를 보장하는 코드는 아닙니다. 결과가 다르면 Python·SQLite 버전, 저널 모드, 드라이버가 제공하는 확장 정보와 실행 계정을 함께 기록하세요. 이 표는 해당 조건의 관찰이며 모든 파일시스템의 결과를 고정하지 않습니다.

컨테이너 경로와 실제 앱 계정을 맞추기

다음 조회 명령은 자신의 Linux Podman 호스트에서 실행하며 app을 컨테이너 이름으로 바꿉니다. 컨테이너 안의 sh·cat·id가 필요합니다. 이 명령 목록은 공식 문서와 대조했지만 이번 환경에는 Podman이 없어 실제 실행하지 않았습니다.

podman inspect app --format '{{json .Mounts}}'
podman top app pid user hpid huser
podman exec app id
podman exec app sh -c 'cat /proc/self/uid_map; cat /proc/self/gid_map'
podman unshare cat /proc/self/uid_map

Mounts에서 호스트 Source·컨테이너 Destination·읽기 전용 여부를 앱의 DB 경로와 맞춥니다. podman top의 user와 huser는 프로세스의 컨테이너·호스트 사용자 판단에 쓰고, pid·hpid로 대상 프로세스를 구분합니다. podman exec app id는 새 exec 프로세스의 계정이므로 앱이 시작 후 권한을 내렸다면 앱 계정과 다를 수 있습니다. 명령 인자나 전체 환경을 덤프할 필요는 없습니다. podman top의 출력 필드를 참고하세요.

UID 매핑 숫자를 읽는 가상 예제

다음 숫자는 설명용 가상 매핑이며 실제 Podman 출력이 아닙니다. 세 열은 내부 시작 ID·외부 시작 ID·구간 길이입니다.

# inside_start outside_start length
0 1000 1
1 100000 65536

첫 줄은 내부 0을 외부 1000에, 둘째 줄은 내부 1부터 65536개를 외부 100000부터 대응시킵니다. 둘째 구간의 내부 1000은 100000 + (1000 - 1) = 100999입니다. 단, “외부”가 호스트라는 사실을 확인했을 때만 이를 호스트 UID로 읽습니다.

자기 namespace 안에서 읽는 /proc/self/uid_map의 외부 열은 부모 namespace 기준입니다. 호스트에서 다른 프로세스의 map을 읽을 때는 읽는 쪽 namespace가 기준이 될 수 있습니다. Rootless Podman에는 중간 namespace가 있을 수 있어 다음 매핑과 이어 계산해야 합니다. podman unshare의 map을 컨테이너 map과 같다고 가정하거나 컨테이너 UID 1000을 그대로 호스트 소유권에 대입하지 마세요. Linux uid_map의 관측 기준과 Podman user namespace 옵션을 함께 확인합니다.

--userns=keep-id 같은 실행 옵션도 매핑에 영향을 줍니다. GID와 보조 그룹도 실제 앱의 파일 접근에 관계합니다. podman unshare는 원격 클라이언트에서 사용할 수 없으므로 그 경우 실제 Linux 호스트에서 확인해야 합니다.

수정할 경계를 한 곳으로 좁히기

확인한 조건 다음 조치 피할 추정
DB 경로와 Mounts 목적지가 다름 앱 설정·마운트 목적지·부모 경로를 대조 파일 chmod만 바꾸면 해결됨
mode=ro 연결에서 쓰기를 시도함 앱이 의도한 읽기/쓰기 계약과 연결 설정 확인 호스트 소유권이 원인임
경로는 맞지만 journal 생성 단계에서 실패함 실제 앱 계정의 부모 쓰기·탐색 권한과 read-only mount 확인 DB 파일이 쓰기 가능하니 충분함
SELinux 호스트에서 거부가 의심됨 AVC 기록·볼륨 라벨·공유 범위를 별도로 확인 일괄 chmod/chown으로 모든 경계를 해결함

:U는 호스트 소유권을 재귀 변경할 수 있고 :Z와 :z는 공유 범위가 달라 무조건 붙일 옵션이 아닙니다. 이번 숫자 예제를 근거로 운영 볼륨의 소유권을 바꾸지 마세요.

WAL은 DB 옆의 보조 파일도 사용합니다. 읽기 전용 WAL에는 별도 허용 조건이 있어 모든 읽기에 디렉터리 쓰기가 필수라고 일반화하지 않습니다. WAL 문서의 조건을 확인하고 수정 후 실제 앱 계정으로 의도한 읽기와 승인된 작은 쓰기 트랜잭션을 검증하세요. 운영 DB에 임의 테스트 행을 넣거나 WAL 파일을 삭제하지 마세요. 이번 실행은 SQLite 파일 권한만 검증했으며 Podman·SELinux 복구 성공을 증명하지 않습니다.