체크포인트 무결성
Laya는 로드 시점에 Hugging Face Hub에서 모델 가중치를 내려받습니다. 기본적으로는 저장소의 기본 리비전이 가리키는 것을 취하는데, 이는 편리하고 오프라인 캐시가 이미 가진 것과 같습니다. 검토된 커밋을 고정하고 싶거나 바이트가 바뀐 체크포인트의 로드를 거부하고 싶다면, 둘 다 사용할 수 있으며 둘 다 선택 사항입니다.
여기 있는 것은 요청하기 전까지 Laya가 로드하는 것을 바꾸지 않으므로, 기존 배포에 이 옵션들을
추가해도 안전합니다. 둘 다 라이브러리 수준입니다. 다이제스트는 환경 변수를 통해 HTTP 서버에
도달하고, 리비전 고정은 LAYA_REVISION을 통해 도달합니다 ——
서버에서 리비전 고정을 참고하십시오.
관련 문서: 배포 변수는 Docker, laya.load와 Agent, 그리고
Router.
리비전 고정
어떤 로더에든 revision을 전달하십시오. 커밋 SHA, 브랜치 또는 태그를 받으며 Hub로 전달됩니다.
import laya
agent = laya.load("convaiinnovations/laya", revision="<commit sha, branch, or tag>")
print(agent.revision) # what the download resolved to
직접 적은 리터럴보다 Laya와 함께 제공되는 검토된 SHA를 선호하십시오. 체크포인트와 함께 갱신되므로 이 형태는 낡을 수 없습니다.
from laya import PINNED_REVISIONS
agent = laya.load(
"convaiinnovations/laya",
revision=PINNED_REVISIONS["convaiinnovations/laya"],
)
Router는 같은 revision을 받고, 체크포인트마다 따로 고정하려면 revisions를 받습니다.
PINNED_REVISIONS의 키는 세 개의 독립형 저장소이므로, Router는 standalone_repos=True로
고정하십시오:
router = laya.Router(standalone_repos=True, revisions={
"english": PINNED_REVISIONS["convaiinnovations/laya"],
"multilingual": PINNED_REVISIONS["convaiinnovations/laya-multilingual"],
"typed-decisions": PINNED_REVISIONS["convaiinnovations/laya-typed-decisions"],
})
기본 Router는 세 체크포인트를 모두 하나의 번들 저장소(convaiinnovations/laya, 하위 폴더로
multilingual/과 typed-decisions/)에서 로드하고, laya-multilingual의 커밋 SHA는 번들 저장소에
존재하지 않기 때문에 이것이 중요합니다. standalone_repos가 없으면 고정은 무시되는 데 그치지 않고
로드가 실패합니다. 번들 저장소에 남고 싶다면, 모델별 revisions= 대신 셋 모두에 하나의
revision=으로 고정하십시오.
둘만 서빙하더라도 셋 모두 고정하십시오. Router는 미리 로드한 것과 무관하게 자기가 아는 모든
체크포인트를 제공하므로, 고정하지 않은 항목은 라우팅 결정 하나만큼만 고정 해제된 로드에서 떨어져
있습니다.
고정이 기본값이 아닌 이유
기본적으로 고정하면 오래된 캐시 스냅샷에서의 로드가 깨지는데, 이는 온디바이스 및 에어갭
배포에서 중요합니다. 고정보다 먼저 존재한 캐시와 함께 HF_HUB_OFFLINE=1을 쓰면 동작이 멈춥니다.
그래서 Laya는 리비전을 전달하지 않는 한 Hub 기본값을 유지하고, 필요할 때 쓸 수 있도록 검토된 SHA를
제공합니다.
산출물 다이제스트 검증
고정된 리비전은 어느 커밋을 가져올지 말합니다. 다이제스트는 어느 바이트를 기대하는지
말합니다. 나열한 모든 파일은 그중 어느 것도 파싱되기 전, 그리고 가중치가 런타임에 도달하기 전에
해싱됩니다. 맵은 체크포인트 기준 상대 경로에서 sha256 16진수로 가는
{path relative to the checkpoint: sha256 hex}입니다 —— 먼저 생성한 뒤 전달하십시오.
둘 다 필요한가?
고정된 리비전은 이미 내용을 고정합니다. Hub는 git이므로 커밋이 트리를 결정하고, 큰 파일은 자체 SHA-256으로 주소가 지정됩니다. 고정하고 다운로드가 성공하면 그 커밋이 지명한 바이트를 가진 것입니다. 따라서 다이제스트는 그 검사를 반복하려고 있는 것이 아니라, 무엇을 신뢰하는지가 다릅니다.
리비전은 Hub에 커밋을 물어보고 그 답을 믿습니다. 다이제스트는 당신이 만들고 당신이 보관하는 기록이며, 로드할 때마다 비교됩니다. 이것은 고정이 주지 않는 세 가지를 줍니다:
- 흔한 경우, 즉 고정하지 않은 경우를 커버합니다. 고정은 선택 사항이고 기본적으로 꺼져 있으므로, 대부분의 배포는 움직이는 브랜치를 따라갑니다. 그때 변경을 알아채는 것은 다이제스트뿐입니다.
- 자기 디스크에 대한 검사입니다. 다운로드 후 체크포인트는 그 박스의 무엇이든 편집할 수 있는 캐시 안의 평범한 파일입니다. 로드 시점에 그것들을 다시 검증하는 것은 없습니다 —— 다이제스트가 유일합니다.
- 출처로부터의 독립성입니다. 미러, 프록시 또는 Hub 자체가 다른 바이트를 서빙했다면, 다이제스트가 검사 대상에게 스스로를 보증하라고 요구하지 않는 유일한 통제입니다.
그 독립성은 맵 생성이 수동 단계인 이유이기도 합니다. 지문은 검사 대상이 당신을 위해 그것을 만들어 주는 순간 독립적인 기록이기를 그칩니다.
맵 생성
이 페이지를 포함해 어디에서든 다이제스트를 복사하지 말고, 검토한 체크포인트에서 생성하십시오. 이를
대신 만들어 주는 명령은 의도적으로 없습니다. Laya가 방금 내려받은 사본에서 계산한 맵은 그 바이트를
해싱한 뒤 자기 자신과 대조해 검증하게 되기 때문입니다. 검사가 의미를 가지려면 어떤 사람이 그
바이트가 원하는 것이라고 결정해야 하므로, 맵 생성이 그 결정이 기록되는 단계입니다. 맵은 정확히
하나의 체크포인트에 속합니다. 번들 저장소는 루트(영어 체크포인트)와 multilingual/에 서로 다른
rl_agent_config.json을 담고 있으므로, 한쪽에서 생성한 맵은 다른 쪽에서 실패합니다.
import hashlib, json, os
CHECKPOINT = "/path/to/checkpoint" # the directory a load actually reads
FILES = [
"rl_agent_config.json",
"tokenizer/tokenizer.json",
"encoder/config.json",
"model.safetensors",
]
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
digests = {rel: sha256(os.path.join(CHECKPOINT, rel)) for rel in FILES}
with open("digests.json", "w") as f:
json.dump(digests, f, indent=2)
torch Agent 로드는 파일 다섯 개를 파싱하는데, 그중 넷이 위의 목록입니다. (ONNXAgent는 다른
집합을 읽으며, 그래프 자체를 다이제스트하기 위해 onnx와 onnx_path 키를 추가로 받습니다.)
다섯 번째인 tokenizer/tokenizer_config.json은 의도적으로 빠졌습니다. Laya가 검증 후에 그것을
정규화해 다시 쓸 수 있고, 그러면 그것을 고정했을 때 다음 로드가 실패하기 때문입니다. 그
재작성은 조건부이며, 파일이 tokenizer_class를 선언하지 않거나, TokenizersBackend를 선언하거나,
extra_special_tokens를 리스트로 담고 있을 때만 일어납니다 —— 그래서 일부 체크포인트에서는 전혀
일어나지 않고 파일을 고정해도 동작하는 것처럼 보입니다. 빼 두는 것이 이식성 있는 선택이며, 파싱되는
파일 하나가 검증되지 않은 채 남는다는 뜻입니다.
이것이 보호하는 것과 보호하지 않는 것을 참고하십시오.
맵 사용
import json
import laya
with open("digests.json") as f:
agent = laya.load("convaiinnovations/laya", expected_sha256=json.load(f))
키는 체크포인트 디렉터리 기준 상대 경로입니다. 불일치는 ValueError를 발생시키고, 나열했지만 없는
파일은 FileNotFoundError를 발생시킵니다. 나열하지 않은 파일은 전혀 검사되지 않으므로, 맵은 보호
대상의 정의이기도 합니다. 이는 Hub 다운로드뿐 아니라 로컬 디렉터리에서도 동작합니다.
코드를 건드리지 않고
LAYA_SHA256_DIGESTS는 같은 맵을 JSON으로 담고 있으며, 명시적 expected_sha256 없이 로더가
호출될 때마다 적용됩니다:
export LAYA_SHA256_DIGESTS="$(cat digests.json)"
laya-serve
이를 대신 생성해 주는 것은 없습니다. 값은 검토한 체크포인트에서 나온 자기 맵입니다. Docker에서는
compose가 시작되기 전에 환경에 있어야 하며, 위처럼 export하거나 compose가 읽는 .env 파일에 두면
됩니다. 서비스가 ${LAYA_SHA256_DIGESTS:-}를 그대로 전달하므로, 설정되지 않은 변수는 조용히 검증
없음을 뜻합니다:
echo "LAYA_SHA256_DIGESTS=$(cat digests.json)" >> .env
docker compose -f compose.yaml -f compose.http.yaml up laya-serve
이 변수에는 _FILE 변형이 없습니다. 그 우회는 비밀을 위한 것이고, 다이제스트 맵은 비밀이 아닙니다.
한 프로세스가 둘 이상을 로드할 때는 각 체크포인트의 이름을 붙이십시오. 이 변수는 두 가지 형태를 가지며, 값 타입이 어느 것인지 말해 줍니다:
# one set of files, checked on every checkpoint the process loads
LAYA_SHA256_DIGESTS='{"rl_agent_config.json": "<sha256>"}'
# a map per checkpoint, which is what a router serving several needs
LAYA_SHA256_DIGESTS='{"english": {"rl_agent_config.json": "<sha256>"},
"multilingual": {"rl_agent_config.json": "<sha256>"}}'
평평한 형태는 verify_digests 자체의 해석이며 같은 경로를 모든 것에 적용하므로, 라우터에서는
체크포인트 하나에만 맞을 수 있고 나머지는 거부합니다. 번들 저장소는 체크포인트마다 별도의
model.safetensors와 rl_agent_config.json을 제공하므로, 이름을 붙여야 합니다:
flat map generated from the english checkpoint
load english ok
load multilingual ValueError: laya: SHA-256 mismatch for rl_agent_config.json
중첩 맵이 이름을 붙이지 않은 체크포인트는 오류가 아니라 의도적으로 고정되지 않으며, 라우터가 모르는
모델 이름은 그 체크포인트를 검증하지 않은 채 두는 대신 예외를 발생시킵니다. en은 english로
해석되며, Router(sha256_digests=...)가 적용하는 것과 같은 정규화입니다.
두 경로는 그 마지막 지점에서 다르며, 둘 다 쓰면 쉽게 걸려 넘어집니다. 환경의 중첩 맵이 빠뜨린
체크포인트는 빈 맵으로 고정되므로, 평평한 맵이 그것으로 새어 들어갈 수 없습니다. 코드에서
Router(sha256_digests=...)에서 빠뜨린 체크포인트는 항목이 전혀 없으므로, 환경이 말하는 것으로
여전히 폴백합니다. 고정하려는 모든 체크포인트를 쓰는 쪽에서 이름으로 밝히십시오.
설정되지 않았거나 빈 변수는 검증 없음을 뜻하므로, 필요 없는 환경에서는 빼 두어도 안전합니다. 잘못된 형식의 JSON은 검사를 조용히 건너뛰지 않고 예외를 발생시키며, 한 객체에 두 형태를 섞는 것은 이름을 대며 거부됩니다.
서버에서 불일치가 나타나는 모습
그것이 어떻게 드러나는지는 미리 로드 여부에 달려 있습니다. 맨 laya-serve는 기본적으로 미리
로드하며(LAYA_PRELOAD=1), 그래서 불일치는 시작 시점에 실패합니다 —— 크게, 결정론적으로. 이
저장소의 컨테이너는 LAYA_PRELOAD=0으로 설정되어 있으므로(compose.http.yaml이고
Docker에 오버라이드가 문서화되어 있습니다), 거기서는 첫 로드가 요청 시에 일어나고
그때까지는 아무것도 검증되지 않습니다. 그때 불일치는 그 체크포인트로 라우팅되는 티켓에 대해
422입니다. laya/serve.py가 ValueError를 HTTPException(422)로 매핑하고 다이제스트 텍스트를
호출자에게 돌려줍니다. 나열되었지만 없는 파일은 대신 FileNotFoundError를 발생시키고, 이는
일반적인 500 “inference failed”로 흘러가며 이유는 컨테이너 로그에만 남습니다.
422를 염두에 두고 계획하십시오. 로그, 대시보드, 알림 규칙에서 클라이언트 오류로 분류되므로, 운영자가 깨진 배포를 찾아보는 기본 위치가 바로 이것이 나타나지 않는 유일한 곳입니다.
서버에서 리비전 고정
LAYA_REVISION은 모든 체크포인트 다운로드에 적용되는 커밋, 브랜치 또는 태그를 담거나, reviewed라는
단어를 담습니다. 후자는 각 저장소를 PINNED_REVISIONS에서 찾아 자체 SHA를 사용합니다:
LAYA_REVISION=reviewed laya-serve
테이블에 항목이 없는 저장소에 reviewed를 쓰면, 고정 없이 로드하는 대신 예외를 발생시킵니다.
조용히 아무것도 아닌 것으로 해석되는 고정이야말로 이 통제가 막으려는 실패입니다. 명시적 revision=
인수는 여전히 변수를 이기고, 설정되지 않음이나 공백은 “요청하지 않음”을 뜻하므로,
HF_HUB_OFFLINE=1 캐시는 이전과 똑같이 로드합니다.
코드에서는 Router가 둘 다 모델별로 받습니다:
router = laya.Router(
revisions={"english": PINNED_REVISIONS["convaiinnovations/laya"]},
sha256_digests={"english": {"rl_agent_config.json": "<sha256>"}},
)
다이제스트는 항상 모델별입니다 —— revision의 라우터 전체 등가물은 없습니다. 커밋 SHA는
체크포인트 간에 공유될 수 있지만 다이제스트는 그럴 수 없기 때문입니다. 배포 변수는
Docker, 전체 생성자는 Router를 참고하십시오.
체크포인트가 갱신될 때
두 통제는 다르게 동작하며, 그중 하나만 당신의 조치를 필요로 합니다.
고정된 리비전은 당신을 현재 위치에 붙잡아 둡니다. 새 체크포인트는 당신이 고정을 바꾸기 전까지
고정된 배포에 도달하지 않으며, 그것이 고정의 요점입니다. PINNED_REVISIONS는 라이브러리와 함께
움직이므로, 더 새로운 검토 커밋을 취하는 것은 SHA를 편집하는 것이 아니라 Laya를 업그레이드하는
것입니다.
다이제스트는 의도적으로 로드를 멈춥니다. 당신의 맵은 당신이 검토한 바이트에서 생성되었습니다.
다른 바이트는 무엇이든 파싱되기 전에 ValueError를 발생시킵니다:
ValueError: laya: SHA-256 mismatch for rl_agent_config.json: expected ae287b56…, got 25061739…
그것은 기능이 작동하는 것이지, 우회해야 할 버그가 아닙니다. 순서가 중요합니다:
- 바이트가 바뀐 이유를 알아내십시오 —— 의도된 릴리스인지, 예상하지 못한 무언가인지.
- 새 체크포인트를 검토하십시오.
- 검토한 사본에서 맵을 다시 생성하십시오.
- 새 맵을 배포하십시오.
3단계로 건너뛰지 마십시오. 방금 도착한 것에 대해 생성기를 다시 돌리면 검사가 통과되고 아무것도 검증되지 않습니다 —— 새 바이트가 존재한다는 이유로 신뢰할 수 있는 것으로 기록하는데, 그것이 바로 다이제스트가 탐지하려고 존재한 상태입니다.
두 가지 세부 사항입니다. LAYA_SHA256_DIGESTS를 통해 전달된 새 맵은 프로세스를 재시작해야 합니다.
실행 중인 서버는 시작할 때의 환경을 유지하기 때문입니다. 그리고 이 순서는 리비전이 고정되지 않은
배포에만 적용됩니다. 두 통제를 모두 켜면 고정을 옮기기 전까지 새 바이트가 도착하지 않습니다.
실제로 로드된 것 확인
모든 에이전트는 자신이 나온 커밋을 기록하며, 로컬 디렉터리에서는 None입니다:
agent.revision # Agent and ONNXAgent
router.loaded_revisions # {"english": "55cf4c4e…", …} for each resident agent
agent.revision은 다운로드가 해석한 스냅샷을 보고하며, 전달한 것으로 폴백하므로, 브랜치나 태그로
고정하면 SHA가 아니라 그 이름을 되돌려 줍니다 —— 이 필드가 SHA이길 원하면 SHA로 고정하십시오.
로컬 디렉터리에서의 로드는 None을 보고하고 거기서는 revision이 무시됩니다. 해석할 Hub
스냅샷이 없기 때문입니다.
서버도 같은 것을 보고하며, 이것이 배포가 생각하는 그 체크포인트를 실행 중인지 확인하는 가장 빠른 방법입니다:
curl -s localhost:8000/health
# {"status":"ok","loaded":["english"],"revisions":{"english":"55cf4c4e…"},"device":"auto"}
laya-ts
TypeScript 패키지는 고정과 다이제스트 부분 —— revision, expectedSha256, 리비전 다시 읽기 —— 을
그대로 옮겼습니다. LAYA_SHA256_DIGESTS 등가물도 서버도 없으므로, 위의 두 절은 여기에 적용되지
않습니다:
import { loadNodeBundle, PINNED_REVISIONS } from "laya-ts";
const bundle = await loadNodeBundle("convaiinnovations/laya", {
revision: PINNED_REVISIONS["convaiinnovations/laya"],
expectedSha256: { "rl_agent_config.json": "<sha256 of that file>" },
});
명시적 리비전은 ~/.cache/laya-ts/ 아래의 디스크 캐시 경로에 합류하므로, 다르게 고정된 산출물이
결코 충돌하지 않습니다. 브라우저에서는 리비전이 대신 요청 URL로 이동하며, 같은 방식으로
CacheStorage의 키가 됩니다. createNodeProvider는 로드하는 ONNX 그래프에 대해 expectedSha256을
받습니다.
이것이 보호하는 것과 보호하지 않는 것
검토한 것과 내용이 달라진 체크포인트를 탐지합니다 —— 업스트림 저장소 편집, 침해된 미러, 손상된 다운로드, 수정된 로컬 사본.
검토하지 않은 체크포인트를 안전하게 만들어 주지는 않습니다. 다이제스트는 바이트가 기록한 것과 일치한다고 말할 뿐이며, 그 바이트를 신뢰할 만하다고 결정하는 것은 여전히 당신의 몫입니다.
의존하기 전에 알아 둘 세 가지 한계:
- 나열한 파일만 검사됩니다. “전부 검증” 모드는 없고, 나열하지 않은 파일을 거부할 방법도 없으므로, 맵에서 빠진 산출물은 검증 없이 로드됩니다. 맵이 보증의 경계입니다.
- 따라서 파싱되는 파일 하나가 그 밖에 있습니다.
tokenizer/tokenizer_config.json은 파싱되지만, Laya가 다이제스트 검사 직후 그것을 정규화해 다시 쓸 수 있으므로, 고정하면 첫 로드에서는 성공하고 다음 로드에서 실패할 수 있습니다. 권장 맵은 그 이유로 이것을 빼 두며, 그래서 그 바이트는 검증되지 않습니다. 재작성은 파일이 선언하는 것에 조건부이므로, 일어나는지 여부는 체크포인트에 달려 있습니다. - 검증은 로드 시점에만 일어납니다. 이후에는 파일을 다시 검사하는 것이 없으며, 공격자가 교체했든 프로세스 자체가 교체했든 마찬가지입니다.