django-sqlite-ops 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,987 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-sqlite-ops
3
+ Version: 0.1.0a1
4
+ Summary: [Experimental] Operations toolkit for running SQLite as a production database in Django
5
+ Project-URL: Homepage, https://github.com/itda-work/django-sqlite-ops
6
+ Project-URL: Repository, https://github.com/itda-work/django-sqlite-ops
7
+ Project-URL: Issues, https://github.com/itda-work/django-sqlite-ops/issues
8
+ Project-URL: Changelog, https://github.com/itda-work/django-sqlite-ops/blob/main/CHANGELOG.md
9
+ Author: itda-work
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: backup,django,litestream,operations,replication,sqlite,wal
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Framework :: Django :: 6.1
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: POSIX
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Database
24
+ Requires-Python: >=3.13
25
+ Requires-Dist: django>=5.2
26
+ Provides-Extra: channels-lite
27
+ Requires-Dist: channels-lite; extra == 'channels-lite'
28
+ Provides-Extra: nats
29
+ Requires-Dist: channels-nats; extra == 'nats'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # django-sqlite-ops
33
+
34
+ > ⚠️ **실험적 프로젝트입니다 (Experimental).** itda-work 가 실험 목적으로 공개하는 라이브러리입니다. API·동작·지원 범위가 예고 없이 바뀔 수 있으니, 운영 환경에 쓸 때는 버전을 고정하고 [CHANGELOG](https://github.com/itda-work/django-sqlite-ops/blob/main/CHANGELOG.md) 를 확인하세요.
35
+
36
+ Django 에서 SQLite 를 운영 DB 로 안전하게 쓰게 하는 **운영 도구**다. 권장 설정, 부팅 시 복원 판정, 진단, 복제 헬스, 배포 프로필을 맡는다. DB 백엔드를 바꾸거나 감싸지 않고, 표준 `DATABASES` 설정만 다룬다.
37
+
38
+ ## 현재 상태
39
+
40
+ PyPI 에 **실험적 사전 릴리스**로 배포한다([D-28](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md), 2026-10-26 결정). 첫 버전은 `0.1.0a1` 이다. 사전 릴리스는 버전을 명시해 설치한다(아래 [설치](#설치)). 바뀐 점은 [CHANGELOG](https://github.com/itda-work/django-sqlite-ops/blob/main/CHANGELOG.md) 에 적는다. 외부 홍보는 하지 않는다. 패키지 이름 `django-sqlite-ops` 는 확정이다(D-28 이 D-6 을 갱신).
41
+
42
+ | 기능 | 상태 | 이슈·근거 |
43
+ |---|---|---|
44
+ | [권장 설정](#권장-설정) `sqlite_database()` | 구현됨 | [#1](https://github.com/itda-work/django-sqlite-ops/issues/1) |
45
+ | [시스템 체크](#시스템-체크) | 구현됨 | [#5](https://github.com/itda-work/django-sqlite-ops/issues/5) |
46
+ | [`sqlite_doctor`](#sqlite_doctor) 관리 명령 | 구현됨 | [#6](https://github.com/itda-work/django-sqlite-ops/issues/6) |
47
+ | [boot CLI](#boot-cli) (복원 판정·복원·잠금) | 구현됨 | [#2](https://github.com/itda-work/django-sqlite-ops/issues/2) · [#3](https://github.com/itda-work/django-sqlite-ops/issues/3) · [#4](https://github.com/itda-work/django-sqlite-ops/issues/4) |
48
+ | [복제 헬스](#복제-헬스) | 구현됨 | [#7](https://github.com/itda-work/django-sqlite-ops/issues/7) |
49
+ | [channels-lite 패치](#channels-lite-패치) | 구현됨 | [#8](https://github.com/itda-work/django-sqlite-ops/issues/8) |
50
+ | [배포 프로필 문서](#배포-프로필) | 구현됨 | [#9](https://github.com/itda-work/django-sqlite-ops/issues/9) |
51
+ | 회귀 랩(Docker 로 실패 경계 L1–L8 재현, 프로필 Dockerfile·compose 종단 검증, PRAGMA 벤치). 수동 실행(`scripts/lab.sh`, GitHub Actions `lab.yml`), 기본 CI 밖. arm64·amd64 모두 통과 | 구현됨 | [#10](https://github.com/itda-work/django-sqlite-ops/issues/10) · [lab/README.md](https://github.com/itda-work/django-sqlite-ops/blob/main/lab/README.md) · [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md) |
52
+ | [복원 검증](#복원-검증---restore-test) `sqlite_doctor --restore-test` | 구현됨 | [#52](https://github.com/itda-work/django-sqlite-ops/issues/52)(설계) · [#54](https://github.com/itda-work/django-sqlite-ops/issues/54) · [DESIGN §6-2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#6-2-sqlite_doctor-명시-실행-실제-연결) |
53
+ | VFS 읽기 복제본용 DB 라우터 | 예정(2단계) | [DESIGN §3-2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#3-2-2단계-이후) |
54
+
55
+ ## 설치
56
+
57
+ 요구 버전: Python 3.13+, Django 5.2+, SQLite 3.37+. boot CLI 는 POSIX(Linux·macOS)와 PATH 의 `litestream` 0.5.17 이 필요하다([boot CLI](#boot-cli)).
58
+
59
+ PyPI 에서 설치한다. 사전 릴리스는 버전을 명시해 설치한다. 실험적 프로젝트이므로 그 버전으로 고정해 둔다.
60
+
61
+ ```bash
62
+ pip install "django-sqlite-ops==0.1.0a1"
63
+ # 또는
64
+ uv add "django-sqlite-ops==0.1.0a1"
65
+ ```
66
+
67
+ 필수 의존성은 Django 하나다. 채널 레이어는 extra 로 고른다: `[nats]`(channels-nats), `[channels-lite]`(channels-lite 만. aio 레이어에 필요한 의존성은 [channels-lite 패치 '함정'](#channels-lite-패치)).
68
+
69
+ ```bash
70
+ pip install "django-sqlite-ops[nats]==0.1.0a1"
71
+ uv add "django-sqlite-ops[channels-lite]==0.1.0a1"
72
+ ```
73
+
74
+ `pip install --pre` 나 `uv add --prerelease=allow` 는 쓰지 않는다. 이 패키지뿐 아니라 Django 를 비롯한 **모든 의존성**의 사전 릴리스까지 허용한다(`uv add --prerelease=allow` 는 `uv.lock` 에 `prerelease-mode = "allow"` 로 남아 이후의 해석에도 적용된다). 버전을 지정자에 명시하면 그 사전 릴리스만 받아들여진다. 정식 릴리스가 나온 뒤에는 명시하지 않으면 사전 릴리스를 고르지 않는다.
75
+
76
+ 릴리스되지 않은 `main` 을 쓰려면 GitHub 에서 받는다: `pip install git+https://github.com/itda-work/django-sqlite-ops`.
77
+
78
+ ## 빠른 시작
79
+
80
+ 컨테이너 한 대에 앱 프로세스 하나(`single-server`)로 배포하는 최소 조합이다. 경로는 컨테이너 기준 `/data/app.sqlite3`·`/etc/litestream.yml` 이다. Dockerfile·compose 까지 포함한 전체 조각과 운영 절차는 [배포 프로필](#배포-프로필) 문서에 있다.
81
+
82
+ **1. `settings.py`** — 앱 등록, 권장 설정, 헬스 설정.
83
+
84
+ ```python
85
+ # settings.py
86
+ from django_sqlite_ops.database import sqlite_database
87
+
88
+ INSTALLED_APPS = [
89
+ # ... 프로젝트의 앱들
90
+ "django_sqlite_ops", # manage.py check 가 시스템 체크를 돈다
91
+ ]
92
+
93
+ DATABASES = {
94
+ # 실제 경로로 쓴다. boot 의 --db, litestream.yml 의 path 와 같은 값 (경로 규칙)
95
+ "default": sqlite_database("/data/app.sqlite3"),
96
+ }
97
+
98
+ SQLITE_OPS = {
99
+ "PROFILE": "single-server",
100
+ "HEALTH": {"DATABASES": {"default": {"litestream_config": "/etc/litestream.yml"}}},
101
+ }
102
+ ```
103
+
104
+ `sqlite_database()` 가 무엇을 켜는지는 [권장 설정](#권장-설정), `SQLITE_OPS["HEALTH"]` 의 다른 키는 [복제 헬스](#복제-헬스).
105
+
106
+ **2. `urls.py`** — 복제 헬스 엔드포인트. 내부망에만 노출한다([노출 범위](#복제-헬스)).
107
+
108
+ ```python
109
+ # urls.py
110
+ from django.urls import path
111
+
112
+ from django_sqlite_ops.health import health_view
113
+
114
+ urlpatterns = [
115
+ path("internal/sqlite-health", health_view),
116
+ ]
117
+ ```
118
+
119
+ **3. `litestream.yml`** — `path` 는 settings 의 `NAME` 과 같은 실제 경로다. endpoint·자격 증명은 [single-server §3](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server.md#litestreamyml).
120
+
121
+ ```yaml
122
+ # /etc/litestream.yml
123
+ dbs:
124
+ - path: /data/app.sqlite3
125
+ replica:
126
+ type: s3
127
+ bucket: my-app-backups
128
+ path: app # 버킷 안 prefix. 오타가 나면 '복제본 없음'과 구분되지 않는다
129
+ region: us-east-1
130
+ ```
131
+
132
+ **4. `entrypoint.sh`** — boot 가 판정·복원한 뒤 litestream 이 앱을 띄우고 복제한다. `migrate` 는 `-exec` 안에서 돈다([boot CLI](#boot-cli)).
133
+
134
+ ```bash
135
+ #!/bin/sh
136
+ # BOOT_FLAGS: 한 번만 쓰는 boot 옵션(--init-new 등). 평소에는 비워 둔다.
137
+ set -eu
138
+ # shellcheck disable=SC2086 # BOOT_FLAGS 는 단어로 나뉘어야 한다
139
+ exec python -m django_sqlite_ops.boot \
140
+ --db /data/app.sqlite3 \
141
+ --config /etc/litestream.yml \
142
+ ${BOOT_FLAGS:-} \
143
+ -- litestream replicate -config /etc/litestream.yml \
144
+ -exec "sh -c 'python manage.py migrate --noinput && exec uvicorn proj.asgi:application --host 0.0.0.0 --port 8000 --workers 1'"
145
+ ```
146
+
147
+ **5. 첫 배포** — 볼륨도 복제본도 비어 있으면 boot 는 거부한다. 생애 첫 배포에만 `BOOT_FLAGS=--init-new` 로 띄우고, 첫 복제를 확인한 뒤 비운다([생애 첫 배포](#생애-첫-배포---init-new-를-한-번-쓰고-끈다)). 이미 운영 중인 DB 를 올린다면 `--adopt-existing` 이다([기존 DB 도입](#기존-db-도입---adopt-existing-을-한-번-쓰고-끈다)).
148
+
149
+ **6. 확인** — 배포마다 이 순서로 본다.
150
+
151
+ ```bash
152
+ python manage.py check --deploy # 설정만 본다(DB 를 열지 않음). sqlite_ops.* 가 없어야 한다
153
+ python manage.py sqlite_doctor --litestream-config /etc/litestream.yml # 실제로 연결해 진단. exit 0
154
+ curl -s http://127.0.0.1:8000/internal/sqlite-health # 본문 status 가 caught_up
155
+ ```
156
+
157
+ - `check --deploy` 는 빌드 단계·CI 에서도 돌린다([시스템 체크](#시스템-체크)).
158
+ - `sqlite_doctor` 는 DB 에 연결하므로 `init_command` 가 실행된다([주의](#sqlite_doctor)).
159
+ - 헬스의 첫 응답은 `unknown`(`not_checked`)이다. `REFRESH`(기본 15초) 뒤 다시 본다. HTTP 상태는 항상 200 이므로 본문의 `status` 로 판정한다([복제 헬스](#복제-헬스)).
160
+
161
+ ## 활용 가이드
162
+
163
+ ### 권장 설정
164
+
165
+ `sqlite_database(경로)` 는 그대로 `DATABASES` 에 들어가는 평범한 dict 를 돌려준다. DB 백엔드를 바꾸거나 감싸지 않는다.
166
+
167
+ ```python
168
+ {
169
+ "ENGINE": "django.db.backends.sqlite3",
170
+ "NAME": "/path/to/app.sqlite3",
171
+ "OPTIONS": {
172
+ "transaction_mode": "IMMEDIATE",
173
+ "init_command": "PRAGMA journal_mode=WAL;PRAGMA synchronous=NORMAL;PRAGMA busy_timeout=5000",
174
+ },
175
+ }
176
+ ```
177
+
178
+ `django_sqlite_ops.database` 는 표준 라이브러리만 import 한다. 그래서 `settings.py` 에서 불러도 Django 를 미리 불러오지 않는다. 경로는 문자열이나 `Path` 로 준다(`BASE_DIR / "app.sqlite3"` 등). 운영에서는 boot·Litestream 과 같은 [실제 경로](#경로-규칙)를 쓴다.
179
+
180
+ #### 기본값
181
+
182
+ 실측 근거가 있는 값만 기본으로 켠다. 근거는 [`docs/DESIGN.md`](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md) §6-0 에 있다.
183
+
184
+ | 항목 | 기본값 | 이유 |
185
+ |---|---|---|
186
+ | `transaction_mode` | `IMMEDIATE` | 트랜잭션 시작 때 쓰기 잠금을 잡는다. gunicorn 4 워커·스레드 8 로 1,200건 쓸 때 `database is locked` 0건 |
187
+ | `journal_mode` | `WAL` | 읽기와 쓰기가 서로 막지 않는다. Litestream 이 없는 개발·테스트 환경도 운영과 같게 맞춘다 |
188
+ | `synchronous` | `NORMAL` | WAL 에서 쓰기 비용을 줄인다. 체크포인트 전에 전원이 나가면 마지막 트랜잭션이 빠질 수 있다 |
189
+ | `busy_timeout` | `5000` (밀리초) | 잠금을 만나면 바로 실패하지 않고 5초 기다린다. Python `sqlite3` 의 `timeout` 기본 5초와 같은 값 |
190
+
191
+ 건드리지 않는 것: `foreign_keys` 는 Django 가 이미 켠다. `wal_autocheckpoint` 는 Litestream 이 체크포인트를 관리하므로 바꾸지 않는다. `temp_store`, `mmap_size`, `cache_size`, `journal_size_limit` 는 기본값이 아니다. 회귀 랩 벤치에서 개선이 반복해서 재현된 것이 없고, `cache_size=-65536` 만 켜면 오히려 처리량·p99 가 반복해서 나빠졌다(`CONN_MAX_AGE` 기본 0, [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md#pragma-벤치)). `CONN_MAX_AGE=None`·WAL 이 자라는 부하로 다시 잰 [재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md)에서도 개선은 재현되지 않았다. `cache_size` 를 올릴 때는 자기 부하로 잰다. 최소 재현([원인 조사](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/cache-size-2026-10-12.md))에서 64 MiB 로 올리면 방향이 부하에 따라 갈렸다. 연결당 요청 1~10개의 혼합 부하(인덱스 없는 정렬 10%), 연결 하나에서 많은 페이지를 한 번씩만 읽는 요청, 여러 연결이 쓰는 재사용 연결에서는 처리량이 크게 떨어졌다(8 MiB 도 같음). 캐시가 상한에 닿기 전에는 페이지 버퍼를 재활용하지 않고 새로 할당·해제하기 때문이다. 반대로 한 연결(요청·트랜잭션) 안에서 같은 페이지를 다시 읽는 요청과 다른 연결의 쓰기가 없는 재사용 연결에서는 좋아졌다. `journal_size_limit` 은 Litestream 과 함께 쓰면 앱에서 걸어도 WAL 크기를 거의 묶지 못했다(64 MiB 로 잘린 뒤 다시 자람). SQLite 는 WAL 재시작 뒤 첫 커밋한 연결의 한도로만 자르고 Litestream 은 체크포인트 직후 자기 연결로 쓰기 때문으로 본다(구조는 소스로 확인, 어느 연결이 먼저 커밋했는지는 관측하지 않음). 필요하면 `pragmas` 로 직접 켜고 자기 부하로 잰다.
192
+
193
+ #### 프로필
194
+
195
+ `profile` 은 `"single-server"`(기본)와 `"single-server-multiproc"` 두 가지다. 지금은 두 프로필의 DB 설정이 같다. 배포 형태(워커 프로세스가 하나인지 여럿인지)에 맞는 이름을 고르면, 나중에 프로필별 값이 갈라질 때 설정을 고치지 않아도 된다. 다른 이름은 `ValueError` 다.
196
+
197
+ ```python
198
+ from django_sqlite_ops.database import sqlite_database
199
+
200
+ db = sqlite_database("/srv/app/app.sqlite3", profile="single-server-multiproc")
201
+ ```
202
+
203
+ #### PRAGMA 바꾸기: `pragmas`
204
+
205
+ `pragmas` 의 값으로 기본 PRAGMA 를 덮거나 새 PRAGMA 를 더한다. 값이 `None` 이면 그 PRAGMA 를 뺀다. 이름은 SQLite 처럼 대소문자를 가리지 않는다.
206
+
207
+ ```python
208
+ from django_sqlite_ops.database import sqlite_database
209
+
210
+ db = sqlite_database(
211
+ "/srv/app/app.sqlite3",
212
+ pragmas={
213
+ "synchronous": "FULL", # 덮기
214
+ "temp_store": "MEMORY", # 더하기
215
+ "busy_timeout": None, # 빼기
216
+ },
217
+ )
218
+ assert db["OPTIONS"]["init_command"] == (
219
+ "PRAGMA journal_mode=WAL;PRAGMA synchronous=FULL;PRAGMA temp_store=MEMORY"
220
+ )
221
+ ```
222
+
223
+ 값은 정수, `True`/`False`(`ON`/`OFF` 로 바뀐다), 또는 `WAL`·`-2000` 같은 따옴표 없는 단어만 받는다.
224
+
225
+ #### `OPTIONS` 더하기: `options`
226
+
227
+ `options` 의 키는 `OPTIONS` 에 더해지고, 같은 키는 덮는다. `init_command` 만은 받지 않는다.
228
+
229
+ ```python
230
+ from django_sqlite_ops.database import sqlite_database
231
+
232
+ db = sqlite_database("/srv/app/app.sqlite3", options={"check_same_thread": False})
233
+ assert db["OPTIONS"]["transaction_mode"] == "IMMEDIATE"
234
+ assert db["OPTIONS"]["check_same_thread"] is False
235
+ ```
236
+
237
+ #### `timeout` 과 `busy_timeout`
238
+
239
+ 둘 다 잠금을 기다리는 시간이지만 단위가 다르다. `options` 의 `timeout` 은 초, PRAGMA `busy_timeout` 은 밀리초다. Django 는 연결을 연 뒤에 `init_command` 를 실행하므로 `busy_timeout` 이 이긴다. 그래서 `options={"timeout": 20}` 만 주면 대기 시간은 여전히 5000ms 다.
240
+
241
+ ```python
242
+ from django_sqlite_ops.database import sqlite_database
243
+
244
+ # 대기 시간을 20초로: busy_timeout 을 덮는다 (권장)
245
+ db = sqlite_database("/srv/app/app.sqlite3", pragmas={"busy_timeout": 20000})
246
+
247
+ # timeout 을 쓰고 싶다면 busy_timeout 을 함께 뺀다
248
+ db = sqlite_database(
249
+ "/srv/app/app.sqlite3",
250
+ options={"timeout": 20},
251
+ pragmas={"busy_timeout": None},
252
+ )
253
+ ```
254
+
255
+ #### 거부되는 입력
256
+
257
+ 판정할 수 없는 설정은 조용히 넘기지 않고 `ValueError` 로 거부한다.
258
+
259
+ | 입력 | 이유 |
260
+ |---|---|
261
+ | `options={"init_command": ...}` | 생성한 PRAGMA 와 충돌한다. PRAGMA 는 `pragmas=` 로 준다 |
262
+ | 식별자가 아닌 PRAGMA 이름 (`"journal-mode"`, `"main.journal_mode"`, `"a;b"`) | Django 는 `init_command` 를 `;` 로 나눠 문장마다 실행한다. 이름에 다른 문장이 끼어드는 것을 막는다 |
263
+ | 정수·불리언·따옴표 없는 단어가 아닌 값 (`"WAL; DROP TABLE t"`, `"'x'"`, `1.5`) | 위와 같은 이유 |
264
+ | 대소문자만 다른 같은 이름을 두 번 (`{"busy_timeout": 1, "BUSY_TIMEOUT": None}`) | 어느 쪽을 적용할지 정할 수 없다 |
265
+ | 알 수 없는 `profile` | 권장값 표에 없는 프로필이다 |
266
+
267
+ #### 여러 DB 별칭
268
+
269
+ 별칭마다 따로 부른다. 파일이 다르면 잠금도 따로라서 쓰기가 많은 데이터를 다른 파일로 떼어 낼 수 있다.
270
+
271
+ ```python
272
+ from pathlib import Path
273
+
274
+ from django_sqlite_ops.database import sqlite_database
275
+
276
+ BASE_DIR = Path(__file__).resolve().parent.parent
277
+
278
+ DATABASES = {
279
+ "default": sqlite_database(BASE_DIR / "app.sqlite3"),
280
+ "events": sqlite_database(BASE_DIR / "events.sqlite3", pragmas={"busy_timeout": 10000}),
281
+ }
282
+ ```
283
+
284
+ #### 직접 쓴 설정과 dj-lite
285
+
286
+ `sqlite_database()` 를 쓰지 않아도 된다. 직접 쓴 dict 나 dj-lite 가 만든 설정도 [시스템 체크](#시스템-체크)가 같은 권장값 표를 기준으로 검사한다. [`sqlite_doctor`](#sqlite_doctor) 도 같은 표로 실제 값을 비교한다.
287
+
288
+ ### 시스템 체크
289
+
290
+ `INSTALLED_APPS` 에 `"django_sqlite_ops"` 를 넣으면 `manage.py check` 가 `DATABASES` 의 SQLite 별칭(`ENGINE` 이 `django.db.backends.sqlite3`)을 [권장값](#기본값)과 비교한다. **설정만 읽고 DB 를 열지 않는다.** 그래서 DB 파일이 없어도, 빌드 단계에서도 돌릴 수 있다. 실제로 적용된 PRAGMA 값은 [`sqlite_doctor`](#sqlite_doctor)가 본다.
291
+
292
+ ```bash
293
+ python manage.py check --deploy # W001·W002·W004 는 --deploy 일 때만 나온다
294
+ ```
295
+
296
+ | ID | 조건 | 언제 | 고치는 법 |
297
+ |---|---|---|---|
298
+ | `sqlite_ops.E001` | `SQLITE_OPS` 가 dict 가 아니거나 `PROFILE` 이 알 수 없는 이름 | 항상 | `PROFILE` 을 `"single-server"`·`"single-server-multiproc"` 중 하나로. 이 오류가 있으면 다른 체크는 돌지 않는다 |
299
+ | `sqlite_ops.E002` | `SQLITE_OPS["HEALTH"]` 가 잘못됨: dict 가 아님, 모르는 키, `DATABASES` 가 비었거나 `DATABASES` 에 없는·sqlite3 가 아닌 별칭, `litestream_config` 없음, `REFRESH` 가 양수가 아님, `BACKLOG_GRACE` 가 음수 등. `HEALTH` 가 없으면 검사하지 않는다 | 항상 | [복제 헬스](#복제-헬스)의 설정 형식대로 고친다 |
300
+ | `sqlite_ops.W001` | `OPTIONS["transaction_mode"]` 가 `IMMEDIATE` 가 아님(없음 포함, 대소문자 무시) | `--deploy` | `sqlite_database()` 를 쓰거나 `"transaction_mode": "IMMEDIATE"` 를 넣는다 |
301
+ | `sqlite_ops.W002` | `OPTIONS["init_command"]` 에서 `journal_mode` 가 `WAL` 로 설정되지 않음, 또는 `journal_mode` 를 언급하는 문장의 형식을 판정할 수 없음 | `--deploy` | `sqlite_database()` 를 쓰거나 `init_command` 에 `PRAGMA journal_mode=WAL` 을 넣는다 |
302
+ | `sqlite_ops.W003` | 이름이 Litestream VFS(`vfs=litestream` 이 든 `file:` URI)인 별칭의 `CONN_MAX_AGE` 가 `None` 이 아님, 그리고 [ASGI 근거](#asgi-판단)가 없음(WSGI 로 본다) | 항상 | 그 별칭에 `"CONN_MAX_AGE": None`. WSGI 실측에서 요청당 1,008ms → 1.7ms |
303
+ | `sqlite_ops.W005` | VFS 가 아닌 SQLite 별칭의 `CONN_MAX_AGE` 가 `None`, 그리고 [ASGI 근거](#asgi-판단)가 있음 | 항상 | 그 별칭의 `"CONN_MAX_AGE"` 를 `0` 으로 하거나 키를 지운다. ASGI 동기 뷰에서는 연결이 재사용되지 않고 닫히지도 않는다([문제 해결](#설정체크)) |
304
+ | `sqlite_ops.W004` | `NAME` 의 URI 로 별칭 역할(쓰기·읽기 전용·메모리·VFS)을 판정할 수 없음. 이때 W001·W002 는 내지 않는다 | `--deploy` | authority 는 비우거나 `localhost` 로(`file:///path`), `%00` 을 빼고, URI 쿼리 키 `mode`·`immutable`·`vfs` 를 한 번씩, 표준 표기(`mode=ro\|rw\|rwc\|memory`, `immutable=1\|0`)로 쓴다 |
305
+
306
+ - 기준값은 `sqlite_database()` 와 같은 권장값 표에서 읽는다. 프로필은 `SQLITE_OPS["PROFILE"]` 이고 없으면 `"single-server"` 다.
307
+ - `sqlite_database()` 로 만든 설정은 경고가 없다(W003 은 VFS 별칭에 `CONN_MAX_AGE` 를 따로 줘야 한다).
308
+ - W002 는 Django 처럼 `init_command` 를 `;` 로 나눈 각 문장을 본다. SQL 주석(`-- …`, `/* … */`)은 지우고 본다. `PRAGMA journal_mode = wal`, `PRAGMA main.journal_mode=WAL`, `PRAGMA "journal_mode"=WAL`, `PRAGMA journal_mode('wal')` 처럼 대소문자·공백·인용 식별자·`main.` 접두가 달라도 인정하고, 여러 번 설정했으면 마지막 값을 본다. `temp.` 같은 다른 스키마는 이 DB 의 모드를 바꾸지 않으므로 세지 않는다.
309
+ - 정적 체크는 SQL 파서가 아니다. `journal_mode` 를 언급하는데 위 형식이 아닌 문장(`SELECT … pragma_journal_mode`, 알 수 없는 값 등)이 있으면 **판정할 수 없다는 W002** 를 낸다. 메시지에 그 문장이 나온다. `PRAGMA journal_mode=WAL` 형식으로 고쳐 쓴다.
310
+ - 별칭의 역할은 `NAME` 으로 판별한다. `NAME` 이 `Path` 면 문자열로 바꿔 본다. `file:` URI 는 SQLite 처럼 첫 `?`·`#` 로 파일명과 쿼리를 나눈 **뒤** 퍼센트 디코딩한다(`file:%3Amemory%3A` 는 메모리, `…/q%3Fmode%3Dro.sqlite3` 는 파일명이 `q?mode=ro.sqlite3` 인 쓰기 DB).
311
+ - 메모리 DB — `:memory:`, 파일명이 정확히 `:memory:` 인 URI(`file::memory:`, `file::memory:?cache=shared`), `mode=memory`: WAL 이 의미 없어 W002 를 건너뛴다. `file::memory:backup.sqlite3` 는 실제 파일이라 검사한다.
312
+ - 읽기 전용 — `mode=ro`, `immutable` 참값(`1`·`yes`·`true`·`on`, 대소문자 무시): W001·W002 를 건너뛴다. 읽기 전용 연결에 `PRAGMA journal_mode=WAL` 을 넣으면 `attempt to write a readonly database` 로 연결이 깨지거나(`mode=ro`) 아무 효과가 없다(`immutable`). `immutable` 거짓값(`0`·`no`·`false`·`off`)은 쓰기 DB 다.
313
+ - Litestream VFS(`vfs=litestream`): W001·W002 를 건너뛴다. W003 은 그대로 본다.
314
+ - **판정할 수 없음 → W004** — `mode`·`immutable`·`vfs` 가 두 번 이상 나오거나(`mode=rwc&mode=ro`), `mode` 가 `ro`·`rw`·`rwc`·`memory` 가 아니거나(`mode=RO`, `mode=ro%00x`), `immutable` 이 위 불리언 표기가 아닐 때(`immutable=2`), 디코딩한 파일명이나 쿼리 키·값에 NUL(`%00`)이 있을 때(`mode%00x=ro` — SQLite 는 NUL 앞까지만 읽어 역할 키가 숨는다. URI 가 아닌 일반 경로의 `%00` 은 글자 그대로라 해당 없다), `file://` 뒤 authority 가 빈 값이나 정확히 `localhost` 가 아닐 때(`file://example.com/…`, `file://LOCALHOST/…` — SQLite 는 연결 오류를 낸다. `file://localhost/srv/app.sqlite3`·`file:///srv/app.sqlite3` 는 로컬 파일이다). 이때는 W001·W002 를 내지 않는다. SQLite 는 중복 키를 순서에 따라 다르게 해석해서(`mode=rwc&mode=ro` 는 읽기 전용, `mode=ro&mode=rwc` 는 연결 오류) 쓰기 권고를 따르면 연결이 깨질 수 있기 때문이다. W004 는 쓰기 설정을 넣어도 사라지지 않는다. 쿼리 키를 한 번씩, 표준 표기로 고쳐 쓴다.
315
+ - W003 은 ASGI 에서는 내지 않는다. ASGI 의 VFS 별칭 영속 연결은 아직 재지 않았고, Django 는 async 에서 영속 연결을 끄라고 권한다.
316
+ - W005 는 ASGI 에서 일반(VFS 아닌) 별칭에 `CONN_MAX_AGE=None` 을 준 경우다. uvicorn·Django 6.1 동기 뷰 실측에서 DB 요청마다 연결이 새로 열렸고(재사용 없음), 요청이 끝나도 닫히지 않아 열린 DB 파일 디스크립터가 810–982개(한도 1024)까지 쌓이며 `unable to open database file` 500 이 났다. fd 고갈이 원인이라는 것은 추정이다([재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md#결과-연결-재사용)).
317
+
318
+ #### ASGI 판단
319
+
320
+ W003 과 W005 는 같은 규칙으로 ASGI 인지 판단한다. 한 설정에서 둘의 판단이 엇갈리지 않는다(W003 은 WSGI 일 때만, W005 는 ASGI 일 때만 난다). 서버 실행 명령은 설정에서 보이지 않으므로 아래 근거만 본다.
321
+
322
+ | 설정 | 판단 |
323
+ |---|---|
324
+ | `ASGI_APPLICATION` 이 빈 값이 아님 | ASGI |
325
+ | `SQLITE_OPS` 에 `"PROFILE"` 키가 있음(값은 무엇이든). 두 [배포 프로필](#배포-프로필)은 ASGI 서버(uvicorn)로 띄운다 | ASGI |
326
+ | 둘 다 없음(`WSGI_APPLICATION` 은 `startproject` 가 늘 넣으므로 보지 않는다) | WSGI |
327
+
328
+ - 놓치는 경우: `SQLITE_OPS["PROFILE"]` 도 `ASGI_APPLICATION` 도 없이 `uvicorn proj.asgi:application` 으로 띄우면 WSGI 로 본다. W005 가 나지 않고, VFS 별칭이면 W003 이 난다. 프로필을 적어 두면 ASGI 로 판단된다.
329
+ - 잘못 잡는 경우: 프로필을 적었거나 `ASGI_APPLICATION` 이 있는데 실제로는 gunicorn(WSGI)으로 띄우면 ASGI 로 본다. W005 가 나고, VFS 별칭의 W003 은 나지 않는다. 이때는 `SILENCED_SYSTEM_CHECKS = ["sqlite_ops.W005"]` 로 끈다.
330
+ - 경고를 끄려면 Django 표준 `SILENCED_SYSTEM_CHECKS` 를 쓴다. 이 앱의 체크만 돌리려면 `check --tag sqlite_ops`.
331
+
332
+ ```python
333
+ # settings.py — 직접 쓴 설정도 같은 기준으로 검사된다
334
+ INSTALLED_APPS = ["django_sqlite_ops"]
335
+
336
+ SQLITE_OPS = {"PROFILE": "single-server-multiproc"}
337
+
338
+ DATABASES = {
339
+ "default": {
340
+ "ENGINE": "django.db.backends.sqlite3",
341
+ "NAME": "/srv/app/app.sqlite3",
342
+ "OPTIONS": {
343
+ "transaction_mode": "IMMEDIATE",
344
+ "init_command": "PRAGMA journal_mode=WAL;PRAGMA busy_timeout=5000",
345
+ },
346
+ },
347
+ }
348
+ ```
349
+
350
+ ### `sqlite_doctor`
351
+
352
+ `manage.py check` 는 설정 문자열만 본다. `sqlite_doctor` 는 **DB 에 실제로 연결해** `init_command` 가 실행된 뒤의 값을 보고, 파일시스템 종류·Litestream 설정·채널 레이어·열린 파일 한도까지 한 번에 진단한다. 배포 직후, 설정을 바꾼 뒤, 장애를 조사할 때 명시적으로 실행한다.
353
+
354
+ ```bash
355
+ python manage.py sqlite_doctor
356
+ python manage.py sqlite_doctor --database default --litestream-config /etc/litestream.yml
357
+ python manage.py sqlite_doctor --json # 모니터링·스크립트용
358
+ ```
359
+
360
+ | 옵션 | 뜻 |
361
+ |---|---|
362
+ | `--database ALIAS` | 볼 별칭(여러 번 줄 수 있다). 없으면 sqlite3 엔진 별칭 전부 |
363
+ | `--litestream-config PATH` | Litestream 설정 파일. 주면 그 DB 목록을 `DATABASES` 와 대조한다. 없으면 이 섹션은 건너뛴다. 상대 경로는 명령을 실행한 디렉터리 기준이다 |
364
+ | `--litestream BIN` | Litestream 바이너리(기본 PATH 의 `litestream`, 0.5.17 만 받는다) |
365
+ | `--json` | JSON 출력(스키마 `version: 1`, [DESIGN §6-2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md)). 열린 파일 한도는 `section` `process`, `key` `nofile_soft`·`nofile_hard`(값은 정수 또는 `"unlimited"`) 또는 `nofile`(값 `null`, 읽지 못함) |
366
+
367
+ > **주의 — 연결하면 `init_command` 가 실행되고 `-wal`·`-shm` 이 생길 수 있다.** Django 는 연결할 때마다 `OPTIONS["init_command"]` 를 실행하므로 진단이 DB 를 바꿀 수 있다. 예를 들어 `PRAGMA journal_mode=WAL` 은 파일에 남는다. 또 WAL 모드 DB 는 연결만 해도 SQLite 가 `-wal`·`-shm` 파일을 만든다(`init_command` 가 없어도). 쓰기 별칭은 앱이 연결할 때와 같은 일이므로 그대로 연결한다. 운영 DB 에서 돌린다는 점을 알고 실행한다.
368
+ >
369
+ > **읽기 전용 별칭은 파일을 바꾸지 않는다.** DB 헤더가 WAL 이면 `-shm` 이 있든 없든 연결하지 않고 `unknown` 으로 적는다. WAL 읽기는 SQLite 가 본래 `-shm` 에 쓰고 `-wal` 을 만들 수 있기 때문이다(재현함: 없던 `-wal` 생성, 빈 `-shm` 이 32KB 로 커짐, 쓰는 프로세스가 열려 있거나 비정상 종료 뒤 남은 `-shm` 의 내용 변경). `immutable=1` URI 는 사이드카를 건드리지 않으므로(실측) 연결한다. **읽기 전용 WAL 복제본은 쓰기 쪽 별칭이나 `immutable` URI 로 진단한다.** 예: `DATABASES["ro"]` 가 `file:/srv/app.sqlite3?mode=ro` 면 같은 파일의 쓰기 별칭(`default`)을 `--database default` 로 보거나, 진단용으로 `file:/srv/app.sqlite3?immutable=1` 별칭을 둔다(`immutable` 은 `-wal` 의 커밋을 무시하므로 쓰는 프로세스가 도는 중에는 최신 내용이 아닐 수 있다).
370
+ >
371
+ > **DB 파일이 없으면 연결하지 않는다.** SQLite 는 없는 파일에 연결하면 빈 DB 를 만든다. 그러면 boot 가 다음 부팅에서 복원 대신 그 빈 DB 를 판정하게 된다. 그래서 파일이 없으면 오류로 적고 연결하지 않는다.
372
+
373
+ 무엇을 보나:
374
+
375
+ - **database** — 별칭마다 Django 가 실제로 여는 경로(`OPTIONS["database"]` 가 있으면 `NAME` 대신 그것. 연결하지 않고 Django 의 연결 매개변수에서 읽는다)를 기준으로 역할(쓰기·읽기 전용·메모리·VFS), 파일 크기, `-wal` 크기(연결 전), 실제 `transaction_mode`·`journal_mode`·`synchronous`·`busy_timeout`·`foreign_keys`·SQLite 버전. [권장값](#기본값)과 다르면 `warn`. 읽기 전용 별칭은 쓰기 권고(`transaction_mode`·`journal_mode`·`synchronous`)를 비교하지 않는다. 메모리 DB 는 "메모리 DB" 로만 적는다. Litestream VFS 별칭과 역할을 판정할 수 없는 별칭(W004, 로컬이 아닌 URI authority 포함)은 연결하지 않고 `unknown`. 연결 실패는 `error`.
376
+ - **mount** — 위의 실제 경로로 DB 파일(없으면 존재하는 상위 디렉터리)의 파일시스템 종류. NFS·SMB/CIFS·AFP·sshfs 같은 네트워크 파일시스템이면 `warn` 이다. SQLite 의 파일 잠금을 믿을 수 없고 WAL 이 동작하지 않는다([SQLite 문서](https://www.sqlite.org/useovernet.html)). 로컬로 확인되지 않은 종류(알 수 없는 FUSE 등)는 `unknown`.
377
+ - **litestream** — `litestream databases -config PATH -json` 으로 설정의 DB 목록을 읽어 실제 경로로 대조한다. 쓰기 별칭이 설정에 없으면 `warn`(복제되지 않음). 단 channels-lite 전용 채널 DB(앱 DB 와 다른 파일)는 복제에서 빼는 것이 규칙이라 이 경고를 내지 않는다. 설정 경로가 실제 경로가 아니면(부모에 링크) `warn` — boot 가 거부하는 구성이다([경로 규칙](#경로-규칙)). 상대 경로·`dir:` 항목은 Litestream 의 작업 디렉터리 기준으로 풀려 대조할 수 없으므로 `warn`. 설정에만 있는 DB 는 `ok`(정보). 설정 파일 없음·깨진 YAML·바이너리 없음은 `error`.
378
+ - **channels** — `CHANNEL_LAYERS` 를 읽기만 한다(`channels` 를 import 하지 않는다. 예외는 아래 `patch` 항목). 백엔드별 의미론을 한 줄로 요약한다. `InMemoryChannelLayer` 는 프로필이 `single-server-multiproc` 이면 `warn`. channels-lite 는 채널 DB 가 앱 DB 와 같은 파일이면 `warn`, Litestream 설정의 복제 대상이면 `warn`. 모르는 백엔드(`channels_redis` pub/sub 포함)는 `unknown`. `AIOSQLiteChannelLayer` 를 쓰거나 `PATCH_CHANNELS_LITE_AIO` 가 켜져 있으면 [중복 배달 패치](#channels-lite-패치) 상태를 `patch` 항목으로 보여 준다. 이때만 channels-lite 를 import 한다(DB 는 열지 않는다).
379
+ - **process** — 명령을 실행한 프로세스의 열린 파일 한도(`RLIMIT_NOFILE`)를 `nofile_soft`·`nofile_hard` 로 보여 준다(무한대는 `unlimited`). **보고만 하고 판정하지 않으므로 늘 `ok` 이고 종료 코드를 바꾸지 않는다.** 필요한 값은 동시 접속 수에 달려 있어 근거 있는 경고 기준이 없기 때문이다. 표준 라이브러리 `resource` 가 없는 플랫폼(Windows)에서는 `nofile` 한 줄에 `unavailable` 로 적는다. 컨테이너에서는 `docker compose exec app python manage.py sqlite_doctor` 로 앱과 같은 한도를 본다(재현함: `exec` 프로세스도 컨테이너의 한도를 받는다). 설정법·계산법은 [fd 한도 가이드](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md).
380
+
381
+ 출력 예:
382
+
383
+ ```text
384
+ [settings]
385
+ OK - profile single-server
386
+
387
+ [database]
388
+ OK default role write
389
+ OK default file 8192 — bytes
390
+ OK default wal - — no -wal file
391
+ OK default transaction_mode IMMEDIATE (expected IMMEDIATE)
392
+ OK default journal_mode WAL (expected WAL)
393
+ WARN default synchronous FULL (expected NORMAL) — differs from the recommended value (DESIGN §6-0)
394
+ OK default busy_timeout 5000 (expected 5000)
395
+ OK default foreign_keys 1
396
+ OK default sqlite_version 3.53.1
397
+
398
+ [mount]
399
+ OK default filesystem ext4 — local filesystem
400
+
401
+ [litestream]
402
+ OK - config /etc/litestream.yml — 1 database(s)
403
+ OK default replicated /srv/app/app.sqlite3 — in the config
404
+
405
+ [channels]
406
+ OK default backend channels.layers.InMemoryChannelLayer — in-memory: one process only; messages do not reach other processes or workers
407
+
408
+ [process]
409
+ OK - nofile_soft 1024 — RLIMIT_NOFILE of this process; reported, not judged (docs/profiles/fd-limits.md)
410
+ OK - nofile_hard 524288 — RLIMIT_NOFILE of this process; reported, not judged (docs/profiles/fd-limits.md)
411
+
412
+ summary: 1 warning(s), 0 error(s), 0 unknown -> exit 1
413
+ ```
414
+
415
+ | 종료 코드 | 뜻 |
416
+ |---|---|
417
+ | 0 | 문제 없음 |
418
+ | 1 | 경고 또는 판정할 수 없는 항목(`unknown`)이 있음 |
419
+ | 2 | 오류가 있음(DB 파일 없음, 연결 실패, Litestream 설정을 읽지 못함 등) |
420
+
421
+ `call_command("sqlite_doctor")` 로 부르면 종료 코드가 0 이 아닐 때 `SystemExit` 가 난다.
422
+
423
+ #### 복원 검증 (`--restore-test`)
424
+
425
+ 헬스는 "지금 복제가 따라오는가"만 본다. `--restore-test` 는 **복제본으로 실제 복구할 수 있는가**를 본다. 쓰기 DB 마다 복제본을 `--restore-dir` 아래 새 작업 디렉터리에 실제로 복원하고, 복원 TXID 를 복원 직전의 원격 TXID 와 비교한 뒤, 복원본에 `PRAGMA integrity_check` 를 돌린다. **단독 모드다**: 이 옵션을 주면 `restore` 섹션만 내고 위의 다른 진단은 하지 않는다.
426
+
427
+ ```bash
428
+ python manage.py sqlite_doctor --restore-test \
429
+ --restore-dir /var/backups/dso-restore \
430
+ --litestream-config /etc/litestream.yml \
431
+ --database default --json
432
+ ```
433
+
434
+ | 옵션 | 뜻 |
435
+ |---|---|
436
+ | `--restore-dir DIR` | **필수.** 복원본을 둘 디렉터리. 미리 만들어 둔 실제 디렉터리여야 한다(마지막 구성요소까지 링크 아님, 부모에도 링크 없음). 그 아래에 실행마다 `dso-restore-XXXX/`(권한 0700)를 새로 만든다. 기본값이 없는 이유: `/tmp` 가 tmpfs 인 서버에서 큰 DB 를 복원하면 RAM 을 먹는다 |
437
+ | `--litestream-config PATH` | **필수.** 복원할 설정 파일. DB 경로가 설정의 `dbs[].path` 와 같은 실제 경로여야 한다([경로 규칙](#경로-규칙)) |
438
+ | `--database ALIAS` | 볼 별칭(반복). 없으면 **쓰기 파일 DB 인 sqlite3 별칭 전부**를 복원하고, 메모리·읽기 전용·VFS 별칭은 `alias` `ok` 'skipped' 로만 적는다(종료 코드에 영향 없음. URI 를 판정할 수 없는 별칭이 있으면 아래 보호 규칙으로 거부된다). 직접 고른 별칭이 쓰기 파일 DB 가 아니면 `unknown`. 복원할 쓰기 DB 가 하나도 없으면 `error`(exit 2) |
439
+ | `--integrity full\|quick` | `full`(기본, `integrity_check`) 또는 `quick`(`quick_check`) |
440
+ | `--restore-timeout SEC` | 별칭마다 복원의 시간 제한(기본 600초) |
441
+ | `--integrity-timeout SEC` | 무결성 검사의 **별도** 시간 제한(기본 1800초). 넘으면 검사를 끊고 `integrity` `error`. 시한이 검사 시작 전에 지나도 10ms 간격으로 다시 끊는다. 그 사이에 검사가 끝나 버리면 결과가 ok 여도 시한 초과(`timeout`)로 보고하고, 손상 결과면 오류 줄 뒤에 `(the --integrity-timeout Ns timer had also fired)` 를 붙인다(2026-10-25 확인, #63) |
442
+ | `--keep` | 모두 통과해도 복원본을 남긴다 |
443
+ | `--litestream BIN`·`--json` | 위와 같다 |
444
+
445
+ **운영 파일을 건드리지 않는다.** Django DB 연결을 열지 않으므로(일반 doctor 와 달리) `init_command` 도 실행되지 않는다. 복원본은 읽기 전용 `immutable` URI 로 연다. `litestream restore -o` 는 운영 DB·`-wal`·`-shm`·메타를 읽지도 쓰지도 않는다(0.5.17, 전후 해시·mtime 비교와 strace 로 재현함, #54). 그래도 `--restore-dir` 이 아래와 같거나 그 안이거나 그것을 품으면 **아무것도 만들기 전에 거부**한다(`restore_dir` `error`, exit 2). 대상은 `--database` 로 무엇을 고르든 `DATABASES` 의 **SQLite 백엔드 별칭 전부**의 합집합이다(경로만 계산하고 연결은 열지 않는다). ENGINE 이 `django.db.backends.sqlite3` 가 아니어도 백엔드의 `vendor` 가 `sqlite` 면(sqlite3 를 상속한 자체 백엔드, SpatiaLite) 포함한다. 백엔드를 불러오지 못하는 별칭(예: 그 환경에 드라이버가 없는 `postgresql` 별칭)이 있으면 SQLite 인지 알 수 없으므로 거부한다 — 이 명령은 운영과 같은 환경(드라이버 설치됨)에서 돌린다. 그중 경로를 알 수 없는 별칭 — 설정이 잘못돼 `get_connection_params()` 가 실패하거나, URI 를 판정할 수 없는 별칭(역할 `unknown`: `mode` 중복, 비표준 `mode`/`immutable` 값 등) — 이 있으면 보호할 경로를 다 모르므로 `--database` 와 무관하게 거부한다. 판정 불가 URI 별칭은 실행 대상에서는 'skipped' 지만, 그 설정을 고치기 전까지 이 모드 자체가 돌지 않는다.
446
+
447
+ - 운영 DB 의 **부모 디렉터리**(그 안의 DB·사이드카·boot 잠금·상태 파일·boot 임시 디렉터리가 모두 여기 든다)
448
+ - Litestream 메타: `SQLITE_OPS["HEALTH"]["DATABASES"][alias]["meta_path"]`, 없으면 기본 `.<db>-litestream/`. 메타가 링크면 링크와 그 실체 둘 다
449
+ - DB 경로에 링크가 끼어 있으면(부모 디렉터리 링크, `링크/../data` 처럼 링크 뒤의 `..`) 글자 그대로의 위치와 링크를 따라간 실제 위치 둘 다. 실행 대상 DB 파일 자체가 링크면 `database` `error` 다(boot 와 같은 [경로 규칙](#경로-규칙))
450
+
451
+ 검사를 통과한 `--restore-dir` 이 실행 중(앞 별칭의 복원·검사 동안) 다른 디렉터리나 링크로 바뀌면 다음 별칭의 작업 디렉터리를 만들기 전에 `restore_dir` `error` 로 멈춘다.
452
+
453
+ > **Litestream `dir:`(테넌트) 구성은 거부된다.** 설정에 `dir:` 항목이 있으면 `config` `error`(exit 2)로 아무것도 만들기 전에 끝난다. `dir:`+`recursive`+`watch` 감시 아래에 복원하면 운영 replicate 가 복원본을 새 DB 로 잡아 복제하는데(0.5.17 재현함), `litestream databases -json` 은 감시 디렉터리를 내주지 않아 겹치는지 판정할 수 없기 때문이다(D-26). 감시 디렉터리를 명시로 받는 것은 후속이다. **같은 호스트의 다른 설정 파일**로 도는 replicate 의 `dir:` 감시는 판별하지 못한다 — `--restore-dir` 은 어떤 Litestream `dir:` 감시 대상 아래에도 두지 않는다.
454
+ >
455
+ > **상대 `path:` 도 거부된다.** 실제 위치가 replicate 를 띄운 작업 디렉터리에 따라 바뀌어 보호할 경로를 정할 수 없기 때문이다(D-26). `dbs[].path` 는 절대 경로로 쓴다.
456
+
457
+ > **DATABASES 밖의 DB.** `--litestream-config` 에 있는 DB 는 `DATABASES` 에 없어도(같은 `litestream.yml` 로 복제하는 다른 서비스의 DB 등) 부모 디렉터리와 기본 메타 `.<db>-litestream/` 를 같은 규칙으로 보호한다(메시지에 `litestream config database <경로>`). 그래서 이 모드는 설정 목록(`litestream databases`)을 읽은 뒤에야 `--restore-dir` 검사를 끝낸다 — 목록을 읽지 못하면 `config` `error` 로 아무것도 만들지 않는다. 목록 조회는 임시 디렉터리(TMPDIR)에서 하므로 TMPDIR 이 `--restore-dir` 과 같거나 그 안이면 거부한다(예: Linux 에서 `--restore-dir /tmp`) — TMPDIR 을 다른 곳으로 둔다. 같은 호스트의 **다른** 설정 파일이 복제하는 DB 와 설정의 `meta-path` 는 판별하지 못한다.
458
+
459
+ > **메타 판별의 한계.** 이 명령은 Litestream YAML 의 `meta-path` 를 읽지 않는다(YAML 파서를 넣지 않고, `litestream databases -json` 은 메타 경로를 내주지 않는다 — 0.5.17 재현함). YAML 에 `meta-path` 를 DB 디렉터리 **밖**으로 두고 그 경로를 헬스 설정의 `meta_path` 에도 적지 않았다면 그 메타는 보호하지 못한다. 메타를 옮겼다면 헬스 설정에도 같은 `meta_path` 를 적는다. **file 복제본 디렉터리**도 경로가 YAML 에만 있어 판별하지 못한다 — 그 안을 `--restore-dir` 로 주지 않는다.
460
+ >
461
+ > **bind mount 의 한계.** 겹침은 경로 문자열과 `--restore-dir` 자신·조상의 (장치, inode)로만 비교한다. DB 디렉터리 안의 하위 디렉터리를 bind mount 로 다른 경로에 노출했다면(예: 컨테이너에서 `/srv/app` 을 `/data`, `/srv/app/restore` 를 `/restore` 로 따로 마운트) 그 경로가 DB 디렉터리 안이라는 것을 판별하지 못한다. 그런 디렉터리를 `--restore-dir` 로 주지 않는다. 하드링크와 메타 하위 디렉터리의 링크(예: `.<db>-litestream/ltx` 를 다른 볼륨으로 링크)도 같은 성격이라 판별하지 못한다.
462
+
463
+ 보고 항목(`section` `restore`):
464
+
465
+ | key | 값 | 수준 |
466
+ |---|---|---|
467
+ | `space` | 여유 바이트(`expected` = 필요량 추정치) | (로컬 DB + `-wal`) × 1.2 보다 적으면 `error`. 별칭을 시작하기 직전에 잰다 |
468
+ | `restore_dir` | 만든 작업 디렉터리 | `--restore-dir` 이 없음·링크·디렉터리 아님·겹침(`DATABASES` 별칭 또는 `--litestream-config` 에만 있는 DB), TMPDIR 이 그 안(Linux `--restore-dir /tmp`), 설정을 읽는 동안 바뀜이면 `error`(별칭 `null`) |
469
+ | `remote_txid` | 복원 직전 원격 최대 TXID(16자리 16진수) | 복제본이 비었으면 `error`(0.5.17 은 복제본 경로 오타도 같은 결과다), 조회 실패·타임아웃이면 `unknown` 이고 복원은 그대로 한다 |
470
+ | `restore` | `restored`/`failed` | 실패·타임아웃이면 `error` 이고 `integrity` 는 내지 않는다 |
471
+ | `restored_txid` | 복원 TXID(`expected` = `remote_txid`) | 원격보다 작으면 `warn`, 원격을 몰랐으면 `unknown` |
472
+ | `integrity` | `full`/`quick` | 열기·검사 실패, 시간 초과면 `error`. 오류 줄 앞 5줄 또는 `timeout` 을 `message` 에 |
473
+ | `duration_ms`·`size`·`tables`·`page_count` | 복원 시간, 복원본 바이트, 테이블 수, 페이지 수 | 보고(`ok`) |
474
+ | `output` | 남긴 작업 디렉터리 | 보고(`ok`). 정리하다 실패하면 `warn` |
475
+ | `alias`·`database`·`config` | 별칭 종류, DB 경로, 설정 파일 | 기본 선택에서 쓰기 파일 DB 가 아니면 `alias` `ok`(skipped), 직접 고른 별칭이면 `unknown`, 복원할 쓰기 DB 가 하나도 없으면 별칭 `null` 의 `alias` `error`, 경로가 실제 경로가 아니거나 DB 파일이 링크이거나 설정에 없으면 `database` `error`, 설정을 읽지 못하거나 `dir:` 항목·상대 `path:` 가 있으면 `config` `error` |
476
+
477
+ **정리 규칙.** `restore`·`integrity` 가 둘 다 `ok` 면 작업 디렉터리를 지운다. TXID 가 `warn`·`unknown` 이어도 지운다(그 원인은 원격 쪽에 있고 산출물로 설명되지 않는다). 하나라도 `error`(무결성 시간 초과 포함)거나 원격이 비어 멈췄으면 남기고 `output` 에 경로를 적는다. `--keep` 이면 늘 남긴다. **오래된 산출물은 자동으로 지우지 않는다** — 확인한 뒤 운영자가 치운다. 종료 코드는 위 표와 같다(0·1·2). `--restore-dir` 누락 같은 인자 오류도 2 다.
478
+
479
+ > **비용 경고.** 복원은 스냅샷 전체와 그 뒤 LTX 를 내려받는다. 시간과 S3 egress 가 DB 크기에 비례한다. 처음에 한 번 손으로 돌려 `duration_ms` 와 무결성 검사 시간(`integrity` 의 `message`)을 보고 주기를 정한다(참고: 700MB DB 의 `full` 검사가 macOS 에서 2.6초, `quick` 이 0.4초 — #54 실측). 주기는 1회 소요 시간보다 넉넉히 길게 잡는다.
480
+
481
+ 겹치는 실행은 이름이 충돌하지 않지만 부하가 쌓인다. 명령이 막지 않으므로 cron 에서는 `flock -n` 으로 감싼다:
482
+
483
+ ```cron
484
+ # /etc/cron.d/dso-restore-test — 매일 04:17, 앞 실행이 아직 돌면 건너뛴다
485
+ 17 4 * * * app cd /srv/app && flock -n /var/lock/dso-restore-test.lock python manage.py sqlite_doctor --restore-test --restore-dir /var/backups/dso-restore --litestream-config /etc/litestream.yml --database default --json > /var/log/dso-restore-test.json 2>&1
486
+ ```
487
+
488
+ systemd timer 는 같은 유닛이 겹쳐 돌지 않으므로 `flock` 이 필요 없다:
489
+
490
+ ```ini
491
+ # /etc/systemd/system/dso-restore-test.service
492
+ [Unit]
493
+ Description=django-sqlite-ops restore test
494
+
495
+ [Service]
496
+ Type=oneshot
497
+ User=app
498
+ WorkingDirectory=/srv/app
499
+ ExecStart=/srv/app/.venv/bin/python manage.py sqlite_doctor --restore-test --restore-dir /var/backups/dso-restore --litestream-config /etc/litestream.yml --database default --json
500
+ # 종료 코드 1(warn·unknown)은 성공으로 친다. 2(error)만 유닛을 failed 로 만든다
501
+ SuccessExitStatus=1
502
+
503
+ # /etc/systemd/system/dso-restore-test.timer
504
+ [Unit]
505
+ Description=Daily django-sqlite-ops restore test
506
+
507
+ [Timer]
508
+ OnCalendar=*-*-* 04:17
509
+ RandomizedDelaySec=15m
510
+ Persistent=true
511
+
512
+ [Install]
513
+ WantedBy=timers.target
514
+ ```
515
+
516
+ `systemctl enable --now dso-restore-test.timer` 로 켠다. systemd 는 `Type=oneshot` 에서도 0 이 아닌 종료 코드를 실패로 보므로, `SuccessExitStatus=1` 이 없으면 exit 1 에도 유닛이 failed 가 되고 `OnFailure=` 가 돈다([systemd.service(5) `SuccessExitStatus=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#SuccessExitStatus=)). 위 예는 **2 만 실패로 본다** — `OnFailure=` 나 모니터링으로 2 를 알리고, 1(`warn`·`unknown`, 대개 원격 조회 실패)은 JSON 출력이나 헬스로 따로 본다.
517
+
518
+ ### boot CLI
519
+
520
+ 컨테이너가 뜰 때 Litestream 복제본과 로컬 DB 를 비교해 **쓸 수 있으면 그대로, 볼륨이 비었으면 복원, 판정할 수 없으면 기동을 거부**한 뒤 다음 명령으로 넘어간다. Django 를 import 하지 않는 독립 CLI 다. `manage.py` 명령은 `django.setup()` 중에 빈 DB 파일을 만들 수 있고, 그러면 복원이 건너뛰어지기 때문이다([DESIGN §4-1](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md)).
521
+
522
+ 필요한 것: POSIX(Linux·macOS), PATH 의 `litestream` 0.5.17(검증한 버전만 받는다), DB 마다 복제본이 적힌 Litestream 설정 파일. 설치는 [공식 문서](https://litestream.io/install/)를 따른다.
523
+
524
+ #### 실행 형태
525
+
526
+ ```bash
527
+ python -m django_sqlite_ops.boot --db /data/app.sqlite3 --config /etc/litestream.yml \
528
+ [boot 옵션] -- litestream replicate -config /etc/litestream.yml -exec "앱 명령"
529
+ ```
530
+
531
+ `--` 뒤 명령은 boot 가 판정을 통과했을 때 `exec` 로 boot 프로세스를 대체한다. `migrate` 는 boot 앞이 아니라 그 명령 안에서(`-exec "sh -c 'python manage.py migrate --noinput && exec uvicorn ...'"`) 돌린다. 컨테이너 entrypoint 전체는 [빠른 시작](#빠른-시작) 4단계와 [배포 프로필](#배포-프로필) 문서에 있다.
532
+
533
+ | 옵션 | 기본값 | 뜻 |
534
+ |---|---|---|
535
+ | `--db PATH` | (필수) | SQLite DB 경로. Litestream 설정의 `path` 와 같아야 한다 |
536
+ | `--config PATH` | (필수) | Litestream 설정 파일 |
537
+ | `--on-unknown` | `refuse` | 판정할 수 없을 때: `refuse`(거부) · `restore`(로컬을 격리하고 복원) · `keep-local`(로컬 유지, 헬스에 `unknown_at_boot`. 원격 조회 실패([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md))와 [0바이트 DB](#0바이트-db-복구-empty_db)(메타가 있으면 `empty_db` [D-17](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md), 없고 원격 조회가 성공했으면 `no_local_meta` [D-18](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md))는 이 값이어도 거부) |
538
+ | `--init-new` | 꺼짐 | 생애 첫 배포에서 새 DB 로 시작. [한 번 쓰고 끈다](#생애-첫-배포---init-new-를-한-번-쓰고-끈다) |
539
+ | `--adopt-existing` | 꺼짐 | 기존 DB 를 처음 Litestream 에 올린다. 0바이트 파일은 도입하지 않는다([D-18](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). [한 번 쓰고 끈다](#기존-db-도입---adopt-existing-을-한-번-쓰고-끈다) |
540
+ | `--meta-path PATH` | `<db 디렉터리>/.<db 이름>-litestream` | Litestream 설정에 `meta-path` 를 바꿨다면 같은 값을 준다. 자기만의 **디렉터리**여야 한다: DB·사이드카·`<db>.boot.lock`·`<db>.boot-state.json`·`--config`·`<db>.restore-*`·`<db>.stale-*` 와 같거나 그 조상·자손이면 exit 64, 있는데 디렉터리가 아니면(파일·FIFO·끊어진 링크) exit 2([D-19](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
541
+ | `--litestream BIN` | `litestream` | 실행 파일 |
542
+ | `--ltx-timeout S` | `30` | 원격 TXID 조회 제한 시간(초). 넘으면 거부한다 |
543
+ | `--restore-timeout S` | `600` | 복원 제한 시간(초). DB 크기에 맞춰 늘린다 |
544
+
545
+ #### 경로 규칙
546
+
547
+ 이 절이 실제 경로 규칙([D-15](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md))의 정본이다. `sqlite_doctor` 와 헬스도 같은 규칙으로 본다.
548
+
549
+ - boot 의 `--db`·`--meta-path` 는 **실제 경로**로 준다. 부모 경로에 심볼릭 링크·`..`·없는 디렉터리가 있으면 exit 64 이고, 쓸 실제 경로를 안내한다. 경로는 파일 이름으로 끝나야 한다.
550
+ - 예: `/data -> /mnt/volume` 처럼 부모가 링크면 `--db /data/app.sqlite3` 는 받지 않는다. 링크를 따라간 실제 경로(`/mnt/volume/app.sqlite3`)를 쓴다.
551
+ - settings 의 `NAME`, boot 의 `--db`, Litestream 설정의 `dbs[].path` 는 **모두 같은 실제 경로**다. boot 는 이 경로로 Litestream 을 조회·복원하므로, 설정에 없는 경로면 Litestream 이 `database not found in config` 로 실패하고 boot 는 `remote_error` 로 거부한다.
552
+ - 다른 도구의 표시: `sqlite_doctor` 는 Litestream 설정의 경로가 실제 경로가 아니면 `litestream` 섹션에 `warn`(`config_path`)을 낸다. 헬스는 DB 경로나 `meta_path` 가 실제 경로가 아니면 그 별칭을 `unknown`(`path_not_real`)으로 보고한다.
553
+
554
+ #### 무엇을 하나
555
+
556
+ 1. `<db>.boot.lock` 을 잠근다. 이 잠금은 **exec 된 명령에 상속**된다. 그래서 `litestream replicate`(와 그것이 띄운 앱)가 살아 있는 동안 같은 볼륨에서 boot 를 또 돌리면 exit 5 로 막힌다. 다른 머신끼리의 이중 실행은 막지 못한다.
557
+ 2. 앞선 부팅이 격리 도중 죽었으면(`<db>.stale-<ts>.partial/`) 그 격리부터 마저 끝낸다.
558
+ 3. 로컬 DB·로컬 메타의 TXID·원격 복제본의 TXID 를 보고 판정한다([DESIGN §4-3](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md) 규칙표).
559
+ 4. 복원이 필요하면 DB 옆 임시 디렉터리(`<db>.restore-…/`)에 먼저 복원하고, 성공했을 때만 로컬을 `<db>.stale-<ts>/` 로 옮긴 뒤 복원본을 제자리에 놓는다. 복원이 실패하면 로컬은 그대로다.
560
+ 5. DB 가 있으면 읽기 전용으로 `PRAGMA quick_check` 를 한다. DB 가 없으면(새 DB) 만들지 않는다.
561
+ 6. `<db>.boot-state.json` 에 판정 결과를 남기고 명령을 exec 한다.
562
+
563
+ stderr 의 `[boot] ...` 줄만 보면 무슨 일이 있었는지 알 수 있다.
564
+
565
+ ```text
566
+ [boot] lock: /data/app.sqlite3.boot.lock
567
+ [boot] inputs: local_exists=False local_empty=False local_txid=None remote=txid 25
568
+ [boot] decision: state=fresh action=restore reason_code=restore_from_remote reason=no local db; restore remote txid 25
569
+ [boot] restore: /data/app.sqlite3 -> /data/app.sqlite3.restore-20261008T010203.000001Z-1a2b3c4d/app.sqlite3
570
+ [boot] restore: ok, txid 25
571
+ [boot] install: /data/app.sqlite3
572
+ [boot] integrity: quick_check ok
573
+ [boot] state: /data/app.sqlite3.boot-state.json
574
+ [boot] exec: litestream replicate -config /etc/litestream.yml -exec uvicorn ...
575
+ ```
576
+
577
+ #### 종료 코드
578
+
579
+ | 코드 | 뜻 | 할 일 |
580
+ |---|---|---|
581
+ | (exec) | 통과. 이후 코드는 exec 된 명령의 것이다 | — |
582
+ | `2` | 거부. 판정할 수 없다 | 아래 사유 코드별 대처 |
583
+ | `3` | 무결성 실패(`quick_check` 가 `ok` 아님) | DB 파일을 조사한다. 판정은 `match` 라 `--on-unknown restore` 만으로는 복원되지 않는다. 복제본이 정본이면 DB 파일(과 `-wal`·`-shm`)을 다른 곳으로 옮기고 `--on-unknown restore` 로 다시 부팅한다. 남은 메타가 `stale_meta` 로 격리되고 복원된다 |
584
+ | `4` | 복원 실패 | 로그의 `restore failed:` 사유를 본다. 임시 복원이 실패했으면 로컬은 그대로이고, 남은 `<db>.restore-…/` 는 조사 뒤 지운다. 격리·설치 중 I/O 실패도 4 다. 격리 도중이면 `<db>.stale-…partial/` 이 남고 다음 부팅이 이어서 끝낸다. 격리를 마친 뒤(설치 중) 실패했으면 다음 부팅이 남은 상태에 따라 다시 복원하거나(로컬이 비었을 때) `no_local_meta` 로 거부한다(아래 '복원 직후 크래시 복구') |
585
+ | `5` | 잠금 실패 | 같은 볼륨에서 이미 돌고 있는 컨테이너·프로세스를 멈춘다 |
586
+ | `64` | 사용법 오류 | 인자를 고친다. `--` 뒤 명령이 꼭 있어야 한다. 경로가 실제 경로가 아니면 메시지의 실제 경로로 바꾸고 Litestream 설정도 같은 경로로 맞춘다. `--meta-path ... overlaps` 면 메타를 Litestream 설정의 `meta-path` 와 같은 자기만의 디렉터리로 준다 |
587
+ | `127` | `--` 뒤 명령을 실행할 수 없음 | 명령 이름·PATH·실행 권한을 확인한다 |
588
+
589
+ #### 생애 첫 배포: `--init-new` 를 한 번 쓰고 끈다
590
+
591
+ 볼륨도 복제본도 비어 있으면 boot 는 거부한다(`no_replica_no_local`). Litestream 은 복제본 경로·prefix 오타와 "복제본 없음"을 같은 빈 목록으로 돌려주므로, 빈 목록만 보고 새 DB 를 시작하면 오타 난 경로에 새 DB 를 올려 기존 복제본을 버리게 된다. 처음 배포할 때만 `--init-new` 를 붙인다. boot 는 DB 파일을 만들지 않고 넘어가고, 앱(`migrate`)이 만든다.
592
+
593
+ ```bash
594
+ python -m django_sqlite_ops.boot --db /data/app.sqlite3 --config /etc/litestream.yml \
595
+ --init-new -- litestream replicate -config /etc/litestream.yml -exec "..."
596
+ ```
597
+
598
+ 첫 복제가 끝나면(헬스 본문 `status` 가 `caught_up`) **`--init-new` 를 지운다.** 컨테이너라면 `BOOT_FLAGS` 로 넘기고 비운다([배포 프로필](#배포-프로필)). 켜 둔 채로 두면, 나중에 볼륨을 잃고 설정의 복제본 경로까지 틀렸을 때 다시 빈 DB 로 시작한다([D-13](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)).
599
+
600
+ #### 기존 DB 도입: `--adopt-existing` 을 한 번 쓰고 끈다
601
+
602
+ 이미 운영 중인 DB 를 처음 Litestream 에 올리면 로컬 메타가 없고 복제본은 비어 있다. 이때만 `--adopt-existing` 을 붙인다. 정확히 (로컬 DB 있음, 로컬 메타 없음, 원격 빈 목록) 일 때만 통과시키고 다른 조합에는 효과가 없다. 첫 복제가 끝나면 지운다([D-11](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). `--on-unknown keep-local` 을 도입 절차로 쓰지 않는다.
603
+
604
+ DB 파일이 0바이트면 도입할 데이터가 없으므로 `--adopt-existing` 이 있어도 `no_local_meta` 로 거부한다([D-18](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). 메타 없는 0바이트에서 원격 조회가 실패하면 그 전에 `remote_error` 로 거부된다([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). 도입하면 빈 DB 가 복제본의 첫 세대가 되고, 복제본 경로가 틀려 진짜 복제본이 다른 곳에 있을 때도 구분할 수 없다. 대처는 아래 '0바이트 DB 복구'의 '메타가 없을 때'를 따른다.
605
+
606
+ #### 거부됐을 때 (exit 2): 사유 코드별 대처
607
+
608
+ 로그의 `decision: ... reason_code=...` 와 그 아래 `hint:` 줄을 본다.
609
+
610
+ | 사유 코드 | 뜻 | 대처 |
611
+ |---|---|---|
612
+ | `no_replica_no_local` | 볼륨도 복제본도 비었다 | 첫 배포면 `--init-new` 를 한 번. 아니면 설정의 복제본 경로·prefix 를 확인한다 |
613
+ | `remote_error` | 복제본 조회 실패·타임아웃 | 네트워크·자격 증명·endpoint(평문 HTTP 면 `http://` 를 붙인다)를 고친다. 이 경우는 `--on-unknown` 과 무관하게 거부한다([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
614
+ | `remote_ahead` | 복제본이 이 볼륨보다 새롭다(옛 볼륨으로 재부팅) | 복제본이 정본이면 `--on-unknown restore` 로 한 번 부팅한다. 로컬은 `<db>.stale-<ts>/` 에 남는다 |
615
+ | `no_local_meta` | DB 는 있는데 Litestream 메타가 없다 | 아래 '복원 직후 크래시 복구'. 기존 DB 를 처음 올리는 중이고 복제본이 비었으면 `--adopt-existing`. 사유에 `local db is 0 bytes` 가 있으면 아래 '0바이트 DB 복구'의 '메타가 없을 때'([D-18](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
616
+ | `stale_meta` | DB 없이 메타만 남았다 | `--on-unknown restore` 로 메타를 격리하고 복원한다 |
617
+ | `empty_db` | DB 파일이 0바이트인데 메타는 복제된 적이 있다고 말한다 | 아래 '0바이트 DB 복구'. 기존 파일이 정말 비었는지 확인하고 `--on-unknown restore` 로 한 번 부팅한다. `keep-local` 도 거부된다([D-17](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
618
+ | `remote_empty` | 복제한 적 있는 DB 인데 복제본이 비었다 | 복제본 경로·prefix·버킷을 확인한다. 로컬을 그대로 쓰려면 `--on-unknown keep-local` |
619
+ | `orphan_sidecars` | DB 파일 없이 `-wal`/`-shm`/`-journal` 만 있다 | 파일을 조사한다. 복제본이 정본이면 `--on-unknown restore` 로 격리하고 복원한다 |
620
+
621
+ `--on-unknown` 은 문제를 푼 그 한 번만 쓰고 원래(`refuse`)로 돌린다. 격리된 `<db>.stale-<ts>/` 는 boot 가 지우지 않는다. 확인한 뒤 직접 지운다.
622
+
623
+ #### 복원 직후 크래시 복구
624
+
625
+ 복원한 DB 옆에는 Litestream 메타가 없다. `litestream replicate` 가 첫 변경을 기록하기 전에 컨테이너가 죽으면, 다음 부팅은 "DB 는 있는데 메타가 없다"(`no_local_meta`)로 거부된다. 이 DB 는 방금 복제본에서 받은 것이므로 한 번만 `--on-unknown restore` 로 부팅하면 격리하고 다시 복원한다([D-14](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)).
626
+
627
+ ```bash
628
+ python -m django_sqlite_ops.boot --db /data/app.sqlite3 --config /etc/litestream.yml \
629
+ --on-unknown restore -- litestream replicate -config /etc/litestream.yml -exec "..."
630
+ ```
631
+
632
+ #### 0바이트 DB 복구 (`empty_db`)
633
+
634
+ 잘못된 복사나 DB 를 지운 뒤 열린 연결 때문에 DB 파일만 0바이트가 되고 Litestream 메타(`.<db>-litestream/`)가 남을 수 있다. SQLite 는 빈 파일을 빈 DB 로 열고 `quick_check` 도 `ok` 라서, 메타의 TXID 만 믿고 진행하면 앱이 빈 DB 에 테이블을 새로 만들고 그것을 복제해 **복제본 최신본에서 기존 데이터가 사라진다**(#38 재현). 그래서 boot 는 이 조합을 `empty_db` 로 거부한다. `-wal`·`-shm` 이 남아 있어도 DB 파일이 있으므로 `orphan_sidecars` 가 아니라 `empty_db` 이고, 남은 파일 이름이 사유에 나온다.
635
+
636
+ ```text
637
+ [boot] inputs: local_exists=True local_empty=True local_txid=25 remote=txid 25
638
+ [boot] decision: state=unknown action=refuse reason_code=empty_db reason=local db is 0 bytes (-wal, -shm present) but local meta has txid 25; remote txid 25
639
+ ```
640
+
641
+ 1. DB 파일이 정말 비어야 하는 것이 아닌지(다른 곳에 옮겨 둔 원본이 있는지) 확인한다.
642
+ 2. 복제본이 정본이면 `--on-unknown restore` 로 한 번 부팅한다. 빈 파일·사이드카·메타는 `<db>.stale-<ts>/` 로 옮겨지고 복제본이 복원된다. 복제본 조회가 실패하면 이 경우에도 거부한다([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)).
643
+ 3. 복제본이 비었으면(`remote=empty`) 복원할 것이 없어 `restore` 도 거부된다. 빈 파일·사이드카·메타를 확인해 직접 치운 뒤 `--init-new` 로 한 번 부팅한다(첫 배포 절차, [D-13](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)).
644
+ 4. `--on-unknown keep-local` 도 거부된다([D-17](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). 빈 DB 로 앱을 띄우면 그 상태의 쓰기가 복제돼 복제본 최신본에서 기존 데이터가 사라지기 때문이다. 빈 DB 로 다시 시작하는 별도 옵션은 없다.
645
+ 5. 이미 빈 DB 로 앱이 돌아 복제까지 됐다면 최신 복제본에는 원래 데이터가 없다. `litestream restore -txid <이전 TXID>` 로 사고 이전 시점을 따로 복원해 확인한다.
646
+
647
+ **메타가 없을 때.** 메타 없는 0바이트 파일은 원격 조회가 성공하면 `empty_db` 가 아니라 `no_local_meta` 다([D-16](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). 원격 조회가 실패하면 다른 경우처럼 `remote_error` 로 거부되니 복제본 접근부터 고친다([D-12](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). 사유에 0바이트임과 남은 사이드카가 나오고, `--adopt-existing` 이 있어도 도입하지 않으며 `--on-unknown keep-local` 도 거부한다([D-18](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)).
648
+
649
+ ```text
650
+ [boot] inputs: local_exists=True local_empty=True local_txid=None remote=empty
651
+ [boot] decision: state=unknown action=refuse reason_code=no_local_meta reason=local db is 0 bytes and local meta is missing; remote empty
652
+ ```
653
+
654
+ - 복제본에 TXID 가 있으면(`remote=txid N`) `--on-unknown restore` 로 한 번 부팅한다. 빈 파일·사이드카는 `<db>.stale-<ts>/` 로 옮겨지고 복제본이 복원된다.
655
+ - 복제본이 비었으면(`remote=empty`) 먼저 복제본 경로·prefix 가 맞는지 확인한다(빈 목록은 경로 오타와 구분되지 않는다, [D-13](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)). 맞다면 빈 파일·사이드카를 확인해 직접 치운 뒤 `--init-new` 로 한 번 부팅한다. 0바이트 파일이 남아 있으면 `--init-new` 도 거부된다.
656
+
657
+ #### 함정
658
+
659
+ - 경로는 [경로 규칙](#경로-규칙)대로 준다.
660
+ - `--meta-path` 를 DB 디렉터리 자체나 그 조상으로 두지 않는다. 메타가 DB 를 포함하면 인자 해석에서 거부된다(exit 64).
661
+ - `--meta-path` 를 `<db>.boot.lock`·`<db>.boot-state.json`·DB·사이드카·Litestream 설정 파일이나 boot 가 만드는 `<db>.restore-*`·`<db>.stale-*` 로 주지 않는다. 메타는 복원 때 격리되므로, 예전에는 잠금 파일을 메타로 주면 boot 가 자기 잠금을 옮겨 두 번째 부팅이 같은 볼륨을 잠글 수 있었다(재현함, #39). 지금은 잠금 파일을 만들기 전에 exit 64 로 거부한다. 대소문자만 다른 이름(`APP.DB.BOOT.LOCK`, macOS 에서 `DATA` 처럼 같은 디렉터리의 다른 철자)도 같은 것으로 보고 거부한다. 메타 경로에 정규 파일·FIFO·끊어진 링크가 있으면 판정 전에 exit 2 다. Litestream 설정의 `meta-path` 와 같은 디렉터리를 준다.
662
+ - 메타 디렉터리는 DB 와 같은 파일시스템에 둔다. 격리는 rename 으로 하므로 다른 볼륨에 있으면 거부한다.
663
+ - **격리가 필요할 때(`restore` 조치) DB·`-wal`·`-shm`·`-journal`·메타 디렉터리 경로가 심볼릭 링크면 거부한다(exit 2).** 링크를 옮기면 실체가 밖에 남기 때문이다(v0.1 제약). 판정만 하고 진행하는 부팅(`match` 등)에서는 메타 디렉터리 링크를 따라가므로 평소에는 드러나지 않는다. 볼륨 안에 실제 파일·디렉터리로 둔다.
664
+ - DB 나 `--meta-path` 의 이름을 `manifest.json`(대소문자만 다른 `MANIFEST.JSON` 포함)으로 하지 않는다. 격리 디렉터리의 예약 이름이라 격리가 필요할 때 복원 전에 exit 2 로 거부된다. macOS 에서는 `MANIFEST.JSON` 이 격리 디렉터리의 manifest 와 같은 파일이라, 예전에는 격리가 manifest 를 덮어 중단 뒤 재개가 막혔다(재현함, #50). 다른 디렉터리에 둔 메타라도 이름이 DB·사이드카 이름과 대소문자·유니코드 정규형만 다르면(`APP.DB-WAL`) 격리 안에서 겹치므로 거부된다.
665
+ - 격리 도중 죽어 `<db>.stale-…partial/` 이 남았다면 **같은 `--db`·`--meta-path`** 로 다시 부팅한다. 다르면 판정할 수 없어 거부한다. 그 안의 파일을 손으로 바꾸지 않는다.
666
+ - `<db>.boot.lock` 을 지우지 않는다. flock 잠금은 파일에 걸리므로, 앱이 도는 중에 잠금 파일을 지우거나 바꾸면 다음 boot 가 새 파일을 잠가 이중 실행을 막지 못한다. 잠금 파일이 링크거나 정규 파일이 아니면 exit 5 다.
667
+ - boot 는 Windows 에서 검증하지 않았다. `fcntl` 이 없으면 exit 64 다.
668
+
669
+ ### 복제 헬스
670
+
671
+ "지금 복제가 따라오는가"를 `caught_up / backlog / unknown` 으로 보고하는 JSON 엔드포인트다. Litestream 은 S3 가 끊겨도 로그·`status`·메트릭에 아무것도 남기지 않으므로(실측 D3), 헬스는 Litestream 의 자기 보고 대신 두 가지를 직접 본다.
672
+
673
+ 1. **TXID 비교 (로컬 L0 → 복제본)** — 로컬 메타의 최대 TXID(`.<db>-litestream/ltx/0/`)와 복제본의 최대 TXID(`litestream ltx -level all -json`). 업로드가 막히면(S3 끊김) 로컬이 앞선다.
674
+ 2. **WAL 위치 비교 (DB → 로컬 L0)** — 최신 로컬 L0 파일의 헤더에는 그 L0 가 원래 WAL 의 어디까지를 담았는지(WAL 오프셋·크기·salt)가 있다. 현재 `-wal` 파일의 헤더와 프레임 헤더를 읽어(salt·누적 체크섬이 맞는 프레임만) 그 위치 **뒤에 커밋이 있는지** 본다. Litestream 자신도 다음 동기화를 이 위치에서 이어 간다. `litestream replicate` 가 죽거나 멈추면 TXID 는 로컬·원격이 같은 채로 멈춰 1 만으로는 그 뒤의 쓰기가 보이지 않는데, 2 가 그것을 잡는다.
675
+
676
+ **DB 연결을 열지 않는다.** 모두 파일 읽기(`-wal` 은 읽기 전용으로 열어 헤더만)와 `litestream` 명령으로만 본다.
677
+
678
+ > **파일 시각은 "그 커밋이 복제본에 들어갔다"의 증거가 아니다.** Litestream 은 L0 를 만들 때 담을 WAL 범위를 먼저 정하고 DB 를 복사한 뒤 파일을 닫는다. 그래서 복사 중에 들어온 커밋은 L0 에 없는데도 L0 의 mtime 은 그 커밋보다 늦다(재현함: 그 상태에서 Litestream 이 멈추면 복원본에 행이 하나 없다). 헬스는 시각이 아니라 WAL 위치로 판정한다. 파일 시각은 WAL 로 판정할 수 없을 때 `backlog` 쪽으로만 쓰는 보조 근거다(아래).
679
+
680
+ ```python
681
+ # settings.py
682
+ from django_sqlite_ops.database import sqlite_database
683
+
684
+ INSTALLED_APPS = ["django_sqlite_ops"]
685
+
686
+ DATABASES = {"default": sqlite_database("/srv/app/app.sqlite3")}
687
+
688
+ SQLITE_OPS = {
689
+ "HEALTH": {
690
+ "DATABASES": {
691
+ # 별칭마다 Litestream 설정 파일. meta_path(설정에 meta-path 를 바꿨을 때)와
692
+ # litestream(바이너리, 기본 PATH 의 litestream)은 선택이다.
693
+ "default": {"litestream_config": "/etc/litestream.yml"},
694
+ },
695
+ "REFRESH": 15, # 초, 원격 조회 주기 (기본 15)
696
+ "BACKLOG_GRACE": 60, # 초, 로컬이 앞선 상태를 backlog 로 볼 때까지 (기본 60)
697
+ },
698
+ }
699
+ ```
700
+
701
+ ```python
702
+ # urls.py
703
+ from django.urls import path
704
+
705
+ from django_sqlite_ops.health import health_view
706
+
707
+ urlpatterns = [
708
+ path("internal/sqlite-health", health_view),
709
+ ]
710
+ ```
711
+
712
+ 응답 예(`GET /internal/sqlite-health`):
713
+
714
+ ```json
715
+ {
716
+ "version": 1,
717
+ "status": "caught_up",
718
+ "refresh": 15.0,
719
+ "backlog_grace": 60.0,
720
+ "databases": {
721
+ "default": {
722
+ "status": "caught_up",
723
+ "code": "in_sync",
724
+ "reason": "local and replica are at the same TXID and every -wal commit is in the latest L0",
725
+ "path": "/srv/app/app.sqlite3",
726
+ "local_txid": "00000000000004d2",
727
+ "remote_txid": "00000000000004d2",
728
+ "checked_at": "2026-10-08T01:02:18.412Z",
729
+ "age": 3.2,
730
+ "backlog_since": null,
731
+ "pending_since": null,
732
+ "db_changed_at": "2026-10-08T01:02:11.020Z",
733
+ "ltx_at": "2026-10-08T01:02:11.533Z",
734
+ "wal": {"evidence": "in_sync", "reason": "every -wal commit is within the latest L0",
735
+ "ltx_wal_end": 4152, "wal_commit_end": null},
736
+ "boot_state": {"state": "match", "action": "proceed", "reason_code": "local_current",
737
+ "reason": "...", "unknown_at_boot": false,
738
+ "litestream_version": "0.5.17", "at": "2026-10-08T01:02:03Z"},
739
+ "boot_state_error": null
740
+ }
741
+ }
742
+ }
743
+ ```
744
+
745
+ | 상태 | `code` | 뜻 |
746
+ |---|---|---|
747
+ | `caught_up` | `in_sync` | 로컬 TXID == 복제본 TXID, 그리고 `-wal` 의 커밋이 모두 최신 L0 범위 안 |
748
+ | `caught_up` | `local_ahead_within_grace` | 로컬 TXID 가 앞서 있지만 아직 올라가지 않은 TXID 가 기다린 시간이 `BACKLOG_GRACE` 보다 짧음(정상 업로드 지연). `backlog_since` 가 함께 나온다 |
749
+ | `caught_up` | `db_changed_within_grace` | `-wal` 에 최신 L0 뒤의 커밋이 있지만 `BACKLOG_GRACE` 보다 짧음(Litestream 이 곧 L0 를 쓴다). `pending_since` 가 함께 나온다 |
750
+ | `backlog` | `local_ahead` | 아직 올라가지 않은 로컬 TXID 가 `BACKLOG_GRACE` 이상 기다림. 업로드가 막혔거나 쓰기를 못 따라간다(S3 끊김, 자격 증명 만료) |
751
+ | `backlog` | `db_not_replicated` | `-wal` 에 최신 L0 뒤의 커밋이 `BACKLOG_GRACE` 이상 남아 있음(또는 WAL 근거가 없을 때 파일 시각이 그만큼 L0 보다 새로움). `litestream replicate` 가 죽었거나 멈췄을 수 있다 |
752
+ | `unknown` | `no_wal_evidence` | `-wal` 로 판정할 수 없음: `-wal` 이 없거나(마지막 연결이 닫혀 지워짐) 비었음, L0 이후 WAL 이 다시 시작됐는데 아직 커밋이 없음 등. **이때는 `caught_up` 으로 단정하지 않는다** |
753
+ | `unknown` | `file_time_backwards` | WAL 근거가 없는데 DB·`-wal`·L0 의 파일 시각이 앞선 관측보다 뒤로 감(시계 변경 등) |
754
+ | `unknown` | `db_missing` · `db_empty` · `db_not_regular` · `db_stat_failed` | DB 본 파일이 없음 · 0바이트 · 정규 파일이 아님(디렉터리 등) · 상태를 읽을 수 없음. `-wal`·Litestream 메타·복제본이 맞아도 그것이 붙을 DB 가 없으므로 정상이 아니다. 아래 [DB 본 파일](#db-본-파일-상태) 참고 |
755
+ | `unknown` | `remote_error` · `remote_empty` · `no_local_meta` · `remote_ahead` · `unknown_at_boot` · `path_not_real` · `not_file_db` · `refresh_failed` · `not_checked` · `stale` | 판정할 수 없음: 원격 조회 실패, 복제본이 빈 목록(경로·prefix 오타와 구분되지 않는다), 로컬 메타 없음(`litestream replicate` 가 돌지 않음), **복제본이 로컬보다 앞섬**(다른 기계가 같은 복제본에 쓰는 중일 수 있다), 부팅 상태 파일의 `unknown_at_boot`(boot 가 `--on-unknown keep-local` 로 진행함), [경로 규칙](#경로-규칙) 위반, 파일 DB 가 아님, 갱신 중 예외, 아직 첫 조회 전, 마지막 결과가 `REFRESH × 3` 보다 오래됨(갱신 스레드가 멈춤) |
756
+
757
+ `local_ahead` 의 backlog 시간은 **처음 관측한 미업로드 로컬 TXID 가 원격에 올라가기까지 기다린 시간**이다. 가장 오래된 미업로드 커밋의 엄밀한 나이나 로컬·원격의 총 격차가 아니다.
758
+
759
+ `code` 는 고정된 값이라 알람 규칙에 쓸 수 있다. 설정이 없거나 틀리면 최상위에 `code`(`not_configured`·`invalid_config`)와 `reason` 이 붙는다.
760
+
761
+ **WAL 위치 근거** (형식: [SQLite WAL](https://www.sqlite.org/fileformat2.html#walformat), superfly/ltx v0.5.2 `Header`, Litestream 0.5.17 `db.go`):
762
+ - 판정: 현재 `-wal` 의 salt 가 최신 L0 의 salt 와 같으면, L0 끝(`WALOffset + WALSize`) 뒤에 유효한 커밋 프레임이 있으면 pending, 없으면 in_sync. salt 가 다르면(L0 이후 WAL 이 다시 시작됨) 새 salt 의 커밋이 있으면 pending, 없으면 판정할 수 없음.
763
+ - 프레임은 salt 와 누적 체크섬이 맞을 때만 센다(SQLite 의 복구 규칙과 같다). 쓰다 만 프레임은 커밋으로 세지 않는다. L0 끝 뒤의 프레임만, 첫 커밋까지만 읽는다(한 번에 최대 64 MiB).
764
+ - 잠금 없이 읽으므로 읽는 도중 체크포인트가 `-wal` 을 비우거나 다시 시작할 수 있다. 그래서 "L0 뒤 커밋 없음"이라고 답하기 전에 처음 연 `-wal` 이 그대로인지(같은 파일, 같은 크기, 같은 헤더) 다시 보고, 바뀌었으면 한 번만 다시 읽는다. 그래도 바뀌면 판정하지 않는다(`no_wal_evidence`).
765
+ - pending 지속 시간은 처음 관측한 때부터 monotonic 으로 세고, 최신 L0 의 WAL 위치가 바뀌면(Litestream 이 진행 중) 다시 센다. 쓰기가 계속되는 바쁜 DB 도 Litestream 이 살아 있으면 `backlog` 가 되지 않는다(실측: 30ms 간격 쓰기 30초, 0.5초마다 판정, grace 3초에서 모두 `caught_up`).
766
+ - Litestream 이 도는 동안에는 Litestream 이 연결을 쥐고 있어 `-wal` 이 남으므로 이 근거가 늘 있다.
767
+
768
+ **보조 근거: 파일 시각** — WAL 로 판정할 수 없을 때만 쓴다(주로 Litestream 이 멈춘 뒤 앱의 마지막 연결이 닫혀 `-wal` 이 지워졌을 때).
769
+ - DB 변경 시각 `max(mtime(DB), mtime(DB-wal))` 이 최신 L0 의 mtime 보다 늦은 상태가 `BACKLOG_GRACE` 이상 이어지면 `backlog`(`db_not_replicated`, 사유에 "file times only"). 아니면 **`unknown`(`no_wal_evidence`) — 시각만으로 `caught_up` 이라고 하지 않는다.**
770
+ - Litestream 이 멈춰 있으면 앱의 마지막 연결이 닫힐 때의 체크포인트(읽기만 했어도)와 `replicate -once` 의 종료가 DB mtime 을 바꾼다(실측). 그래서 이 경로의 `db_not_replicated` 는 "DB 가 쓰이는데 Litestream 이 진행하지 않는다"는 뜻이다.
771
+ - 파일 시각이 앞선 관측보다 뒤로 가면(벽시계 역행, 파일 복사) `unknown`(`file_time_backwards`)이고 앞선 근거를 지우지 않는다. 다른 프로세스가 DB 파일을 `touch`·복사·덮어쓰면 이 경로는 틀릴 수 있다.
772
+ - 드문 경우: Litestream 이 도는데 `-wal` 이 TRUNCATE 체크포인트로 0바이트가 되고 그 뒤 쓰기가 없으면(기본 설정에서는 WAL 이 약 500MB 를 넘을 때만) 다음 쓰기까지 `unknown` 이거나, 파일 시각 때문에 grace 뒤 `backlog` 로 보일 수 있다.
773
+
774
+ - **전체 `status` 는 별칭 중 가장 나쁜 것**이다(`unknown` > `backlog` > `caught_up`). `unknown` 이 가장 나쁜 이유: 복제가 따라오는지조차 말할 수 없다는 뜻이라, 뒤처진 것을 아는 `backlog` 보다 더 큰 문제를 감출 수 있다.
775
+ - **요청마다 S3 를 부르지 않는다.** 프로세스마다 데몬 스레드 하나가 `REFRESH` 초마다 조회하고 뷰는 마지막 결과를 읽기만 한다. 스레드는 **첫 헬스 요청 때** 시작하므로 배포 직후 첫 응답은 `unknown`("not checked yet")이다. 관리 명령·마이그레이션에서는 스레드가 돌지 않는다. gunicorn `--preload` 처럼 포크하는 서버에서도 워커마다 PID 를 보고 다시 시작한다.
776
+ - **S3 가 끊기면 `code` 가 `stale` 과 `remote_error` 를 오간다.** 원격 조회(`litestream ltx`)는 S3 가 연결을 거부해도 양방향으로 응답하지 않아도 30초 타임아웃까지 매달린다(바꿀 수 없다). 그동안 마지막 관측이 `REFRESH × 3` 보다 오래되면 `stale`, 타임아웃으로 끝난 직후에는 `remote_error` 이고, 단절이 이어지면 `REFRESH + 30` 초 주기로 번갈아 나온다(회귀 랩에서 재현함, [#65](https://github.com/itda-work/django-sqlite-ops/issues/65)). 둘 다 `unknown` 이므로 경보는 `code` 가 아니라 `status` 로 건다. 갱신 스레드는 하나라 **별칭이 여럿이면 한 별칭의 매달림이 다른 별칭도 `stale` 로 만들 수 있다**(매달린 시간 30초가 `2 × REFRESH` 를 넘을 때, 즉 `REFRESH` 를 15초보다 줄였을 때 뚜렷하다). 이때 `stale` 의 `reason` 끝에 원인 별칭과 조회 경과 초가 나온다: `the refresh thread has been checking alias 'slow' for 31s`(자기 자신이면 `this alias`). 매달림이 끝나고 스레드가 `REFRESH` 를 쉬는 동안에는 `the last refresh spent 30s on alias 'slow'` 처럼 직전 갱신에서 가장 오래 걸린 조회가 나온다. 그 조회도 `REFRESH` 보다 짧았다면 `the refresh thread may be stuck` 이다(#72).
777
+ - **지속 시간(`backlog_since`·`pending_since` 부터의 시간, `REFRESH × 3` 판정)은 프로세스 메모리에서 monotonic 시계로 잰다.** 주 근거(TXID·WAL 위치)는 벽시계를 쓰지 않으므로 벽시계가 NTP 로 뒤로 가도 판정이 되돌아가지 않는다. 보조 근거(파일 시각)는 벽시계를 쓰므로 역행을 보면 `unknown` 으로 둔다. 응답의 시각 문자열(UTC, 밀리초)은 표시용 벽시계다. 앱을 재시작하면 추적이 초기화되어, 재시작 직후에는 오래된 backlog 도 `BACKLOG_GRACE` 동안 `caught_up` 으로 보인다. 워커마다 따로 추적하므로 워커별 응답이 몇 초 다를 수 있다.
778
+ - **`ATOMIC_REQUESTS` 를 켠 별칭이 있어도 헬스 요청은 트랜잭션으로 감싸지 않는다.** Django 는 감싸려고 뷰 실행 전에 연결을 연다(그러면 DB 파일이 생길 수 있다). `health_view` 는 모든 별칭에서 빠지도록 표시돼 있다. 헬스 뷰를 다른 데코레이터로 감쌀 때는 `functools.wraps` 로 이 표시(`_non_atomic_requests`)를 옮긴다.
779
+ - **"마지막 업로드 시각"은 쓰지 않는다.** 쓰기가 없는 정상 DB 도 업로드 시각은 오래되기 때문이다.
780
+ - 부팅 상태 파일 `<db>.boot-state.json`([boot CLI](#boot-cli)가 씀)을 `boot_state` 로 보여 준다. 파일이 없거나 형식이 틀리면 `boot_state_error` 에 그 사실만 적고 상태는 바꾸지 않는다(boot 를 쓰지 않는 배포도 있다). `unknown_at_boot` 가 참이면 다음 정상 부팅까지 `unknown` 이다.
781
+ - DB 경로는 Django 가 실제로 여는 경로(`OPTIONS["database"]` 포함, [`sqlite_doctor`](#sqlite_doctor)와 같은 방식)다. boot 와 같은 [경로 규칙](#경로-규칙)을 따른다: 부모 경로에 심볼릭 링크·`..` 가 있거나 DB 파일 자체가 링크면 그 별칭은 `unknown`(`path_not_real`)이다. `meta_path` 도 같다(`..` 를 접지 않고 쓴 그대로 검사한다. 상대 경로는 작업 디렉터리만 앞에 붙인다).
782
+ - 설정이 잘못되면 `manage.py check` 가 `sqlite_ops.E002` 를 내고, 뷰는 `unknown` 과 사유를 돌려준다.
783
+
784
+ #### DB 본 파일 상태
785
+
786
+ 헬스는 DB 연결을 열지 않고 DB 본 파일을 `lstat` 한 번으로 본다(파일을 만들거나 바꾸지 않는다). 아래는 모두 `unknown` 이므로 기본은 HTTP 200, `?strict=1` 이면 503 이다.
787
+
788
+ | `code` | 뜻 | 대처 |
789
+ |---|---|---|
790
+ | `db_missing` | DB 파일이 없다(`-wal`·메타는 남아 있을 수 있다) | boot 로그를 본다. 부팅 중 boot 가 격리·복원하는 사이라면 곧 사라진다. 계속되면 DB 를 지운 원인을 찾고, 복제본이 정본이면 앱을 멈춘 뒤 다시 부팅한다. boot 는 이 상태를 [`orphan_sidecars`·`stale_meta`](#거부됐을-때-exit-2-사유-코드별-대처)(사이드카·메타만 남음)로 거부하므로 그 대처대로 `--on-unknown restore` 를 쓴다 |
791
+ | `db_empty` | DB 파일이 0바이트다 | 위와 같다. boot 는 메타가 남은 0바이트 DB 를 [`empty_db`](#0바이트-db-복구-empty_db) 로 거부하므로 그 절차를 따른다 |
792
+ | `db_not_regular` | DB 경로가 디렉터리·FIFO 등이다 | 설정의 `NAME` 과 실제 파일을 확인한다 |
793
+ | `db_stat_failed` | 권한 등으로 상태를 읽을 수 없다 | `django_sqlite_ops.health` 로그(WARNING)의 원문을 보고 권한을 고친다 |
794
+
795
+ 원격·로컬 조회와 `backlog_since`·`pending_since` 추적은 이 동안에도 계속한다. 그래서 DB 가 잠깐 없다가 돌아오면 그 사이의 backlog 시간도 이어서 센다.
796
+
797
+ **HTTP 상태는 항상 200 이다.** 헬스 엔드포인트를 로드밸런서의 헬스 체크로 쓰면, 복제가 뒤처지거나 S3 가 끊겼다는 이유로 앱이 서비스에서 빠진다. 복제 지연은 데이터 손실 위험이지 요청을 못 받는 상태가 아니므로 앱을 내리면 안 된다. 모니터링은 **본문의 `status`** 로 알람을 건다(`backlog` 나 `unknown` 이 몇 분 이어지면 경보). HTTP 코드로만 판정할 수 있는 모니터를 쓴다면 `?strict=1` 을 붙인다. 그러면 `caught_up` 이 아닐 때 503 이다. **로드밸런서 헬스 체크에는 `strict` 를 쓰지 않는다.**
798
+
799
+ **인증하지 않는다. 내부망에만 노출한다.** 응답에는 DB 파일 경로, TXID, 파일 시각, 부팅 상태가 들어간다. **사유(`reason`)는 고정 문장에 경로·TXID·초만 넣는다.** Litestream 의 stderr 나 예외 원문은 응답에 넣지 않는다(Litestream 은 설정 오류 메시지에 endpoint 의 `user:password@` 까지 그대로 찍는다 — 재현함). 원문은 로거 `django_sqlite_ops.health` 에 WARNING 으로, 같은 메시지는 한 번만 남는다. 로그에 남기기 전에 URL 의 사용자 정보, 쿼리의 서명·토큰류 값, `secret-access-key: …` 같은 `키: 값` 을 가린다(`health.redact()`). 완전한 비밀 탐지기는 아니므로 로그도 접근을 제한한다. 공개 URL 에 붙여야 한다면 웹 서버에서 IP 로 막거나 자체 인증 뷰로 감싼다.
800
+
801
+ ```python
802
+ # settings.py — 원문 진단 로그를 보려면
803
+ LOGGING = {
804
+ "version": 1,
805
+ "handlers": {"console": {"class": "logging.StreamHandler"}},
806
+ "loggers": {"django_sqlite_ops.health": {"handlers": ["console"], "level": "WARNING"}},
807
+ }
808
+ ```
809
+
810
+ 헬스는 "지금 복제가 따라오는가"만 말한다. 복제본으로 실제 복구할 수 있는지는 주기적인 [복원 검증](#복원-검증---restore-test)(`sqlite_doctor --restore-test`)으로 따로 본다. 그 결과는 헬스 응답에 섞지 않는다.
811
+
812
+ ### channels-lite 패치
813
+
814
+ channels-lite 의 aio 레이어(`channels_lite.layers.aio.AIOSQLiteChannelLayer`)를 쓸 때 켜는 우리 쪽 호환 패치다. channels-lite 자체는 [추천하지 않는다](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#8-배포-프로필과-채널-레이어). 그래도 aio 레이어를 쓴다면 켠다.
815
+
816
+ #### 무엇을 고치나
817
+
818
+ 일반 채널(이름에 `!` 가 없는 채널, 예: 워커가 나눠 받는 작업 채널)을 수신자 둘 이상이 경쟁할 때, **한 메시지가 두 수신자에게 모두 배달된다.** 수신자는 `UPDATE ... SET delivered=1 WHERE id=? AND delivered=0` 로 메시지를 선점하는데, 성공 여부를 이번 UPDATE 의 행 수가 아니라 연결이 열린 뒤 누적된 변경 수(`total_changes`)로 판정하기 때문이다. 풀에서 다시 받은 연결이 앞서 한 번이라도 쓰기(`send`·`group_add` 등)를 했으면 경쟁에서 진 수신자도 성공으로 판정한다. 별도 프로세스 둘에서도, 한 프로세스 안의 독립 레이어 둘에서도 재현되고 기본 `pool_size=10` 에서도 일어난다(#8). 두 수신자의 조회가 겹칠 때만 생기므로 빈도는 부하와 스케줄에 달려 있다.
819
+
820
+ 패치는 메서드 하나(`_receive_single_from_db`)만 바꾼다. 원본을 그대로 옮기고 판정만 UPDATE 커서의 `rowcount == 1` 로 바꾼다. ORM 판(`channels_lite.layers.core.SQLiteChannelLayer`)은 `aupdate()` 의 행 수로 판정하므로 해당하지 않는다.
821
+
822
+ #### 켜기
823
+
824
+ 자동으로 켜지지 않는다. 설정으로 켜면 앱 `ready()` 에서 적용한다.
825
+
826
+ ```python
827
+ # settings.py
828
+ from pathlib import Path
829
+
830
+ from django_sqlite_ops.database import sqlite_database
831
+
832
+ BASE_DIR = Path(__file__).resolve().parent.parent
833
+
834
+ INSTALLED_APPS = ["django_sqlite_ops"]
835
+
836
+ DATABASES = {
837
+ "default": sqlite_database(BASE_DIR / "db.sqlite3"),
838
+ # 채널 DB 는 별도 파일로 두고 Litestream 복제에서 뺀다 (DESIGN §8)
839
+ "channels": sqlite_database(BASE_DIR / "channels.sqlite3"),
840
+ }
841
+
842
+ CHANNEL_LAYERS = {
843
+ "default": {
844
+ "BACKEND": "channels_lite.layers.aio.AIOSQLiteChannelLayer",
845
+ "CONFIG": {"database": "channels"},
846
+ },
847
+ }
848
+
849
+ SQLITE_OPS = {"PATCH_CHANNELS_LITE_AIO": True}
850
+ ```
851
+
852
+ 정확히 `True` 일 때만 켠다(`"true"` 같은 문자열은 무시). `INSTALLED_APPS` 에 `"django_sqlite_ops"` 가 있어야 한다. 앱을 넣지 않는 프로세스라면 채널 레이어를 쓰기 전에 직접 부른다.
853
+
854
+ ```python
855
+ # asgi.py — django.setup() 뒤, 채널 레이어를 쓰기 전에
856
+ from django_sqlite_ops.compat import channels_lite
857
+
858
+ applied = channels_lite.apply() # 적용됐으면 True. 두 번 불러도 한 번만 바꾼다
859
+ ```
860
+
861
+ `apply()` 는 DB 를 열지 않는다. channels-lite 가 설치돼 있지 않으면 아무것도 하지 않고 `False` 를 돌려준다. `channels_lite.status()` 로 현재 상태(`applicable`·`applied`·`reason`)를 볼 수 있다.
862
+
863
+ #### 버전 게이트
864
+
865
+ 설치된 channels-lite 가 **`==0.4.0`** 이고 원본 메서드의 소스가 검증한 0.4.0 소스와 같을 때만 바꾼다. 다른 판이거나, 같은 판이라도 소스가 다르거나, 소스를 읽을 수 없는 배포(`.pyc` 만)면 바꾸지 않는다. 이때 앱은 그대로 뜨고 `sqlite_doctor` 가 사유를 알린다. channels-lite 를 올렸다면 이 패키지가 그 판을 검증할 때까지 doctor 경고가 남는다.
866
+
867
+ #### doctor 표시
868
+
869
+ `sqlite_doctor` 의 `channels` 섹션 `patch` 항목(`value` 는 설치된 판, `expected` 는 `channels-lite==0.4.0`):
870
+
871
+ | 상황 | 수준 | 메시지 |
872
+ |---|---|---|
873
+ | 적용됨 | `ok` | `duplicate-delivery patch applied (channels-lite 0.4.0)` |
874
+ | aio 레이어를 쓰는데 설정이 꺼져 있음 | `warn` | 설정을 켜라는 안내 |
875
+ | 설정이 켜졌지만 적용 안 됨(판·소스 불일치, aio 의존성 없음, channels-lite 없음) | `warn` | `... is True but the patch is not applied: <사유>` |
876
+ | 설정이 꺼졌고 게이트 밖 | `warn` | `duplicate-delivery patch is not available: <사유>` |
877
+
878
+ doctor 는 별도 프로세스라 앱 코드에서 `apply()` 를 직접 부른 경우는 보지 못한다. doctor 경고를 없애려면 설정으로 켠다.
879
+
880
+ #### 함정
881
+
882
+ - aio 레이어에는 channels-lite 의 `[aio]` extra(aiosqlite·aiosqlitepool·msgspec)가 필요하다. 이 패키지의 `[channels-lite]` extra 는 channels-lite 만 설치한다: `pip install "channels-lite[aio]==0.4.0"`.
883
+ - 패치는 프로세스마다 적용된다. 워커·ASGI 서버 프로세스가 모두 같은 설정을 읽어야 한다.
884
+ - 새 DB 에 여러 연결이 동시에 처음 `journal_mode=WAL` 로 바꾸면 그중 일부가 `database is locked` 로 실패할 수 있다(원인 미확정 — `busy_timeout` 을 먼저 걸어도 재현됨, 리뷰 대조 실험). channels-lite 는 연결마다 기본 `init_command` 로 `PRAGMA journal_mode=WAL` 을 실행하므로(0.4.0 `channels_lite/layers/aio.py:50-58`), 새 채널 DB 에 여러 프로세스가 동시에 처음 붙을 때 생긴다(테스트 준비에서 재현, #8). 배포 때 `migrate` 직후 채널 DB 를 미리 `PRAGMA journal_mode=WAL` 로 바꿔 둔다. 이 패치가 고치는 문제는 아니다.
885
+
886
+ ## 배포 프로필
887
+
888
+ 배포 형태별로 프로세스 트리, 복사해 쓰는 설정 조각(`settings.py`·`urls.py`·`litestream.yml`·Dockerfile·`entrypoint.sh`·compose), 운영 절차, 함정을 한 문서에 모았다. 절차와 조각이 길어서 README 가 아니라 `docs/profiles/` 에 둔다.
889
+
890
+ | 프로필 | 언제 | 채널 레이어 | 문서 |
891
+ |---|---|---|---|
892
+ | `single-server` | 머신 1대, 앱 프로세스 1개 | `InMemoryChannelLayer` | [docs/profiles/single-server.md](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server.md) |
893
+ | `single-server-multiproc` | 머신 1대, 앱 프로세스 여럿 | channels-nats(추천) · channels_redis(호환) | [docs/profiles/single-server-multiproc.md](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server-multiproc.md) |
894
+
895
+ - 두 프로필 모두 **쓰는 머신은 1대**다. 두 머신이 같은 복제본에 쓰면 나중 쪽이 경고 없이 이긴다. 배포는 정지 후 기동이다.
896
+ - 프로세스 트리는 `boot`(잠금) → exec → `litestream replicate -exec "sh -c 'migrate && exec uvicorn ...'"` 다. litestream 이 PID 1 이다.
897
+ - 한 번만 쓰는 boot 옵션(`--init-new`·`--adopt-existing`·`--on-unknown restore`)은 이미지에 굳히지 않고 `BOOT_FLAGS` 환경 변수로 넘긴 뒤 비운다.
898
+ - 열린 파일 한도(`nofile`)의 확인·설정(Docker `--ulimit`, compose `ulimits`, systemd `LimitNOFILE=`)·계산법은 두 프로필 공통 문서 [docs/profiles/fd-limits.md](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md) 에 있다. 프로필 compose 예에는 `ulimits` 를 넣지 않았다(이유는 그 문서 §6).
899
+ - `settings.py`·`urls.py` 조각은 테스트가 실행하고 `check --deploy` 를 통과하는지 본다. `litestream.yml` 은 실제 litestream 0.5.17 이 읽는지, `entrypoint.sh` 는 문법을 본다. Dockerfile·compose 는 회귀 랩이 문서 블록을 그대로 꺼내 빌드하고 띄워 종단 검증했다(첫 배포, `check --deploy`, `sqlite_doctor`, `docker stop` 시 exit 0·복제 400/400, 빈 볼륨 소유자 복사, 워커 2개 — [결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-08.md)). multiproc 은 channels-nats(`[nats]` extra)를 넣어 워커 사이 전달도 랩에서 확인했다(2026-10-26, 아래). 2026-10-08 의 multiproc 검증은 랩이 nats 컨테이너를 띄우지 않은 채 돈 것이었다.
900
+ - **multiproc 채널 전달(랩 P2, 2026-10-26, channels-nats 0.10.0, uvicorn 워커 2)**: 그룹·프로세스 전용 채널은 워커마다 200/200(워커 → 워커 포함), 일반 채널 큐 그룹은 합 200·중복 0. 수신 루프를 남긴 채 `docker stop` 이 exit 0·0.74초로 끝났다. 손실은 계약대로 두 곳에서만 났다: 일반 채널을 `wait_for` 로 폴링할 때 두 폴링 사이(10ms 폴링에서 16/200), 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘칠 때(간격 없는 버스트 200건). 보내는 쪽에는 어느 쪽도 예외가 없다. 아래 '채널 레이어' 함정 두 줄을 본다([결과](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/lab-2026-10-26-multiproc-nats.md)).
901
+
902
+ ## 문제 해결
903
+
904
+ 흩어진 함정을 증상 → 원인 → 대처로 모았다. 자세한 설명은 각 절이 정본이고 여기서는 링크만 단다. 근거는 **재현함**(이 저장소의 테스트·실측에서 다시 일으킴), **코드상 확인**(구현을 읽어 확인), **실측**(`docs/research/` 의 측정)으로 구분한다.
905
+
906
+ ### 설정·체크
907
+
908
+ | 증상 | 원인 | 대처 | 근거 |
909
+ |---|---|---|---|
910
+ | `options={"timeout": 20}` 을 줬는데 잠금 대기가 5초다 | Django 는 연결을 연 뒤 `init_command` 를 실행하므로 `PRAGMA busy_timeout=5000`(밀리초)이 `timeout`(초)을 덮는다 | `pragmas={"busy_timeout": 20000}`([`timeout` 과 `busy_timeout`](#timeout-과-busy_timeout)) | 재현함(연결 뒤 `PRAGMA busy_timeout` 이 5000) |
911
+ | `ValueError: options['init_command'] conflicts with the generated PRAGMAs; use pragmas={...} instead` | `options` 에 `init_command` 를 넣었다 | PRAGMA 는 `pragmas=` 로 준다([거부되는 입력](#거부되는-입력)) | 코드상 확인(`database.py`) |
912
+ | DB 파일 옆에 `-wal`·`-shm` 이 생긴다 | WAL 모드의 정상 동작. 연결이 열린 동안 커밋 일부는 `-wal` 에만 있다 | 복사·백업할 때 DB 파일만 따로 옮기지 않는다 | 재현함(`tests/test_readme.py` 가 연결 중 두 파일을 확인) |
913
+ | ASGI(uvicorn) 에서 부하가 몰리면 `OperationalError: unable to open database file` 로 500 이 난다 | `CONN_MAX_AGE=None` 을 줬다. ASGI 의 동기 뷰는 요청마다 새 스레드에서 돌아 연결이 재사용되지 않고, 닫히지 않은 연결이 파일 디스크립터를 쌓는다(동시 16·48 에서 DB fd 810–982개, 한도 1024). [시스템 체크](#시스템-체크) `sqlite_ops.W005` 가 이 설정을 알린다(ASGI 로 [판단](#asgi-판단)될 때) | 일반 별칭은 `CONN_MAX_AGE` 를 주지 않는다(기본 0). 재사용 이득도 없었다. fd 한도를 올리는 것은 대처가 아니다([fd 한도 가이드 §5](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md#5-한도를-올려도-해결되지-않는-것--conn_max_agenone--asgi)) | 재현함([재측정](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/bench-2026-10-08.md#결과-연결-재사용). fd 고갈이 원인이라는 것은 추정). fd 한도를 1,048,576 으로 올리면 동시 16·30분에서 오류는 없었지만 fd 중앙값 1,370개·앱 RSS 중앙값 411.5 MiB 를 썼다(`0` 은 82개·73.2 MiB). 이득 없이 자원만 더 쓴다([장시간 실행](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/fd-soak-2026-10-09.md)) |
914
+ | 동시 접속(웹소켓·keep-alive)이 많을 때 `OSError: [Errno 24] Too many open files` 또는 `unable to open database file` | 프로세스의 열린 파일 한도(`RLIMIT_NOFILE` soft)에 닿았다. 한도를 주지 않은 컨테이너는 Docker 데몬 설정을 따른다(colima 에서 soft 1024) | `/proc/1/limits` 나 `sqlite_doctor` 의 `[process]` 로 현재 값을 보고, 피크 동시 접속으로 잰 fd 수에 여유를 더해 compose `ulimits`·`docker run --ulimit`·systemd `LimitNOFILE=` 로 올린다. hard 는 `fs.nr_open` 을 넘길 수 없다. 먼저 위 `CONN_MAX_AGE=None` 줄이 아닌지 본다([fd 한도 가이드](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md)) | 공식 문서([getrlimit(2)](https://man7.org/linux/man-pages/man2/getrlimit.2.html): 한도 초과는 `EMFILE`), 재현함(soft 를 64 로 낮춘 프로세스에서 `open` 은 `OSError(24, 'Too many open files')`, `sqlite3.connect` 는 `unable to open database file` — macOS. compose `ulimits` 적용·`nr_open` 초과 시 기동 실패 — colima, #32) |
915
+ | `manage.py check` 에 `sqlite_ops.*` 가 안 나온다 | 앱이 `INSTALLED_APPS` 에 없다. W001·W002·W004 는 `--deploy` 일 때만 나온다 | 앱을 넣고 `check --deploy` 로 돌린다([시스템 체크](#시스템-체크)) | 코드상 확인(`checks.py`) |
916
+
917
+ ### 진단(`sqlite_doctor`)
918
+
919
+ | 증상 | 원인 | 대처 | 근거 |
920
+ |---|---|---|---|
921
+ | doctor 를 돌린 뒤 `-wal`·`-shm` 이 생기거나 `journal_mode` 가 바뀌었다 | doctor 는 실제로 연결하므로 `init_command` 가 실행되고, WAL DB 는 연결만 해도 사이드카가 생긴다 | 운영 DB 에서 돈다는 것을 알고 실행한다([주의](#sqlite_doctor)) | 재현함 |
922
+ | `[process]` 의 `nofile_soft` 가 compose 에 준 값과 다르다 | doctor 는 자기 프로세스의 한도를 본다. 다른 셸이나 호스트에서 돌렸거나, 앱이 `setrlimit` 으로 바꿨다 | `docker compose exec app ...` 로 컨테이너 안에서 돌리고, 앱 PID 의 `/proc/<pid>/limits` 와 맞춰 본다([fd 한도 가이드 §2](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/fd-limits.md#2-현재-한도-확인)) | 재현함(#32) |
923
+ | 읽기 전용 별칭(`mode=ro`)이 `unknown` 이다 | WAL DB 는 읽기 전용 연결도 `-shm` 에 쓰고 `-wal` 을 만들 수 있어 연결하지 않는다 | 같은 파일의 쓰기 별칭이나 `immutable=1` URI 별칭으로 진단한다([주의](#sqlite_doctor)) | 재현함 |
924
+ | DB 파일이 없다는 `error` | 없는 파일에 연결하면 SQLite 가 빈 DB 를 만들어 다음 boot 의 복원을 막으므로 연결하지 않는다 | 경로를 확인하거나 boot 로 먼저 복원한다 | 코드상 확인(`doctor.py`) |
925
+ | `--restore-test` 가 `restore_dir` `error` 로 바로 끝난다 | `--restore-dir` 이 없거나 링크이거나, 운영 DB 디렉터리·메타와 겹친다. 같은 `litestream.yml` 에만 있는 다른 서비스 DB 의 디렉터리와 겹쳐도 거부한다(메시지에 `litestream config database`). TMPDIR 이 `--restore-dir` 과 같거나 그 안이어도 거부한다(Linux 에서 `--restore-dir /tmp`). 모두 아무것도 만들지 않고 거부 | DB 디렉터리·메타 밖의 실제 디렉터리를 미리 만들어 준다. TMPDIR 이 걸리면 `TMPDIR=/var/tmp` 처럼 밖으로 옮기거나 다른 디렉터리를 준다([복원 검증](#복원-검증---restore-test)) | 재현함(#54·#57 테스트) |
926
+ | `--restore-test` 의 `remote_txid` 가 `error`(복제본이 비었다) | 복제본에 LTX 가 없다. 0.5.17 은 복제본 경로·prefix 오타도 같은 결과다 | 설정의 복제본 경로를 확인하고 헬스(`remote_txid`)도 본다 | 재현함 |
927
+ | `--restore-test` 산출물이 `--restore-dir` 에 쌓인다 | 실패(또는 `--keep`)한 실행의 작업 디렉터리는 조사용으로 남기고 자동으로 지우지 않는다 | `output` 경로를 확인한 뒤 지운다 | 코드상 확인(`restore_check.py`) |
928
+
929
+ ### boot
930
+
931
+ | 증상 | 원인 | 대처 | 근거 |
932
+ |---|---|---|---|
933
+ | 첫 배포에서 exit 2, `no_replica_no_local` | Litestream 은 복제본 경로·prefix 오타와 "복제본 없음"을 똑같이 rc 0·`[]` 로 돌려준다 | 경로를 확인하고 `--init-new` 를 한 번만([생애 첫 배포](#생애-첫-배포---init-new-를-한-번-쓰고-끈다)) | 재현함(Litestream 0.5.17, #3) |
934
+ | exit 2, `remote_error` 이고 사유에 `timed out` (평문 HTTP S3 호환 서버) | `endpoint` 에 스킴이 없으면 Litestream 이 HTTPS 로 접속해 끝없이 기다리고, boot 가 `--ltx-timeout`(기본 30초)에서 끊는다 | `endpoint: http://…` 로 스킴을 붙인다 | 재현함(평범한 HTTP 서버로) |
935
+ | S3 가 내려가 연결을 거부하는데 boot 가 30초 걸려 exit 2, `remote_error`(사유 `timed out`) | Litestream 0.5.17 의 `ltx` 는 연결 거부에도 곧바로 실패하지 않고 재시도하며 기다린다 | S3 를 복구한 뒤 다시 띄운다. 더 빨리 거부하려면 `--ltx-timeout` 을 줄인다 | 재현함(회귀 랩 L4: toxiproxy 로 끊음 → 30.5초) |
936
+ | exit 2, `remote_error` 이고 사유에 `database not found in config` | `--db` 가 `litestream.yml` 의 `path` 와 다르다 | 세 곳을 같은 실제 경로로([경로 규칙](#경로-규칙)) | 재현함(fixture `ltx_db_not_in_config`) |
937
+ | exit 64, 실제 경로 안내 | `--db`·`--meta-path` 의 부모에 심볼릭 링크·`..`·없는 디렉터리 | 안내된 실제 경로를 쓰고, 이미지에서 부모 디렉터리를 만든다([경로 규칙](#경로-규칙)) | 코드상 확인([D-15](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md)) |
938
+ | exit 64, `--meta-path ... overlaps the boot lock`(또는 db·state file·`--config`·restore/quarantine area) | `--meta-path` 가 boot 소유 경로와 같거나 조상·자손이다. 그대로 두면 복원 때 boot 가 잠금·상태 파일을 격리해 잠금 보호가 사라진다 | Litestream 설정의 `meta-path` 와 같은 자기만의 디렉터리를 준다([함정](#함정)) | 재현함(#39, 실제 litestream 으로 두 번째 잠금 획득) |
939
+ | exit 2, `--meta-path ... is not a directory`·`broken symbolic link` | 메타 경로에 정규 파일·FIFO·끊어진 링크가 있다 | 메타 경로를 확인해 디렉터리로 두거나 경로를 고친다. boot 는 그 파일을 격리하지 않는다 | 코드상 확인(#39 테스트) |
940
+ | exit 5 | 같은 볼륨에서 boot 나 그것이 exec 한 앱이 이미 돈다(잠금은 exec 된 명령에 상속) | 앞선 컨테이너를 멈춘다. `<db>.boot.lock` 은 지우지 않는다([함정](#boot-cli)) | 코드상 확인 |
941
+ | 옛 볼륨으로 다시 띄우자 exit 2, `remote_ahead` | 복제본이 이 볼륨보다 새롭다. 보호가 없으면 최신본이 옛 볼륨으로 덮인다 | 복제본이 정본이면 `--on-unknown restore` 로 한 번([사유 코드별 대처](#거부됐을-때-exit-2-사유-코드별-대처)) | 실측(docker D4: 150건이 55건으로 덮임), 재현함(회귀 랩 L2: boot 가 exit 2 로 막고 복제본 무변경) |
942
+ | 복원 직후 컨테이너가 죽은 뒤 exit 2, `no_local_meta` | 복원한 DB 옆에는 Litestream 메타가 없고 `replicate` 가 첫 변경을 기록해야 생긴다 | `--on-unknown restore` 로 한 번([복원 직후 크래시 복구](#복원-직후-크래시-복구)) | 실측(restore 결과에 메타 없음), [D-14](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md) |
943
+ | 직접 만든 entrypoint 의 `litestream restore -if-db-not-exists` 가 복원을 건너뛴다 | 0바이트 DB 파일이 있으면 rc 0 으로 건너뛴다 | boot 를 쓴다. `manage.py` 를 boot 앞에서 부르지 않는다([boot CLI](#boot-cli)) | 재현함([DESIGN §4-1](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md#4-1-왜-django-관리-명령이-아닌가)) |
944
+
945
+ ### 복제·헬스
946
+
947
+ | 증상 | 원인 | 대처 | 근거 |
948
+ |---|---|---|---|
949
+ | S3 가 끊겼는데 Litestream 로그·`status`·메트릭이 조용하다 | Litestream 은 업로드 실패를 드러내지 않는다 | 헬스 본문 `status` 로 알람을 건다([복제 헬스](#복제-헬스)) | 실측([docker D3](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/litestream-django-docker.md#d3-관측-조용한-실패-위험)) |
950
+ | S3 가 통째로 끊긴 동안 헬스가 `backlog` 가 아니라 `unknown`(`stale` 또는 `remote_error`) | 헬스의 원격 조회도 같은 S3 로 간다. 조회가 타임아웃(30초)까지 매달리면 결과가 `REFRESH × 3` 보다 오래되어 `stale`, 끝나면 `remote_error` 다. 업로드만 끊기고 조회가 되면 `backlog`(`local_ahead`)다 | 둘 다 알람 대상으로 둔다(`caught_up` 이 아니면 경보) | 재현함(회귀 랩 L8a·L8b) |
951
+ | 배포 직후 헬스가 `unknown`(`not_checked`) | 갱신 스레드는 첫 헬스 요청 때 시작한다 | `REFRESH`(기본 15초) 뒤 다시 본다 | 코드상 확인(`health.py`) |
952
+ | 헬스가 `backlog` 인데 HTTP 200 이다 | 설계상 항상 200 이다. 복제 지연으로 로드밸런서가 앱을 빼면 안 된다 | 본문 `status` 로 알람. HTTP 코드만 보는 모니터는 `?strict=1`(로드밸런서에는 쓰지 않는다) | 코드상 확인(`health.py`) |
953
+ | 헬스가 `unknown`(`no_wal_evidence`) | `-wal` 이 없거나 비어 WAL 위치로 판정할 수 없다. Litestream 이 돌면 `-wal` 이 남는다 | `litestream replicate` 가 도는지 본다 | 코드상 확인(`health.py`) |
954
+ | 헬스가 `unknown`(`remote_ahead`) | 복제본이 로컬보다 앞섰다. 다른 머신이 같은 복제본에 쓰는 중일 수 있다 | 쓰는 머신이 1대인지 확인한다. 두 머신이 쓰면 나중 쪽이 경고 없이 이긴다 | 실측(scenarios S2b) |
955
+ | 헬스가 `unknown`(`db_missing`·`db_empty`) | DB 본 파일이 없거나 0바이트다. `-wal`·메타가 남아 예전에는 `caught_up` 으로 보였다 | [DB 본 파일 상태](#db-본-파일-상태)의 대처를 따른다 | 재현함(#40, 실제 Litestream 0.5.17) |
956
+ | 헬스가 계속 `unknown`(`unknown_at_boot`) | boot 가 `--on-unknown keep-local` 로 진행했다 | 원인을 푼 뒤 옵션 없이 다시 부팅한다 | 코드상 확인(`health.py`) |
957
+
958
+ ### 채널 레이어
959
+
960
+ | 증상 | 원인 | 대처 | 근거 |
961
+ |---|---|---|---|
962
+ | 워커를 늘렸더니 `group_send` 가 다른 워커의 클라이언트에 닿지 않는다 | `InMemoryChannelLayer` 는 프로세스 안에서만 전한다. doctor 는 프로필이 `single-server-multiproc` 일 때만 경고한다 | [`single-server-multiproc`](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server-multiproc.md) 과 channels-nats 를 쓴다. `SQLITE_OPS["PROFILE"]` 을 배포 형태와 맞춘다 | 코드상 확인(`doctor.py`) |
963
+ | channels-lite aio 레이어에서 한 메시지가 두 수신자에게 배달된다 | 선점 성공을 누적 `total_changes` 로 판정한다 | `SQLITE_OPS["PATCH_CHANNELS_LITE_AIO"] = True`([channels-lite 패치](#channels-lite-패치)) | 재현함(#8) |
964
+ | 새 채널 DB 에 여러 프로세스가 처음 붙을 때 `database is locked` | 여러 연결이 동시에 처음 `journal_mode=WAL` 로 바꾸면 일부가 실패한다(원인 미확정) | `migrate` 직후 채널 DB 를 미리 WAL 로 바꿔 둔다 | 재현함(#8) |
965
+ | 버스트로 보낸 메시지 일부가 사라지는데 경고는 "1 message(s) dropped so far" 한 줄뿐이다 | channels-nats 는 받는 쪽 mailbox 가 `capacity`(기본 100)를 넘치면 버린다(`ChannelFull` 없음). 경고는 60초에 한 번으로 묶이고 합계는 자리가 난 뒤 다음 메시지가 올 때 "has room again after dropping N" 으로 나온다 — 첫 줄만 보면 과소평가한다(실측 1줄 / 실제 1–16건) | 합계 줄을 본다. 한 번에 몰아 보내는 양이 `capacity` 를 넘으면 `capacity` 를 올리거나 나눠 보낸다. channels-nats D2 실측의 196/200 도 같은 모양이라 이것으로 본다(추론) | 재현함(#66) |
966
+ | 한 워커가 채널 메시지를 하나도 받지 못한다. 로그에 `nats: encountered error … InvalidStateError` | 구독 확인(`new_channel`·`group_add`·일반 채널 첫 `receive`)이 취소되거나 시간 초과되면 nats-py 2.16.0 의 읽기 루프가 끝나 그 연결이 그룹 메시지까지 받지 못한다. 그 뒤의 `new_channel`·`group_add` 도 예외 없이 성공한다. 일반 채널을 1ms `wait_for` 로 폴링하는 것은 그 한 예다(알려진 결함, 회복 시간 미검증) | 일반 채널을 `wait_for` 로 폴링하지 않는다. 10ms 폴링에서도 재현됐다(랩 RTT 약 0.25ms 에서 P2 6회 중 1회, 2026-10-26 확인). 안전한 폴링 간격은 없다. 브로커가 10초 넘게 멈췄다 살아난 뒤에도 같은 일이 날 수 있다(nats-py flush 타임아웃·레이어 `publish_deadline`). 로그에 위 줄이 있는 워커는 재시작한다. 상류 처리는 [이관 문서](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/handoff-marco-nats-flush-cancel.md) | 1ms·10ms 폴링: 재현함(#66). 타임아웃·deadline 경로: 코드상 확인 |
967
+ | HTTP 는 되는데 웹소켓 연결이 안 된다 | `uvicorn` 만 설치하면 웹소켓 구현이 없다 | `uvicorn[standard]` 를 설치한다([single-server §3](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/profiles/single-server.md#dockerfile)) | 출처(uvicorn 0.54.0 메타데이터) |
968
+
969
+ ## 개발
970
+
971
+ 설계서: [`docs/DESIGN.md`](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DESIGN.md) · 결정: [`docs/DECISIONS.md`](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/DECISIONS.md) · 근거: [`docs/research/`](https://github.com/itda-work/django-sqlite-ops/blob/main/docs/research/) · 이슈: [GitHub Issues](https://github.com/itda-work/django-sqlite-ops/issues)
972
+
973
+ ```bash
974
+ uv venv && uv pip install -e . --group dev
975
+ uv run --no-sync pytest
976
+
977
+ scripts/ci-local.sh # GitHub Actions CI 를 act 로 로컬 실행 (lint + 전체 매트릭스)
978
+ scripts/ci-local.sh test 3.14 6.1 # 매트릭스 한 칸만
979
+ scripts/ci-local.sh litestream # 실제 litestream 바이너리 + channels-lite[aio]==0.4.0 으로 통합 테스트 (둘 중 하나가 없어 skip 되면 실패)
980
+
981
+ scripts/lab.sh run all # 회귀 랩 L1–L8·프로필 종단 검증 (Docker, 약 8분, 끝나면 down -v)
982
+ scripts/lab.sh bench # PRAGMA 벤치 (lab/README.md)
983
+
984
+ gh workflow run lab.yml -f scenarios=all # 회귀 랩을 GitHub 러너(amd64)에서 수동 실행. 결과는 artifact (벤치 제외)
985
+ ```
986
+
987
+ 릴리스: `CHANGELOG.md` 의 `[Unreleased]` 를 새 버전 절로 옮기고 `pyproject.toml` 의 version 을 올린 뒤 `python3 scripts/release_check.py v<버전>` 이 통과하면 태그 `v<버전>` 을 push 한다. `.github/workflows/release.yml` 이 같은 검사 뒤 PyPI(trusted publishing)와 GitHub 릴리스에 올린다. 산출물은 `uv build --out-dir <디렉터리>` 뒤 `python3 scripts/release_check.py v<버전> --dist <디렉터리>` 로 미리 볼 수 있다(빌드된 Version, PyPI 설명의 상대 링크). 태그 순서: PR 병합 → `git fetch` → `git tag v<버전> origin/main` → 그 커밋에서 `python3 scripts/release_check.py v<버전>` → `git push origin v<버전>`. squash 병합이라 브랜치의 커밋은 main 의 조상이 아니어서, 거기에 붙인 태그는 release.yml 의 조상 검사가 막는다.