1인 회사 시스템 build

SSH로 명령을 던지면 command not found 가 나는 이유

터미널로 접속하면 되는데 ssh 서버 "명령" 형태로는 실패하는 문제. macOS·리눅스에서 비대화형 셸이 읽는 설정 파일이 다르기 때문이며, .zshenv로 해결합니다.


사람이 볼 땐 되는데 자동화에서만 안 된다

원격 서버의 컨테이너 상태를 확인하려고 명령을 던졌습니다.

ssh server "docker ps --format '{{.Names}}' | wc -l"

0이 돌아왔습니다. 그런데 그 서버에는 컨테이너 11개가 멀쩡히 돌고 있었습니다.

직접 접속해서 확인하면 정상입니다.

ssh server
$ docker ps --format '{{.Names}}' | wc -l
11

같은 서버, 같은 명령인데 결과가 다릅니다. 그리고 더 나쁜 건, 앞의 경우가 에러처럼 보이지 않았다는 점입니다. docker: command not found는 표준 에러로 나가고 wc -l은 빈 입력을 받아 0을 찍습니다. 숫자가 나왔으니 정상적인 응답처럼 보입니다.

모니터링 스크립트가 이 값을 읽고 있었다면 “컨테이너 0개”라는 사실과 구분할 방법이 없습니다.

두 경우가 읽는 파일이 다르다

zsh는 어떤 방식으로 호출됐는지에 따라 읽는 설정 파일이 다릅니다.

파일읽는 시점
.zshenv모든 경우
.zprofile로그인 셸
.zshrc대화형 셸
.zlogin로그인 셸 (마지막)

ssh server로 접속해서 프롬프트를 받으면 로그인 셸이자 대화형 셸이라 네 개를 다 읽습니다.

ssh server "명령"은 다릅니다. 이 경우 sshd는 사용자 셸을 zsh -c "명령" 형태로 실행합니다. 로그인 셸도 아니고 대화형 셸도 아니므로, .zshenv만 읽습니다.

Homebrew 설치 안내는 보통 이렇게 안내합니다.

echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile

.zprofile입니다. 그래서 터미널로 접속하면 잡히고, 명령을 던지면 안 잡힙니다.

macOS는 기본 경로조차 로그인 셸에서 채운다

리눅스보다 macOS에서 이 문제가 더 크게 나타나는 이유가 있습니다.

macOS는 /etc/paths/etc/paths.d/의 내용을 PATH에 넣어주는 path_helper라는 도구를 쓰는데, 이걸 호출하는 자리가 /etc/zprofile입니다.

# /etc/zprofile 안
if [ -x /usr/libexec/path_helper ]; then
    eval `/usr/libexec/path_helper -s`
fi

즉 비대화형 셸에는 Homebrew 경로만 없는 게 아니라 /usr/local/bin처럼 당연히 있을 것 같은 경로도 없을 수 있습니다.

직접 확인해 보면 차이가 분명합니다.

# 접속한 뒤 확인
ssh server
$ echo $PATH
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

# 명령으로 던져서 확인
ssh server 'echo $PATH'
/usr/bin:/bin:/usr/sbin:/sbin

진단 · 세 줄이면 확인된다

어떤 상태인지 먼저 확인합니다. 따옴표를 홑따옴표로 써야 로컬이 아니라 원격에서 확장됩니다.

# 1. 비대화형 셸의 PATH
ssh server 'echo $PATH'

# 2. 문제의 명령이 잡히는지
ssh server 'command -v docker || echo "못 찾음"'

# 3. 어떤 셸로 실행되는지
ssh server 'echo $0; echo "대화형: ${-}"'

2번이 “못 찾음”인데 접속해서는 which docker가 경로를 뱉는다면 이 문제입니다.

해결 · .zshenv에 경로를 둔다

~/.zshenv를 만들고 PATH만 넣습니다.

# ~/.zshenv — 모든 셸 호출에서 읽힌다
# Apple 실리콘
if [ -x /opt/homebrew/bin/brew ]; then
  eval "$(/opt/homebrew/bin/brew shellenv)"
fi

# 인텔 맥
if [ -x /usr/local/bin/brew ]; then
  eval "$(/usr/local/bin/brew shellenv)"
fi

# 도커 데스크톱·OrbStack 등이 심는 경로가 따로 있다면 함께
export PATH="$HOME/.local/bin:$PATH"

적용 확인은 위 진단 명령을 다시 돌리면 됩니다.

ssh server 'command -v docker'
/opt/homebrew/bin/docker

.zshenv에 넣으면 안 되는 것

.zshenv모든 셸 호출마다 실행됩니다. 여기에는 두 가지 제약이 따릅니다.

첫째, 화면에 무언가 출력하면 안 됩니다. scp, rsync, git은 원격 셸의 표준 출력을 프로토콜 데이터로 읽습니다. 환영 메시지나 echo 한 줄이 섞이면 전송이 깨집니다.

둘째, 무거운 초기화를 넣으면 안 됩니다. nvm, rbenv, conda 초기화를 여기 넣으면 모든 원격 명령이 그만큼 느려집니다. 이런 것들은 .zshrc에 두고, 정말 자동화에서 필요한 버전만 PATH에 직접 박는 편이 낫습니다.

bash를 쓴다면

bash는 파일 구성이 다릅니다. 비대화형·비로그인 셸에서는 기본적으로 아무 설정 파일도 읽지 않습니다. 다만 SSH로 명령을 실행할 때는 BASH_ENV 환경 변수가 가리키는 파일을 읽습니다.

# ~/.ssh/environment (sshd_config에 PermitUserEnvironment yes 필요)
BASH_ENV=/home/user/.bashenv

설정이 번거롭기 때문에, bash 환경에서는 스크립트 안에서 절대 경로를 쓰는 방식이 더 흔합니다.

ssh server '/usr/bin/docker ps'

같은 얼굴을 한 실패들

이 문제를 고치고 나서 며칠 사이에 나온 다른 실패들도 같은 계열이었습니다.

  • 폰에서 mosh로 접속이 안 됐는데, mosh-server not found였습니다. mosh는 접속 시 원격에서 mosh-server를 실행하는데 그게 비대화형 경로였습니다
  • 파일 전송이 상대 기기에서만 무한 대기했습니다. 에러가 없어서 더 늦게 발견했습니다

공통점은 하나입니다. 에러가 에러처럼 보이지 않았다는 것입니다. 0으로 보이거나, 무한히 기다리거나, 다른 곳의 문제처럼 보였습니다.

자동화 쪽에 남길 장치

경로를 고치는 것과 별개로, 값이 이상할 때 알아챌 수 있게 해두는 편이 낫습니다. 위 예시라면 명령 실패와 “정말 0개”를 구분하게 만듭니다.

#!/usr/bin/env bash
set -euo pipefail

out=$(ssh server 'command -v docker >/dev/null || exit 127; docker ps --format "{{.Names}}"')
rc=$?

if [ "$rc" -eq 127 ]; then
  echo "docker 명령을 찾지 못했습니다 (PATH 문제 의심)" >&2
  exit 1
fi

count=$(printf '%s' "$out" | grep -c . || true)
echo "컨테이너 ${count}"

set -euo pipefail을 쓸 때 주의할 점이 하나 있습니다. 한글이 섞인 문자열에서 $count개라고 쓰면 셸이 count개라는 변수 이름으로 읽습니다. ${count}개처럼 중괄호로 감싸야 합니다. 저는 이걸로 스크립트가 unbound variable로 죽는 걸 한 번 겪었습니다.

정리

  • ssh 서버 "명령"은 로그인 셸도 대화형 셸도 아니다. zsh는 .zshenv 읽는다
  • macOS는 path_helper/etc/zprofile에서 부르므로 기본 경로조차 없을 수 있다
  • 진단은 ssh 서버 'echo $PATH'ssh 서버 'command -v 도구' 두 줄
  • 해결은 ~/.zshenv에 PATH만 둔다. 출력·무거운 초기화는 넣지 않는다
  • 명령 실패와 “정말 0건”을 구분하는 장치를 자동화 쪽에 같이 넣는다

자주 묻는 질문

ssh로 접속하면 docker가 되는데 ssh 서버 "docker ps" 는 왜 실패하나요?

두 경우가 읽는 설정 파일이 다르기 때문입니다. 접속해서 프롬프트를 받으면 로그인 셸이라 .zprofile과 .zshrc를 읽습니다. 명령을 인자로 던지면 비대화형 셸이라 zsh는 .zshenv만 읽습니다. PATH 설정이 .zprofile에만 있으면 후자에서는 명령을 찾지 못합니다.

zsh는 어떤 상황에 어떤 파일을 읽나요?

.zshenv는 모든 경우에 읽습니다. .zprofile은 로그인 셸에서만, .zshrc는 대화형 셸에서만, .zlogin은 로그인 셸의 마지막에 읽습니다. SSH로 명령을 던지는 경우는 로그인도 대화형도 아니므로 .zshenv만 해당합니다.

macOS에서 Homebrew 경로가 로그인할 때만 잡히는 이유는 무엇인가요?

Homebrew 설치 안내가 shellenv 호출을 .zprofile에 넣도록 안내하기 때문입니다. 또한 macOS는 path_helper로 /etc/paths의 기본 경로를 채우는데 이 역시 /etc/zprofile에서 호출되므로, 비대화형 셸에는 /usr/local/bin조차 없을 수 있습니다.

.zshenv에 아무거나 넣어도 되나요?

아닙니다. .zshenv는 모든 셸 호출마다 실행되므로 무거운 초기화나 출력이 있으면 scp·rsync·git 같은 도구가 깨집니다. 환경 변수와 PATH 설정만 넣고, 화면에 무언가 출력하는 명령은 넣지 않아야 합니다.

#SSH#zsh#macOS#자동화#트러블슈팅