왼쪽의 패키지 목록 카드에서 요청 경로 화살표가 오른쪽 저장소 격자로 이어지고, 격자 칸 두 곳이 비어 있어 목록에 적힌 파일이 서버에서 빠진 상황을 나타낸 개념 일러스트

Failed to fetch packages.ros.org/ros2 404 Not Found가 뜰 때 apt update부터 다시 하면 풀릴까: 공식 설치 순서와 오프라인 저장소로 갱신 전후 실패 개수 재 보기


Read in English

ROS 2 패키지를 apt로 설치하다가 E: Failed to fetch http://packages.ros.org/ros2/ubuntu/pool/main/... 404 Not Found가 찍히고 설치가 멈추면, 저장소 설정을 고쳐야 하는지, 아니면 apt update를 다시 하면 풀리는지부터 막힌다. 공식 설치 문서도 sudo apt update를 맨 앞에 두니 가장 싸게 해 볼 만한 시도이긴 하다. 다만 그 한 줄을 실행했다는 사실만으로는 답이 나오지 않고, 같은 설치 명령을 갱신 전과 후에 돌려 E: Failed to fetch 줄이 몇 개에서 몇 개로 바뀌었는지를 세어야 판정이 선다.

네트워크를 끊은 ROS 2 Jazzy 컨테이너 안에 packages.ros.org처럼 옛 파일을 지우는 작은 저장소를 만들고 다섯 번 반복해 재 보니, 답은 조건에 따라 갈렸다. 내 기계의 목록만 낡았을 때는 apt-get update 한 번으로 실패가 5줄에서 0줄이 됐다. update가 새 목록을 내려받지 못했거나 서버가 옛 목록을 계속 내준 경우에는 update 뒤에도 5줄이 그대로였고, 뒤쪽 경우는 update 출력이 Hit과 종료 코드(exit code, 명령이 끝나며 돌려주는 숫자로 0이 정상) 0으로 겉보기에 멀쩡했다. 근거는 ROS 2·apt 공식 문서와 소스(2026-10-24 확인), 그리고 이 오프라인 모델의 측정이다. 실제 packages.ros.org에서 난 특정 404의 원인을 확정하는 실험은 아니다.

터미널과 sudo, Ubuntu 코드네임(noble은 24.04, jammy는 22.04), ROS 2 배포판 이름(Jazzy, Humble) 정도를 알면 따라올 수 있다. HTTP 404는 “요청한 주소에 파일이 없다”는 서버 응답이다.

404가 어느 단계에서 났는지부터 가른다

여기서 구분할 404의 위치는 두 곳이다. 하나는 apt update 도중 저장소의 Release나 InRelease 파일에서 나는 404다. InRelease는 저장소 전체의 메타데이터와 서명을 담은 파일이라, 여기서 404가 나면 저장소 주소나 Ubuntu 코드네임이 틀렸거나 그 배포판을 저장소가 지원하지 않는다는 쪽을 먼저 의심한다.

다른 하나는 apt install이나 apt upgrade 도중 pool/main/.../*.deb 주소에서 나는 404다. pool은 실제 패키지 파일(.deb)이 놓인 디렉터리다. 원래 질문인 Robotics Stack Exchange 104189(2023-09-18, Ubuntu 22.04와 Humble)의 오류가 이쪽이었다. apt upgrade 중 ros-humble-rviz2_11.2.7-1jammy.20230814.171234_amd64.deb를 받으려다 404가 났고, 2026-10-24에 같은 파일 주소를 다시 요청해도 404였다. 이 글은 이 두 번째 경우만 다룬다.

apt는 내 기계의 목록을 보고 서버에 파일을 요청한다

apt가 무엇을 내려받을지는 서버에 그때그때 묻지 않는다. 미리 받아 둔 패키지 목록(package index, 저장소의 Packages 파일)에 “어떤 패키지의 어떤 버전이 pool의 어느 경로에 있다”가 적혀 있고, 설치할 때는 그 경로를 그대로 요청한다. apt-get update는 매뉴얼 표현으로 “resynchronize the package index files from their sources”, 즉 이 목록을 저장소에서 다시 받아 맞추는 명령이다. 받은 목록은 /var/lib/apt/lists 아래에 저장된다(apt-get(8), Ubuntu Noble).

식당에 비유하면 목록은 손님 손에 있는 메뉴판이고 pool은 주방이다. 메뉴판이 낡으면 주방에 이미 없는 요리를 주문하게 되고, 주방은 “없다”(404)고 답한다. apt update는 메뉴판을 새로 받아 오는 일이다. 비유가 맞는 범위는 딱 여기까지다. 새 메뉴판을 받으러 갔다가 서명이 맞지 않아 거절당하는 경우나, 중간 배달원 격인 프록시(proxy, 요청을 대신 전달하고 응답을 저장해 두기도 하는 중간 서버)가 옛 메뉴판을 건네는 경우는 이 비유로 설명되지 않는다. 뒤의 실측에서 C와 D가 이 두 경우를 흉내 낸 조건이다.

ROS 저장소에서는 옛 파일이 실제로 지워진다

메뉴판이 낡을 수 있는 이유는 ROS 쪽 공식 문서에 적혀 있다. ROS 2 snapshot 저장소 문서는 “The main ROS apt repository only holds the most recent version of each package.”라고 쓰고, 이어서 “When a sync happens, the older versions of the updated packages are removed from the main repository and can no longer be installed.”라고 적는다(Snapshot-Repository.rst L20-L23). sync(동기화)는 시험용 ros-testing 저장소에 모인 새 빌드를 메인 저장소로 옮기는 작업이고, 이때 갱신된 패키지의 옛 버전이 메인 저장소에서 빠진다. 문서는 주기를 “periodically”라고만 쓴다. 최근 사례로는 2026-10-06에 게시된 Jazzy 2026-10-05 sync 공지가 새 패키지 190개와 업데이트 312개를 알렸다.

그러니 sync 전에 받아 둔 목록에는 이미 지워진 빌드 경로가 남아 있을 수 있다. 104189의 채택 답이 “이 파일은 서버에 없고 빌드 날짜가 더 늦은 20230822 버전이 있다”고 한 것도 같은 상황으로 읽힌다.

공식 설치 순서가 update를 맨 앞에 두는 이유

ROS 2 Jazzy 설치 문서는 저장소를 설정한 다음 “Update your apt repository caches after setting up the repositories.”라며 sudo apt update를 실행하게 한다. 문서가 말하는 apt repository cache가 위의 패키지 목록이다. 이어서 “ROS 2 packages are built on frequently updated Ubuntu systems. It is always recommended that you ensure your system is up to date before installing new packages.”라며 sudo apt upgrade를 거친 뒤에야 sudo apt install ros-jazzy-desktop(또는 ros-jazzy-ros-base)으로 넘어간다. rolling 원문은 둘째 문장을 “Always make sure that your system is up to date before installing new packages.”로 조금 다르게 쓰지만 뜻은 같다(Ubuntu-Install-Debs.rst L106-L121).

세 명령의 역할은 다르다. update는 목록을 갱신하고, upgrade는 이미 설치된 패키지를 그 목록 기준 최신으로 올리고, install은 지정한 패키지를 설치한다. upgrade와 install은 둘 다 목록에 적힌 경로로 파일을 요청하므로, 목록이 낡았다면 둘 다 같은 404를 만난다. apt-get 매뉴얼도 “An update should always be performed before an upgrade or dist-upgrade.”라고 적는다.

update를 했는데도 목록이 그대로일 수 있다

apt 소스를 보면 update가 실행됐다고 목록이 반드시 바뀌지는 않는다. 목록 파일을 내려받지 못하면 apt는 “Some index files failed to download. They have been ignored, or old ones used instead.”를 내고, 일시적인 네트워크 오류는 경고(W:), 그 밖의 실패는 오류(E:)로 표시한다. 어느 쪽이든 옛 목록은 남아서 계속 쓰인다(apt-pkg/update.cc L108-L139). 출력 줄 앞의 Get:은 새로 받았다는 표시이고, Hit:은 서버가 “지난번 이후 바뀐 것이 없다”(HTTP 304 Not Modified)고 응답한 항목에 찍힌다(acqprogress.cc L66-L78).

104189의 질문자가 올린 update 출력에는 Hit:9 http://packages.ros.org/ros2/ubuntu jammy InRelease가 있었고, 그 뒤에도 404가 계속됐다. 질문자는 결국 /var/lib/apt/lists/*를 지운 뒤 해결했다고 적었다. 그 전에 시도한 apt clean이 소용없었던 이유는 매뉴얼로 설명된다. clean이 비우는 곳은 내려받은 .deb를 쌓아 두는 /var/cache/apt/archives/이고, 목록이 있는 /var/lib/apt/lists는 건드리지 않는다. 왜 Hit이 찍혔는지(프록시 캐시인지, 미러(mirror, 같은 저장소를 복제해 제공하는 다른 서버) 사이의 시차인지)는 공개 자료로 확인되지 않는다.

내 기계의 패키지 목록과 sync 뒤 서버 pool을 비교한 세 가지 요청 경로 개념도: update가 새 목록을 받아 성공하는 경우, update가 Packages 404로 옛 목록을 유지한 채 요청해 실패하는 경우, 서버가 옛 목록을 304로 유지해 Hit이 찍히고도 실패하는 경우

실패 개수 두 번으로 가설을 판정하는 식

“목록이 낡아서 404가 났다”는 생각은 확인된 사실이 아니라 가설이다. 이 가설은 update 한 번으로 시험할 수 있다. 같은 명령을 갱신 전후에 돌려 E: Failed to fetch 줄을 센 다음, 갱신으로 사라진 비율을 계산한다.

설명된 실패 비율 = (갱신 전 실패 줄 수 − 갱신 후 실패 줄 수) ÷ 갱신 전 실패 줄 수

단위는 줄이다. apt는 내려받지 못한 항목마다 Failed to fetch 한 줄을 출력하므로(private-download.cc L99-L122), 줄 수가 곧 받지 못한 파일 수다. 진행 줄의 Err:N도 같은 실패를 한 번 더 보여 주는 표시라 함께 더하지 않는다. 비율이 1이면 실패 전부가 낡은 목록으로 설명되니 가설을 유지하고, 0이면 이 가설로는 하나도 설명되지 않으니 버리고 다른 원인으로 넘어간다.

숫자를 넣어 보면 이렇다. 패키지 20개를 설치하려는데 sync로 그중 5개의 옛 빌드가 서버에서 지워졌다고 하자. 20과 5는 계산을 위해 고른 값이고 실제 ROS 저장소의 비율을 대표하지 않는다. 요청 중 5/20 = 0.25가 지워진 경로를 가리키므로 갱신 전 실패는 5줄로 예측된다. update로 목록이 바뀌어 갱신 후 0줄이면 (5 − 0) ÷ 5 = 1, 목록이 바뀌지 않아 5줄이 남으면 (5 − 5) ÷ 5 = 0이다. sync가 없었다면 낡은 목록으로도 0/20 = 0, 즉 실패가 없어야 한다. 이 계산은 예제 코드 example.py가 각 식을 math.isclose로 단언하며 검산했고, 실행 기록은 verification.json에 남아 있다. 산술 검산일 뿐 apt를 실행한 기록은 아니다.

이 예제의 기록에는 “15개는 정상으로 받아진다”는 결론이 있는데, “받아진다”와 “설치된다”를 구분해야 한다. apt는 파일을 하나라도 받지 못하면 “Unable to fetch some archives, maybe run apt-get update or try with –fix-missing?”를 내고 설치 단계 전체를 멈춘다(private-install.cc L405). 그래서 갱신 전 시도에서 실제로 설치되는 패키지는 15개가 아니라 0개로 예측했다. 오류 문구에 apt 스스로 update를 첫 처방으로 적어 둔 점도 눈여겨볼 만하다. Debian 최신 3.1 계열은 이 문구를 “maybe run apt update”로 바꿨으므로, 독자 화면의 문구는 apt 버전에 따라 조금 다를 수 있다.

오프라인 저장소에서 여섯 조건을 재 봤다

실측은 공개 컨테이너 이미지 하나 안에서만 이뤄졌다. Python 표준 라이브러리 http.server를 루프백 주소(loopback, 자기 자신에게만 연결되는 127.0.0.1)에 띄우고, 그 위에 packages.ros.org처럼 pool/main/<첫 글자>/<이름>/ 경로를 쓰는 작은 apt 저장소를 만들었다. 패키지는 .deb 생성 도구 dpkg-deb로 만든 빈 더미 패키지 20개다. 서버 쪽 sync는 snapshot 문서의 설명을 따라 5개를 새 버전으로 바꾸고 옛 .deb를 지우는 방식으로 흉내 냈다. apt는 실험용 sources·목록·캐시 디렉터리만 보게 격리했고, 명령은 사람용 apt 대신 스크립트에 권장되는 apt-get을 LC_ALL=C(영어 메시지 고정)로 실행했다(apt(8)).

조건은 여섯 가지다. A는 목록만 낡은 경우, B는 원래 질문처럼 낡은 목록으로 upgrade를 먼저 한 경우, C는 두 번째 update 때 서버가 Packages 파일만 404로 돌려주는 경우다. C는 2019년 ROS 저장소 키 교체 때처럼 “update가 실패해 옛 목록이 쓰이는” 상황을 흉내 낸 유사 모델이며, 서명 오류 자체를 재현하지는 않았다. D는 pool만 sync하고 목록은 옛 것을 계속 내주는 경우로, update 출력에 Hit이 찍히는데도 404가 남는 원래 질문의 관측을 재현하려는 모델이다. 실제 원인이 프록시인지 미러인지까지 재현한 것은 아니다. E는 sync가 없는 경계 조건, F는 갱신 없이 --fix-missing을 붙인 경우다.

시험 실행에서 고친 곳은 세 군데이고, 그중 측정 조건을 바꾼 것은 하나다. 처음에는 C의 서버 차단 조건이 /hlc/Packages로 시작하는 요청만 막았는데, apt가 이런 저장소에서 /hlc/./Packages.gz처럼 ./가 들어간 경로를 요청해서 차단이 걸리지 않았다. 요청 경로를 정규화한 뒤 비교하도록 고친 버전이 아래 측정에 쓰였다. 나머지 두 건은 기록 형식만 손봤다. 단계 기록의 command 필드가 첫 인자만 담아 apt-get -y로 찍히던 것을 하위 명령까지 남기게 고쳤고, 하네스가 출력의 끝부분만 보관해 앞쪽 환경 줄과 A 조건이 잘리던 문제는 키 이름을 짧게 하고, 문제없는 준비 단계 출력을 생략하고, 오류 줄 예시를 단계별 한 줄(C의 update만 두 줄)로 줄여 해결했다.

재현 스크립트와 실행별 원본 출력은 실험 저장소 디렉터리에 있다. 측정은 하네스(harness, 스크립트를 고정 조건으로 반복 실행하고 출력을 그대로 보관하는 HuntLab 자동 실행기)가 맡았다. 측정에 쓴 스크립트를 고정 커밋에서 받아 해시를 확인한 뒤, 하네스와 같은 이미지 digest(이미지 내용으로 계산한 고정 식별자로, 태그와 달리 바뀌지 않는다)·네트워크 차단·자원 제한으로 실행하면 된다(Docker 필요).

curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/f14f4e1ef10e4c990146f1f8dafcffff6b5f5e2c/experiments/ros2-apt-failed-to-fetch-404-apt-update-before-after/experiment.py
echo "1e20489f1268dee7e911bd85b4897222cbd73fce605136fc387f9f3ad77c05c4  experiment.py" | sha256sum -c
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"

출력은 단계마다 JSON 한 줄이고, 마지막 줄의 summary에 조건별 갱신 전후 개수와 explained_ratio가 들어 있다. 포트 번호와 경과 시간은 실행마다 달라진다.

측정 환경은 이미지 ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca(ros:jazzy-ros-base), apt 2.8.3, dpkg 1.22.6, Python 3.12.3, ROS_DISTRO=jazzy, root(uid 0), 네트워크 없음, 1 CPU·1 GiB다. 하네스가 5회 실행했고 5회 모두 종료 코드 0이었으며, 포트 번호와 경과 시간을 빼면 다섯 번의 개수·종료 코드·설치 수가 모두 같았다. 이미지의 /var/lib/apt/lists에는 파일이 0개였다. 새 컨테이너는 목록 없이 시작한다는 뜻이다. RMW(ROS 2 노드 사이 통신을 맡는 미들웨어 구현, ROS middleware)는 기본값 rmw_fastrtps_cpp로 기록됐지만, 이 실험은 노드를 띄우지 않으므로 결과와 관계가 없다.

조건 실행 전 계산(예측) 실측 E: Failed to fetch 갱신 전 → 후 실측 설치 상태 실측 설명된 실패 비율
A 목록만 낡음 5 → 0, 갱신 전 설치 0, 비율 1 5 → 0 (update의 저장소 Release 줄 Get, 종료 코드 0) 갱신 전 0개, 갱신 후 20개(1.0 15개, 2.0 5개) 1.0
B upgrade를 update보다 먼저 5 → 0, 비율 1 5 → 0 (update Get) 갱신 전 20개 모두 1.0 그대로, 갱신 후 1.2 5개·1.0 15개 1.0
C update가 목록 파일에서 404 5 → 5, 비율 0 5 → 5 (update 종료 코드 100, update 출력의 E: Failed to fetch 1줄, “old ones used instead” 있음) 0개 0.0
D 서버가 옛 목록을 계속 제공 5 → 5, 비율 0 5 → 5 (update Hit, 서버 304 응답 1회, 종료 코드 0) 0개 0.0
E sync 없음 0 0 20개 계산하지 않음(갱신 전 0)
F 갱신 없이 --fix-missing 실패 5, 설치 15, 종료 코드는 예측하지 않음 5 (갱신 없음) 15개(모두 1.0), 종료 코드 100 해당 없음

A의 갱신 전 설치 시도에서 서버는 .deb 15개에 200, 5개에 404로 응답했다. 그래도 실제 설치는 0개였다. 갱신 후 시도에서 서버가 새로 내준 .deb는 5개였다.

하네스 1회차 원본 출력 일부다. 앞의 다섯 줄은 단계 기록 그대로이고, 마지막 두 줄은 C의 update 단계에서 수집된 오류 줄 예시(diagnostic_samples) 문자열이다.

{"scenario": "A_stale_list", "step": "install_before_update", "command": "apt-get install", "exit_code": 100, "failed_fetch_lines": 5, "err_lines": 5, "unable_to_fetch_msg": true, "old_ones_used_msg": false, "release_line": null, "srv_404_deb": 5, "srv_404_index": 0, "srv_200_deb": 15, "srv_304": 0, "seconds": 0.068, "installed": 0, "versions": {}}
{"scenario": "C_index_404", "step": "update_index_blocked", "command": "apt-get update", "exit_code": 100, "failed_fetch_lines": 1, "err_lines": 2, "unable_to_fetch_msg": false, "old_ones_used_msg": true, "release_line": "Get", "srv_404_deb": 0, "srv_404_index": 2, "srv_200_deb": 0, "srv_304": 0, "seconds": 0.038, "installed": 0, "versions": {}}
{"scenario": "D_stale_index_hit", "step": "update_index_unchanged", "command": "apt-get update", "exit_code": 0, "failed_fetch_lines": 0, "err_lines": 0, "unable_to_fetch_msg": false, "old_ones_used_msg": false, "release_line": "Hit", "srv_404_deb": 0, "srv_404_index": 0, "srv_200_deb": 0, "srv_304": 1, "seconds": 0.036, "installed": 0, "versions": {}}
{"scenario": "D_stale_index_hit", "step": "install_after_update", "command": "apt-get install", "exit_code": 100, "failed_fetch_lines": 5, "err_lines": 5, "unable_to_fetch_msg": true, "old_ones_used_msg": false, "release_line": null, "srv_404_deb": 5, "srv_404_index": 0, "srv_200_deb": 0, "srv_304": 0, "seconds": 0.056, "installed": 0, "versions": {}}
{"scenario": "F_fix_missing", "step": "install_fix_missing", "command": "apt-get --fix-missing install", "exit_code": 100, "failed_fetch_lines": 5, "err_lines": 5, "unable_to_fetch_msg": false, "old_ones_used_msg": false, "release_line": null, "srv_404_deb": 5, "srv_404_index": 0, "srv_200_deb": 15, "srv_304": 0, "seconds": 1.252, "installed": 15, "versions": {"1.0": 15}}
E: Failed to fetch http://127.0.0.1:45857/hlc/./Packages  404  File not found [IP: 127.0.0.1 45857]
E: Some index files failed to download. They have been ignored, or old ones used instead.

404 사유 문구가 Not Found가 아니라 File not found인 것은 실험 서버인 Python http.server의 문구다. 그래서 개수는 문구가 아니라 E: Failed to fetch로 시작하는 줄로 셌다.

결과는 실행 전에 적어 둔 예측과 모든 칸에서 같았다. 예측하지 않고 비워 둔 칸은 F의 종료 코드 하나였고, 100으로 측정됐다. 이 숫자들에서 읽을 수 있는 것은 네 가지다.

첫째, 내려받기 성공과 설치 성공은 다르다. A의 첫 시도에서 서버는 15개 파일을 정상으로 내줬지만 설치된 패키지는 0개였다. 하나라도 못 받으면 설치 단계 전체가 멈춘다는 소스 설명과 맞는다. 갱신 후 서버가 새로 내준 파일이 5개뿐이었던 것은, 먼저 받아 둔 15개가 내려받기 캐시에 남아 다시 쓰였기 때문으로 해석한다. 캐시 내용을 따로 확인하지는 않았다.

둘째, update 출력의 Release 줄만 보고 판단하면 안 된다. C에서는 Release가 Get으로 새로 받아졌는데도 Packages에서 404가 나서 옛 목록이 그대로 쓰였다. 이 경우를 가르는 신호는 update의 종료 코드 100과 “old ones used instead” 줄이었다.

셋째, D에서는 update 출력에 문제를 알리는 줄이 하나도 없었다. 종료 코드 0, Hit, 경고 없음. 그런데도 실패는 5줄 그대로였다. update 출력만으로는 “목록이 이미 최신이다”와 “서버나 중간 경로가 옛 목록을 내준다”를 구분할 수 없었고, 갱신 후 실패 개수를 다시 센 뒤에야 차이가 드러났다. 개수를 세는 단계를 건너뛰면 안 되는 이유가 이것이다.

넷째, --fix-missing은 해결책이 아니다. F에서 15개는 설치됐지만 지워진 5개는 설치되지 않았고 종료 코드는 여전히 100이었다. 매뉴얼이 설명하듯 못 받은 패키지를 보류하고 나머지를 진행할 뿐이다. 이때 “Unable to fetch some archives” 줄이 출력되지 않았으므로, 그 문구만 찾는 스크립트는 실패를 놓칠 수 있다.

내 기계에서 같은 판정을 하는 순서

아래 명령은 실험의 셈 규칙을 실제 기계용으로 옮긴 것이다. 실제 packages.ros.org를 쓰는 기계에서 실행해 확인한 명령은 아니다. 지금 실패하는 명령이 install이 아니라 upgrade라면 1번과 3번을 그 명령으로 바꾼다. 실험처럼 LC_ALL=C를 붙여야 같은 영어 문자열로 셀 수 있다.

# 1) 지금 실패하는 명령을 그대로 다시 실행하고 실패 줄을 센다
sudo env LC_ALL=C apt-get install ros-jazzy-ros-base 2>&1 | tee before.log
grep -c '^E: Failed to fetch' before.log

# 2) 목록을 갱신하고 ROS 저장소 줄, 경고·오류 줄, 종료 코드를 확인한다
sudo env LC_ALL=C apt-get update 2>&1 | tee update.log; echo "update exit=${PIPESTATUS[0]}"
grep 'packages.ros.org' update.log
grep -E '^(W|E):' update.log

# 3) 1번과 같은 명령을 다시 실행하고 다시 센다
sudo env LC_ALL=C apt-get install ros-jazzy-ros-base 2>&1 | tee after.log
grep -c '^E: Failed to fetch' after.log

1번에서 404 줄의 주소가 pool/.../*.deb인지 먼저 본다. InRelease나 Release라면 이 절차가 아니라 저장소 주소와 코드네임을 확인할 차례다. 그다음 2번 출력과 3번의 개수를 함께 읽는다.

update 출력 갱신 후 실패 줄 판정 다음 확인
종료 코드 0, ROS 저장소 줄 Get, W:/E: 없음 0 목록이 낡았다는 가설 유지(A·B와 같은 모양) 다른 기계도 같은 순서로 갱신
“Some index files failed…” 줄 또는 종료 코드 100 그대로 update가 새 목록을 못 받아 옛 목록이 쓰임(C와 같은 모양) 그 W:/E: 줄의 원인. 서명 오류, 주소, 코드네임
종료 코드 0, ROS 저장소 줄 Hit, 오류 없음 그대로 받은 목록 자체가 옛것일 가능성(D와 같은 모양). 원인 미확정 프록시·미러 경로, 아래 Date: 비교

연구실처럼 로봇 PC와 개발 PC가 여러 대라면, 각 기계가 받아 둔 ROS 저장소 InRelease의 Date: 필드(저장소 메타데이터가 생성된 시각)를 비교하면 어느 기계의 목록이 오래됐는지 알 수 있다. 2026-10-24에 확인한 서버의 noble용 InRelease 값은 Date: Tue, 06 Oct 2026 09:01:08 UTC로, 2026-10-05 sync 공지가 게시된 날과 맞았다. 다음 sync가 있으면 이 값은 바뀌므로, 비교할 때마다 같은 코드네임의 서버 값을 다시 읽고 그보다 이른 날짜를 가진 기계부터 갱신한다. jammy를 쓰는 기계라면 주소의 noble을 jammy로 바꾼다. /var/lib/apt/lists 안의 파일 이름 규칙은 apt 내부 규칙이라 공식 문서로 확인하지 못했다. 아래 명령이 아무것도 찾지 못하면 ls /var/lib/apt/lists로 이름을 먼저 본다.

# 내 기계가 받아 둔 목록의 생성 시각
grep -m1 '^Date:' /var/lib/apt/lists/*packages.ros.org*InRelease
# 서버의 현재 값(noble 기준)
curl -s http://packages.ros.org/ros2/ubuntu/dists/noble/InRelease | grep -m1 '^Date:'

확인 문제

문제 1. (설명용으로 만든 상황이다) 어떤 PC에서 update 출력에 Hit:5 http://packages.ros.org/ros2/ubuntu noble InRelease가 찍혔고, W:/E: 줄은 없으며 종료 코드는 0이었다. 같은 설치 명령의 E: Failed to fetch는 갱신 전 3줄, 갱신 후에도 3줄이었다. 설명된 실패 비율은 얼마이고, 다음에 무엇을 확인할까?

답: (3 − 3) ÷ 3 = 0이다. 내 기계의 목록만 낡았다는 가설로는 실패가 하나도 설명되지 않는다. update가 실패했다는 신호도 없으니 실측의 D와 같은 모양이다. 서버나 중간 경로가 내준 목록 자체가 옛것일 가능성을 보고, 프록시를 거치는지와 InRelease의 Date:를 서버의 최신 값과 비교한다. D에서 update는 성공했지만 서버가 “변경 없음”으로 답해 목록이 바뀌지 않았다. 같은 경로로 update만 반복해서 이 상황이 바뀐다는 근거는 이 실험에 없다.

문제 2. 갱신 전 4줄이 갱신 후 1줄로 줄었다면?

답: (4 − 1) ÷ 4 = 0.75다. 3줄은 낡은 목록으로 설명되지만 남은 1줄은 아니다. 이 실험에는 없는 경우라서 남은 줄을 하나의 별개 문제로 다시 본다. 그 줄의 주소가 어느 저장소와 어느 배포판 코드네임을 가리키는지부터 확인한다.

update로 안 풀릴 때와 옛 버전이 꼭 필요할 때

sudo rm /var/lib/apt/lists/*로 목록을 통째로 지우는 방법은 104189의 질문자가 해결했다고 보고한 방법이고, 2024년 답변은 ROS 목록만 지우는 변형을 권했다. 이 글에서는 검증하지 않았고, 시스템의 모든 저장소 목록을 지우는 일이라 첫 처방으로 권하지 않는다. 위 표로 원인 후보를 확인한 뒤의 마지막 수단으로 남겨 둔다. 비슷해 보이는 apt-get distclean도 매뉴얼상 Release/InRelease 파일은 남기므로, 목록 전체 삭제와 같은 동작이라고 볼 수 없다. 프록시 캐시를 거치는 환경이라면 apt의 Acquire::http::No-Cache, Acquire::http::Max-Age 설정이 관련 항목이다(apt-transport-http(1)). 이 설정의 효과도 이번 실험에서는 재현하지 않았다.

지워진 옛 버전을 일부러 설치해야 한다면 메인 저장소가 아니라 snapshot 저장소를 쓰라는 것이 공식 답이다. 설치 문서 FAQ의 “How to I install an older version of a distro?”에 대한 답이 “Use the snapshot repository”다(Ubuntu-Install-Debs.rst L243-L244). snapshot은 sync 직후 메인 저장소를 복사해 둔 저장소인데, LTS 배포판에만 있고 Rolling에는 없다. 버그·보안 수정을 받지 않고 최소 6개월 보관되며, snapshot 사이의 ABI(application binary interface, 컴파일된 프로그램끼리 맞물리는 이진 규약) 호환은 보장되지 않는다. 그래서 snapshot을 바꿀 때는 ROS 패키지를 한꺼번에 갱신하라고 문서가 안내한다(Installing from the snapshot repository). 버전을 고정해야 하는 배포 장비에 맞는 경로이고, 보안 수정이 필요한 개발 PC에는 맞지 않는다.

실제 로봇 PC라면 하나 더 조심해야 한다. 이 부분은 공식 문서가 다루지 않는 HuntLab의 편집 판단이다. 공식 순서의 sudo apt upgrade는 설치된 패키지 전체를 올리므로 모터 드라이버나 ROS 라이브러리가 함께 바뀔 수 있다. 업그레이드와 설치는 로봇의 노드를 모두 멈추고, 액추에이터 전원을 차단하거나 비상정지를 건 상태에서 한다. 끝난 뒤에는 저속·무부하로 동작을 다시 확인하고 나서 평소 작업으로 돌아간다. 이 글의 실측은 컨테이너 안의 패키지 관리 실험이고, 로봇이나 시뮬레이터를 실행하지 않았다.

“update로 풀렸다”고 말할 수 있는 것은 갱신 뒤 실패 줄이 0으로 줄었을 때뿐이다. 개수가 그대로라면 update를 반복하지 말고, update 출력의 종료 코드와 W:/E: 줄부터 다시 읽는다. 그 줄에 NO_PUBKEY나 서명 오류가 보인다면 목록이 갱신되지 않은 것이므로 apt update에서 ROS 2 저장소 서명 오류(F42ED6FBAB17C654)가 날 때 확인할 것으로 이어서 확인하면 된다. 이 글에 제휴·협찬 링크는 없다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다