nicolasff / webdis

A Redis HTTP interface with JSON output
https://webd.is
BSD 2-Clause "Simplified" License
2.82k stars 307 forks source link

Direct redis access, redis-cli and pub/sub build #229

Open cevanno opened 1 year ago

cevanno commented 1 year ago

I'd like to use your app as a docker image for pub/sub and database services, and keep redis in the same image. For testing and maintenance I want to still have access to redis directly (to use redis-cli and redis-desktop) while also using the webdis frontend for production network apps. But even though I EXPOSE ports 7379 6379 in the Dockerfile and then do 'docker run -p 7379:7379 -p 6379:6379' the container does not respond to redis commands or the redis-cli. The terminal says "Error: Server closed the connection" and the webdis.log doesn't even have an entry that the command was attempted. Is external redis access blocked? Can it be unblocked?

Just as a test I ran a generic Redis image in docker and exposed port 6379 in the launch and I can communicate with it with redis-cli from another location (WSL Ubuntu in my case), so this is why I think something in this code or config is blocking redis from external access.

For pub/sub I set "websockets"=true in webdis.json as the readme instructs, but there is also a webdis.prod.json that appears to be used in the Dockerfile as the "authoritive" config file so NOT modifying that one seems 'wrong'? Can you confirm that this file is ignored from my perspective?

nicolasff commented 1 year ago

Hello @cevanno,

You can't access the port because Redis is listening only on 127.0.0.1 by default, and it looks like EXPOSE needs the port to be bound on eth0 instead of just the loopback interface. In the Redis config file used in the Webdis image, this is configured by the line bind 127.0.0.1 -::1, it's part of the default Redis config.

You can instruct Redis to listen on all interfaces within the container, just like Webdis has to do with http_host set to 0.0.0.0. I wasn't sure whether adding a new bind line to redis.conf would work or if the default one would need to be removed, so I tried just adding the new line at the end of the file and it seems to work.

As documented in redis.conf, to listen on all interfaces you need bind * -::*. You can add it to the Dockerfile on the same line as the daemonize change, just make sure to use single quotes around the string to make sure the * isn't expanded by the shell.

With the EXPOSE change that you've already found, the full diff for Dockerfile is then:

diff --git Dockerfile Dockerfile
index d77b5ee..8cec061 100644
--- Dockerfile
+++ Dockerfile
@@ -14,7 +14,7 @@ FROM alpine:3.14.3
 RUN apk update && apk add libevent msgpack-c 'redis>6.2.6' openssl libssl1.1 libcrypto1.1 && rm -f /var/cache/apk/* /usr/bin/redis-benchmark /usr/bin/redis-cli
 COPY --from=stage /usr/local/bin/webdis /usr/local/bin/webdis-ssl /usr/local/bin/
 COPY --from=stage /etc/webdis.prod.json /etc/webdis.prod.json
-RUN echo "daemonize yes" >> /etc/redis.conf
+RUN echo "daemonize yes" >> /etc/redis.conf && echo 'bind * -::*' >> /etc/redis.conf
 CMD /usr/bin/redis-server /etc/redis.conf && /usr/local/bin/webdis /etc/webdis.prod.json

-EXPOSE 7379
+EXPOSE 7379 6379

I built it as webdis:issue229 with the following command:

docker build -t webdis:issue229 .

and then ran it as a foreground container in the same terminal:

docker run --rm -p 127.0.0.1:7379:7379 -p 127.0.0.1:6379:6379 webdis:issue229

In a separate terminal, I was able to verify that both HTTP requests and redis-cli commands worked as expected:

$ curl -s http://127.0.0.1:7379/PING
{"PING":[true,"PONG"]}

$ redis-cli ping
PONG

As for the two config files, the idea is to have two different files for two use cases:

webdis.json is the default file name that Webdis looks for if no parameters are passed in, so this is for people who have just built Webdis by running make and now just want to try it out locally with ./webdis

webdis.prod.json is a bit different and is provided for people who want to build a package or a Docker image and run it as a service. This is why it's the file referenced in Dockerfile, where it is used to build the docker image. The fact that it's actually being edited in Dockerfile to change the daemonize setting is kind of a legacy artifact. Webdis predates Docker by several years, and before containers we'd generally run services as local daemons so "daemonize": true made sense. This doesn't work when it's the front-facing process in a container.

From what I understand it sounds like you're planning to run a custom image with these minor port changes, so if you want to also enable websockets you'd need to either make the change in webdis.prod.json before building the image, or to edit the sed command that edits webdis.prod.json in the Dockerfile. This is because unlike in redis.conf you can't simply append a line to this JSON file, so you'd need to insert "websockets": true, somewhere in the middle. You can try doing it this way:

-RUN sed -i -e 's/"daemonize":.*true,/"daemonize": false,/g' /etc/webdis.prod.json
+RUN sed -i -e 's/"daemonize":.*true,/"daemonize": false, "websockets": true,/g' /etc/webdis.prod.json

Make sure not to miss the trailing comma after true, just like there was one at the end of "daemonize": false,.

With all of these changes, the full Dockerfile could look like this:

FROM alpine:3.14.3 AS stage
LABEL maintainer="Nicolas Favre-Felix <n.favrefelix@gmail.com>"

RUN apk update && apk add wget make gcc libevent-dev msgpack-c-dev musl-dev openssl-dev bsd-compat-headers jq
RUN wget -q https://api.github.com/repos/nicolasff/webdis/tags -O /dev/stdout | jq '.[] | .name' | head -1  | sed 's/"//g' > latest
RUN wget https://github.com/nicolasff/webdis/archive/$(cat latest).tar.gz -O webdis-latest.tar.gz
RUN tar -xvzf webdis-latest.tar.gz
RUN cd webdis-$(cat latest) && make && make install && make clean && make SSL=1 && cp webdis /usr/local/bin/webdis-ssl && cd ..
RUN sed -i -e 's/"daemonize":.*true,/"daemonize": false, "websockets": true,/g' /etc/webdis.prod.json

# main image
FROM alpine:3.14.3
# Required dependencies, with versions fixing known security vulnerabilities
RUN apk update && apk add libevent msgpack-c 'redis>6.2.6' openssl libssl1.1 libcrypto1.1 && rm -f /var/cache/apk/* /usr/bin/redis-benchmark /usr/bin/redis-cli
COPY --from=stage /usr/local/bin/webdis /usr/local/bin/webdis-ssl /usr/local/bin/
COPY --from=stage /etc/webdis.prod.json /etc/webdis.prod.json
RUN echo "daemonize yes" >> /etc/redis.conf && echo 'bind * -::*' >> /etc/redis.conf
CMD /usr/bin/redis-server /etc/redis.conf && /usr/local/bin/webdis /etc/webdis.prod.json

EXPOSE 7379 6379

I hope this helps! Let me know if you're still blocked after this.

cevanno commented 1 year ago

Thanks! I had found the same kinds of solutions. Your solutions for the redis.conf file did not work without a little tweak and gives this error: docker: Error response from daemon: Ports are not available: exposing port TCP 127.0.0.1:6379 -> 0.0.0.0:0: listen tcp 127.0.0.1:6379: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.

Removing the 127.0.0.1's let it start but all pubsub SUBSCRIBE messages get the "Error: Server closed the connection" so I modified your RUN command to: RUN echo "daemonize yes" >> /etc/redis.conf && echo 'bind * -::*' >> /etc/redis.conf && echo 'protected-mode no' >> /etc/redis.conf

And this works in my environment. Since your test passed there is some difference in our environments that might be a problem?

A problem I see with your fix to RUN sed -i -e 's/"daemonize":.*true,/"daemonize": false, "websockets": true,/g' /etc/webdis.prod.json Is that the input file comes from your wget image and not from the local file. My solution to this was to simply copy the local file in before the sed call like this:

COPY webdis.prod.json /etc/webdis.prod.json
RUN sed -i -e 's/"daemonize":.*true,/"daemonize": false,/g' /etc/webdis.prod.json

Do you agree this is better?

nicolasff commented 1 year ago

Glad you managed to make progress!

I'm surprised you got an error with the -p command, the IP part is definitely supported (documented here) and I use it often. What it does is listen only on localhost on the docker host, so that you can connect from the same host but the port is not exposed to other hosts on all the networks you're connected to:

Note that ports which are not bound to the host (i.e., -p 80:80 instead of -p 127.0.0.1:80:80) will be accessible from the outside.

The error message you pasted is saying that you already have a process listening on port 6379, likely either from a previous Webdis+Redis container that was still running or from a local Redis service. You can find this mystery process with the following command on macOS:

sudo lsof -i -P -n | grep LISTEN | grep :6379

or this way on most Linux distros:

sudo netstat -nlp | grep :6379

Reading the documentation for protected-mode, your proposed change seems fine to me. I only tried to run PING over HTTP and with redis-cli as shown above, but this doesn't say anything about different commands behaving differently; rather this is a restriction at connection time even before the command is received. So yes this should be ok, but I can't tell why you actually need it. The fact that the command was a SUBSCRIBE should not make a difference: see the implementation in Redis for reference, which is closing the connection early without reading from it.

So I'm not sure why we're observing some differences in behavior, it's certainly unexpected given that Docker is supposed to be a way to neatly package and run software the same way regardless of the host OS. For what it's worth I ran the commands above with Docker Desktop 4.15.0 (93002) on macOS 12.6. It's possible that the way Redis sees the incoming connection as being made on loopback or not depends on the version of Docker and the host it runs on, hence the difference in whether protected-mode is needed. I could certainly see this happening, although I've never encountered this particular issue myself and I would see it as an inconsistency in Docker if not a bug.

the input file comes from your wget image and not from the local file […] Do you agree this is better?

This is really up to you, and how you want to manage this file. I don't think there's a fundamental difference, you can achieve the same results either way. I'd say the main difference is this: the advantage of pulling the file from the downloaded archive is that you only need to maintain the Dockerfile changes, while the advantage of having a local copy is that you can tune it locally to your liking and can remove the RUN sed… command from the Dockerfile (having changed daemonize in the local copy). I think it's really a matter of preference and how you want to do this; if I was in your situation I would probably maintain a custom local copy as well.

cevanno commented 1 year ago

OK, all good. Yes I had a zombie process as you suspected and after removing it the -p 127.0.0.1:6379:6379 form ran with 'docker run' However, try as I might it would not accept a connection without 'protected-mode no'. My read of the documentation is the same as yours but I suspect the loopback address is on the docker image so message attempts from Windows Postman or another WSL linux vm are considered 'foreign' Thank you so much.