Jigsaw의 Outline-Server를 ARM 환경에서 실행 가능하게 만들기
Jigsaw의 Outline 서버에 비공식 멀티 플랫폼 지원을 더하기 위해서 무엇을 했는지 확인해보세요.
프로젝트를 진행할 당시에도 ARM 서버는 이미 활황이었습니다. 대부분의 클라우드 서비스 제공자가 ARM 아키텍쳐를 제공하고 있었습니다. 또 Raspberry Pi는 예로부터 소규모 개인 및 홈 규모의 서버 운영에 나쁘지 않은 성능을 제공하고 있어 찾는 사람도 많았습니다. 하지만 안타깝게도 Jigsaw의 shadowsocks 기반 Outline VPN은 공식적으로 ARM 지원을 하고 있지 않았습니다. 또 대안으로 나온 프로젝트들은 이미 대부분 오래되었거나 지원이 끊긴 상태였습니다. 여기에서는 제가 지속적으로 최신 버전을 유지하도록 하는 패키지(outline-server-multiarch)를 빌드한 배경과 과정을 보여드려요.
이 글에서는 생각의 과정을 나열했기 때문에 글 자체가 구조적이지 못합니다. 조금 더 정리된 버전을 찾으신다면 아래 이슈에서 확인 부탁드립니다.
이전의 ARM 프로젝트와는 무엇이 다른가?
사실 이미 Outline 서버를 ARM 플랫폼에서 실행하고자 하는 시도들은 많았습니다. GitHub에 등록된 이슈와 풀 리퀘스트를 보면 이를 바로 알 수 있습니다. VPN은 외부에서 내부 네트워크에 접근하기 위해서 사용이 불가피한 경우가 많고 특히 Raspberry Pi 등으로 적은 재원으로도 손쉽게 리눅스 서버 구축이 가능하여 많은 사람들이 이미 ARM 생태계에 머무르고 있었으리라 생각됩니다.
- kugaevsky/outline-server-arm64, 당시에는 업데이트가 되지 않았던 것으로 기억하는데 지금은 또 패치가 된 것처럼 보입니다.
- jigsaw-code/outline-server#598, PR이 생성된 시기에는 최신 스택이었지만 지금은 더 좋은 대안이 있습니다.
저는 과거의 사례를 찾아보면서 몇 가지 문제점을 특정할 수 있었는데 특히 보안의 경우가 가장 걱정되었습니다. 그 이유는 Outline는 기본적으로 보안 유지가 목적이기도 한 VPN 서버이기 때문에 항시 최신 버전으로 업데이트하는 것이 필수적입니다. 그러나 위와 같은 개별 포크를 사용한다면 혹시나 유지보수가 늦을 수 있다는 점이 우려됩니다. 그래서 저는 프로젝트 자체를 손보는 것보다는 "동적으로 스크립트를 주입하여 Outline 서버 빌드 과정을 일부 수정하는 것이 가장 합리적이다"고 판단하였습니다.
또 GitHub Actions를 사용하면 큰 힘을 들이지 않고서도 CI를 통해 프로젝트를 자동으로 빌드할 수 있습니다. 무엇보다도 GitHub의 인프라와 연동하기 또한 굉장히 편리하기 때문에 바로 GHCR(GitHub Container Registry)로 배포할 수도 있었기에 단순함을 유지하기에도 좋았습니다.
Outline은 Docker를 통해 배포된답니다.
가장 먼저 알아야 할 것은 바로 Outline의 배포 과정이었습니다. 저는 당시에 사용자의 입장에서만 Outline을 사용해본 경험이 유일했기에 소스코드를 처음부터 뒤적거려보기로 했습니다. 그나마 다행인 것은 Jigsaw-Code의 Outline-Server는 TypeScript 프로젝트였고 실제 서버는 정적 바이너리로 패키지 내부에 임베딩되어 있었습니다. 각각 build_action.sh
와 연결된 Dockerfile
을 보면 알 수 있었습니다.
본 글에 첨부된 모든 코드는 Outline-Server 기여자들에게 귀속됩니다.
COPY third_party third_party
배포에 사용되는 Dockerfile의 33번 줄에서 third_party
폴더가 복사되는 것을 확인하였고 실제 third_party 폴더에는 outline-ss-server
와 prometheus
, shellcheck
가 포함된 것을 볼 수 있습니다.
그렇다면 필요한 사항을 다시 정리해봅니다.
- 먼저 최신 버전의 Outline과 서드파티 소프트웨어들을 확인하고 다운로드할 수 있어야 합니다.
- 가능하면 성능과 보안 상의 이유로 차후 버전에서 Breaking Changes가 예상되지 않는 서드파티 소프트웨어는 최신 버전으로 교체해줍니다.
- 플랫폼 간 이미지 빌드가 가능한 환경을 확인하고 CI를 실행해줄 워크플로우를 작성합니다.
ARM 생태계는 x86과는 다르게 생각보다 집중적이진 않습니다.
물론 그 과정이 순탄치는 않았습니다. 쉘 스크립팅을 한다는 것부터가 문제였겠지만 능력적인 문제가 아닌 논리적인 문제들 중에서도 특히 효과적으로 아키텍쳐를 파악하는데에는 많은 문제가 있었습니다. 효과적으로 아키텍쳐를 파악함이라 함은 Docker나 시스템 유틸리티를 통해서 큰 노력없이 일관된 아키텍쳐 명칭을 가져올 수 있어야 한다는 것입니다. 하지만 제 개발 환경부터가 그렇지 않았습니다. 저는 쉘 스크립트이니 쉘 환경을 그대로 이용하고자 uname
을 통해 플랫폼을 확인하려 들었습니다.
$ uname -a
Darwin gohojeong-ui-MacBook-Pro.local 21.5.0 Darwin Kernel Version 21.5.0: Tue Apr 26 21:08:29 PDT 2022; root:xnu-8020.121.3~4/RELEASE_ARM64_T8101 arm64
위 출력은 제 개발 환경인 M1 프로세서 기반의 시스템에서 출력되는 메세지입니다. 자세히보면 arm64
환경임을 알려주고 있습니다. 하지만 aarch64
라는 단어도 한 번 쯔음 들어보셨을 겁니다. 그렇다면 이 둘은 같을까요? 결론부터 이야기하자면 둘은 "현재"는 같은 아키텍쳐를 의미합니다. Apple의 LLVM이 aarch64
를 사용하기로 했기 때문이죠.

하지만 실제로 어떤 용어를 사용할지는 컴파일러에 따라 달라집니다. 단순히 aarch64
만 지원하면 되지 않을까 싶을 수도 있지만 ARM 컴퓨팅의 생태계 특성상 제가 알고 있는 것 이상으로 다양한 제품군이 존재하기에 흔한 이슈 발생을 피하려면 생각 이상으로 플랫폼의 구분을 명확히 해주어야 했습니다. 다시 위 명령어를 실행시킨 환경을 생각해보세요. M1은 오래된 플랫폼이 아니니까요. 또 iPhone을 탈옥시켜 아키텍쳐를 확인해보면 64비트 iPhone에서도 동일하게 arm64
를 출력하게 됩니다. 이렇게 ARM 생태계는 x86 이상으로 굉장히 다양하게 구성되어 있습니다.
사실 여기까지 모두 아키텍쳐 이름을 맵핑하는 함수를 작성하여서 문제를 해결할 수 있다고 하여도 더 큰 문제가 남아있습니다. 여러분은 64비트 플랫폼에서 32비트 uname
프로세스가 동작하는 일을 보신 적이 있나요? 64비트 플랫폼일지라도 uname
이 32비트라면 armv7l
을 출력하게 됩니다. 사실 이건 꽤 저희가 자주 보는 상황입니다. 다른 것도 아니고 Raspberry Pi의 경우가 그렇습니다. 그 이유는 Raspbian의 64비트 버전은 항시 베타 버전이나 다름없기 때문에 사람들은 32비트 체계를 사용하도록 되어 있죠. 하지만 여전히 64비트 바이너리를 지원합니다.
반대로 Oracle Cloud의 ARM 인스턴스의 경우엔 64비트 운영체제가 그대로 들어가기 때문에 정상 출력이 확인됩니다. 그래도 32비트 프로세스가 모종의 이유로 다른 프로그램 때문에 사용된다면 어떡하죠? 저는 너무나도 불안해지기 시작했습니다.

그래서 GitHub Actions와 Docker Buildx를 사용해서 uname -a
의 출력을 몽땅 확인해보기로 했습니다.
#10 [linux/arm64 2/2] RUN uname -a
#10 0.719 Linux buildkitsandbox 5.11.0-1027-azure #30~20.04.1-Ubuntu SMP Wed Jan 12 20:56:50 UTC 2022 aarch64 Linux
#10 DONE 0.7s
#13 [linux/amd64 2/2] RUN uname -a
#13 0.638 Linux buildkitsandbox 5.11.0-1027-azure #30~20.04.1-Ubuntu SMP Wed Jan 12 20:56:50 UTC 2022 x86_64 Linux
#13 DONE 0.7s
#14 [linux/arm/v7 2/2] RUN uname -a
#14 0.707 Linux buildkitsandbox 5.11.0-1027-azure #30~20.04.1-Ubuntu SMP Wed Jan 12 20:56:50 UTC 2022 armv7l Linux
#14 DONE 0.8s
#12 [linux/arm/v6 2/2] RUN uname -a
#12 0.752 Linux buildkitsandbox 5.11.0-1027-azure #30~20.04.1-Ubuntu SMP Wed Jan 12 20:56:50 UTC 2022 armv7l Linux
#12 DONE 0.8s
하지만 이게 끝이 아닙니다. 동네 사람들 이것 좀 보세요. 저는 그만 정신을 잃고 말았습니다. 이번엔 armv6
와 armv7
의 출력이 동일하게 나와버리기 시작했습니다. 그래서 눈을 의심하면서 uname -a
의 가능한 출력 리스트를 찾아보았습니다.

사실 그만두었어야 했어요.
있는지도 모르는 희망을 부여잡고 다시 검색을 해본 결과 다행히 그 이유를 찾을 수 있었습니다. "Single-Boards"에 따르면 ARMv6과 v7의 핵심 레지스터는 동일하게 때문에 바이너리 또한 호환된다고 합니다.

ARMv6 core registers and ARMv7 core registers are the same. ARMv7 is backward compatible with ARMv6, so binaries compiled for ARMv6 should also work on ARMv7.
그러나 여전히 메이저 버전의 차이라는 것은 무시할 수 없었습니다. 또 armv7
이 아니라 armv6
로 컴파일된다면 차라리 걱정이 덜했을 겁니다. 또 분명 각각의 사용자가 최신의 바이너리를 사용하고 싶을 것이 눈에 선했습니다. 더 나아가서 Prometheus와 outline-ss-server 모두 그 둘을 구분짓고 배포 중이었습니다. 그래서 결국 uname
을 사용한 플랫폼 구분은 여기에서 멈추기로 하고 Docker에서 제공하는 환경 변수에서 의존해보기로 했습니다.
Docker BuildKit의 $TARGETPLATFORM
.
그렇게 해서 찾은 환경변수가 $TARGETPLATFORM
입니다. 최신의 Docker는 BuildKit과 Buildx를 통해서 다중 플랫폼 빌드를 제공합니다. 그래서 이를 담고 있을 환경변수가 어딘가에 존재할 것이라 생각되었습니다. 또 찾아보니 Dockerfile에서도 사용이 가능하더군요. 즉, 아래와 같이 세부 경로에 영향을 주어 필요한 요소만 명시적으로 복사가 가능하다는 것이었습니다.
ARG TARGETPLATFORM
RUN mkdir -p third_party
COPY third_party/outline-ss-server/${TARGETPLATFORM} third_party/outline-ss-server
COPY third_party/prometheus/${TARGETPLATFORM} third_party/prometheus
또 ARMv6와 v7이 비록 동일한 환경에서 실행되더라도 환경변수를 통해 내부에 임베딩된 바이너리를 구분해서 다운로드할 수 있기 때문에 더 문제가 일어날 상황도 많지 않아보였습니다. 최소한 uname
을 사용할 때보단 말이죠.
그래도 몇 가지 해결해야 할 과업은 남아있었습니다.
- Docker Buildx의 특징으로 인한 적절한 테스트 방법 부재
- Docker Content Trust 제거 혹은 통과시키기
저에게는 특히 Docker Buildx의 특징에 안 익숙한 점이 문제가 되었습니다. 대비적으로 Docker Content Trust는 제거하면 일단은 해결되기 때문입니다. 특히 Node.JS 12 버전 이후의 Docker Image부터는 Docker Content Trust가 제공되지 않지만 Outline 서버에 의해서는 필요한 최소 버전이 16이기에 어쩔 수 없다는 핑계가 있었죠.
하지만 Docker Buildx는 기존의 Docker 이미지 빌드 과정과는 많이 달랐습니다. 특히 Docker가 하위 호환성을 보장하기 위한 결정 중 하나로 바로 빌드 후 즉시 이미지를 업로드할 수 있도록 하게 만들어진 것에 적응하지 못했기 때문이었습니다. Docker 데몬에서는 한 이름에 하나의 이미지 밖에 처리하지 못하지만 그렇게 된다면 Docker Registry에서도 여러 플랫폼에 필요한 이미지를 같은 이름으로 다루지 못하는 문제가 생기게 됩니다. 그렇기에 Docker Buildx는 Registry로 즉시 이미지들을 전송시키게 됩니다.
그러나 이 점을 간과하고 로컬에서 테스트를 진행하면서 왜 로컬 데몬에서는 실제로 되지 않느냐를 꽤 오랫동안 두고 앉아있었죠. 적어도 지금은 Registry와 로컬 멀티 플랫폼 이미지의 처리 방식 차이를 알고 있게 되었네요. 그렇게 마지막으로 다시 설치 스크립트를 되짚어보면서 Docker Content Trust를 비활성화하고 빌드 스크립트를 완성시켜 릴리즈하게 되었습니다.
부디 여러분은 멀티 플랫폼으로 인한 고통은 받지 않았으면 좋겠다는 것으로 글을 마치겠습니다. 읽어주셔서 감사합니다.